Skip to main content

robonix_executor/
config.rs

1// SPDX-License-Identifier: MulanPSL-2.0
2// Author: wheatfox <wheatfox17@icloud.com>
3//
4// Executor config — same three-source resolution as pilot:
5//   compiled defaults < YAML at $ROBONIX_CONFIG_PATH < CLI flags / env.
6
7use anyhow::{Context, Result};
8use clap::Parser;
9use serde::Deserialize;
10use std::path::{Path, PathBuf};
11
12pub const DEFAULT_EXECUTOR_PROVIDER_ID: &str = "executor";
13pub const EXECUTOR_NAMESPACE: &str = "robonix/system/executor";
14pub const DEFAULT_ATLAS_ENDPOINT: &str = "127.0.0.1:50051";
15pub const DEFAULT_LISTEN: &str = "127.0.0.1:50061";
16
17#[derive(Debug, Clone)]
18pub struct ExecutorConfig {
19    pub atlas_endpoint: String,
20    pub listen: String,
21    pub id: String,
22}
23
24#[derive(Parser, Debug)]
25#[command(
26    name = "robonix-executor",
27    about = "Robonix Executor — tool-call dispatch runtime"
28)]
29pub struct Args {
30    /// Atlas control-plane endpoint. Also reads `ROBONIX_ATLAS` (the var rbnx /
31    /// the Python API / liaison use) as an alias; see `env_atlas`.
32    #[arg(long, env = "ROBONIX_ATLAS_ENDPOINT")]
33    pub atlas: Option<String>,
34
35    /// Address the SystemExecutor gRPC service binds to.
36    #[arg(long, env = "ROBONIX_EXECUTOR_LISTEN")]
37    pub listen: Option<String>,
38
39    /// Override executor's id (singleton; rarely needed).
40    #[arg(long, env = "ROBONIX_EXECUTOR_PROVIDER_ID")]
41    pub id: Option<String>,
42
43    /// Optional YAML config file (rbnx writes this; CLI/env still override).
44    #[arg(long, env = "ROBONIX_CONFIG_PATH")]
45    pub config: Option<PathBuf>,
46
47    /// Log level for this component (`debug`/`info`/`warn`/`error`). Sets the
48    /// scribe log-file floor; falls back to `SCRIBE_FILE_LEVEL` / `info`.
49    /// Normally arrives inside `--config-json`, not as a standalone flag.
50    #[arg(long)]
51    pub log: Option<String>,
52
53    /// The component's `system.executor` manifest block, serialized to JSON by
54    /// rbnx and passed as one arg (`--config-json '{…}'`). Parsed by the binary
55    /// itself — see `robonix_scribe::init_from_config`, which reads the `log`
56    /// key from it so the manifest's per-component level reaches the log.
57    #[arg(long)]
58    pub config_json: Option<String>,
59}
60
61#[derive(Default, Deserialize)]
62struct FileConfig {
63    #[serde(default)]
64    atlas_endpoint: Option<String>,
65    #[serde(default)]
66    listen: Option<String>,
67    #[serde(default)]
68    id: Option<String>,
69}
70
71impl ExecutorConfig {
72    pub fn resolve(args: Args) -> Result<Self> {
73        let file_cfg: FileConfig = match &args.config {
74            Some(path) => load_yaml(path)?,
75            None => FileConfig::default(),
76        };
77        Ok(Self {
78            atlas_endpoint: args
79                .atlas
80                .or_else(env_atlas)
81                .or(file_cfg.atlas_endpoint)
82                .unwrap_or_else(|| DEFAULT_ATLAS_ENDPOINT.to_string()),
83            listen: args
84                .listen
85                .or(file_cfg.listen)
86                .unwrap_or_else(|| DEFAULT_LISTEN.to_string()),
87            id: args
88                .id
89                .or(file_cfg.id)
90                .unwrap_or_else(|| DEFAULT_EXECUTOR_PROVIDER_ID.to_string()),
91        })
92    }
93}
94
95/// Read the `ROBONIX_ATLAS` env var as an atlas-endpoint alias.
96///
97/// rbnx, the Python API, and liaison all configure the atlas endpoint via
98/// `ROBONIX_ATLAS`, while executor/pilot historically only honored
99/// `ROBONIX_ATLAS_ENDPOINT` (the clap `env`). Accepting `ROBONIX_ATLAS` here as
100/// well means a single env var configures every component. Without it, setting
101/// only `ROBONIX_ATLAS` left executor silently falling back to
102/// `DEFAULT_ATLAS_ENDPOINT` (127.0.0.1:50051) — it would then dial the wrong
103/// atlas and log 127.0.0.1 even after the operator "changed" the endpoint.
104/// Empty values are ignored so an exported-but-blank var doesn't shadow later
105/// sources.
106fn env_atlas() -> Option<String> {
107    std::env::var("ROBONIX_ATLAS")
108        .ok()
109        .filter(|v| !v.is_empty())
110}
111
112fn load_yaml(path: &Path) -> Result<FileConfig> {
113    let raw = std::fs::read_to_string(path)
114        .with_context(|| format!("read executor config '{}'", path.display()))?;
115    serde_yaml::from_str(&raw)
116        .with_context(|| format!("parse executor config '{}'", path.display()))
117}