commit 45d7d5e64d14697b144dce99ba5719b6968878cd
parent 67da82d341870391792bcc700a34049edd7aaace
Author: triesap <tyson@radroots.org>
Date: Mon, 20 Apr 2026 17:34:05 +0000
remove workflow account mutation
Diffstat:
6 files changed, 189 insertions(+), 57 deletions(-)
diff --git a/src/cli.rs b/src/cli.rs
@@ -86,6 +86,7 @@ Examples:
radroots setup both
This workflow layer sits on top of the existing account, local, and farm commands.
+Use `radroots account create` or `radroots account select` explicitly when no actor is resolved.
";
const STATUS_HELP: &str = "\
@@ -95,6 +96,7 @@ Examples:
radroots config show
This workflow summary reflects the current readiness and configuration surfaces.
+When no actor is resolved, it points to explicit account commands instead of mutating account state.
";
const ACCOUNT_HELP: &str = "\
diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs
@@ -675,7 +675,7 @@ pub struct SetupView {
pub state: String,
pub source: String,
pub role: String,
- pub selected_account_id: String,
+ pub account_resolution: AccountResolutionView,
pub local_state: String,
pub local_root: String,
pub relay_state: String,
diff --git a/src/render/mod.rs b/src/render/mod.rs
@@ -3597,7 +3597,9 @@ fn render_setup(stdout: &mut dyn Write, view: &SetupView) -> Result<(), RuntimeE
&view.ready,
&view.needs_attention,
&view.next,
- )
+ )?;
+ writeln!(stdout)?;
+ render_account_resolution(stdout, &view.account_resolution)
}
fn render_status_summary(stdout: &mut dyn Write, view: &StatusView) -> Result<(), RuntimeError> {
diff --git a/src/runtime/workflow.rs b/src/runtime/workflow.rs
@@ -1,7 +1,7 @@
use crate::cli::{FarmScopedArgs, SetupRoleArg};
use crate::domain::runtime::{SetupView, StatusView};
use crate::runtime::RuntimeError;
-use crate::runtime::accounts::{self, AccountRecordView};
+use crate::runtime::accounts::{self};
use crate::runtime::config::RuntimeConfig;
use crate::runtime::{farm, local};
@@ -9,51 +9,66 @@ 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 account_resolution = accounts::resolve_account_resolution(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 state = "saved";
+ let mut ready = Vec::new();
let mut needs_attention = Vec::new();
let mut next = Vec::new();
+ if account_resolution.resolved_account.is_some() {
+ ready.push("Resolved account".to_owned());
+ } else {
+ state = "unconfigured";
+ needs_attention.push("Resolved account".to_owned());
+ push_unresolved_account_actions(config, &mut next, Some(role))?;
+ }
+
+ 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 {
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());
+ if account_resolution.resolved_account.is_some() {
+ 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 => {}
}
- SetupRoleArg::Buyer => {}
- }
- match role {
- SetupRoleArg::Buyer | SetupRoleArg::Both if relay_configured => {
- push_next(&mut next, Some("radroots market search tomatoes"));
+ 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));
- }
+ if !relay_configured {
+ push_next(&mut next, Some(RELAY_SETUP_ACTION));
+ }
- push_next(&mut next, Some("radroots status"));
+ push_next(&mut next, Some("radroots status"));
+ }
Ok(SetupView {
- state: "saved".to_owned(),
+ state: state.to_owned(),
source: WORKFLOW_SOURCE.to_owned(),
role: role_name(role).to_owned(),
- selected_account_id: account.record.account_id.to_string(),
+ account_resolution: accounts::account_resolution_view(&account_resolution),
local_state: local_status.state,
local_root: local_status.local_root,
relay_state: relay_state(config).to_owned(),
@@ -82,6 +97,7 @@ pub fn status(config: &RuntimeConfig) -> Result<StatusView, RuntimeError> {
} else {
state = "unconfigured";
needs_attention.push("Resolved account".to_owned());
+ push_unresolved_account_actions(config, &mut next, None)?;
}
if local_status.state == "ready" {
@@ -108,7 +124,7 @@ pub fn status(config: &RuntimeConfig) -> Result<StatusView, RuntimeError> {
_ => {}
}
}
- } else {
+ } else if account_resolution.resolved_account.is_some() {
push_next(&mut next, Some("radroots setup buyer"));
push_next(&mut next, Some("radroots setup seller"));
if account_resolution.resolved_account.is_some()
@@ -136,19 +152,6 @@ pub fn status(config: &RuntimeConfig) -> Result<StatusView, RuntimeError> {
})
}
-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> {
@@ -238,6 +241,47 @@ fn role_name(role: SetupRoleArg) -> &'static str {
}
}
+fn push_unresolved_account_actions(
+ config: &RuntimeConfig,
+ next: &mut Vec<String>,
+ setup_role: Option<SetupRoleArg>,
+) -> Result<(), RuntimeError> {
+ match unresolved_account_resolution_state(config)? {
+ UnresolvedAccountResolutionState::NoAccounts => {
+ push_next(next, Some("radroots account create"));
+ }
+ UnresolvedAccountResolutionState::AccountsExistWithoutDefault => {
+ push_next(next, Some("radroots account list"));
+ push_next(next, Some("radroots account select <selector>"));
+ }
+ }
+
+ if let Some(role) = setup_role {
+ push_next(next, Some(setup_command(role)));
+ }
+
+ Ok(())
+}
+
+fn unresolved_account_resolution_state(
+ config: &RuntimeConfig,
+) -> Result<UnresolvedAccountResolutionState, RuntimeError> {
+ let snapshot = accounts::snapshot(config)?;
+ Ok(if snapshot.accounts.is_empty() {
+ UnresolvedAccountResolutionState::NoAccounts
+ } else {
+ UnresolvedAccountResolutionState::AccountsExistWithoutDefault
+ })
+}
+
+fn setup_command(role: SetupRoleArg) -> &'static str {
+ match role {
+ SetupRoleArg::Seller => "radroots setup seller",
+ SetupRoleArg::Buyer => "radroots setup buyer",
+ SetupRoleArg::Both => "radroots setup both",
+ }
+}
+
fn push_next(next: &mut Vec<String>, command: Option<&str>) {
let Some(command) = command else {
return;
@@ -246,3 +290,9 @@ fn push_next(next: &mut Vec<String>, command: Option<&str>) {
next.push(command.to_owned());
}
}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum UnresolvedAccountResolutionState {
+ NoAccounts,
+ AccountsExistWithoutDefault,
+}
diff --git a/tests/help.rs b/tests/help.rs
@@ -39,6 +39,9 @@ fn setup_help_describes_the_landed_workflow_layer() {
assert!(stdout.contains(
"This workflow layer sits on top of the existing account, local, and farm commands."
));
+ assert!(stdout.contains(
+ "Use `radroots account create` or `radroots account select` explicitly when no actor is resolved."
+ ));
assert!(!stdout.contains("being added"));
}
@@ -54,6 +57,9 @@ fn status_help_describes_current_readiness_surfaces() {
assert!(stdout.contains(
"This workflow summary reflects the current readiness and configuration surfaces."
));
+ assert!(stdout.contains(
+ "When no actor is resolved, it points to explicit account commands instead of mutating account state."
+ ));
assert!(!stdout.contains("being added"));
}
diff --git a/tests/workflow.rs b/tests/workflow.rs
@@ -6,6 +6,14 @@ use assert_cmd::prelude::*;
use serde_json::Value;
use tempfile::tempdir;
+fn data_root(workdir: &Path) -> std::path::PathBuf {
+ if cfg!(windows) {
+ workdir.join("local").join("Radroots").join("data")
+ } else {
+ workdir.join("home").join(".radroots").join("data")
+ }
+}
+
fn cli_command_in(workdir: &Path) -> Command {
let mut command = Command::cargo_bin("radroots").expect("binary");
command.current_dir(workdir);
@@ -47,34 +55,27 @@ fn write_workspace_config(workdir: &Path, contents: &str) {
}
#[test]
-fn setup_seller_creates_local_state_and_reports_farm_attention() {
+fn setup_seller_without_account_is_unconfigured_and_does_not_create_account() {
let dir = tempdir().expect("tempdir");
+ let store_path = data_root(dir.path()).join("shared/accounts/store.json");
let output = cli_command_in(dir.path())
.args(["setup", "seller"])
.output()
.expect("run setup seller");
- assert!(output.status.success());
+ assert_eq!(output.status.code(), Some(3));
let stdout = String::from_utf8(output.stdout).expect("utf8 stdout");
- assert!(stdout.contains("Setup saved"));
+ assert!(stdout.contains("Not ready yet"));
assert!(stdout.contains("Ready"));
- assert!(stdout.contains("Selected account"));
+ assert!(stdout.contains("Resolved 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 init"));
- 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");
+ assert!(stdout.contains("Account resolution"));
+ assert!(stdout.contains("radroots account create"));
+ assert!(stdout.contains("radroots setup seller"));
+ assert!(!store_path.exists());
let local_output = cli_command_in(dir.path())
.args(["--json", "local", "status"])
@@ -87,7 +88,38 @@ fn setup_seller_creates_local_state_and_reports_farm_attention() {
}
#[test]
-fn status_is_unconfigured_before_setup() {
+fn setup_seller_with_default_account_reports_farm_attention() {
+ let dir = tempdir().expect("tempdir");
+ let store_path = data_root(dir.path()).join("shared/accounts/store.json");
+
+ let account_output = cli_command_in(dir.path())
+ .args(["account", "new"])
+ .output()
+ .expect("run account new");
+ assert!(account_output.status.success());
+ assert!(store_path.exists());
+
+ 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("Resolved 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("Account resolution"));
+ assert!(stdout.contains("radroots farm init"));
+ assert!(stdout.contains("radroots status"));
+}
+
+#[test]
+fn status_is_unconfigured_before_account_setup() {
let dir = tempdir().expect("tempdir");
let output = cli_command_in(dir.path())
@@ -107,15 +139,55 @@ fn status_is_unconfigured_before_setup() {
"Relay configuration"
])
);
+ assert_eq!(json["next"], serde_json::json!(["radroots account create"]));
+}
+
+#[test]
+fn status_points_to_account_selection_when_accounts_exist_without_default() {
+ let dir = tempdir().expect("tempdir");
+ let store_path = data_root(dir.path()).join("shared/accounts/store.json");
+
+ let account_output = cli_command_in(dir.path())
+ .args(["account", "new"])
+ .output()
+ .expect("run account new");
+ assert!(account_output.status.success());
+ let mut store_json: Value =
+ serde_json::from_slice(fs::read(&store_path).expect("read store").as_slice())
+ .expect("parse store");
+ store_json["selected_account_id"] = Value::Null;
+ fs::write(
+ &store_path,
+ serde_json::to_vec_pretty(&store_json).expect("serialize store"),
+ )
+ .expect("write store");
+
+ 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["account_resolution"]["source"], "none");
assert_eq!(
json["next"],
- serde_json::json!(["radroots setup buyer", "radroots setup seller"])
+ serde_json::json!([
+ "radroots account list",
+ "radroots account select <selector>"
+ ])
);
}
#[test]
fn status_calls_out_missing_relay_after_buyer_setup() {
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 setup = cli_command_in(dir.path())
.args(["setup", "buyer"])
.output()