Skip to main content

oxide_batch_cli/
run.rs

1//! Invocation orchestration.
2//!
3//! One invocation runs in three stages. [`prepare`] parses the closed grammar
4//! and resolves configuration without opening a connection, so a configuration
5//! error always exits before any repository is contacted. [`local`] answers the
6//! commands a repository is not required for. [`dispatch`] calls the portable
7//! services and writes the bounded result.
8//!
9//! Output is written only after a mutating command's durable effect is
10//! committed, and a write failure never causes a second mutating call.
11
12use 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/// The durable schema state one repository reports.
42#[derive(Clone, Copy, Debug, Eq, PartialEq)]
43pub struct SchemaState {
44    /// The schema version installed in the repository, when one is readable.
45    pub installed: Option<u32>,
46    /// The schema version this build supports.
47    pub supported: u32,
48}
49
50impl SchemaState {
51    /// Returns whether the installed schema requires migration.
52    #[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    /// Returns whether the installed schema is newer than this build supports.
61    #[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
70/// Reports the durable schema version without changing it.
71///
72/// `schema status` never migrates. A migration is a separate, privileged
73/// action of a dedicated migrator identity.
74pub trait SchemaReport: Send + Sync {
75    /// Reads the installed and supported schema versions.
76    fn schema_state(&self) -> BoxFuture<'_, Result<SchemaState, RepositoryError>>;
77}
78
79/// Produces the current evidence-bound recovery proposal for the CLI.
80pub trait RecoveryProposalPort: Send + Sync {
81    /// Gathers one proposal without changing durable state.
82    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/// A repository that reports no durable schema, such as the in-memory adapter.
116#[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
129/// The portable services one invocation calls.
130///
131/// The CLI owns no correctness rule of its own; every guard belongs to these
132/// services.
133pub 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    /// Binds already-constructed services.
144    #[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    /// Attaches the evidence source required by `execution recover`.
166    #[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/// One parsed and validated invocation awaiting dispatch.
174#[derive(Debug)]
175pub struct Plan {
176    command: Command,
177    arguments: Arguments,
178    config: Configuration,
179    writer: Writer,
180    operation_id: Option<String>,
181    /// Launch parameters, resolved while the host is still available and
182    /// before any repository connection is opened.
183    parameters: JobParameters,
184}
185
186impl Plan {
187    /// Returns the command this invocation selected.
188    #[must_use]
189    pub const fn command(&self) -> Command {
190        self.command
191    }
192
193    /// Borrows the effective configuration.
194    #[must_use]
195    pub const fn config(&self) -> &Configuration {
196        &self.config
197    }
198
199    /// Returns the client deadline this invocation requested.
200    #[must_use]
201    pub const fn timeout(&self) -> Duration {
202        self.config.client_timeout()
203    }
204}
205
206/// Parses the grammar and resolves configuration.
207///
208/// No repository connection is opened, so a usage or configuration error is
209/// always reported before any connection attempt.
210///
211/// # Errors
212///
213/// Returns the exit category of a rejected invocation. The rejection has
214/// already been written as a redacted diagnostic.
215pub 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    // The confirmation and non-interactive safeguards run before any
263    // connection is opened, so a refused destructive command contacts no
264    // repository at all.
265    authorize(host, &mut plan)?;
266    Ok(plan)
267}
268
269/// Answers the commands that require no repository connection.
270///
271/// Returns `None` when the command needs a repository.
272#[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
294/// Applies the confirmation and non-interactive safeguards.
295///
296/// Returns the resolved operation identifier, or the exit category of a
297/// refused invocation. Nothing is mutated when this returns an error.
298fn 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            // An automated caller must name the identifier it will replay
308            // after an ambiguous outcome, so the CLI never invents one it
309            // cannot report back on a broken pipe.
310            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
366/// Renders the exact target of a destructive command for confirmation.
367fn 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
390/// Runs one command against the portable services.
391///
392/// The supplied `deadline` future completes when the client deadline elapses.
393/// Passing a future that never completes disables the deadline; the process
394/// entry point supplies a timer.
395pub 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
427/// Writes one response and returns the exit category actually reported.
428fn 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            // The durable effect, if any, is already committed. The operation
433            // identifier lets the caller re-read or replay, so no mutating call
434            // is repeated to recover a display failure.
435            ExitCategory::OutputFailure
436        }
437    }
438}
439
440/// Writes a redacted diagnostic for a rejection that produced no response.
441///
442/// The code is omitted when it repeats the category, so a reader never sees the
443/// same word twice.
444fn 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
453/// Builds the bounded page request of a paginated command.
454fn 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
474/// Renders one page and its pagination fields.
475fn 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    // A job's definition identity is observable through the newest instance's
717    // newest attempt, because the repository records the identity it guarded.
718    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
924/// Builds the typed parameter set of a launch.
925///
926/// A parameter value is launch input rather than output, so it is read here and
927/// never rendered back. The file form carries the type and identity role that
928/// the `name=value` form cannot express.
929fn 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
963/// Reads one typed parameter entry of a parameter file.
964fn 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
1248/// Resolves one framework failure category by its stable name.
1249fn 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
1288/// Decodes a 32-byte digest written as lowercase or uppercase hexadecimal.
1289fn 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    // A destructive purge cannot be issued from arguments alone: the digest of
1335    // the plan the operator reviewed must still describe the same candidates.
1336    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
1448/// Resolves one batch status by its stable name.
1449fn 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}