cli

Command-line interface for Radroots
git clone https://radroots.dev/git/cli.git
Log | Files | Refs | README | LICENSE

commit 588e939e567f14affc7c02907b8d8c7fe3b24a89
parent a77ce55010d17778a5930964af0a815b3825962f
Author: triesap <tyson@radroots.org>
Date:   Sun, 26 Apr 2026 23:14:50 +0000

cli: back runtime operations with adapters

- add the runtime operation service for signer, relay, sync, runtime, and job operations
- delegate runtime actions to existing runtime management and bridge helpers
- preserve unconfigured and unavailable states in typed operation envelopes
- cover signer, relay, sync, job, and runtime status service paths with tests

Diffstat:
Msrc/main.rs | 1+
Asrc/operation_runtime.rs | 630+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 631 insertions(+), 0 deletions(-)

diff --git a/src/main.rs b/src/main.rs @@ -6,6 +6,7 @@ mod domain; mod operation_adapter; mod operation_core; mod operation_registry; +mod operation_runtime; mod output_contract; mod render; mod runtime; diff --git a/src/operation_runtime.rs b/src/operation_runtime.rs @@ -0,0 +1,630 @@ +#![allow(dead_code)] + +use serde::Serialize; +use serde_json::{Value, json}; + +use crate::cli::SyncWatchArgs; +use crate::operation_adapter::{ + JobGetRequest, JobGetResult, JobListRequest, JobListResult, JobWatchRequest, JobWatchResult, + OperationAdapterError, OperationRequest, OperationRequestData, OperationRequestPayload, + OperationResult, OperationResultData, OperationService, RelayListRequest, RelayListResult, + RuntimeConfigGetRequest, RuntimeConfigGetResult, RuntimeLogWatchRequest, RuntimeLogWatchResult, + RuntimeRestartRequest, RuntimeRestartResult, RuntimeStartRequest, RuntimeStartResult, + RuntimeStatusGetRequest, RuntimeStatusGetResult, RuntimeStopRequest, RuntimeStopResult, + SignerStatusGetRequest, SignerStatusGetResult, SyncPullRequest, SyncPullResult, + SyncPushRequest, SyncPushResult, SyncStatusGetRequest, SyncStatusGetResult, SyncWatchRequest, + SyncWatchResult, +}; +use crate::runtime::RuntimeError; +use crate::runtime::config::RuntimeConfig; +use crate::runtime::daemon::{self, DaemonRpcError}; +use crate::runtime::management::{ + RuntimeLifecycleAction, inspect_action, inspect_config_show, inspect_logs, inspect_status, +}; + +const DEFAULT_RUNTIME_ID: &str = "radrootsd"; + +pub struct RuntimeOperationService<'a> { + config: &'a RuntimeConfig, +} + +impl<'a> RuntimeOperationService<'a> { + pub fn new(config: &'a RuntimeConfig) -> Self { + Self { config } + } +} + +impl OperationService<SignerStatusGetRequest> for RuntimeOperationService<'_> { + type Result = SignerStatusGetResult; + + fn execute( + &self, + _request: OperationRequest<SignerStatusGetRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let view = crate::runtime::signer::resolve_signer_status(self.config); + serialized_operation_result::<SignerStatusGetResult, _>(&view) + } +} + +impl OperationService<RelayListRequest> for RuntimeOperationService<'_> { + type Result = RelayListResult; + + fn execute( + &self, + _request: OperationRequest<RelayListRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let view = crate::runtime::network::relay_list(self.config); + serialized_operation_result::<RelayListResult, _>(&view) + } +} + +impl OperationService<SyncStatusGetRequest> for RuntimeOperationService<'_> { + type Result = SyncStatusGetResult; + + fn execute( + &self, + _request: OperationRequest<SyncStatusGetRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let view = map_runtime(crate::runtime::sync::status(self.config))?; + serialized_operation_result::<SyncStatusGetResult, _>(&view) + } +} + +impl OperationService<SyncPullRequest> for RuntimeOperationService<'_> { + type Result = SyncPullResult; + + fn execute( + &self, + _request: OperationRequest<SyncPullRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let view = map_runtime(crate::runtime::sync::pull(self.config))?; + serialized_operation_result::<SyncPullResult, _>(&view) + } +} + +impl OperationService<SyncPushRequest> for RuntimeOperationService<'_> { + type Result = SyncPushResult; + + fn execute( + &self, + _request: OperationRequest<SyncPushRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let view = map_runtime(crate::runtime::sync::push(self.config))?; + serialized_operation_result::<SyncPushResult, _>(&view) + } +} + +impl OperationService<SyncWatchRequest> for RuntimeOperationService<'_> { + type Result = SyncWatchResult; + + fn execute( + &self, + request: OperationRequest<SyncWatchRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let args = SyncWatchArgs { + frames: usize_input(&request, "frames").unwrap_or(1), + interval_ms: u64_input(&request, "interval_ms").unwrap_or(1_000), + }; + let view = map_runtime(crate::runtime::sync::watch(self.config, &args))?; + serialized_operation_result::<SyncWatchResult, _>(&view) + } +} + +impl OperationService<RuntimeStatusGetRequest> for RuntimeOperationService<'_> { + type Result = RuntimeStatusGetResult; + + fn execute( + &self, + request: OperationRequest<RuntimeStatusGetRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let target = runtime_target(&request); + let inspection = map_runtime(inspect_status( + self.config, + target.runtime_id.as_str(), + target.instance_id.as_deref(), + ))?; + serialized_operation_result::<RuntimeStatusGetResult, _>(&inspection.view) + } +} + +impl OperationService<RuntimeStartRequest> for RuntimeOperationService<'_> { + type Result = RuntimeStartResult; + + fn execute( + &self, + request: OperationRequest<RuntimeStartRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + runtime_action::<RuntimeStartResult>(self.config, &request, RuntimeLifecycleAction::Start) + } +} + +impl OperationService<RuntimeStopRequest> for RuntimeOperationService<'_> { + type Result = RuntimeStopResult; + + fn execute( + &self, + request: OperationRequest<RuntimeStopRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + runtime_action::<RuntimeStopResult>(self.config, &request, RuntimeLifecycleAction::Stop) + } +} + +impl OperationService<RuntimeRestartRequest> for RuntimeOperationService<'_> { + type Result = RuntimeRestartResult; + + fn execute( + &self, + request: OperationRequest<RuntimeRestartRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + runtime_action::<RuntimeRestartResult>( + self.config, + &request, + RuntimeLifecycleAction::Restart, + ) + } +} + +impl OperationService<RuntimeLogWatchRequest> for RuntimeOperationService<'_> { + type Result = RuntimeLogWatchResult; + + fn execute( + &self, + request: OperationRequest<RuntimeLogWatchRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let target = runtime_target(&request); + let inspection = map_runtime(inspect_logs( + self.config, + target.runtime_id.as_str(), + target.instance_id.as_deref(), + ))?; + serialized_operation_result::<RuntimeLogWatchResult, _>(&inspection.view) + } +} + +impl OperationService<RuntimeConfigGetRequest> for RuntimeOperationService<'_> { + type Result = RuntimeConfigGetResult; + + fn execute( + &self, + request: OperationRequest<RuntimeConfigGetRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let target = runtime_target(&request); + let inspection = map_runtime(inspect_config_show( + self.config, + target.runtime_id.as_str(), + target.instance_id.as_deref(), + ))?; + serialized_operation_result::<RuntimeConfigGetResult, _>(&inspection.view) + } +} + +impl OperationService<JobListRequest> for RuntimeOperationService<'_> { + type Result = JobListResult; + + fn execute( + &self, + _request: OperationRequest<JobListRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + match daemon::bridge_job_list(self.config) { + Ok(jobs) => json_operation_result::<JobListResult>(json!({ + "state": if jobs.is_empty() { "empty" } else { "ready" }, + "source": daemon::bridge_source(), + "rpc_url": self.config.rpc.url, + "count": jobs.len(), + "reason": null, + "jobs": jobs, + "actions": [], + })), + Err(error) => job_error_result::<JobListResult>(self.config, error, None), + } + } +} + +impl OperationService<JobGetRequest> for RuntimeOperationService<'_> { + type Result = JobGetResult; + + fn execute( + &self, + request: OperationRequest<JobGetRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let job_id = required_string(&request, "job_id")?; + match daemon::bridge_job(self.config, job_id.as_str()) { + Ok(Some(job)) => json_operation_result::<JobGetResult>(json!({ + "state": "ready", + "source": daemon::bridge_source(), + "rpc_url": self.config.rpc.url, + "lookup": job_id, + "reason": null, + "job": job, + "actions": [], + })), + Ok(None) => json_operation_result::<JobGetResult>(json!({ + "state": "missing", + "source": daemon::bridge_source(), + "rpc_url": self.config.rpc.url, + "lookup": job_id, + "reason": format!("job `{job_id}` was not found in radrootsd"), + "job": null, + "actions": ["radroots job list"], + })), + Err(error) => job_error_result::<JobGetResult>(self.config, error, Some(job_id)), + } + } +} + +impl OperationService<JobWatchRequest> for RuntimeOperationService<'_> { + type Result = JobWatchResult; + + fn execute( + &self, + request: OperationRequest<JobWatchRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let job_id = required_string(&request, "job_id")?; + let interval_ms = u64_input(&request, "interval_ms").unwrap_or(1_000); + match daemon::bridge_job(self.config, job_id.as_str()) { + Ok(Some(job)) => json_operation_result::<JobWatchResult>(json!({ + "state": if job.terminal { job.state.as_str() } else { "watching" }, + "source": daemon::bridge_source(), + "rpc_url": self.config.rpc.url, + "job_id": job_id, + "interval_ms": interval_ms, + "reason": null, + "frames": [{ + "sequence": 1, + "observed_at_unix": job.completed_at_unix.unwrap_or(job.requested_at_unix), + "state": job.state, + "terminal": job.terminal, + "signer": job.signer, + "signer_session_id": job.signer_session_id, + "summary": job.relay_outcome_summary, + }], + "actions": [], + })), + Ok(None) => json_operation_result::<JobWatchResult>(json!({ + "state": "missing", + "source": daemon::bridge_source(), + "rpc_url": self.config.rpc.url, + "job_id": job_id, + "interval_ms": interval_ms, + "reason": format!("job `{job_id}` was not found in radrootsd"), + "frames": [], + "actions": ["radroots job list"], + })), + Err(error) => job_error_result::<JobWatchResult>(self.config, error, Some(job_id)), + } + } +} + +fn runtime_action<R>( + config: &RuntimeConfig, + request: &OperationRequest<impl OperationRequestPayload + OperationRequestData>, + action: RuntimeLifecycleAction, +) -> Result<OperationResult<R>, OperationAdapterError> +where + R: OperationResultData, +{ + let target = runtime_target(request); + let inspection = map_runtime(inspect_action( + config, + target.runtime_id.as_str(), + target.instance_id.as_deref(), + action, + ))?; + serialized_operation_result::<R, _>(&inspection.view) +} + +fn job_error_result<R>( + config: &RuntimeConfig, + error: DaemonRpcError, + lookup: Option<String>, +) -> Result<OperationResult<R>, OperationAdapterError> +where + R: OperationResultData, +{ + let (state, reason, actions) = match error { + DaemonRpcError::Unconfigured(reason) + | DaemonRpcError::Unauthorized(reason) + | DaemonRpcError::MethodUnavailable(reason) => ( + "unconfigured", + reason, + vec![ + "set RADROOTS_RPC_BEARER_TOKEN in .env or your shell", + "start radrootsd with bridge ingress enabled", + ], + ), + DaemonRpcError::External(reason) => ( + "unavailable", + reason, + vec!["start radrootsd and verify the rpc url"], + ), + DaemonRpcError::InvalidResponse(reason) + | DaemonRpcError::Remote(reason) + | DaemonRpcError::UnknownJob(reason) => ("error", reason, Vec::new()), + }; + json_operation_result::<R>(json!({ + "state": state, + "source": daemon::bridge_source(), + "rpc_url": config.rpc.url, + "lookup": lookup, + "reason": reason, + "count": 0, + "jobs": [], + "job": null, + "actions": actions, + })) +} + +fn serialized_operation_result<R, T>(value: &T) -> Result<OperationResult<R>, OperationAdapterError> +where + R: OperationResultData, + T: Serialize, +{ + OperationResult::new(R::from_serializable(value)?) +} + +fn json_operation_result<R>(value: Value) -> Result<OperationResult<R>, OperationAdapterError> +where + R: OperationResultData, +{ + OperationResult::new(R::from_value(value)) +} + +fn map_runtime<T>(result: Result<T, RuntimeError>) -> Result<T, OperationAdapterError> { + result.map_err(|error| OperationAdapterError::Runtime(error.to_string())) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct RuntimeTargetInput { + runtime_id: String, + instance_id: Option<String>, +} + +fn runtime_target<P>(request: &OperationRequest<P>) -> RuntimeTargetInput +where + P: OperationRequestPayload + OperationRequestData, +{ + RuntimeTargetInput { + runtime_id: string_input(request, "runtime_id") + .unwrap_or_else(|| DEFAULT_RUNTIME_ID.into()), + instance_id: string_input(request, "instance_id"), + } +} + +fn required_string<P>( + request: &OperationRequest<P>, + key: &str, +) -> Result<String, OperationAdapterError> +where + P: OperationRequestPayload + OperationRequestData, +{ + string_input(request, key).ok_or_else(|| OperationAdapterError::InvalidInput { + operation_id: request.operation_id().to_owned(), + message: format!("missing required `{key}` input"), + }) +} + +fn string_input<P>(request: &OperationRequest<P>, key: &str) -> Option<String> +where + P: OperationRequestPayload + OperationRequestData, +{ + request + .payload + .input() + .get(key) + .and_then(Value::as_str) + .map(str::to_owned) +} + +fn usize_input<P>(request: &OperationRequest<P>, key: &str) -> Option<usize> +where + P: OperationRequestPayload + OperationRequestData, +{ + request + .payload + .input() + .get(key) + .and_then(Value::as_u64) + .and_then(|value| usize::try_from(value).ok()) +} + +fn u64_input<P>(request: &OperationRequest<P>, key: &str) -> Option<u64> +where + P: OperationRequestPayload + OperationRequestData, +{ + request.payload.input().get(key).and_then(Value::as_u64) +} + +#[cfg(test)] +mod tests { + use std::path::{Path, PathBuf}; + + use radroots_runtime_paths::RadrootsMigrationReport; + use radroots_secret_vault::RadrootsSecretBackend; + use tempfile::tempdir; + + use super::RuntimeOperationService; + use crate::operation_adapter::{ + JobListRequest, OperationAdapter, OperationContext, OperationRequest, RelayListRequest, + RuntimeStatusGetRequest, SignerStatusGetRequest, SyncStatusGetRequest, + }; + use crate::runtime::config::{ + AccountConfig, AccountSecretContractConfig, HyfConfig, IdentityConfig, InteractionConfig, + LocalConfig, LoggingConfig, MigrationConfig, MycConfig, OutputConfig, OutputFormat, + PathsConfig, RelayConfig, RelayConfigSource, RelayPublishPolicy, RpcConfig, RuntimeConfig, + SignerBackend, SignerConfig, Verbosity, + }; + + #[test] + fn runtime_service_backs_signer_and_relay_status() { + let dir = tempdir().expect("tempdir"); + let config = sample_config(dir.path(), vec!["wss://relay.test".into()]); + let service = OperationAdapter::new(RuntimeOperationService::new(&config)); + + let signer = OperationRequest::new( + OperationContext::default(), + SignerStatusGetRequest::default(), + ) + .expect("signer status request"); + let signer_envelope = service + .execute(signer) + .expect("signer status result") + .to_envelope(OperationContext::default().envelope_context("req_signer")) + .expect("signer envelope"); + assert_eq!(signer_envelope.operation_id, "signer.status.get"); + assert_eq!(signer_envelope.result["state"], "unconfigured"); + + let relay = OperationRequest::new(OperationContext::default(), RelayListRequest::default()) + .expect("relay list request"); + let relay_envelope = service + .execute(relay) + .expect("relay list result") + .to_envelope(OperationContext::default().envelope_context("req_relay")) + .expect("relay envelope"); + assert_eq!(relay_envelope.operation_id, "relay.list"); + assert_eq!(relay_envelope.result["state"], "configured"); + assert_eq!(relay_envelope.result["count"], 1); + } + + #[test] + fn runtime_service_backs_sync_and_job_unavailable_states() { + let dir = tempdir().expect("tempdir"); + let config = sample_config(dir.path(), Vec::new()); + let service = OperationAdapter::new(RuntimeOperationService::new(&config)); + + let sync = + OperationRequest::new(OperationContext::default(), SyncStatusGetRequest::default()) + .expect("sync status request"); + let sync_envelope = service + .execute(sync) + .expect("sync status result") + .to_envelope(OperationContext::default().envelope_context("req_sync")) + .expect("sync envelope"); + assert_eq!(sync_envelope.operation_id, "sync.status.get"); + assert_eq!(sync_envelope.result["state"], "unconfigured"); + + let job = OperationRequest::new(OperationContext::default(), JobListRequest::default()) + .expect("job list request"); + let job_envelope = service + .execute(job) + .expect("job list result") + .to_envelope(OperationContext::default().envelope_context("req_job")) + .expect("job envelope"); + assert_eq!(job_envelope.operation_id, "job.list"); + assert_eq!(job_envelope.result["state"], "unconfigured"); + assert!(job_envelope.result["reason"].is_string()); + } + + #[test] + fn runtime_service_backs_runtime_status_default_target() { + let dir = tempdir().expect("tempdir"); + let config = sample_config(dir.path(), Vec::new()); + let service = OperationAdapter::new(RuntimeOperationService::new(&config)); + let request = OperationRequest::new( + OperationContext::default(), + RuntimeStatusGetRequest::default(), + ) + .expect("runtime status request"); + let envelope = service + .execute(request) + .expect("runtime status result") + .to_envelope(OperationContext::default().envelope_context("req_runtime")) + .expect("runtime envelope"); + + assert_eq!(envelope.operation_id, "runtime.status.get"); + assert_eq!(envelope.result["runtime_id"], "radrootsd"); + assert!(envelope.result["state"].is_string()); + } + + fn sample_config(root: &Path, relays: Vec<String>) -> RuntimeConfig { + let data = root.join("data"); + let logs = root.join("logs"); + let secrets = root.join("secrets"); + RuntimeConfig { + output: OutputConfig { + format: OutputFormat::Human, + verbosity: Verbosity::Normal, + color: true, + dry_run: false, + }, + interaction: InteractionConfig { + input_enabled: true, + assume_yes: false, + stdin_tty: false, + stdout_tty: false, + prompts_allowed: false, + confirmations_allowed: false, + }, + paths: PathsConfig { + profile: "interactive_user".into(), + profile_source: "test".into(), + allowed_profiles: vec!["interactive_user".into(), "repo_local".into()], + root_source: "test".into(), + repo_local_root: None, + repo_local_root_source: None, + subordinate_path_override_source: "runtime_config".into(), + app_namespace: "apps/cli".into(), + shared_accounts_namespace: "shared/accounts".into(), + shared_identities_namespace: "shared/identities".into(), + app_config_path: root.join("config/apps/cli/config.toml"), + workspace_config_path: None, + app_data_root: data.join("apps/cli"), + app_logs_root: logs.join("apps/cli"), + shared_accounts_data_root: data.join("shared/accounts"), + shared_accounts_secrets_root: secrets.join("shared/accounts"), + default_identity_path: secrets.join("shared/identities/default.json"), + }, + migration: MigrationConfig { + report: RadrootsMigrationReport::empty(), + }, + logging: LoggingConfig { + filter: "info".into(), + directory: None, + stdout: false, + }, + account: AccountConfig { + selector: None, + store_path: data.join("shared/accounts/store.json"), + secrets_dir: secrets.join("shared/accounts"), + secret_backend: RadrootsSecretBackend::EncryptedFile, + secret_fallback: None, + }, + account_secret_contract: AccountSecretContractConfig { + default_backend: "host_vault".into(), + default_fallback: Some("encrypted_file".into()), + allowed_backends: vec!["host_vault".into(), "encrypted_file".into()], + host_vault_policy: Some("desktop".into()), + uses_protected_store: true, + }, + identity: IdentityConfig { + path: secrets.join("shared/identities/default.json"), + }, + signer: SignerConfig { + backend: SignerBackend::Local, + }, + relay: RelayConfig { + urls: relays, + publish_policy: RelayPublishPolicy::Any, + source: RelayConfigSource::Defaults, + }, + local: LocalConfig { + root: data.join("apps/cli/replica"), + replica_db_path: data.join("apps/cli/replica/replica.sqlite"), + backups_dir: data.join("apps/cli/replica/backups"), + exports_dir: data.join("apps/cli/replica/exports"), + }, + myc: MycConfig { + executable: PathBuf::from("myc"), + status_timeout_ms: 2_000, + }, + hyf: HyfConfig { + enabled: false, + executable: PathBuf::from("hyfd"), + }, + rpc: RpcConfig { + url: "http://127.0.0.1:7070".into(), + bridge_bearer_token: None, + }, + capability_bindings: Vec::new(), + } + } +}