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
8 changes: 8 additions & 0 deletions .github/workflows/_harness-integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,15 @@ jobs:
with:
toolchain: 1.97.1

# The integration crate links the harness (for its response-language
# detector); these tests never serve the harness UI, so a stub bundle
# stands in for it and no Node/pnpm is needed here.
- name: Stub the embedded harness UI
run: mkdir -p harness/ui/dist && touch harness/ui/dist/page.js harness/ui/dist/styles.css

- name: Integration crate unit tests
env:
SKIP_UI_BUILD: "1"
run: cargo test --locked --manifest-path harness/Cargo.toml -p harness-integration

- name: Validate integration scenarios
Expand Down
7 changes: 4 additions & 3 deletions ade/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,11 @@ iii trigger compose::add worker=harness
The provider workers install with the harness, but they need credentials before any model appears — until then the model picker reads **no models** and chat won't generate. The first time a person loads the ADE on a machine it opens a setup wizard that does this for you — never in a browser under automation (an e2e run, an agent's browser session), which gets the page it asked for. Reopen it any time from the command palette: **Set up the harness**.

1. **Models** — `console::onboarding::scan` looks for the Claude Code and Codex CLIs and whether each is signed in (presence and paths only, never a credential), and the step recommends what it found: a signed-in Claude Code or Codex adds [`provider-claude-code`](https://github.com/iii-hq/workers/tree/main/provider-claude-code) / [`provider-openai-codex`](https://github.com/iii-hq/workers/tree/main/provider-openai-codex) and needs no key; a CLI installed but not signed in says what to do, then **Scan again**. The [`secrets`](https://github.com/iii-hq/workers/tree/main/secrets) worker comes with `llm-router` (a dependency, so the wizard never asks to add it), and `secrets::detect` looks for provider keys in your shell profile and the project's `.env`, shows them masked (`sk-ant…9f2c`) and recommends their providers. Each key is kept where you choose: **Encrypted** (the default; a found key is imported by the secrets worker itself, a pasted one is stored there) or **Environment variable** (the variable already in `.env` is shared as it is, or a pasted key is written to `.env`). Either way only a reference, `secret://ANTHROPIC_API_KEY` or `env://ANTHROPIC_API_KEY`, is written to the `llm-router` configuration, so no key lands in `./config`. The same choice is in each provider's key field in the model picker and in **Settings → Workers → llm-router**. Every other provider worker in the registry is listed too.
2. **Judge** (optional) — adds [`judge`](https://github.com/iii-hq/workers/tree/main/judge) with Jev ([`judge-typesafe`](https://github.com/iii-hq/workers/tree/main/judge-typesafe), its `TYPESAFE_API_KEY` behind a `secret://` reference) or a local judge, and explains where the harness uses it.
3. **Ready** — what is connected and every worker setup added. With a model connected, it offers to keep going with the guided tour of the ADE: **Start the tour** adds the [`onboarding`](https://github.com/iii-hq/workers/tree/main/onboarding) worker if it is not running and opens its page beside the chat, where it walks through the ADE stage by stage. **Skip the tour** goes straight to the composer.
2. **Browser** (shown only when the [`browser`](https://github.com/iii-hq/workers/tree/main/browser) worker is installed and `browser::chromium::status` finds no Chromium) — one click on **Download Chromium** runs `browser::chromium::install` (about 200 MB, once per machine) and follows its `browser::chromium-install-progress` events; **I'd rather install it myself** shows the command for the OS and **Check again**. The command palette's **Install Chromium for the browser worker** and an **Install Chromium** action on a failed `browser::*` call (`chromium_missing`) open the wizard on this step.
3. **Judge** (optional) — adds [`judge`](https://github.com/iii-hq/workers/tree/main/judge) with Jev ([`judge-typesafe`](https://github.com/iii-hq/workers/tree/main/judge-typesafe), its `TYPESAFE_API_KEY` stored encrypted by `secrets`) or a local judge, and explains where the harness uses it.
4. **Ready** — what is connected, every worker setup added, and the project's example prompts. **Finish** closes the wizard. The prompts come from `onboarding.yaml` in the project folder (`console::onboarding::prompts`, read on each call; a missing or invalid file shows none): each one has a `title`, an optional `description`, the `agent` profile it runs with, the `prompt`, and `models`, a priority list of `{ provider, model, effort? }`. Clicking one opens a new chat with the prompt in the message box (not sent), its agent profile selected, and the first listed model the router serves here, at that effort; with none available the chat keeps its usual model.

Nothing is added without a click: each step lists the exact actions it will run — `compose::add` with each worker and why, `secrets::import` / `secrets::set`, the configuration value written — and, once you continue, logs them live with the compose phase of each worker being added. Finishing or skipping is remembered per machine in `<data_dir>/onboarding.json` (`console::onboarding::get` / `::set`).
Nothing is added without a click, and every worker a step adds is named before it runs and logged as it is added: iii is composable, and each worker adds behavior to the project (`worker-compose.yaml`). The wizard keeps everything else in plain words (no function ids, configuration entries, key references or paths); the actions behind each step are `compose::add`, `secrets::import` / `secrets::set` and a configuration write. Finishing or skipping is remembered per machine in `<data_dir>/onboarding.json` (`console::onboarding::get` / `::set`).

The wizard opens by itself only where it can help: never once a model is connected (the router already serves one from a running provider worker), never when the router cannot answer, and never where it is turned off. A deployed ADE starts with an empty data directory, which reads as a first run, so turn it off there: `onboarding.auto_open: false` in the ADE configuration (**Settings → Workers → ADE → Setup**), or `III_CONSOLE_ONBOARDING_AUTO_OPEN=false` in the worker's environment for one environment whatever the configuration says. `console::onboarding::get` reports the result as `auto_open`. The command palette opens the wizard either way.

Expand Down
2 changes: 1 addition & 1 deletion ade/iii.worker.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ scripts:

config:
http_port: 3113
http_host: 127.0.0.1
http_host: 0.0.0.0
# Ephemeral workspace layout (tabs/panes) — never the committed config YAML.
data_dir: data/ade
dependencies:
Expand Down
12 changes: 10 additions & 2 deletions ade/src/compose/watch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -249,8 +249,16 @@ async fn run_watch(
healthy: Arc<AtomicBool>,
) {
let mut pending: HashMap<ChangeKind, PendingEvent> = HashMap::new();
let mut tick = tokio::time::interval(Duration::from_millis(25));
loop {
// Sleep until the earliest coalesced change is due; with nothing
// pending, wait for the filesystem alone (no idle wake-ups).
let next_due = pending.values().map(|event| event.due).min();
let due_timer = async move {
match next_due {
Some(due) => tokio::time::sleep_until(due).await,
None => std::future::pending::<()>().await,
}
};
tokio::select! {
event = rx.recv() => {
let Some(event) = event else { break };
Expand All @@ -270,7 +278,7 @@ async fn run_watch(
}
}
}
_ = tick.tick() => {
_ = due_timer => {
let now = tokio::time::Instant::now();
let due: Vec<ChangeKind> = pending
.iter()
Expand Down
42 changes: 42 additions & 0 deletions ade/src/configuration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ pub type ApplyLock = Arc<tokio::sync::Mutex<()>>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeConfig {
pub http_port: u16,
/// The stored `http_host`, when set. Boot-time only: read before the
/// listener binds, never rebinds live.
pub http_host: Option<std::net::IpAddr>,
/// Directory for ephemeral per-instance state, as configured (NOT yet
/// resolved — see [`RuntimeConfig::resolved_data_dir`]).
pub data_dir: String,
Expand All @@ -53,6 +56,7 @@ impl RuntimeConfig {
pub fn fallback(http_port: u16, data_dir: &str) -> Self {
Self {
http_port,
http_host: None,
data_dir: data_dir.to_string(),
disabled_workers: HashSet::new(),
}
Expand Down Expand Up @@ -108,6 +112,12 @@ fn schema() -> Value {
"default": 3113,
"description": "TCP port for the ADE UI, injected assets, and /ws proxy. Changes rebind the listener live."
},
"http_host": {
"type": "string",
"minLength": 1,
"default": "0.0.0.0",
"description": "Interface the ADE listens on: 0.0.0.0 (the default) for every interface, so other machines on the network can open it; 127.0.0.1 for this machine only. Applies at the next start."
},
"data_dir": {
"type": "string",
"minLength": 1,
Expand Down Expand Up @@ -312,6 +322,18 @@ fn runtime_config_from(
}
};

let http_host = match value.and_then(|value| value.get("http_host")) {
None | Some(Value::Null) => None,
Some(Value::String(host)) => Some(host.trim().parse().map_err(|_| {
format!(
"stored `console.http_host` {host:?} is not an IP address; use 0.0.0.0, 127.0.0.1 or an interface address"
)
})?),
Some(_) => {
return Err("stored `console.http_host` must be an IP address string".to_string())
}
};

let data_dir = match value.and_then(|value| value.get("data_dir")) {
None | Some(Value::Null) => fallback_data_dir.to_string(),
Some(Value::String(dir)) if !dir.trim().is_empty() => dir.trim().to_string(),
Expand All @@ -320,6 +342,7 @@ fn runtime_config_from(

Ok(RuntimeConfig {
http_port,
http_host,
data_dir,
disabled_workers: value.map(disabled_workers_from).unwrap_or_default(),
})
Expand Down Expand Up @@ -667,6 +690,25 @@ mod tests {
assert_eq!(section["worker_sources"]["type"], "object");
}

#[test]
fn stored_http_host_is_read_for_the_next_start() {
let local =
runtime_config_from(Some(&json!({ "http_host": "127.0.0.1" })), 3113, "data/ade")
.unwrap();
assert_eq!(local.http_host, Some("127.0.0.1".parse().unwrap()));
assert_eq!(
runtime_config_from(Some(&json!({})), 3113, "data/ade")
.unwrap()
.http_host,
None
);
assert!(
runtime_config_from(Some(&json!({ "http_host": "localhost" })), 3113, "data/ade")
.is_err()
);
assert_eq!(schema()["properties"]["http_host"]["default"], "0.0.0.0");
}

#[test]
fn schema_exposes_the_live_http_port() {
let port = &schema()["properties"]["http_port"];
Expand Down
2 changes: 1 addition & 1 deletion ade/src/functions/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ pub fn register_all(
crate::conversations::register(iii);
crate::compose::register(iii);
tracing::info!(
"registered console::status, console::ui-manifest, console::subscribe, console::working-directory::{{propose,inject-guidance}}, console::workspace::{{get,set,list,open,close}}, console::conversations::*, console::onboarding::{{scan,get,set}}, console::compose::*, and the console::workspace::changed and console::compose::changed trigger types"
"registered console::status, console::ui-manifest, console::subscribe, console::working-directory::{{propose,inject-guidance}}, console::workspace::{{get,set,list,open,close}}, console::conversations::*, console::onboarding::{{scan,get,set,prompts}}, console::compose::*, and the console::workspace::changed and console::compose::changed trigger types"
);
}

Expand Down
34 changes: 31 additions & 3 deletions ade/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,16 +86,16 @@ async fn main() -> Result<()> {
if let Some(port) = cli.http_port {
cfg.http_port = port;
}
let host_from_cli = cli.http_host.is_some();
if let Some(host) = cli.http_host {
cfg.http_host = host;
}
let bind_host: std::net::IpAddr = cfg.http_host.trim().parse().map_err(|error| {
let seed_host: std::net::IpAddr = cfg.http_host.trim().parse().map_err(|error| {
anyhow::anyhow!(
"http_host {:?} is not an IP address ({error}); use 127.0.0.1, 0.0.0.0 or an interface address",
cfg.http_host
)
})?;
server::set_bind_host(bind_host);

let engine_url = cli.url;

Expand Down Expand Up @@ -140,6 +140,11 @@ async fn main() -> Result<()> {
};
cfg.http_port = runtime_config.http_port;
cfg.data_dir = runtime_config.data_dir.clone();
// The interface is a boot-time decision: `--http-host` wins, then the
// stored `http_host` (compose's `config_override` lands there), then the
// local seed (`0.0.0.0` unless the seed file says otherwise).
let bind_host = bind_host_for(host_from_cli, seed_host, runtime_config.http_host);
server::set_bind_host(bind_host);

// Ephemeral per-instance state (the workspace tabs/panes layout) lives
// under `data_dir`, never in the committed configuration YAML. Entries
Expand All @@ -153,6 +158,7 @@ async fn main() -> Result<()> {
}

tracing::info!(
http_host = %bind_host,
http_port = cfg.http_port,
data_dir = %workspace.dir().await.display(),
engine_url = %redact_url(&engine_url),
Expand Down Expand Up @@ -286,9 +292,31 @@ fn redact_url(s: &str) -> String {
}
}

/// The interface to bind: an explicit `--http-host` first, then the stored
/// configuration value, then the local seed.
fn bind_host_for(
host_from_cli: bool,
seed_host: std::net::IpAddr,
stored: Option<std::net::IpAddr>,
) -> std::net::IpAddr {
if host_from_cli {
return seed_host;
}
stored.unwrap_or(seed_host)
}

#[cfg(test)]
mod tests {
use super::redact_url;
use super::{bind_host_for, redact_url};

#[test]
fn bind_host_prefers_cli_then_stored_then_seed() {
let any = std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED);
let local = std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST);
assert_eq!(bind_host_for(false, any, None), any);
assert_eq!(bind_host_for(false, any, Some(local)), local);
assert_eq!(bind_host_for(true, any, Some(local)), any);
}

#[test]
fn redact_url_strips_userinfo_only() {
Expand Down
Loading
Loading