1use std::future::Future;
13use std::pin::pin;
14use std::sync::Arc;
15use std::time::Duration;
16
17use futures_util::future::{Either, select};
18use serde_json::{Value, json};
19
20use oxide_batch::{
21 ActorRef, BatchStatus, BoxFuture, Cursor, DefinitionIdentity, ExecutionVersion,
22 ExplorerRepository, FailureCategory, FailureId, FailureSummary, IncidentEventBuffer,
23 JobExecutionId, JobExplorer, JobInstanceId, JobInstanceKey, JobName, JobOperator, JobParameter,
24 JobParameters, JobRepository, OperationId, OperatorOutcome, OperatorOutcomeClass,
25 OperatorRequest, Page, PageRequest, PageSize, ParameterName, ParameterRole, ParameterValue,
26 PurgeBatchBound, PurgePlanRequest, ReasonCode, RecoveryDirective, RecoveryError,
27 RecoveryProposal, RecoveryProposer, RecoveryRepository, RepositoryError, RetentionService,
28 StepExecutionId, TelemetryEventSink, TerminalStatusSet,
29};
30
31use crate::args::{Arguments, DirectiveArg, RecordArg};
32use crate::catalog::DefinitionCatalog;
33use crate::command::Command;
34use crate::config::{Configuration, resolve};
35use crate::exit::ExitCategory;
36use crate::failure;
37use crate::host::Host;
38use crate::output::{Diagnostic, PageInfo, Response, Writer};
39use crate::project;
40
41#[derive(Clone, Copy, Debug, Eq, PartialEq)]
43pub struct SchemaState {
44 pub installed: Option<u32>,
46 pub supported: u32,
48}
49
50impl SchemaState {
51 #[must_use]
53 pub const fn migration_required(&self) -> bool {
54 match self.installed {
55 Some(installed) => installed < self.supported,
56 None => true,
57 }
58 }
59
60 #[must_use]
62 pub const fn newer_than_supported(&self) -> bool {
63 match self.installed {
64 Some(installed) => installed > self.supported,
65 None => false,
66 }
67 }
68}
69
70pub trait SchemaReport: Send + Sync {
75 fn schema_state(&self) -> BoxFuture<'_, Result<SchemaState, RepositoryError>>;
77}
78
79pub trait RecoveryProposalPort: Send + Sync {
81 fn propose(
83 &self,
84 execution_id: JobExecutionId,
85 ) -> BoxFuture<'_, Result<RecoveryProposal, RecoveryError>>;
86}
87
88impl<R: RecoveryRepository> RecoveryProposalPort for RecoveryProposer<R> {
89 fn propose(
90 &self,
91 execution_id: JobExecutionId,
92 ) -> BoxFuture<'_, Result<RecoveryProposal, RecoveryError>> {
93 Box::pin(async move { self.propose(execution_id).await })
94 }
95}
96
97#[derive(Clone, Copy, Debug, Default)]
98struct NoRecoveryProposals;
99
100impl RecoveryProposalPort for NoRecoveryProposals {
101 fn propose(
102 &self,
103 _execution_id: JobExecutionId,
104 ) -> BoxFuture<'_, Result<RecoveryProposal, RecoveryError>> {
105 Box::pin(async {
106 Err(RecoveryError::Repository(
107 RepositoryError::UnsupportedCapability {
108 capability: oxide_batch::RepositoryCapability::OperatorRequests,
109 },
110 ))
111 })
112 }
113}
114
115#[derive(Clone, Copy, Debug, Default)]
117pub struct NoSchema;
118
119impl SchemaReport for NoSchema {
120 fn schema_state(&self) -> BoxFuture<'_, Result<SchemaState, RepositoryError>> {
121 Box::pin(async {
122 Err(RepositoryError::UnsupportedCapability {
123 capability: oxide_batch::RepositoryCapability::OperatorRequests,
124 })
125 })
126 }
127}
128
129pub struct Services<R, S> {
134 operator: JobOperator<R>,
135 retention: RetentionService<R>,
136 explorer: JobExplorer<S>,
137 recovery: Box<dyn RecoveryProposalPort>,
138 schema: Box<dyn SchemaReport>,
139 events: Arc<IncidentEventBuffer>,
140}
141
142impl<R: JobRepository, S: ExplorerRepository> Services<R, S> {
143 #[must_use]
145 pub fn new(
146 operator: JobOperator<R>,
147 retention: RetentionService<R>,
148 explorer: JobExplorer<S>,
149 schema: Box<dyn SchemaReport>,
150 ) -> Self {
151 let events = Arc::new(IncidentEventBuffer::default());
152 let operator_sink: Arc<dyn TelemetryEventSink> = events.clone();
153 let explorer_sink: Arc<dyn TelemetryEventSink> = events.clone();
154 let retention_sink: Arc<dyn TelemetryEventSink> = events.clone();
155 Self {
156 operator: operator.with_event_sink(operator_sink),
157 retention: retention.with_event_sink(retention_sink),
158 explorer: explorer.with_event_sink(explorer_sink),
159 recovery: Box::new(NoRecoveryProposals),
160 schema,
161 events,
162 }
163 }
164
165 #[must_use]
167 pub fn with_recovery_proposals(mut self, recovery: Box<dyn RecoveryProposalPort>) -> Self {
168 self.recovery = recovery;
169 self
170 }
171}
172
173#[derive(Debug)]
175pub struct Plan {
176 command: Command,
177 arguments: Arguments,
178 config: Configuration,
179 writer: Writer,
180 operation_id: Option<String>,
181 parameters: JobParameters,
184}
185
186impl Plan {
187 #[must_use]
189 pub const fn command(&self) -> Command {
190 self.command
191 }
192
193 #[must_use]
195 pub const fn config(&self) -> &Configuration {
196 &self.config
197 }
198
199 #[must_use]
201 pub const fn timeout(&self) -> Duration {
202 self.config.client_timeout()
203 }
204}
205
206pub fn prepare<H: Host>(host: &mut H, argv: &[String]) -> Result<Plan, ExitCategory> {
216 let (command, arguments) = match crate::args::parse(argv) {
217 Ok(parsed) => parsed,
218 Err(error) => {
219 let category = error.category();
220 report(
221 host,
222 category,
223 &Diagnostic::new(error.code(), error.to_string()),
224 );
225 return Err(category);
226 }
227 };
228 let config = match resolve(host, &arguments) {
229 Ok(config) => config,
230 Err(error) => {
231 for issue in error.issues() {
232 report(
233 host,
234 ExitCategory::ConfigurationInvalid,
235 &Diagnostic::new("INVALID_CONFIGURATION_VALUE", issue.to_string()),
236 );
237 }
238 return Err(ExitCategory::ConfigurationInvalid);
239 }
240 };
241 let color = !arguments.no_color && host.is_stdout_terminal();
242 let writer = Writer::new(config.output(), color);
243 let parameters = match launch_parameters(host, &arguments) {
244 Ok(parameters) => parameters,
245 Err(detail) => {
246 report(
247 host,
248 ExitCategory::Usage,
249 &Diagnostic::new("PARAMETERS_INVALID", detail),
250 );
251 return Err(ExitCategory::Usage);
252 }
253 };
254 let mut plan = Plan {
255 command,
256 arguments,
257 config,
258 writer,
259 operation_id: None,
260 parameters,
261 };
262 authorize(host, &mut plan)?;
266 Ok(plan)
267}
268
269#[must_use]
273pub fn local<H: Host>(host: &mut H, plan: &Plan) -> Option<ExitCategory> {
274 if !matches!(plan.command, Command::ConfigShow) {
275 return None;
276 }
277 let rows: Vec<Value> = plan
278 .config
279 .effective()
280 .into_iter()
281 .map(|value| {
282 json!({
283 "key": value.key(),
284 "value": value.value(),
285 "source": value.source().as_str(),
286 "redacted": value.is_redacted(),
287 })
288 })
289 .collect();
290 let response = Response::success(Command::ConfigShow, json!(rows));
291 Some(emit(host, plan, &response))
292}
293
294fn authorize<H: Host>(host: &mut H, plan: &mut Plan) -> Result<(), ExitCategory> {
299 if !plan.command.is_mutating() {
300 return Ok(());
301 }
302 let interactive = host.is_stdin_interactive();
303 let operation_id = if let Some(value) = plan.arguments.operation_id.clone() {
304 value
305 } else {
306 if !interactive {
307 report(
311 host,
312 ExitCategory::Usage,
313 &Diagnostic::new(
314 "OPERATION_ID_REQUIRED",
315 "a mutating command requires --operation-id when standard input is not a terminal",
316 ),
317 );
318 return Err(ExitCategory::Usage);
319 }
320 let generated = host.new_operation_id();
321 host.write_stderr(format!("operation-id: {generated}\n").as_bytes());
322 generated
323 };
324 plan.operation_id = Some(operation_id.clone());
325
326 if !plan.command.class().requires_confirmation() {
327 return Ok(());
328 }
329 if plan.arguments.yes {
330 return Ok(());
331 }
332 if !interactive {
333 report(
334 host,
335 ExitCategory::ConfirmationRequired,
336 &Diagnostic::new(
337 "CONFIRMATION_REQUIRED",
338 "a destructive command requires --yes when standard input is not a terminal",
339 ),
340 );
341 return Err(ExitCategory::ConfirmationRequired);
342 }
343 let summary = target_summary(plan);
344 host.write_stderr(
345 format!(
346 "{} [{}] {summary}\noperation-id: {operation_id}\nconfirm (yes/no): ",
347 plan.command,
348 plan.command.class(),
349 )
350 .as_bytes(),
351 );
352 let response = host.read_confirmation().unwrap_or(None);
353 let confirmed = response.is_some_and(|value| value.trim().eq_ignore_ascii_case("yes"));
354 if confirmed {
355 Ok(())
356 } else {
357 report(
358 host,
359 ExitCategory::ConfirmationRequired,
360 &Diagnostic::new("CONFIRMATION_DECLINED", "the confirmation was not given"),
361 );
362 Err(ExitCategory::ConfirmationRequired)
363 }
364}
365
366fn target_summary(plan: &Plan) -> String {
368 let mut parts = Vec::new();
369 if let Some(job) = &plan.arguments.job {
370 parts.push(format!("job={job}"));
371 }
372 if let Some(instance) = plan.arguments.instance {
373 parts.push(format!("instance={instance}"));
374 }
375 if let Some(execution) = plan.arguments.execution {
376 parts.push(format!("execution={execution}"));
377 }
378 if let Some(version) = plan.arguments.expected_version {
379 parts.push(format!("expected-version={version}"));
380 }
381 if let Some(digest) = &plan.arguments.plan_digest {
382 parts.push(format!("plan-digest={digest}"));
383 }
384 if plan.arguments.dry_run {
385 parts.push("dry-run".to_owned());
386 }
387 parts.join(" ")
388}
389
390pub async fn dispatch<H, R, S, D>(
396 host: &mut H,
397 plan: &mut Plan,
398 services: &Services<R, S>,
399 catalog: &DefinitionCatalog,
400 deadline: D,
401) -> ExitCategory
402where
403 H: Host,
404 R: JobRepository,
405 S: ExplorerRepository,
406 D: Future<Output = ()>,
407{
408 let response = {
409 let work = pin!(run_command(host, plan, services, catalog));
410 let deadline = pin!(deadline);
411 match select(work, deadline).await {
412 Either::Left((response, _)) => response,
413 Either::Right(((), _)) => Response::failed(
414 plan.command,
415 ExitCategory::DeadlineExceeded,
416 json!({ "operation_id": plan.operation_id }),
417 )
418 .with_diagnostic(Diagnostic::new(
419 "DEADLINE_EXCEEDED",
420 "the client deadline elapsed; the durable outcome is undetermined",
421 )),
422 }
423 };
424 emit(host, plan, &response)
425}
426
427fn emit<H: Host>(host: &mut H, plan: &Plan, response: &Response) -> ExitCategory {
429 match plan.writer.emit(host, response) {
430 Ok(()) => response.category(),
431 Err(_) => {
432 ExitCategory::OutputFailure
436 }
437 }
438}
439
440fn report<H: Host>(host: &mut H, category: ExitCategory, diagnostic: &Diagnostic) {
445 let line = if diagnostic.code == category.as_str() {
446 format!("{category}: {}\n", diagnostic.detail)
447 } else {
448 format!("{category}: {}: {}\n", diagnostic.code, diagnostic.detail)
449 };
450 host.write_stderr(line.as_bytes());
451}
452
453fn page_request(plan: &Plan) -> Result<PageRequest, Response> {
455 let size = PageSize::new(plan.config.page_size()).map_err(|error| {
456 Response::failed(plan.command, failure::explorer(&error), Value::Null)
457 .with_diagnostic(failure::explorer_diagnostic(&error))
458 })?;
459 match &plan.arguments.cursor {
460 None => Ok(PageRequest::first(size)),
461 Some(token) => {
462 let cursor = Cursor::from_hex(token).map_err(|_| {
463 Response::failed(plan.command, ExitCategory::GuardRejected, Value::Null)
464 .with_diagnostic(Diagnostic::new(
465 "CURSOR_REJECTED",
466 "the continuation token is not a valid cursor",
467 ))
468 })?;
469 Ok(PageRequest::resume(size, cursor))
470 }
471 }
472}
473
474fn paged<T>(plan: &Plan, page: &Page<T>, rows: Vec<Value>) -> Response {
476 let info = PageInfo {
477 page_size: plan.config.page_size(),
478 returned: rows.len(),
479 next_cursor: page.next_cursor().map(Cursor::to_string),
480 };
481 Response::success(plan.command, Value::Array(rows)).with_page(info)
482}
483
484#[allow(clippy::too_many_lines)]
485async fn run_command<H, R, S>(
486 host: &mut H,
487 plan: &Plan,
488 services: &Services<R, S>,
489 catalog: &DefinitionCatalog,
490) -> Response
491where
492 H: Host,
493 R: JobRepository,
494 S: ExplorerRepository,
495{
496 match plan.command {
497 Command::ConfigShow => Response::success(plan.command, Value::Null),
498 Command::JobList => match page_request(plan) {
499 Err(response) => response,
500 Ok(request) => match services.explorer.list_job_names(&request).await {
501 Ok(page) => {
502 let rows = page
503 .rows()
504 .iter()
505 .map(|name| json!({ "job_name": name.as_str() }))
506 .collect();
507 paged(plan, &page, rows)
508 }
509 Err(error) => explorer_failure(plan, &error),
510 },
511 },
512 Command::JobShow => job_show(plan, services).await,
513 Command::InstanceList => match (job_name(plan), page_request(plan)) {
514 (Err(response), _) | (Ok(_), Err(response)) => response,
515 (Ok(name), Ok(request)) => {
516 match services.explorer.list_instances(&name, &request).await {
517 Ok(page) => {
518 let rows = page.rows().iter().map(project::instance).collect();
519 paged(plan, &page, rows)
520 }
521 Err(error) => explorer_failure(plan, &error),
522 }
523 }
524 },
525 Command::InstanceShow => instance_show(plan, services).await,
526 Command::ExecutionList => execution_list(plan, services).await,
527 Command::ExecutionShow => match execution_id(plan) {
528 Err(response) => response,
529 Ok(id) => match services.explorer.get_execution(id).await {
530 Ok(Some(execution)) => {
531 let mut projection = project::execution(&execution);
532 let proposal = services
533 .recovery
534 .propose(id)
535 .await
536 .ok()
537 .map_or(Value::Null, |value| project::recovery_proposal(&value));
538 projection["recovery_proposal"] = proposal;
539 Response::success(plan.command, projection)
540 }
541 Ok(None) => not_found(plan, "execution"),
542 Err(error) => explorer_failure(plan, &error),
543 },
544 },
545 Command::ExecutionSteps => match (execution_id(plan), page_request(plan)) {
546 (Err(response), _) | (Ok(_), Err(response)) => response,
547 (Ok(id), Ok(request)) => {
548 match services.explorer.list_step_executions(id, &request).await {
549 Ok(page) => {
550 let rows = page.rows().iter().map(project::step).collect();
551 paged(plan, &page, rows)
552 }
553 Err(error) => explorer_failure(plan, &error),
554 }
555 }
556 },
557 Command::ExecutionPartitions => match (step_id(plan), page_request(plan)) {
558 (Err(response), _) | (Ok(_), Err(response)) => response,
559 (Ok(id), Ok(request)) => {
560 match services.explorer.list_step_partitions(id, &request).await {
561 Ok(page) => {
562 let rows = page.rows().iter().map(project::partition).collect();
563 paged(plan, &page, rows)
564 }
565 Err(error) => explorer_failure(plan, &error),
566 }
567 }
568 },
569 Command::ExecutionHistory => execution_history(plan, services).await,
570 Command::ExecutionStop
571 | Command::ExecutionRestart
572 | Command::ExecutionAbandon
573 | Command::ExecutionRecover
574 | Command::Launch => operator_command(plan, services, catalog).await,
575 Command::RetentionPlan => retention_plan(plan, services).await,
576 Command::RetentionApply => retention_apply(plan, services).await,
577 Command::RetentionHold | Command::RetentionRelease => retention_hold(plan, services).await,
578 Command::SchemaStatus => schema_status(plan, services).await,
579 Command::DiagnosticsBundle => diagnostics_bundle(host, plan, services).await,
580 }
581}
582
583async fn diagnostics_bundle<H, R, S>(
584 host: &mut H,
585 plan: &Plan,
586 services: &Services<R, S>,
587) -> Response
588where
589 H: Host,
590 R: JobRepository,
591 S: ExplorerRepository,
592{
593 let execution_id = match execution_id(plan) {
594 Ok(value) => value,
595 Err(response) => return response,
596 };
597 let Some(target) = plan.arguments.out.as_deref() else {
598 return usage(plan, "MISSING_BUNDLE_TARGET", "the command requires --out");
599 };
600 match crate::bundle::write(
601 host,
602 target,
603 &plan.config,
604 &services.explorer,
605 services.schema.as_ref(),
606 &services.events,
607 execution_id,
608 )
609 .await
610 {
611 Ok(bundle) => Response::success(
612 plan.command,
613 json!({
614 "manifest_checksum": bundle.manifest_checksum(),
615 "total_bytes": bundle.total_bytes(),
616 }),
617 ),
618 Err(crate::bundle::BundleError::TargetNotFound) => not_found(plan, "execution"),
619 Err(crate::bundle::BundleError::Explorer(error)) => explorer_failure(plan, &error),
620 Err(crate::bundle::BundleError::Write) => {
621 Response::failed(plan.command, ExitCategory::OutputFailure, Value::Null)
622 .with_diagnostic(Diagnostic::new(
623 "BUNDLE_WRITE_FAILED",
624 "the new diagnostics bundle directory could not be written",
625 ))
626 }
627 Err(crate::bundle::BundleError::Encoding) => {
628 Response::failed(plan.command, ExitCategory::Internal, Value::Null).with_diagnostic(
629 Diagnostic::new(
630 "BUNDLE_ENCODING_FAILED",
631 "the redacted diagnostics bundle could not be encoded",
632 ),
633 )
634 }
635 }
636}
637
638fn explorer_failure(plan: &Plan, error: &oxide_batch::ExplorerError) -> Response {
639 Response::failed(plan.command, failure::explorer(error), Value::Null)
640 .with_diagnostic(failure::explorer_diagnostic(error))
641}
642
643fn not_found(plan: &Plan, target: &str) -> Response {
644 Response::failed(plan.command, ExitCategory::TargetNotFound, Value::Null).with_diagnostic(
645 Diagnostic::new("TARGET_NOT_FOUND", format!("the {target} does not exist")),
646 )
647}
648
649fn usage(plan: &Plan, code: &str, detail: &str) -> Response {
650 Response::failed(plan.command, ExitCategory::Usage, Value::Null)
651 .with_diagnostic(Diagnostic::new(code, detail))
652}
653
654fn job_name(plan: &Plan) -> Result<JobName, Response> {
655 let raw = plan
656 .arguments
657 .job
658 .as_deref()
659 .ok_or_else(|| usage(plan, "MISSING_JOB", "the command requires --job"))?;
660 JobName::new(raw).map_err(|_| usage(plan, "INVALID_JOB", "the job name is not valid"))
661}
662
663fn instance_id(plan: &Plan) -> Result<JobInstanceId, Response> {
664 let raw = plan
665 .arguments
666 .instance
667 .ok_or_else(|| usage(plan, "MISSING_INSTANCE", "the command requires --instance"))?;
668 JobInstanceId::new(raw).map_err(|_| {
669 usage(
670 plan,
671 "INVALID_INSTANCE",
672 "the instance identifier is not valid",
673 )
674 })
675}
676
677fn execution_id(plan: &Plan) -> Result<JobExecutionId, Response> {
678 let raw = plan.arguments.execution.ok_or_else(|| {
679 usage(
680 plan,
681 "MISSING_EXECUTION",
682 "the command requires --execution",
683 )
684 })?;
685 JobExecutionId::new(raw).map_err(|_| {
686 usage(
687 plan,
688 "INVALID_EXECUTION",
689 "the execution identifier is not valid",
690 )
691 })
692}
693
694fn step_id(plan: &Plan) -> Result<StepExecutionId, Response> {
695 let raw = plan
696 .arguments
697 .step
698 .ok_or_else(|| usage(plan, "MISSING_STEP", "the command requires --step"))?;
699 StepExecutionId::new(raw)
700 .map_err(|_| usage(plan, "INVALID_STEP", "the step identifier is not valid"))
701}
702
703async fn job_show<R, S>(plan: &Plan, services: &Services<R, S>) -> Response
704where
705 R: JobRepository,
706 S: ExplorerRepository,
707{
708 let name = match job_name(plan) {
709 Ok(name) => name,
710 Err(response) => return response,
711 };
712 let request = match page_request(plan) {
713 Ok(request) => request,
714 Err(response) => return response,
715 };
716 match services.explorer.list_instances(&name, &request).await {
719 Err(error) => explorer_failure(plan, &error),
720 Ok(page) => {
721 let Some(instance) = page.rows().first() else {
722 return not_found(plan, "job");
723 };
724 match services
725 .explorer
726 .list_executions(instance.id(), &request)
727 .await
728 {
729 Err(error) => explorer_failure(plan, &error),
730 Ok(executions) => {
731 let definition = executions.rows().first().map_or(Value::Null, |execution| {
732 project::execution(execution)["definition"].clone()
733 });
734 Response::success(
735 plan.command,
736 json!({
737 "job_name": name.as_str(),
738 "newest_instance_id": instance.id().get(),
739 "definition": definition,
740 }),
741 )
742 }
743 }
744 }
745 }
746}
747
748async fn instance_show<R, S>(plan: &Plan, services: &Services<R, S>) -> Response
749where
750 R: JobRepository,
751 S: ExplorerRepository,
752{
753 let id = match instance_id(plan) {
754 Ok(id) => id,
755 Err(response) => return response,
756 };
757 let request = match page_request(plan) {
758 Ok(request) => request,
759 Err(response) => return response,
760 };
761 match services.explorer.list_executions(id, &request).await {
762 Err(error) => explorer_failure(plan, &error),
763 Ok(page) => match page.rows().first() {
764 None => not_found(plan, "instance"),
765 Some(execution) => {
766 let name = execution.job_name().clone();
767 match services.explorer.list_instances(&name, &request).await {
768 Err(error) => explorer_failure(plan, &error),
769 Ok(instances) => instances
770 .rows()
771 .iter()
772 .find(|candidate| candidate.id() == id)
773 .map_or_else(
774 || not_found(plan, "instance"),
775 |instance| Response::success(plan.command, project::instance(instance)),
776 ),
777 }
778 }
779 },
780 }
781}
782
783async fn execution_list<R, S>(plan: &Plan, services: &Services<R, S>) -> Response
784where
785 R: JobRepository,
786 S: ExplorerRepository,
787{
788 let request = match page_request(plan) {
789 Ok(request) => request,
790 Err(response) => return response,
791 };
792 if let Some(age) = &plan.arguments.unresolved_age {
793 let Some(minimum) = crate::config::parse_public_duration(age) else {
794 return usage(
795 plan,
796 "INVALID_AGE",
797 "the age bound must be an integer with a unit",
798 );
799 };
800 return match services
801 .explorer
802 .list_unresolved_executions(minimum, &request)
803 .await
804 {
805 Ok(page) => {
806 let rows = page.rows().iter().map(project::execution).collect();
807 paged(plan, &page, rows)
808 }
809 Err(error) => explorer_failure(plan, &error),
810 };
811 }
812 let id = match instance_id(plan) {
813 Ok(id) => id,
814 Err(response) => return response,
815 };
816 match services.explorer.list_executions(id, &request).await {
817 Ok(page) => {
818 let rows = page.rows().iter().map(project::execution).collect();
819 paged(plan, &page, rows)
820 }
821 Err(error) => explorer_failure(plan, &error),
822 }
823}
824
825async fn execution_history<R, S>(plan: &Plan, services: &Services<R, S>) -> Response
826where
827 R: JobRepository,
828 S: ExplorerRepository,
829{
830 let id = match execution_id(plan) {
831 Ok(id) => id,
832 Err(response) => return response,
833 };
834 let request = match page_request(plan) {
835 Ok(request) => request,
836 Err(response) => return response,
837 };
838 match plan.arguments.record.unwrap_or_default() {
839 RecordArg::Operator => match services.explorer.list_operator_requests(id, &request).await {
840 Ok(page) => {
841 let rows = page.rows().iter().map(project::operator_record).collect();
842 paged(plan, &page, rows)
843 }
844 Err(error) => explorer_failure(plan, &error),
845 },
846 RecordArg::Recovery => match services
847 .explorer
848 .list_recovery_decisions(id, &request)
849 .await
850 {
851 Ok(page) => {
852 let rows = page.rows().iter().map(project::recovery_decision).collect();
853 paged(plan, &page, rows)
854 }
855 Err(error) => explorer_failure(plan, &error),
856 },
857 RecordArg::Flow => match services.explorer.list_flow_decisions(id, &request).await {
858 Ok(page) => {
859 let rows = page.rows().iter().map(project::flow_decision).collect();
860 paged(plan, &page, rows)
861 }
862 Err(error) => explorer_failure(plan, &error),
863 },
864 }
865}
866
867fn operation_id(plan: &Plan) -> Result<OperationId, Response> {
868 let raw = plan.operation_id.as_deref().ok_or_else(|| {
869 usage(
870 plan,
871 "MISSING_OPERATION_ID",
872 "the command requires --operation-id",
873 )
874 })?;
875 OperationId::new(raw).map_err(|error| usage(plan, "INVALID_OPERATION_ID", &error.to_string()))
876}
877
878fn actor(plan: &Plan) -> Result<ActorRef, Response> {
879 let raw = plan
880 .arguments
881 .actor
882 .as_deref()
883 .ok_or_else(|| usage(plan, "MISSING_ACTOR", "the command requires --actor"))?;
884 ActorRef::new(raw).map_err(|error| usage(plan, "INVALID_ACTOR", &error.to_string()))
885}
886
887fn reason(plan: &Plan) -> Result<ReasonCode, Response> {
888 let raw = plan
889 .arguments
890 .reason
891 .as_deref()
892 .ok_or_else(|| usage(plan, "MISSING_REASON", "the command requires --reason"))?;
893 ReasonCode::new(raw).map_err(|error| usage(plan, "INVALID_REASON", &error.to_string()))
894}
895
896fn expected_version(plan: &Plan) -> Result<ExecutionVersion, Response> {
897 plan.arguments
898 .expected_version
899 .map(ExecutionVersion::new)
900 .ok_or_else(|| {
901 usage(
902 plan,
903 "MISSING_EXPECTED_VERSION",
904 "the command requires --expected-version",
905 )
906 })
907}
908
909fn definition_for(
910 plan: &Plan,
911 catalog: &DefinitionCatalog,
912 name: &JobName,
913) -> Result<DefinitionIdentity, Response> {
914 catalog.get(name).cloned().ok_or_else(|| {
915 Response::failed(plan.command, ExitCategory::GuardRejected, Value::Null).with_diagnostic(
916 Diagnostic::new(
917 "JOB_NOT_REGISTERED",
918 "the job is not registered in this binary's definition catalog",
919 ),
920 )
921 })
922}
923
924fn launch_parameters<H: Host>(
930 host: &H,
931 arguments: &Arguments,
932) -> Result<JobParameters, &'static str> {
933 let mut parameters = JobParameters::new();
934 if let Some(path) = &arguments.parameters_file {
935 let bytes = host
936 .read_file(path)
937 .map_err(|_| "the parameter file is unreadable")?;
938 let document: Value =
939 serde_json::from_slice(&bytes).map_err(|_| "the parameter file is not valid JSON")?;
940 let Value::Object(entries) = document else {
941 return Err("the parameter file must be a JSON object");
942 };
943 for (name, entry) in entries {
944 let parameter = typed_parameter(&entry)?;
945 let name = ParameterName::new(&name).map_err(|_| "a parameter name is not valid")?;
946 parameters
947 .insert(name, parameter)
948 .map_err(|_| "a parameter name is duplicated")?;
949 }
950 }
951 for (name, value) in &arguments.parameters {
952 let name =
953 ParameterName::new(name.as_str()).map_err(|_| "a parameter name is not valid")?;
954 let value =
955 ParameterValue::string(value.clone()).map_err(|_| "a parameter value is too long")?;
956 parameters
957 .insert(name, JobParameter::new(value, ParameterRole::Identifying))
958 .map_err(|_| "a parameter name is duplicated")?;
959 }
960 Ok(parameters)
961}
962
963fn typed_parameter(entry: &Value) -> Result<JobParameter, &'static str> {
965 const INVALID: &str = "a parameter entry is not a valid typed value";
966 let object = entry.as_object().ok_or(INVALID)?;
967 let kind = object.get("type").and_then(Value::as_str).ok_or(INVALID)?;
968 let raw = object.get("value").ok_or(INVALID)?;
969 let value = match kind {
970 "string" => ParameterValue::string(raw.as_str().ok_or(INVALID)?).map_err(|_| INVALID)?,
971 "i64" => ParameterValue::from(raw.as_i64().ok_or(INVALID)?),
972 "u64" => ParameterValue::from(raw.as_u64().ok_or(INVALID)?),
973 "bool" => ParameterValue::from(raw.as_bool().ok_or(INVALID)?),
974 _ => return Err(INVALID),
975 };
976 let role = match object.get("role").and_then(Value::as_str) {
977 None | Some("identifying") => ParameterRole::Identifying,
978 Some("non_identifying") => ParameterRole::NonIdentifying,
979 Some(_) => return Err(INVALID),
980 };
981 Ok(JobParameter::new(value, role))
982}
983
984async fn operator_command<R, S>(
985 plan: &Plan,
986 services: &Services<R, S>,
987 catalog: &DefinitionCatalog,
988) -> Response
989where
990 R: JobRepository,
991 S: ExplorerRepository,
992{
993 let recovery = if matches!(plan.command, Command::ExecutionRecover) {
994 match current_recovery_proposal(plan, services).await {
995 Ok(proposal) => Some(proposal),
996 Err(response) => return response,
997 }
998 } else {
999 None
1000 };
1001 let request = match build_operator_request(plan, catalog, recovery) {
1002 Ok(request) => request,
1003 Err(response) => return response,
1004 };
1005 if plan.arguments.dry_run {
1006 return Response::success(
1007 plan.command,
1008 json!({
1009 "dry_run": true,
1010 "action": request.action().as_str(),
1011 "operation_id": plan.operation_id,
1012 "request_digest": request.digest().to_hex(),
1013 "applied": false,
1014 }),
1015 )
1016 .with_diagnostic(Diagnostic::new(
1017 "DRY_RUN",
1018 "the request was validated and no durable change was made",
1019 ));
1020 }
1021 match services.operator.execute(&request).await {
1022 Ok(outcome) => operator_response(plan, &outcome),
1023 Err(error) => Response::failed(plan.command, failure::operator(&error), Value::Null)
1024 .with_diagnostic(failure::operator_diagnostic(&error)),
1025 }
1026}
1027
1028fn operator_response(plan: &Plan, outcome: &OperatorOutcome) -> Response {
1029 let data = json!({
1030 "outcome": outcome.class().as_str(),
1031 "changed": outcome.changed_state(),
1032 "operation_id": plan.operation_id,
1033 "record": project::operator_record(outcome.record()),
1034 "execution": outcome
1035 .execution()
1036 .map_or(Value::Null, |execution| json!({
1037 "execution_id": execution.id().get(),
1038 "status": execution.metadata().status().as_str(),
1039 "version": execution.version().get(),
1040 })),
1041 });
1042 match outcome.rejection() {
1043 None => {
1044 let response = Response::success(plan.command, data);
1045 if matches!(outcome.class(), OperatorOutcomeClass::Replayed) {
1046 response.with_diagnostic(Diagnostic::new(
1047 "REPLAYED",
1048 "the recorded outcome of this operation identifier was returned",
1049 ))
1050 } else {
1051 response
1052 }
1053 }
1054 Some(rejection) => Response::failed(plan.command, failure::rejection(rejection), data)
1055 .with_diagnostic(Diagnostic::new(
1056 "GUARD_REJECTED",
1057 format!("the action was rejected as {rejection}"),
1058 )),
1059 }
1060}
1061
1062fn build_operator_request(
1063 plan: &Plan,
1064 catalog: &DefinitionCatalog,
1065 recovery: Option<RecoveryProposal>,
1066) -> Result<OperatorRequest, Response> {
1067 let operation = operation_id(plan)?;
1068 let who = actor(plan)?;
1069 match plan.command {
1070 Command::Launch => {
1071 let name = job_name(plan)?;
1072 let definition = definition_for(plan, catalog, &name)?;
1073 let key = JobInstanceKey::new(name, &plan.parameters);
1074 Ok(OperatorRequest::launch(operation, who, key, definition))
1075 }
1076 Command::ExecutionRestart => {
1077 let id = instance_id(plan)?;
1078 let name = match &plan.arguments.job {
1079 Some(_) => job_name(plan)?,
1080 None => {
1081 return Err(usage(
1082 plan,
1083 "MISSING_JOB",
1084 "a restart requires --job to select the registered definition",
1085 ));
1086 }
1087 };
1088 let definition = definition_for(plan, catalog, &name)?;
1089 Ok(OperatorRequest::restart(operation, who, id, definition))
1090 }
1091 Command::ExecutionStop => {
1092 let id = execution_id(plan)?;
1093 let version = expected_version(plan)?;
1094 Ok(OperatorRequest::stop(operation, who, id, version))
1095 }
1096 Command::ExecutionAbandon => {
1097 let id = execution_id(plan)?;
1098 let version = expected_version(plan)?;
1099 let why = reason(plan)?;
1100 Ok(OperatorRequest::abandon(operation, who, why, id, version))
1101 }
1102 Command::ExecutionRecover => {
1103 let why = reason(plan)?;
1104 let directive = recovery_directive(plan)?;
1105 let proposal = recovery.ok_or_else(|| {
1106 usage(
1107 plan,
1108 "RECOVERY_EVIDENCE_UNAVAILABLE",
1109 "the command has no current recovery proposal",
1110 )
1111 })?;
1112 Ok(OperatorRequest::recover(
1113 operation, who, why, directive, &proposal,
1114 ))
1115 }
1116 _ => Err(usage(
1117 plan,
1118 "UNSUPPORTED",
1119 "the command is not an operator action",
1120 )),
1121 }
1122}
1123
1124async fn current_recovery_proposal<R, S>(
1125 plan: &Plan,
1126 services: &Services<R, S>,
1127) -> Result<RecoveryProposal, Response>
1128where
1129 R: JobRepository,
1130 S: ExplorerRepository,
1131{
1132 let id = execution_id(plan)?;
1133 let expected = expected_version(plan)?;
1134 let supplied = evidence_digest(plan)?;
1135 let proposal = services.recovery.propose(id).await.map_err(|error| {
1136 let (category, code, message) = match &error {
1137 RecoveryError::Repository(RepositoryError::JobExecutionNotFound { .. }) => (
1138 ExitCategory::TargetNotFound,
1139 "EXECUTION_NOT_FOUND",
1140 "the execution does not exist",
1141 ),
1142 RecoveryError::Repository(repository) => (
1143 failure::repository(repository),
1144 "RECOVERY_EVIDENCE_UNAVAILABLE",
1145 "the repository could not produce recovery evidence",
1146 ),
1147 RecoveryError::ClockEvidenceUnusable => (
1148 ExitCategory::GuardRejected,
1149 "CLOCK_EVIDENCE_UNUSABLE",
1150 "repository and local clocks cannot provide usable recovery evidence",
1151 ),
1152 RecoveryError::OwnedByCurrentProcess => (
1153 ExitCategory::GuardRejected,
1154 "EXECUTION_OWNED",
1155 "the execution is owned by the inspecting process",
1156 ),
1157 RecoveryError::NotStale { .. } => (
1158 ExitCategory::GuardRejected,
1159 "EXECUTION_NOT_STALE",
1160 "the execution has not crossed the configured stale threshold",
1161 ),
1162 RecoveryError::NotRecoverable { .. } => (
1163 ExitCategory::GuardRejected,
1164 "RECOVERY_NOT_ALLOWED",
1165 "the execution is not a recovery candidate",
1166 ),
1167 RecoveryError::InvalidStaleThreshold | RecoveryError::InvalidMaxClockSkew => (
1168 ExitCategory::ConfigurationInvalid,
1169 "RECOVERY_CONFIG_INVALID",
1170 "the recovery evidence configuration is invalid",
1171 ),
1172 _ => (
1173 ExitCategory::Internal,
1174 "RECOVERY_INTERNAL",
1175 "recovery evidence failed with an unrecognized category",
1176 ),
1177 };
1178 Response::failed(plan.command, category, Value::Null)
1179 .with_diagnostic(Diagnostic::new(code, message))
1180 })?;
1181 if proposal.observed_version() != expected {
1182 return Err(Response::failed(
1183 plan.command,
1184 ExitCategory::OptimisticConflict,
1185 json!({"current_version": proposal.observed_version().get()}),
1186 )
1187 .with_diagnostic(Diagnostic::new(
1188 "RECOVERY_EVIDENCE_STALE",
1189 "the supplied version does not match the recovery evidence",
1190 )));
1191 }
1192 if proposal.digest() != &supplied {
1193 return Err(
1194 Response::failed(plan.command, ExitCategory::GuardRejected, Value::Null)
1195 .with_diagnostic(Diagnostic::new(
1196 "RECOVERY_EVIDENCE_STALE",
1197 "the supplied evidence digest does not match the current proposal",
1198 )),
1199 );
1200 }
1201 Ok(proposal)
1202}
1203
1204fn recovery_directive(plan: &Plan) -> Result<RecoveryDirective, Response> {
1205 match plan.arguments.directive {
1206 Some(DirectiveArg::Abandon) => Ok(RecoveryDirective::Abandon),
1207 Some(DirectiveArg::MarkFailed) => {
1208 let category = plan.arguments.failure_category.as_deref().ok_or_else(|| {
1209 usage(
1210 plan,
1211 "MISSING_FAILURE_CATEGORY",
1212 "a mark-failed directive requires --failure-category",
1213 )
1214 })?;
1215 let category = parse_failure_category(category).ok_or_else(|| {
1216 usage(
1217 plan,
1218 "INVALID_FAILURE_CATEGORY",
1219 "the failure category is not a framework category",
1220 )
1221 })?;
1222 let id = plan.arguments.failure_id.ok_or_else(|| {
1223 usage(
1224 plan,
1225 "MISSING_FAILURE_ID",
1226 "a mark-failed directive requires --failure-id",
1227 )
1228 })?;
1229 let id = FailureId::new(id).map_err(|_| {
1230 usage(
1231 plan,
1232 "INVALID_FAILURE_ID",
1233 "the failure identifier is not valid",
1234 )
1235 })?;
1236 Ok(RecoveryDirective::MarkFailed(FailureSummary::new(
1237 category, id,
1238 )))
1239 }
1240 None => Err(usage(
1241 plan,
1242 "MISSING_DIRECTIVE",
1243 "the command requires --directive",
1244 )),
1245 }
1246}
1247
1248fn parse_failure_category(value: &str) -> Option<FailureCategory> {
1250 [
1251 FailureCategory::InvalidDefinition,
1252 FailureCategory::DuplicateExecution,
1253 FailureCategory::IllegalTransition,
1254 FailureCategory::TransientInfrastructure,
1255 FailureCategory::PermanentInfrastructure,
1256 FailureCategory::UserComponent,
1257 FailureCategory::Cancelled,
1258 FailureCategory::Serialization,
1259 FailureCategory::Invariant,
1260 FailureCategory::OptimisticConflict,
1261 FailureCategory::Timeout,
1262 FailureCategory::UnsupportedCapability,
1263 FailureCategory::UnknownCommit,
1264 FailureCategory::ShutdownIncomplete,
1265 FailureCategory::StaleRecovered,
1266 ]
1267 .into_iter()
1268 .find(|candidate| candidate.as_str() == value)
1269}
1270
1271fn evidence_digest(plan: &Plan) -> Result<[u8; 32], Response> {
1272 let raw = plan.arguments.evidence_digest.as_deref().ok_or_else(|| {
1273 usage(
1274 plan,
1275 "MISSING_EVIDENCE",
1276 "the command requires --evidence-digest",
1277 )
1278 })?;
1279 decode_digest(raw).ok_or_else(|| {
1280 usage(
1281 plan,
1282 "INVALID_EVIDENCE",
1283 "the evidence digest must be 64 hexadecimal characters",
1284 )
1285 })
1286}
1287
1288fn decode_digest(value: &str) -> Option<[u8; 32]> {
1290 if value.len() != 64 {
1291 return None;
1292 }
1293 let mut bytes = [0_u8; 32];
1294 for (index, slot) in bytes.iter_mut().enumerate() {
1295 let start = index * 2;
1296 let pair = value.get(start..start + 2)?;
1297 *slot = u8::from_str_radix(pair, 16).ok()?;
1298 }
1299 Some(bytes)
1300}
1301
1302async fn retention_plan<R, S>(plan: &Plan, services: &Services<R, S>) -> Response
1303where
1304 R: JobRepository,
1305 S: ExplorerRepository,
1306{
1307 let request = match build_purge_request(plan) {
1308 Ok(request) => request,
1309 Err(response) => return response,
1310 };
1311 match services.retention.plan_purge(&request).await {
1312 Ok(purge) => Response::success(plan.command, project::purge_plan(&purge)),
1313 Err(error) => Response::failed(plan.command, failure::retention(&error), Value::Null)
1314 .with_diagnostic(failure::retention_diagnostic(&error)),
1315 }
1316}
1317
1318async fn retention_apply<R, S>(plan: &Plan, services: &Services<R, S>) -> Response
1319where
1320 R: JobRepository,
1321 S: ExplorerRepository,
1322{
1323 let request = match build_purge_request(plan) {
1324 Ok(request) => request,
1325 Err(response) => return response,
1326 };
1327 let purge = match services.retention.plan_purge(&request).await {
1328 Ok(purge) => purge,
1329 Err(error) => {
1330 return Response::failed(plan.command, failure::retention(&error), Value::Null)
1331 .with_diagnostic(failure::retention_diagnostic(&error));
1332 }
1333 };
1334 let expected = match &plan.arguments.plan_digest {
1337 Some(digest) => digest.clone(),
1338 None => {
1339 return usage(
1340 plan,
1341 "MISSING_PLAN_DIGEST",
1342 "the command requires --plan-digest from a prior retention plan",
1343 );
1344 }
1345 };
1346 if !expected.eq_ignore_ascii_case(&purge.digest_hex()) {
1347 return Response::failed(
1348 plan.command,
1349 ExitCategory::OptimisticConflict,
1350 json!({ "observed_plan_digest": purge.digest_hex() }),
1351 )
1352 .with_diagnostic(Diagnostic::new(
1353 "PLAN_STALE",
1354 "the observed plan digest does not match the supplied digest; nothing was deleted",
1355 ));
1356 }
1357 if plan.arguments.dry_run {
1358 return Response::success(
1359 plan.command,
1360 json!({
1361 "dry_run": true,
1362 "plan": project::purge_plan(&purge),
1363 "applied": false,
1364 }),
1365 )
1366 .with_diagnostic(Diagnostic::new(
1367 "DRY_RUN",
1368 "the plan was validated and no durable change was made",
1369 ));
1370 }
1371 let operation = match operation_id(plan) {
1372 Ok(value) => value,
1373 Err(response) => return response,
1374 };
1375 let who = match actor(plan) {
1376 Ok(value) => value,
1377 Err(response) => return response,
1378 };
1379 let why = match reason(plan) {
1380 Ok(value) => value,
1381 Err(response) => return response,
1382 };
1383 match services
1384 .retention
1385 .apply_purge(operation, who, why, &purge)
1386 .await
1387 {
1388 Ok(report) => Response::success(
1389 plan.command,
1390 json!({
1391 "outcome": report.outcome().as_str(),
1392 "operation_id": plan.operation_id,
1393 "counts": project::purge_counts(report.counts()),
1394 "record": project::retention_record(report.record()),
1395 }),
1396 ),
1397 Err(error) => Response::failed(plan.command, failure::retention(&error), Value::Null)
1398 .with_diagnostic(failure::retention_diagnostic(&error)),
1399 }
1400}
1401
1402fn build_purge_request(plan: &Plan) -> Result<PurgePlanRequest, Response> {
1403 let name = job_name(plan)?;
1404 let age = plan
1405 .arguments
1406 .older_than
1407 .as_deref()
1408 .and_then(crate::config::parse_public_duration)
1409 .ok_or_else(|| {
1410 usage(
1411 plan,
1412 "MISSING_AGE",
1413 "the command requires --older-than with an integer and a unit",
1414 )
1415 })?;
1416 let batch = match &plan.arguments.batch {
1417 None => PurgeBatchBound::default(),
1418 Some(raw) => {
1419 let parsed: u32 = raw
1420 .parse()
1421 .map_err(|_| usage(plan, "INVALID_BATCH", "the batch bound is not an integer"))?;
1422 PurgeBatchBound::new(parsed).map_err(|error| {
1423 Response::failed(plan.command, failure::retention(&error), Value::Null)
1424 .with_diagnostic(failure::retention_diagnostic(&error))
1425 })?
1426 }
1427 };
1428 let statuses = if plan.arguments.status.is_empty() {
1429 TerminalStatusSet::all()
1430 } else {
1431 let mut parsed = Vec::with_capacity(plan.arguments.status.len());
1432 for raw in &plan.arguments.status {
1433 let status = parse_status(raw)
1434 .ok_or_else(|| usage(plan, "INVALID_STATUS", "the status is not a batch status"))?;
1435 parsed.push(status);
1436 }
1437 TerminalStatusSet::new(parsed).map_err(|error| {
1438 Response::failed(plan.command, failure::retention(&error), Value::Null)
1439 .with_diagnostic(failure::retention_diagnostic(&error))
1440 })?
1441 };
1442 PurgePlanRequest::new(name, statuses, age, batch).map_err(|error| {
1443 Response::failed(plan.command, failure::retention(&error), Value::Null)
1444 .with_diagnostic(failure::retention_diagnostic(&error))
1445 })
1446}
1447
1448fn parse_status(value: &str) -> Option<BatchStatus> {
1450 [
1451 BatchStatus::Starting,
1452 BatchStatus::Started,
1453 BatchStatus::Stopping,
1454 BatchStatus::Stopped,
1455 BatchStatus::Failed,
1456 BatchStatus::Completed,
1457 BatchStatus::Abandoned,
1458 BatchStatus::Unknown,
1459 ]
1460 .into_iter()
1461 .find(|candidate| candidate.as_str() == value)
1462}
1463
1464async fn retention_hold<R, S>(plan: &Plan, services: &Services<R, S>) -> Response
1465where
1466 R: JobRepository,
1467 S: ExplorerRepository,
1468{
1469 let id = match instance_id(plan) {
1470 Ok(id) => id,
1471 Err(response) => return response,
1472 };
1473 let operation = match operation_id(plan) {
1474 Ok(value) => value,
1475 Err(response) => return response,
1476 };
1477 let who = match actor(plan) {
1478 Ok(value) => value,
1479 Err(response) => return response,
1480 };
1481 let why = match reason(plan) {
1482 Ok(value) => value,
1483 Err(response) => return response,
1484 };
1485 let result = if matches!(plan.command, Command::RetentionHold) {
1486 services.retention.place_hold(operation, who, why, id).await
1487 } else {
1488 services
1489 .retention
1490 .release_hold(operation, who, why, id)
1491 .await
1492 };
1493 match result {
1494 Ok(report) => Response::success(
1495 plan.command,
1496 json!({
1497 "outcome": report.outcome().as_str(),
1498 "operation_id": plan.operation_id,
1499 "hold": report.hold().map_or(Value::Null, project::hold),
1500 "record": project::retention_record(report.record()),
1501 }),
1502 ),
1503 Err(error) => Response::failed(plan.command, failure::retention(&error), Value::Null)
1504 .with_diagnostic(failure::retention_diagnostic(&error)),
1505 }
1506}
1507
1508async fn schema_status<R, S>(plan: &Plan, services: &Services<R, S>) -> Response
1509where
1510 R: JobRepository,
1511 S: ExplorerRepository,
1512{
1513 match services.schema.schema_state().await {
1514 Ok(state) => Response::success(
1515 plan.command,
1516 json!({
1517 "installed": state.installed,
1518 "supported": state.supported,
1519 "migration_required": state.migration_required(),
1520 "newer_than_supported": state.newer_than_supported(),
1521 }),
1522 ),
1523 Err(error) => Response::failed(plan.command, failure::repository(&error), Value::Null)
1524 .with_diagnostic(failure::repository_diagnostic(&error)),
1525 }
1526}