Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion crates/iris-agentic-dev-bin/src/cmd/compile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ pub struct CompileCommand {
pub host: Option<String>,
#[arg(long, env = "IRIS_WEB_PORT", default_value = "52773")]
pub web_port: u16,
/// URL path prefix for webgateway/IIS-fronted instances (e.g. irishealth)
#[arg(long, env = "IRIS_WEB_PREFIX", default_value = "")]
pub web_prefix: String,
/// URL scheme: http or https
#[arg(long, env = "IRIS_SCHEME", default_value = "http")]
pub scheme: String,
#[arg(long, env = "IRIS_NAMESPACE", default_value = "USER")]
pub namespace: String,
#[arg(long, env = "IRIS_USERNAME")]
Expand All @@ -30,7 +36,15 @@ pub struct CompileCommand {
impl CompileCommand {
pub async fn run(self) -> Result<()> {
let explicit = self.host.as_ref().map(|host| {
let base_url = format!("http://{}:{}", host, self.web_port);
// Honor prefix + scheme — behind a webgateway (IRIS_WEB_PREFIX) the bare
// http://host:port form can't reach Atelier at all (issue #21, upstream #85).
let scheme = self.scheme.trim_matches('/');
let prefix = self.web_prefix.trim_matches('/');
let base_url = if prefix.is_empty() {
format!("{}://{}:{}", scheme, host, self.web_port)
} else {
format!("{}://{}:{}/{}", scheme, host, self.web_port, prefix)
};
let username = self.username.as_deref().unwrap_or("_SYSTEM");
let password = self.password.as_deref().unwrap_or("SYS");
IrisConnection::new(
Expand Down
34 changes: 27 additions & 7 deletions crates/iris-agentic-dev-bin/src/cmd/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,11 +120,14 @@ impl McpCommand {
ws_root.display()
);
}
let explicit = iris_agentic_dev_core::iris::workspace_config::apply_workspace_config(
explicit,
Some(&self.workspace),
&self.namespace,
);
// _with_path returns the loaded config path so it can be recorded in
// ConnectionState at startup (not just after hot-reload). Issue #21 / upstream #82.
let (explicit, startup_config_path) =
iris_agentic_dev_core::iris::workspace_config::apply_workspace_config_with_path(
explicit,
Some(&self.workspace),
&self.namespace,
);

tokio::spawn(async move {
let conn = match discover_iris(explicit).await {
Expand Down Expand Up @@ -182,8 +185,25 @@ impl McpCommand {
}

// Build ConfigWatcher for .iris-agentic-dev.toml hot-reload (034-live-connection-reload).
let config_watcher = ConfigWatcher::new(ws_root.join(".iris-agentic-dev.toml"));
let tools = IrisTools::with_registry_and_toolset(iris, registry, toolset, config_watcher)?;
// When spawned from a launcher (e.g. Claude Desktop/Code) the CWD is often "/" —
// fall back to $HOME so the watch path is usable without OBJECTSCRIPT_WORKSPACE
// (issue #21, upstream 0c922ec).
let config_root = if ws_root == std::path::Path::new("/") {
std::env::var("HOME")
.ok()
.map(std::path::PathBuf::from)
.unwrap_or(ws_root)
} else {
ws_root
};
let config_watcher = ConfigWatcher::new(config_root.join(".iris-agentic-dev.toml"));
let tools = IrisTools::with_registry_and_toolset(
iris,
registry,
toolset,
config_watcher,
startup_config_path,
)?;

// FR-007: periodically sweep expired elicitation entries.
{
Expand Down
37 changes: 33 additions & 4 deletions crates/iris-agentic-dev-core/src/iris/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ impl IrisConnection {

/// Probe this connection: fetch IRIS version, Atelier API level, and SystemMode.
pub async fn probe(&mut self) {
let client = match Self::http_client() {
let client = match Self::probe_client() {
Ok(c) => c,
Err(_) => return,
};
Expand Down Expand Up @@ -242,7 +242,15 @@ impl IrisConnection {
.execute_via_generator_once(code, namespace, client)
.await
{
Ok(output) => return Ok(output),
Ok(output) => {
if attempt > 0 {
tracing::info!(
"execute_via_generator succeeded on attempt {}",
attempt + 1
);
}
return Ok(output);
}
Err(e) => {
let msg = e.to_string();
// Only retry on network errors or 5xx; 4xx are client errors, don't retry.
Expand All @@ -253,7 +261,9 @@ impl IrisConnection {
if !is_retryable || attempt == delays.len() - 1 {
return Err(e);
}
tracing::warn!(
// Transient on cold-start (private web server still warming up) — debug only;
// the success path logs at info so a recovery is still visible.
tracing::debug!(
"execute_via_generator attempt {} failed ({}), retrying in {:?}",
attempt + 1,
msg,
Expand Down Expand Up @@ -520,7 +530,7 @@ impl IrisConnection {
if !is_retryable || attempt == delays.len() - 1 {
return Err(e);
}
tracing::warn!(
tracing::debug!(
"query attempt {} failed ({}), retrying in {:?}",
attempt + 1,
msg,
Expand Down Expand Up @@ -610,6 +620,25 @@ impl IrisConnection {
Ok(CompileResult { errors, console })
}

/// Short-timeout client used only for the startup probe — a down/unreachable IRIS
/// should fail fast (5s connect / 10s total) instead of stalling startup for the
/// 30s general-client timeout (issue #21, upstream #85).
pub fn probe_client() -> anyhow::Result<reqwest::Client> {
let insecure = std::env::var("IRIS_INSECURE")
.ok()
.map(|v| v == "true" || v == "1")
.unwrap_or_else(|| {
std::env::var("IRIS_TLS_VERIFY")
.map(|v| v == "false" || v == "0")
.unwrap_or(false)
});
Ok(reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(5))
.timeout(std::time::Duration::from_secs(10))
.danger_accept_invalid_certs(insecure)
.build()?)
}

/// Build a reqwest Client suitable for Atelier REST calls.
/// TLS certificate validation is enabled by default; set `IRIS_INSECURE=true` to disable.
pub fn http_client() -> anyhow::Result<reqwest::Client> {
Expand Down
108 changes: 107 additions & 1 deletion crates/iris-agentic-dev-core/src/iris/workspace_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ pub struct WorkspaceConfig {
pub container: Option<String>,
pub namespace: Option<String>,
pub host: Option<String>,
#[serde(alias = "port")]
pub web_port: Option<u16>,
/// URL path prefix for the IRIS web gateway, e.g. "irisaicore" when the
/// Atelier API is served at http://host:port/irisaicore/api/atelier/...
Expand Down Expand Up @@ -67,6 +68,43 @@ pub fn workspace_root(workspace_path: Option<&str>) -> PathBuf {
legacy_root.unwrap_or(cwd)
}

/// Like [`load_workspace_config`] but also returns the path of the file that was loaded,
/// so callers can record it in `ConnectionState` at startup (issue #21, upstream #82).
pub fn load_workspace_config_with_path(
workspace_path: Option<&str>,
) -> Option<(WorkspaceConfig, std::path::PathBuf)> {
let root = workspace_root(workspace_path);
let config_path = if root.join(".iris-agentic-dev.toml").exists() {
root.join(".iris-agentic-dev.toml")
} else if root.join(".iris-dev.toml").exists() {
root.join(".iris-dev.toml")
} else {
return None;
};
let contents = std::fs::read_to_string(&config_path).ok()?;
match toml::from_str::<WorkspaceConfig>(&contents) {
Ok(cfg) => Some((cfg, config_path)),
Err(_) => None,
}
}

/// Like [`apply_workspace_config`] but also returns the path of the config file that was
/// loaded, so callers can record it in `ConnectionState` at startup rather than only
/// after the first hot-reload cycle.
pub fn apply_workspace_config_with_path(
explicit: Option<IrisConnection>,
workspace_path: Option<&str>,
namespace: &str,
) -> (Option<IrisConnection>, Option<std::path::PathBuf>) {
if explicit.is_some() {
return (explicit, None);
}
match load_workspace_config_with_path(workspace_path) {
Some((cfg, path)) => (workspace_config_to_connection(&cfg, namespace), Some(path)),
None => (None, None),
}
}

/// Load `.iris-agentic-dev.toml` from the resolved workspace root.
/// Returns `None` if the file does not exist (not an error).
/// Logs a warning and returns `None` on parse errors — never panics.
Expand Down Expand Up @@ -163,9 +201,20 @@ pub fn workspace_config_to_connection(
.or_else(|| std::env::var("IRIS_PASSWORD").ok())
.unwrap_or_else(|| "SYS".to_string());
// If container is also specified alongside host, update IRIS_CONTAINER so docker
// exec tools (iris_execute fallback, iris_test, etc.) target the right container.
// exec tools (iris_execute fallback, iris_test, etc.) target the right container,
// and use DiscoverySource::Docker so check_config exposes the container name
// instead of reporting container: null (issue #21, upstream #89).
if let Some(ref container) = cfg.container {
std::env::set_var("IRIS_CONTAINER", container);
return Some(IrisConnection::new(
base_url,
namespace,
username,
password,
DiscoverySource::Docker {
container_name: container.clone(),
},
));
}
return Some(IrisConnection::new(
base_url,
Expand Down Expand Up @@ -281,6 +330,63 @@ namespace = "{namespace}"
mod tests {
use super::*;

// ── issue #21: config path threading + Docker source ─────────────────────
#[test]
fn apply_with_path_returns_loaded_path_and_none_when_explicit() {
use crate::iris::connection::DiscoverySource;
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join(".iris-agentic-dev.toml"),
"host = \"path-host\"\nweb_port = 52773\n",
)
.unwrap();
let ws = dir.path().to_str().unwrap();

let (conn, path) = apply_workspace_config_with_path(None, Some(ws), "USER");
let conn = conn.expect("connection from config");
assert!(conn.base_url.contains("path-host"));
assert!(
path.expect("config path")
.ends_with(".iris-agentic-dev.toml"),
"returned path must point at the loaded file"
);

// Explicit connection wins and returns no path.
let explicit = IrisConnection::new(
"http://explicit:52773",
"USER",
"_SYSTEM",
"SYS",
DiscoverySource::EnvVar,
);
let (conn, path) = apply_workspace_config_with_path(Some(explicit), Some(ws), "USER");
assert!(conn.unwrap().base_url.contains("explicit"));
assert!(path.is_none());
}

#[test]
fn host_plus_container_reports_docker_source() {
use crate::iris::connection::DiscoverySource;
let cfg = WorkspaceConfig {
host: Some("localhost".into()),
container: Some("my-iris".into()),
..Default::default()
};
let conn = workspace_config_to_connection(&cfg, "USER").expect("connection");
match conn.source {
DiscoverySource::Docker { ref container_name } => {
assert_eq!(container_name, "my-iris")
}
ref other => panic!("expected Docker source exposing the container, got {other:?}"),
}
}

#[test]
fn web_port_accepts_port_alias() {
let cfg: WorkspaceConfig = toml::from_str("host = \"h\"\nport = 43080\n").unwrap();
assert_eq!(cfg.web_port, Some(43080));
}

#[test]
fn toml_template_native_section_before_container() {
let content = generate_toml_content("my-iris", "USER");
Expand Down
39 changes: 36 additions & 3 deletions crates/iris-agentic-dev-core/src/tools/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1451,7 +1451,13 @@ impl IrisTools {
iris: Option<IrisConnection>,
toolset: Toolset,
) -> anyhow::Result<Self> {
Self::with_registry_and_toolset(iris, crate::skills::SkillRegistry::new(), toolset, None)
Self::with_registry_and_toolset(
iris,
crate::skills::SkillRegistry::new(),
toolset,
None,
None,
)
}

/// Returns the set of tool names registered for the current toolset.
Expand Down Expand Up @@ -1583,13 +1589,14 @@ impl IrisTools {
iris: Option<IrisConnection>,
registry: crate::skills::SkillRegistry,
) -> anyhow::Result<Self> {
Self::with_registry_and_toolset(iris, registry, Toolset::Baseline, None)
Self::with_registry_and_toolset(iris, registry, Toolset::Baseline, None, None)
}
pub fn with_registry_and_toolset(
iris: Option<IrisConnection>,
registry: crate::skills::SkillRegistry,
toolset: Toolset,
config_watcher: Option<ConfigWatcher>,
config_path: Option<std::path::PathBuf>,
) -> anyhow::Result<Self> {
let client = Arc::new(IrisConnection::http_client()?);
let mut router = Self::tool_router();
Expand Down Expand Up @@ -1681,7 +1688,17 @@ impl IrisTools {
router.remove_route(name);
}
}
ConnectionState::from_iris(c, ConnectionSource::AutoDiscovered, None)
{
// Record ConfigFile source (and the path) when the connection came from
// a .iris-agentic-dev.toml — so check_config shows config_file at
// startup, not just after the first hot-reload (issue #21, upstream #82).
let (source, file) = if config_path.is_some() {
(ConnectionSource::ConfigFile, config_path)
} else {
(ConnectionSource::AutoDiscovered, None)
};
ConnectionState::from_iris(c, source, file)
}
}
None => ConnectionState::new_disconnected(ConnectionSource::EnvVars),
};
Expand Down Expand Up @@ -3241,6 +3258,22 @@ do ##class(%UnitTest.Manager).RunTest("{pattern}","{flags}","{token}")"#,
response["config_parse_error"] = serde_json::Value::String(err.clone());
}

// Surface fallback discovery explicitly: a connection with no config file and a
// non-explicit source came from Docker/port-scan discovery, which can silently
// target the wrong instance (issue #21, upstream #82).
let is_explicit = matches!(
conn.source,
ConnectionSource::ConfigFile | ConnectionSource::EnvVars
);
if conn.config_file.is_none() && !is_explicit && conn.iris.is_some() {
response["fallback_warning"] = serde_json::Value::String(
"No .iris-agentic-dev.toml config file found. Connection established via \
fallback discovery (Docker/port scan). Set OBJECTSCRIPT_WORKSPACE or create \
a .iris-agentic-dev.toml in your project root to pin the target instance."
.to_string(),
);
}

ok_json(response)
}

Expand Down
Loading