commit 26deb5b552da91da235c5dc42971c7e4295e993a
parent 9f8d72269b7ac7b27d801b2997edb52d03bf46b3
Author: triesap <tyson@radroots.org>
Date: Thu, 16 Apr 2026 20:26:50 +0000
implement workflow setup and status summaries
Diffstat:
13 files changed, 776 insertions(+), 106 deletions(-)
diff --git a/src/cli.rs b/src/cli.rs
@@ -1,6 +1,6 @@
use clap::{
- error::ErrorKind, ArgAction, Args, CommandFactory, FromArgMatches, Parser, Subcommand,
- ValueEnum,
+ ArgAction, Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum,
+ error::ErrorKind,
};
use std::ffi::{OsStr, OsString};
use std::path::PathBuf;
@@ -339,10 +339,9 @@ fn parse_output_format_value(value: &OsStr) -> Result<OutputFormatArg, clap::Err
fn long_option_name(arg: &OsStr) -> Option<&str> {
let token = arg.to_str()?;
- token.strip_prefix("--").map(|rest| {
- rest.split_once('=')
- .map_or(rest, |(flag, _value)| flag)
- })
+ token
+ .strip_prefix("--")
+ .map(|rest| rest.split_once('=').map_or(rest, |(flag, _value)| flag))
}
fn split_long_option(arg: &OsStr) -> Option<(&str, &OsStr)> {
@@ -615,7 +614,11 @@ pub struct AccountArgs {
#[derive(Debug, Clone, Subcommand)]
pub enum AccountCommand {
- #[command(name = "create", visible_alias = "new", about = "Create a local account")]
+ #[command(
+ name = "create",
+ visible_alias = "new",
+ about = "Create a local account"
+ )]
New,
#[command(
name = "view",
@@ -625,7 +628,11 @@ pub enum AccountCommand {
Whoami,
#[command(name = "list", visible_alias = "ls", about = "List local accounts")]
Ls,
- #[command(name = "select", visible_alias = "use", about = "Select a local account")]
+ #[command(
+ name = "select",
+ visible_alias = "use",
+ about = "Select a local account"
+ )]
Use(AccountUseArgs),
}
@@ -680,7 +687,11 @@ pub enum FarmCommand {
Publish(FarmPublishArgs),
#[command(about = "Create or update a farm draft in one command")]
Setup(FarmSetupArgs),
- #[command(name = "check", visible_alias = "status", about = "Check farm readiness")]
+ #[command(
+ name = "check",
+ visible_alias = "status",
+ about = "Check farm readiness"
+ )]
Status(FarmScopedArgs),
#[command(name = "show", visible_alias = "get", about = "Show the farm draft")]
Get(FarmScopedArgs),
@@ -996,7 +1007,11 @@ pub struct OrderArgs {
#[derive(Debug, Clone, Subcommand)]
pub enum OrderCommand {
- #[command(name = "create", visible_alias = "new", about = "Create a local order draft")]
+ #[command(
+ name = "create",
+ visible_alias = "new",
+ about = "Create a local order draft"
+ )]
New(OrderNewArgs),
#[command(name = "view", visible_alias = "get", about = "Show one order")]
Get(RecordKeyArgs),
@@ -1096,8 +1111,8 @@ mod tests {
AccountCommand, CliArgs, Command, ConfigCommand, FarmCommand, FarmScopeArg, JobCommand,
JobWatchArgs, ListingCommand, LocalCommand, LocalExportFormatArg, MarketCommand,
MycCommand, NetCommand, OrderCommand, OrderWatchArgs, OutputFormatArg, RelayCommand,
- RpcCommand, RuntimeCommand, RuntimeConfigCommand, SellCommand, SetupRoleArg,
- SignerCommand, SyncCommand, SyncWatchArgs,
+ RpcCommand, RuntimeCommand, RuntimeConfigCommand, SellCommand, SetupRoleArg, SignerCommand,
+ SyncCommand, SyncWatchArgs,
};
use crate::runtime::config::OutputFormat;
#[test]
@@ -1358,7 +1373,8 @@ mod tests {
_ => panic!("unexpected command variant"),
}
- let order_create = CliArgs::parse_from(["radroots", "order", "create", "--listing", "eggs"]);
+ let order_create =
+ CliArgs::parse_from(["radroots", "order", "create", "--listing", "eggs"]);
match order_create.command {
Command::Order(order) => match order.command {
OrderCommand::New(args) => assert_eq!(args.listing.as_deref(), Some("eggs")),
@@ -1953,15 +1969,21 @@ mod tests {
#[test]
fn command_contract_helpers_report_supported_modes() {
let config_show = CliArgs::parse_from(["radroots", "config", "show"]);
- assert!(config_show
- .command
- .supports_output_format(OutputFormat::Human));
- assert!(config_show
- .command
- .supports_output_format(OutputFormat::Json));
- assert!(!config_show
- .command
- .supports_output_format(OutputFormat::Ndjson));
+ assert!(
+ config_show
+ .command
+ .supports_output_format(OutputFormat::Human)
+ );
+ assert!(
+ config_show
+ .command
+ .supports_output_format(OutputFormat::Json)
+ );
+ assert!(
+ !config_show
+ .command
+ .supports_output_format(OutputFormat::Ndjson)
+ );
assert!(config_show.command.supports_dry_run());
let account_create = CliArgs::parse_from(["radroots", "account", "create"]);
@@ -1983,9 +2005,11 @@ mod tests {
let farm_check = CliArgs::parse_from(["radroots", "farm", "check"]);
assert_eq!(farm_check.command.display_name(), "farm check");
assert!(farm_check.command.supports_dry_run());
- assert!(!farm_check
- .command
- .supports_output_format(OutputFormat::Ndjson));
+ assert!(
+ !farm_check
+ .command
+ .supports_output_format(OutputFormat::Ndjson)
+ );
let farm_publish = CliArgs::parse_from(["radroots", "farm", "publish"]);
assert_eq!(farm_publish.command.display_name(), "farm publish");
@@ -1996,23 +2020,29 @@ mod tests {
let market_search = CliArgs::parse_from(["radroots", "market", "search", "eggs"]);
assert_eq!(market_search.command.display_name(), "market search");
- assert!(market_search
- .command
- .supports_output_format(OutputFormat::Ndjson));
+ assert!(
+ market_search
+ .command
+ .supports_output_format(OutputFormat::Ndjson)
+ );
let sync_watch = CliArgs::parse_from(["radroots", "sync", "watch", "--frames", "1"]);
- assert!(sync_watch
- .command
- .supports_output_format(OutputFormat::Ndjson));
+ assert!(
+ sync_watch
+ .command
+ .supports_output_format(OutputFormat::Ndjson)
+ );
let sell_add = CliArgs::parse_from(["radroots", "sell", "add"]);
assert_eq!(sell_add.command.display_name(), "sell add");
assert!(!sell_add.command.supports_dry_run());
let order_watch = CliArgs::parse_from(["radroots", "order", "watch", "ord_demo"]);
- assert!(order_watch
- .command
- .supports_output_format(OutputFormat::Ndjson));
+ assert!(
+ order_watch
+ .command
+ .supports_output_format(OutputFormat::Ndjson)
+ );
let order_submit = CliArgs::parse_from(["radroots", "order", "submit", "ord_demo"]);
assert_eq!(order_submit.command.display_name(), "order submit");
@@ -2029,8 +2059,10 @@ mod tests {
let runtime_status = CliArgs::parse_from(["radroots", "runtime", "status", "radrootsd"]);
assert_eq!(runtime_status.command.display_name(), "runtime status");
assert!(runtime_status.command.supports_dry_run());
- assert!(!runtime_status
- .command
- .supports_output_format(OutputFormat::Ndjson));
+ assert!(
+ !runtime_status
+ .command
+ .supports_output_format(OutputFormat::Ndjson)
+ );
}
}
diff --git a/src/commands/listing.rs b/src/commands/listing.rs
@@ -1,7 +1,7 @@
use crate::cli::{ListingFileArgs, ListingMutationArgs, ListingNewArgs, RecordKeyArgs};
use crate::domain::runtime::{CommandOutput, CommandView};
-use crate::runtime::config::RuntimeConfig;
use crate::runtime::RuntimeError;
+use crate::runtime::config::RuntimeConfig;
pub fn new(config: &RuntimeConfig, args: &ListingNewArgs) -> Result<CommandOutput, RuntimeError> {
let view = crate::runtime::listing::scaffold(config, args)?;
diff --git a/src/commands/mod.rs b/src/commands/mod.rs
@@ -13,6 +13,7 @@ pub mod rpc;
pub mod runtime;
pub mod signer;
pub mod sync;
+pub mod workflow;
use crate::cli::{
AccountCommand, Command, ConfigCommand, FarmCommand, JobCommand, ListingCommand, LocalCommand,
@@ -118,9 +119,7 @@ pub fn dispatch(
"`sell restock` will land in the draft-mutation slice; edit the draft file directly for now",
),
},
- Command::Setup(_setup) => planned_command(
- "`setup` will land in the workflow slice; use `account`, `local`, and `farm` directly for now",
- ),
+ Command::Setup(setup) => workflow::setup(config, setup),
Command::Runtime(runtime_command) => match &runtime_command.command {
RuntimeCommand::Install(args) => runtime::install(config, args),
RuntimeCommand::Uninstall(args) => runtime::uninstall(config, args),
@@ -134,9 +133,7 @@ pub fn dispatch(
RuntimeConfigCommand::Set(args) => runtime::config_set(config, args),
},
},
- Command::Status => planned_command(
- "`status` will land in the workflow slice; use `doctor` for readiness details right now",
- ),
+ Command::Status => workflow::status(config),
Command::Sync(sync) => match &sync.command {
SyncCommand::Status => sync::status(config),
SyncCommand::Pull => sync::pull(config),
diff --git a/src/commands/workflow.rs b/src/commands/workflow.rs
@@ -0,0 +1,44 @@
+use crate::cli::SetupArgs;
+use crate::domain::runtime::{
+ CommandDisposition, CommandOutput, CommandView, SetupView, StatusView,
+};
+use crate::runtime::RuntimeError;
+use crate::runtime::config::RuntimeConfig;
+
+pub fn setup(config: &RuntimeConfig, args: &SetupArgs) -> Result<CommandOutput, RuntimeError> {
+ let view = crate::runtime::workflow::setup(config, args.role)?;
+ Ok(setup_output(view))
+}
+
+pub fn status(config: &RuntimeConfig) -> Result<CommandOutput, RuntimeError> {
+ let view = crate::runtime::workflow::status(config)?;
+ Ok(status_output(view))
+}
+
+fn setup_output(view: SetupView) -> CommandOutput {
+ match view.disposition() {
+ CommandDisposition::Success => CommandOutput::success(CommandView::Setup(view)),
+ CommandDisposition::Unconfigured => CommandOutput::unconfigured(CommandView::Setup(view)),
+ CommandDisposition::ExternalUnavailable => {
+ CommandOutput::external_unavailable(CommandView::Setup(view))
+ }
+ CommandDisposition::Unsupported => CommandOutput::unsupported(CommandView::Setup(view)),
+ CommandDisposition::InternalError => {
+ CommandOutput::internal_error(CommandView::Setup(view))
+ }
+ }
+}
+
+fn status_output(view: StatusView) -> CommandOutput {
+ match view.disposition() {
+ CommandDisposition::Success => CommandOutput::success(CommandView::Status(view)),
+ CommandDisposition::Unconfigured => CommandOutput::unconfigured(CommandView::Status(view)),
+ CommandDisposition::ExternalUnavailable => {
+ CommandOutput::external_unavailable(CommandView::Status(view))
+ }
+ CommandDisposition::Unsupported => CommandOutput::unsupported(CommandView::Status(view)),
+ CommandDisposition::InternalError => {
+ CommandOutput::internal_error(CommandView::Status(view))
+ }
+ }
+}
diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs
@@ -118,7 +118,9 @@ pub enum CommandView {
RuntimeConfigShow(RuntimeManagedConfigView),
RuntimeLogs(RuntimeLogsView),
RuntimeStatus(RuntimeStatusView),
+ Setup(SetupView),
SignerStatus(SignerStatusView),
+ Status(StatusView),
SyncPull(SyncActionView),
SyncPush(SyncActionView),
SyncStatus(SyncStatusView),
@@ -650,6 +652,62 @@ pub struct LocalReplicaSyncView {
}
#[derive(Debug, Clone, Serialize)]
+pub struct SetupView {
+ pub state: String,
+ pub source: String,
+ pub role: String,
+ pub selected_account_id: String,
+ pub local_state: String,
+ pub local_root: String,
+ pub relay_state: String,
+ pub relay_count: usize,
+ pub farm_state: String,
+ #[serde(default)]
+ pub ready: Vec<String>,
+ #[serde(default)]
+ pub needs_attention: Vec<String>,
+ #[serde(default)]
+ pub next: Vec<String>,
+}
+
+impl SetupView {
+ pub fn disposition(&self) -> CommandDisposition {
+ match self.state.as_str() {
+ "unconfigured" => CommandDisposition::Unconfigured,
+ _ => CommandDisposition::Success,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct StatusView {
+ pub state: String,
+ pub source: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub selected_account_id: Option<String>,
+ pub local_state: String,
+ pub local_root: String,
+ pub relay_state: String,
+ pub relay_count: usize,
+ pub farm_state: String,
+ #[serde(default)]
+ pub ready: Vec<String>,
+ #[serde(default)]
+ pub needs_attention: Vec<String>,
+ #[serde(default)]
+ pub next: Vec<String>,
+}
+
+impl StatusView {
+ pub fn disposition(&self) -> CommandDisposition {
+ match self.state.as_str() {
+ "unconfigured" => CommandDisposition::Unconfigured,
+ _ => CommandDisposition::Success,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Serialize)]
pub struct FarmSetupView {
pub state: String,
pub source: String,
diff --git a/src/render/mod.rs b/src/render/mod.rs
@@ -8,11 +8,11 @@ use crate::domain::runtime::{
LocalInitView, LocalStatusView, NetStatusView, OrderCancelView, OrderDraftItemView,
OrderGetView, OrderHistoryView, OrderJobView, OrderListView, OrderNewView, OrderSubmitView,
OrderWatchView, OrderWorkflowView, RelayListView, RpcSessionsView, RpcStatusView,
- RuntimeActionView, RuntimeLogsView, RuntimeManagedConfigView, RuntimeStatusView,
- SyncActionView, SyncStatusView, SyncWatchView,
+ RuntimeActionView, RuntimeLogsView, RuntimeManagedConfigView, RuntimeStatusView, SetupView,
+ StatusView, SyncActionView, SyncStatusView, SyncWatchView,
};
-use crate::runtime::config::{OutputConfig, OutputFormat};
use crate::runtime::RuntimeError;
+use crate::runtime::config::{OutputConfig, OutputFormat};
const THIN_RULE: &str = "────────────────────────────────────────────────────";
@@ -178,6 +178,9 @@ fn render_human_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(),
CommandView::RuntimeStatus(view) => {
render_runtime_status(stdout, view)?;
}
+ CommandView::Setup(view) => {
+ render_setup(stdout, view)?;
+ }
CommandView::SignerStatus(view) => {
write_context(
stdout,
@@ -212,6 +215,9 @@ fn render_human_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(),
render_myc_status(stdout, myc, false)?;
}
}
+ CommandView::Status(view) => {
+ render_status_summary(stdout, view)?;
+ }
CommandView::SyncPull(view) => {
render_sync_action(stdout, view)?;
}
@@ -387,10 +393,18 @@ fn render_json_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(),
serde_json::to_writer_pretty(&mut *stdout, view)?;
writeln!(stdout)?;
}
+ CommandView::Setup(view) => {
+ serde_json::to_writer_pretty(&mut *stdout, view)?;
+ writeln!(stdout)?;
+ }
CommandView::SignerStatus(view) => {
serde_json::to_writer_pretty(&mut *stdout, view)?;
writeln!(stdout)?;
}
+ CommandView::Status(view) => {
+ serde_json::to_writer_pretty(&mut *stdout, view)?;
+ writeln!(stdout)?;
+ }
CommandView::SyncPull(view) => {
serde_json::to_writer_pretty(&mut *stdout, view)?;
writeln!(stdout)?;
@@ -496,19 +510,11 @@ fn render_ndjson_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<()
}
fn yes_no(value: bool) -> &'static str {
- if value {
- "yes"
- } else {
- "no"
- }
+ if value { "yes" } else { "no" }
}
fn present_absent(value: bool) -> &'static str {
- if value {
- "present"
- } else {
- "absent"
- }
+ if value { "present" } else { "absent" }
}
fn render_account_list(stdout: &mut dyn Write, view: &AccountListView) -> Result<(), RuntimeError> {
@@ -2454,6 +2460,81 @@ fn render_local_status(stdout: &mut dyn Write, view: &LocalStatusView) -> Result
Ok(())
}
+fn render_setup(stdout: &mut dyn Write, view: &SetupView) -> Result<(), RuntimeError> {
+ render_checklist_summary(
+ stdout,
+ match view.state.as_str() {
+ "unconfigured" => "Not ready yet",
+ _ => "Setup saved",
+ },
+ &view.ready,
+ &view.needs_attention,
+ &view.next,
+ )
+}
+
+fn render_status_summary(stdout: &mut dyn Write, view: &StatusView) -> Result<(), RuntimeError> {
+ render_checklist_summary(
+ stdout,
+ match view.state.as_str() {
+ "unconfigured" => "Not ready yet",
+ _ => "Status",
+ },
+ &view.ready,
+ &view.needs_attention,
+ &view.next,
+ )
+}
+
+fn render_checklist_summary(
+ stdout: &mut dyn Write,
+ headline: &str,
+ ready: &[String],
+ needs_attention: &[String],
+ next: &[String],
+) -> Result<(), RuntimeError> {
+ writeln!(stdout, "{headline}")?;
+
+ let mut wrote_section = false;
+ if !ready.is_empty() || !needs_attention.is_empty() || !next.is_empty() {
+ writeln!(stdout)?;
+ }
+
+ if !ready.is_empty() {
+ render_item_section(stdout, "Ready", ready)?;
+ wrote_section = true;
+ }
+
+ if !needs_attention.is_empty() {
+ if wrote_section {
+ writeln!(stdout)?;
+ }
+ render_item_section(stdout, "Needs attention", needs_attention)?;
+ wrote_section = true;
+ }
+
+ if !next.is_empty() {
+ if wrote_section {
+ writeln!(stdout)?;
+ }
+ render_item_section(stdout, "Next", next)?;
+ }
+
+ Ok(())
+}
+
+fn render_item_section(
+ stdout: &mut dyn Write,
+ title: &str,
+ items: &[String],
+) -> Result<(), RuntimeError> {
+ writeln!(stdout, "{title}")?;
+ for item in items {
+ writeln!(stdout, " {item}")?;
+ }
+ Ok(())
+}
+
fn render_local_backup(stdout: &mut dyn Write, view: &LocalBackupView) -> Result<(), RuntimeError> {
write_context(stdout, format!("local · {}", view.state).as_str())?;
let size_bytes = view.size_bytes.to_string();
@@ -2837,7 +2918,9 @@ fn human_command_name(view: &CommandView) -> &'static str {
CommandView::RuntimeConfigShow(_) => "runtime config show",
CommandView::RuntimeLogs(_) => "runtime logs",
CommandView::RuntimeStatus(_) => "runtime status",
+ CommandView::Setup(_) => "setup",
CommandView::SignerStatus(_) => "signer status",
+ CommandView::Status(_) => "status",
CommandView::SyncPull(_) => "sync pull",
CommandView::SyncPush(_) => "sync push",
CommandView::SyncStatus(_) => "sync status",
@@ -2847,15 +2930,15 @@ fn human_command_name(view: &CommandView) -> &'static str {
#[cfg(test)]
mod tests {
- use super::{render_human_to, render_ndjson_to, render_table, Table};
+ use super::{Table, render_human_to, render_ndjson_to, render_table};
use crate::commands::runtime;
use crate::domain::runtime::{
AccountListView, CommandOutput, CommandView, DoctorCheckView, DoctorView, MycStatusView,
RelayEntryView, RelayListView,
};
use crate::runtime::config::{
- AccountConfig, AccountSecretContractConfig, HyfConfig, IdentityConfig, LocalConfig,
- InteractionConfig, LoggingConfig, MigrationConfig, MycConfig, OutputConfig, OutputFormat,
+ AccountConfig, AccountSecretContractConfig, HyfConfig, IdentityConfig, InteractionConfig,
+ LocalConfig, LoggingConfig, MigrationConfig, MycConfig, OutputConfig, OutputFormat,
PathsConfig, RelayConfig, RelayConfigSource, RelayPublishPolicy, RpcConfig, RuntimeConfig,
SignerBackend, SignerConfig, Verbosity,
};
@@ -2976,10 +3059,11 @@ mod tests {
"/workspace/.radroots/config.toml"
);
assert_eq!(view.account.selector.as_deref(), Some("acct_demo"));
- assert!(view
- .account
- .store_path
- .ends_with(".radroots/data/shared/accounts/store.json"));
+ assert!(
+ view.account
+ .store_path
+ .ends_with(".radroots/data/shared/accounts/store.json")
+ );
assert_eq!(view.relay.count, 2);
assert_eq!(view.relay.publish_policy, "any");
assert!(!view.hyf.enabled);
@@ -2989,10 +3073,11 @@ mod tests {
view.account.secret_backend.contract_default_backend,
"host_vault"
);
- assert!(view
- .local
- .replica_db_path
- .ends_with(".radroots/data/apps/cli/replica/replica.sqlite"));
+ assert!(
+ view.local
+ .replica_db_path
+ .ends_with(".radroots/data/apps/cli/replica/replica.sqlite")
+ );
}
#[test]
@@ -3127,9 +3212,11 @@ mod tests {
));
let mut buffer = Vec::new();
let error = render_ndjson_to(&mut buffer, &output).expect_err("unsupported ndjson");
- assert!(error
- .to_string()
- .contains("`config show` does not support --ndjson"));
+ assert!(
+ error
+ .to_string()
+ .contains("`config show` does not support --ndjson")
+ );
}
#[test]
diff --git a/src/runtime/config.rs b/src/runtime/config.rs
@@ -1699,22 +1699,15 @@ mod tests {
.contains("--quiet, --verbose, and --trace")
);
- let conflicting_aliases = CliArgs::parse_from([
- "radroots",
- "--output",
- "json",
- "--json",
- "config",
- "show",
- ]);
- let error =
- RuntimeConfig::resolve_with_env_file(&conflicting_aliases, &env, &EnvFileValues::default())
- .expect_err("conflicting output aliases");
- assert!(
- error
- .to_string()
- .contains("--output, --json, and --ndjson")
- );
+ let conflicting_aliases =
+ CliArgs::parse_from(["radroots", "--output", "json", "--json", "config", "show"]);
+ let error = RuntimeConfig::resolve_with_env_file(
+ &conflicting_aliases,
+ &env,
+ &EnvFileValues::default(),
+ )
+ .expect_err("conflicting output aliases");
+ assert!(error.to_string().contains("--output, --json, and --ndjson"));
}
#[test]
diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs
@@ -7,6 +7,7 @@ use radroots_core::{
RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity,
RadrootsCoreQuantityPrice, RadrootsCoreUnit,
};
+use radroots_events::RadrootsNostrEvent;
use radroots_events::farm::RadrootsFarmRef;
use radroots_events::kinds::{KIND_LISTING, KIND_LISTING_DRAFT};
use radroots_events::listing::{
@@ -15,7 +16,6 @@ use radroots_events::listing::{
RadrootsListingStatus,
};
use radroots_events::trade::RadrootsTradeListingValidationError;
-use radroots_events::RadrootsNostrEvent;
use radroots_events_codec::d_tag::is_d_tag_base64url;
use radroots_events_codec::listing::encode::to_wire_parts_with_kind;
use radroots_replica_db::ReplicaSql;
@@ -30,14 +30,14 @@ use crate::domain::runtime::{
ListingMutationEventView, ListingMutationJobView, ListingMutationView, ListingNewView,
ListingValidateView, ListingValidationIssueView, SyncFreshnessView,
};
+use crate::runtime::RuntimeError;
use crate::runtime::accounts;
use crate::runtime::config::RuntimeConfig;
use crate::runtime::daemon;
use crate::runtime::daemon::DaemonRpcError;
use crate::runtime::farm_config;
-use crate::runtime::signer::{resolve_actor_write_authority, ActorWriteBindingError};
+use crate::runtime::signer::{ActorWriteBindingError, resolve_actor_write_authority};
use crate::runtime::sync::freshness_from_executor;
-use crate::runtime::RuntimeError;
const DRAFT_KIND: &str = "listing_draft_v1";
const LISTING_SOURCE: &str = "local draft · local first";
@@ -1451,7 +1451,7 @@ fn encode_base64url_no_pad(bytes: [u8; 16]) -> String {
#[cfg(test)]
mod tests {
- use super::{encode_base64url_no_pad, generate_d_tag, ListingDraftDocument, DRAFT_KIND};
+ use super::{DRAFT_KIND, ListingDraftDocument, encode_base64url_no_pad, generate_d_tag};
use radroots_events_codec::d_tag::is_d_tag_base64url;
#[test]
diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs
@@ -18,6 +18,7 @@ pub mod paths;
pub mod provider;
pub mod signer;
pub mod sync;
+pub mod workflow;
use std::process::ExitCode;
diff --git a/src/runtime/workflow.rs b/src/runtime/workflow.rs
@@ -0,0 +1,245 @@
+use crate::cli::{FarmScopedArgs, SetupRoleArg};
+use crate::domain::runtime::{SetupView, StatusView};
+use crate::runtime::RuntimeError;
+use crate::runtime::accounts::{self, AccountRecordView};
+use crate::runtime::config::RuntimeConfig;
+use crate::runtime::{farm, local};
+
+const WORKFLOW_SOURCE: &str = "workflow summary · local first";
+const RELAY_SETUP_ACTION: &str = "radroots relay list --relay wss://relay.example.com";
+
+pub fn setup(config: &RuntimeConfig, role: SetupRoleArg) -> Result<SetupView, RuntimeError> {
+ let account = ensure_selected_account(config)?;
+ let local_status = ensure_local_status(config)?;
+ let farm = inspect_farm(config)?;
+ let relay_configured = relay_configured(config);
+ let relay_count = config.relay.urls.len();
+
+ let mut ready = vec![
+ "Selected account".to_owned(),
+ "Local market data".to_owned(),
+ ];
+ let mut needs_attention = Vec::new();
+ let mut next = Vec::new();
+
+ if relay_configured {
+ ready.push("Relay configuration".to_owned());
+ } else {
+ needs_attention.push("Relay configuration".to_owned());
+ }
+
+ match role {
+ SetupRoleArg::Seller | SetupRoleArg::Both => {
+ apply_farm_attention(&mut ready, &mut needs_attention, &mut next, &farm);
+ push_next(&mut next, farm.primary_next_action.as_deref());
+ }
+ SetupRoleArg::Buyer => {}
+ }
+
+ match role {
+ SetupRoleArg::Buyer | SetupRoleArg::Both if relay_configured => {
+ push_next(&mut next, Some("radroots market search tomatoes"));
+ }
+ _ => {}
+ }
+
+ if !relay_configured {
+ push_next(&mut next, Some(RELAY_SETUP_ACTION));
+ }
+
+ push_next(&mut next, Some("radroots status"));
+
+ Ok(SetupView {
+ state: "saved".to_owned(),
+ source: WORKFLOW_SOURCE.to_owned(),
+ role: role_name(role).to_owned(),
+ selected_account_id: account.record.account_id.to_string(),
+ local_state: local_status.state,
+ local_root: local_status.local_root,
+ relay_state: relay_state(config).to_owned(),
+ relay_count,
+ farm_state: farm.state.to_owned(),
+ ready,
+ needs_attention,
+ next,
+ })
+}
+
+pub fn status(config: &RuntimeConfig) -> Result<StatusView, RuntimeError> {
+ let account = accounts::resolve_account(config)?;
+ let local_status = local::status(config)?;
+ let farm = inspect_farm(config)?;
+ let relay_configured = relay_configured(config);
+ let relay_count = config.relay.urls.len();
+
+ let mut ready = Vec::new();
+ let mut needs_attention = Vec::new();
+ let mut next = Vec::new();
+ let mut state = "ready";
+
+ if account.is_some() {
+ ready.push("Selected account".to_owned());
+ } else {
+ state = "unconfigured";
+ needs_attention.push("Selected account".to_owned());
+ }
+
+ if local_status.state == "ready" {
+ ready.push("Local market data".to_owned());
+ } else {
+ state = "unconfigured";
+ needs_attention.push("Local market data".to_owned());
+ }
+
+ if relay_configured {
+ ready.push("Relay configuration".to_owned());
+ } else {
+ state = "unconfigured";
+ needs_attention.push("Relay configuration".to_owned());
+ }
+
+ if state == "ready" {
+ apply_farm_attention(&mut ready, &mut needs_attention, &mut next, &farm);
+
+ if relay_configured {
+ match farm.state {
+ "draft" | "published" => push_next(&mut next, Some("radroots sell add tomatoes")),
+ "missing" => push_next(&mut next, Some("radroots market search tomatoes")),
+ _ => {}
+ }
+ }
+ } else {
+ push_next(&mut next, Some("radroots setup buyer"));
+ push_next(&mut next, Some("radroots setup seller"));
+ if account.is_some() && local_status.state == "ready" && !relay_configured {
+ next.clear();
+ push_next(&mut next, Some(RELAY_SETUP_ACTION));
+ push_next(&mut next, Some("radroots status"));
+ }
+ }
+
+ Ok(StatusView {
+ state: state.to_owned(),
+ source: WORKFLOW_SOURCE.to_owned(),
+ selected_account_id: account.map(|account| account.record.account_id.to_string()),
+ local_state: local_status.state,
+ local_root: local_status.local_root,
+ relay_state: relay_state(config).to_owned(),
+ relay_count,
+ farm_state: farm.state.to_owned(),
+ ready,
+ needs_attention,
+ next,
+ })
+}
+
+fn ensure_selected_account(config: &RuntimeConfig) -> Result<AccountRecordView, RuntimeError> {
+ if let Some(account) = accounts::resolve_account(config)? {
+ return Ok(account);
+ }
+
+ let snapshot = accounts::snapshot(config)?;
+ if let Some(account) = snapshot.accounts.first() {
+ return accounts::select_account(config, account.record.account_id.as_str());
+ }
+
+ Ok(accounts::create_or_migrate_selected_account(config)?.account)
+}
+
+fn ensure_local_status(
+ config: &RuntimeConfig,
+) -> Result<crate::domain::runtime::LocalStatusView, RuntimeError> {
+ let _ = local::init(config)?;
+ local::status(config)
+}
+
+#[derive(Debug, Clone)]
+struct FarmWorkflowState {
+ state: &'static str,
+ primary_next_action: Option<String>,
+}
+
+fn inspect_farm(config: &RuntimeConfig) -> Result<FarmWorkflowState, RuntimeError> {
+ let view = farm::status(config, &FarmScopedArgs::default())?;
+ if !view.config_present {
+ return Ok(FarmWorkflowState {
+ state: "missing",
+ primary_next_action: view.actions.into_iter().next(),
+ });
+ }
+
+ if view.account_state != "ready" {
+ return Ok(FarmWorkflowState {
+ state: "account_missing",
+ primary_next_action: view.actions.into_iter().next(),
+ });
+ }
+
+ let Some(config_summary) = view.config else {
+ return Ok(FarmWorkflowState {
+ state: "missing",
+ primary_next_action: view.actions.into_iter().next(),
+ });
+ };
+
+ let published = config_summary.publication.profile_state == "published"
+ && config_summary.publication.farm_state == "published";
+
+ Ok(FarmWorkflowState {
+ state: if published { "published" } else { "draft" },
+ primary_next_action: (!published).then(|| "radroots farm publish".to_owned()),
+ })
+}
+
+fn apply_farm_attention(
+ ready: &mut Vec<String>,
+ needs_attention: &mut Vec<String>,
+ next: &mut Vec<String>,
+ farm: &FarmWorkflowState,
+) {
+ match farm.state {
+ "missing" => {
+ needs_attention.push("Farm draft".to_owned());
+ }
+ "draft" => {
+ needs_attention.push("Farm not yet published".to_owned());
+ push_next(next, Some("radroots farm publish"));
+ }
+ "published" => {
+ ready.push("Farm published".to_owned());
+ }
+ "account_missing" => {
+ needs_attention.push("Farm draft account not available locally".to_owned());
+ }
+ _ => {}
+ }
+}
+
+fn relay_configured(config: &RuntimeConfig) -> bool {
+ !config.relay.urls.is_empty()
+}
+
+fn relay_state(config: &RuntimeConfig) -> &'static str {
+ if relay_configured(config) {
+ "configured"
+ } else {
+ "unconfigured"
+ }
+}
+
+fn role_name(role: SetupRoleArg) -> &'static str {
+ match role {
+ SetupRoleArg::Seller => "seller",
+ SetupRoleArg::Buyer => "buyer",
+ SetupRoleArg::Both => "both",
+ }
+}
+
+fn push_next(next: &mut Vec<String>, command: Option<&str>) {
+ let Some(command) = command else {
+ return;
+ };
+ if !next.iter().any(|existing| existing == command) {
+ next.push(command.to_owned());
+ }
+}
diff --git a/tests/help.rs b/tests/help.rs
@@ -71,7 +71,11 @@ fn market_help_is_example_first() {
assert!(stdout.contains("search"));
assert!(stdout.contains("view"));
assert!(stdout.contains("radroots market search tomatoes"));
- assert!(stdout.contains("Compatibility paths: `sync pull`, `find`, and `listing get` remain available."));
+ assert!(
+ stdout.contains(
+ "Compatibility paths: `sync pull`, `find`, and `listing get` remain available."
+ )
+ );
}
#[test]
@@ -91,7 +95,11 @@ fn sell_help_mentions_listing_compatibility() {
assert!(stdout.contains("pause"));
assert!(stdout.contains("reprice"));
assert!(stdout.contains("restock"));
- assert!(stdout.contains("Compatibility path: the advanced `listing` command family remains available."));
+ assert!(
+ stdout.contains(
+ "Compatibility path: the advanced `listing` command family remains available."
+ )
+ );
}
#[test]
diff --git a/tests/listing.rs b/tests/listing.rs
@@ -11,7 +11,7 @@ use std::time::Duration;
use assert_cmd::prelude::*;
use radroots_sql_core::{SqlExecutor, SqliteExecutor};
-use serde_json::{json, Value};
+use serde_json::{Value, json};
use tempfile::tempdir;
fn data_root(workdir: &Path) -> std::path::PathBuf {
@@ -1227,10 +1227,12 @@ fn listing_publish_without_matching_signer_session_exits_unconfigured() {
let publish_json: Value =
serde_json::from_slice(publish_output.stdout.as_slice()).expect("publish json");
assert_eq!(publish_json["state"], "unconfigured");
- assert!(publish_json["reason"]
- .as_str()
- .expect("reason")
- .contains("no authorized signer session matched seller pubkey"));
+ assert!(
+ publish_json["reason"]
+ .as_str()
+ .expect("reason")
+ .contains("no authorized signer session matched seller pubkey")
+ );
let recorded = requests.lock().expect("requests");
assert_eq!(recorded.len(), 1);
@@ -1324,10 +1326,12 @@ fn listing_publish_rejects_requested_session_that_mismatches_seller_pubkey() {
let publish_json: Value =
serde_json::from_slice(publish_output.stdout.as_slice()).expect("publish json");
assert_eq!(publish_json["state"], "unconfigured");
- assert!(publish_json["reason"]
- .as_str()
- .expect("reason")
- .contains("does not match seller pubkey"));
+ assert!(
+ publish_json["reason"]
+ .as_str()
+ .expect("reason")
+ .contains("does not match seller pubkey")
+ );
let recorded = requests.lock().expect("requests");
assert_eq!(recorded.len(), 1);
@@ -1408,10 +1412,11 @@ fn listing_publish_requires_authoritative_write_plane_binding() {
let publish_json: Value =
serde_json::from_slice(publish_output.stdout.as_slice()).expect("publish json");
assert_eq!(publish_json["state"], "unconfigured");
- assert!(publish_json["reason"]
- .as_str()
- .expect("reason")
- .contains("explicit write-plane capability binding or managed radrootsd instance `local`"));
+ assert!(
+ publish_json["reason"].as_str().expect("reason").contains(
+ "explicit write-plane capability binding or managed radrootsd instance `local`"
+ )
+ );
assert!(requests.lock().expect("requests").is_empty());
}
diff --git a/tests/workflow.rs b/tests/workflow.rs
@@ -0,0 +1,200 @@
+use std::fs;
+use std::path::Path;
+use std::process::Command;
+
+use assert_cmd::prelude::*;
+use serde_json::Value;
+use tempfile::tempdir;
+
+fn cli_command_in(workdir: &Path) -> Command {
+ let mut command = Command::cargo_bin("radroots").expect("binary");
+ command.current_dir(workdir);
+ command.env("HOME", workdir.join("home"));
+ command.env("APPDATA", workdir.join("roaming"));
+ command.env("LOCALAPPDATA", workdir.join("local"));
+ for key in [
+ "RADROOTS_ENV_FILE",
+ "RADROOTS_OUTPUT",
+ "RADROOTS_CLI_LOGGING_FILTER",
+ "RADROOTS_CLI_LOGGING_OUTPUT_DIR",
+ "RADROOTS_CLI_LOGGING_STDOUT",
+ "RADROOTS_CLI_PATHS_PROFILE",
+ "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT",
+ "RADROOTS_LOG_FILTER",
+ "RADROOTS_LOG_DIR",
+ "RADROOTS_LOG_STDOUT",
+ "RADROOTS_ACCOUNT",
+ "RADROOTS_ACCOUNT_SECRET_BACKEND",
+ "RADROOTS_ACCOUNT_SECRET_FALLBACK",
+ "RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE",
+ "RADROOTS_IDENTITY_PATH",
+ "RADROOTS_SIGNER",
+ "RADROOTS_RELAYS",
+ "RADROOTS_MYC_EXECUTABLE",
+ "RADROOTS_RPC_URL",
+ "RADROOTS_RPC_BEARER_TOKEN",
+ ] {
+ command.env_remove(key);
+ }
+ command.env("RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", "false");
+ command
+}
+
+fn write_workspace_config(workdir: &Path, contents: &str) {
+ let config_dir = workdir.join(".radroots");
+ fs::create_dir_all(&config_dir).expect("workspace config dir");
+ fs::write(config_dir.join("config.toml"), contents).expect("write workspace config");
+}
+
+#[test]
+fn setup_seller_creates_local_state_and_reports_farm_attention() {
+ let dir = tempdir().expect("tempdir");
+
+ let output = cli_command_in(dir.path())
+ .args(["setup", "seller"])
+ .output()
+ .expect("run setup seller");
+
+ assert!(output.status.success());
+ let stdout = String::from_utf8(output.stdout).expect("utf8 stdout");
+ assert!(stdout.contains("Setup saved"));
+ assert!(stdout.contains("Ready"));
+ assert!(stdout.contains("Selected account"));
+ assert!(stdout.contains("Local market data"));
+ assert!(stdout.contains("Needs attention"));
+ assert!(stdout.contains("Relay configuration"));
+ assert!(stdout.contains("Farm draft"));
+ assert!(stdout.contains("radroots farm setup"));
+ assert!(stdout.contains("radroots status"));
+
+ let account_output = cli_command_in(dir.path())
+ .args(["--json", "account", "view"])
+ .output()
+ .expect("run account view");
+ assert!(account_output.status.success());
+ let account_json: Value =
+ serde_json::from_slice(account_output.stdout.as_slice()).expect("account json");
+ assert_eq!(account_json["state"], "ready");
+
+ let local_output = cli_command_in(dir.path())
+ .args(["--json", "local", "status"])
+ .output()
+ .expect("run local status");
+ assert!(local_output.status.success());
+ let local_json: Value =
+ serde_json::from_slice(local_output.stdout.as_slice()).expect("local json");
+ assert_eq!(local_json["state"], "ready");
+}
+
+#[test]
+fn status_is_unconfigured_before_setup() {
+ let dir = tempdir().expect("tempdir");
+
+ let output = cli_command_in(dir.path())
+ .args(["--json", "status"])
+ .output()
+ .expect("run status");
+
+ assert_eq!(output.status.code(), Some(3));
+ let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("status json");
+ assert_eq!(json["state"], "unconfigured");
+ assert_eq!(json["ready"], Value::Array(Vec::new()));
+ assert_eq!(
+ json["needs_attention"],
+ serde_json::json!([
+ "Selected account",
+ "Local market data",
+ "Relay configuration"
+ ])
+ );
+ assert_eq!(
+ json["next"],
+ serde_json::json!(["radroots setup buyer", "radroots setup seller"])
+ );
+}
+
+#[test]
+fn status_calls_out_missing_relay_after_buyer_setup() {
+ let dir = tempdir().expect("tempdir");
+ let setup = cli_command_in(dir.path())
+ .args(["setup", "buyer"])
+ .output()
+ .expect("run setup buyer");
+ assert!(setup.status.success());
+
+ let output = cli_command_in(dir.path())
+ .args(["--json", "status"])
+ .output()
+ .expect("run status");
+
+ assert_eq!(output.status.code(), Some(3));
+ let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("status json");
+ assert_eq!(json["state"], "unconfigured");
+ assert_eq!(
+ json["ready"],
+ serde_json::json!(["Selected account", "Local market data"])
+ );
+ assert_eq!(
+ json["needs_attention"],
+ serde_json::json!(["Relay configuration"])
+ );
+ assert_eq!(
+ json["next"],
+ serde_json::json!([
+ "radroots relay list --relay wss://relay.example.com",
+ "radroots status"
+ ])
+ );
+}
+
+#[test]
+fn status_reports_farm_publish_need_when_core_state_is_ready() {
+ let dir = tempdir().expect("tempdir");
+
+ let account = cli_command_in(dir.path())
+ .args(["account", "new"])
+ .output()
+ .expect("run account new");
+ assert!(account.status.success());
+
+ let local = cli_command_in(dir.path())
+ .args(["local", "init"])
+ .output()
+ .expect("run local init");
+ assert!(local.status.success());
+
+ write_workspace_config(
+ dir.path(),
+ "[relay]\nurls = [\"wss://relay.one\"]\npublish_policy = \"any\"\n",
+ );
+
+ let farm = cli_command_in(dir.path())
+ .args([
+ "farm",
+ "setup",
+ "--name",
+ "La Huerta",
+ "--location",
+ "San Francisco, CA",
+ ])
+ .output()
+ .expect("run farm setup");
+ assert!(farm.status.success());
+
+ let output = cli_command_in(dir.path())
+ .args(["status"])
+ .output()
+ .expect("run status");
+
+ assert!(output.status.success());
+ let stdout = String::from_utf8(output.stdout).expect("utf8 stdout");
+ assert!(stdout.contains("Status"));
+ assert!(stdout.contains("Ready"));
+ assert!(stdout.contains("Selected account"));
+ assert!(stdout.contains("Local market data"));
+ assert!(stdout.contains("Relay configuration"));
+ assert!(stdout.contains("Needs attention"));
+ assert!(stdout.contains("Farm not yet published"));
+ assert!(stdout.contains("radroots farm publish"));
+ assert!(stdout.contains("radroots sell add tomatoes"));
+}