From be682815f8d8f1aafe112dcfdd86061297a69e4a Mon Sep 17 00:00:00 2001 From: Daniel Vianna <1708810+dmvianna@users.noreply.github.com> Date: Mon, 4 May 2026 12:15:56 +1000 Subject: [PATCH 1/4] update beads; ignore .codex --- .beads/.gitignore | 63 ++++++++++++++++++++++++----------- .beads/dolt-backup-state.json | 4 +++ .beads/dolt-backup.json | 5 +++ .beads/hooks/post-checkout | 6 ++-- .beads/hooks/post-merge | 6 ++-- .beads/hooks/pre-commit | 6 ++-- .beads/metadata.json | 5 +-- .claude/settings.json | 26 +++++++++++++++ .gitignore | 8 +++++ CLAUDE.md | 1 + 10 files changed, 100 insertions(+), 30 deletions(-) create mode 100644 .beads/dolt-backup-state.json create mode 100644 .beads/dolt-backup.json create mode 100644 .claude/settings.json create mode 100644 CLAUDE.md diff --git a/.beads/.gitignore b/.beads/.gitignore index 31bd94b..eb82c48 100644 --- a/.beads/.gitignore +++ b/.beads/.gitignore @@ -1,11 +1,27 @@ # Dolt database (managed by Dolt, not git) dolt/ -dolt-access.lock # Runtime files bd.sock +bd.sock.startlock sync-state.json last-touched +.exclusive-lock + +# Daemon runtime (lock, log, pid) +daemon.* + +# Interactions log (runtime, not versioned) +interactions.jsonl + +# Push state (runtime, per-machine) +push-state.json + +# Lock files (various runtime locks) +*.lock + +# Credential key (encryption key for federation peer auth — never commit) +.beads-credential-key # Local version tracking (prevents upgrade notification spam after git ops) .local_version @@ -17,9 +33,30 @@ redirect # Sync state (local-only, per-machine) # These files are machine-specific and should not be shared across clones .sync.lock -.jsonl.lock -sync_base.jsonl export-state/ +export-state.json + +# Ephemeral store (SQLite - wisps/molecules, intentionally not versioned) +ephemeral.sqlite3 +ephemeral.sqlite3-journal +ephemeral.sqlite3-wal +ephemeral.sqlite3-shm + +# Dolt server management (auto-started by bd) +dolt-server.pid +dolt-server.log +dolt-server.lock +dolt-server.port +dolt-server.activity + +# Corrupt backup directories (created by bd doctor --fix recovery) +*.corrupt.backup/ + +# Backup data (auto-exported JSONL, local-only) +backup/ + +# Per-project environment file (Dolt connection config, GH#2520) +.env # Legacy files (from pre-Dolt versions) *.db @@ -29,19 +66,7 @@ export-state/ *.db-shm db.sqlite bd.db -daemon.lock -daemon.log -daemon-*.log.gz -daemon.pid -beads.base.jsonl -beads.base.meta.json -beads.left.jsonl -beads.left.meta.json -beads.right.jsonl -beads.right.meta.json - -# NOTE: Do NOT add negation patterns (e.g., !issues.jsonl) here. -# They would override fork protection in .git/info/exclude, allowing -# contributors to accidentally commit upstream issue databases. -# The JSONL files (issues.jsonl, interactions.jsonl) and config files -# are tracked by git by default since no pattern above ignores them. +# NOTE: Do NOT add negation patterns here. +# They would override fork protection in .git/info/exclude. +# Config files (metadata.json, config.yaml) are tracked by git by default +# since no pattern above ignores them. diff --git a/.beads/dolt-backup-state.json b/.beads/dolt-backup-state.json new file mode 100644 index 0000000..6f38981 --- /dev/null +++ b/.beads/dolt-backup-state.json @@ -0,0 +1,4 @@ +{ + "last_sync": "2026-05-04T02:02:18.677980026Z", + "duration": "20.148657ms" +} \ No newline at end of file diff --git a/.beads/dolt-backup.json b/.beads/dolt-backup.json new file mode 100644 index 0000000..af554a5 --- /dev/null +++ b/.beads/dolt-backup.json @@ -0,0 +1,5 @@ +{ + "backup_url": "file:///tmp/devloop-beads-backup", + "backup_name": "default", + "created_at": "2026-05-04T02:02:05.569697804Z" +} \ No newline at end of file diff --git a/.beads/hooks/post-checkout b/.beads/hooks/post-checkout index 08ed09e..9b5a906 100755 --- a/.beads/hooks/post-checkout +++ b/.beads/hooks/post-checkout @@ -4,11 +4,11 @@ # # bd (beads) post-checkout hook - thin shim # -# This shim delegates to 'bd hook post-checkout' which contains +# This shim delegates to 'bd hooks run post-checkout' which contains # the actual hook logic. This pattern ensures hook behavior is always # in sync with the installed bd version - no manual updates needed. # -# The 'bd hook' command (singular) supports: +# The 'bd hooks run' command supports: # - Guard against frequent firing (only imports if JSONL changed) # - Per-worktree state tracking # - Dolt branch-then-merge pattern @@ -20,4 +20,4 @@ if ! command -v bd >/dev/null 2>&1; then exit 0 fi -exec bd hook post-checkout "$@" +exec bd hooks run post-checkout "$@" diff --git a/.beads/hooks/post-merge b/.beads/hooks/post-merge index 8b45cb8..e003aa6 100755 --- a/.beads/hooks/post-merge +++ b/.beads/hooks/post-merge @@ -4,11 +4,11 @@ # # bd (beads) post-merge hook - thin shim # -# This shim delegates to 'bd hook post-merge' which contains +# This shim delegates to 'bd hooks run post-merge' which contains # the actual hook logic. This pattern ensures hook behavior is always # in sync with the installed bd version - no manual updates needed. # -# The 'bd hook' command (singular) supports: +# The 'bd hooks run' command supports: # - Branch-then-merge pattern for Dolt (cell-level conflict resolution) # - Per-worktree state tracking # - Hook chaining configuration @@ -21,4 +21,4 @@ if ! command -v bd >/dev/null 2>&1; then exit 0 fi -exec bd hook post-merge "$@" +exec bd hooks run post-merge "$@" diff --git a/.beads/hooks/pre-commit b/.beads/hooks/pre-commit index f14e85d..4dc9931 100755 --- a/.beads/hooks/pre-commit +++ b/.beads/hooks/pre-commit @@ -4,11 +4,11 @@ # # bd (beads) pre-commit hook — thin shim # -# Delegates to 'bd hook pre-commit' which contains the actual hook logic. +# Delegates to 'bd hooks run pre-commit' which contains the actual hook logic. # This pattern ensures hook behavior is always in sync with the installed # bd version — no manual updates needed. # -# The 'bd hook' command supports: +# The 'bd hooks run' command supports: # - Per-worktree export state tracking # - Dolt in-process export (no lock deadlocks) # - Sync-branch routing @@ -22,4 +22,4 @@ if ! command -v bd >/dev/null 2>&1; then exit 0 fi -exec bd hook pre-commit "$@" +exec bd hooks run pre-commit "$@" diff --git a/.beads/metadata.json b/.beads/metadata.json index 336a727..4d00e84 100644 --- a/.beads/metadata.json +++ b/.beads/metadata.json @@ -1,6 +1,7 @@ { "database": "dolt", - "jsonl_export": "issues.jsonl", "backend": "dolt", - "dolt_database": "beads_devloop" + "dolt_mode": "embedded", + "dolt_database": "beads_devloop", + "project_id": "c77ed6d5-2ff6-43aa-aada-aab14edfcda0" } \ No newline at end of file diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..963a538 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,26 @@ +{ + "hooks": { + "PreCompact": [ + { + "hooks": [ + { + "command": "bd prime", + "type": "command" + } + ], + "matcher": "" + } + ], + "SessionStart": [ + { + "hooks": [ + { + "command": "bd prime", + "type": "command" + } + ], + "matcher": "" + } + ] + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index f38a862..9feab12 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,11 @@ /target /examples/*/state.json **/.#* + +# Beads / Dolt files (added by bd init) +.dolt/ +*.db +.beads-credential-key + +# Codex droppings +**/.codex \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md From e7a7bed98462aafbc4c5ef0151bcf62e7e2987f4 Mon Sep 17 00:00:00 2001 From: Daniel Vianna <1708810+dmvianna@users.noreply.github.com> Date: Mon, 4 May 2026 12:47:29 +1000 Subject: [PATCH 2/4] Support parent env interpolation in config Context: client repositories need one source of truth for values supplied by their local environment. The blog config currently repeats CONTAINER_PORT-derived values across process env, tunnel URLs, browser launch URLs, and HTTP probes. Decision: add shell-free expansion for and in process command arguments, process env values, and HTTP probe URLs. The parser lives in a dedicated env_expand module; processes.rs only applies it at spawn/probe boundaries. Alternatives considered: repo-local wrapper scripts would work but duplicate glue in every client repo. Shelling out for expansion was rejected because it would introduce quoting and injection semantics unrelated to devloop's structured config. Tradeoffs: expansion is runtime-bound, so missing environment variables fail when the process or probe is prepared rather than during TOML parsing. That keeps config loading independent of the caller environment while still failing loudly with field context. Architectural impact: environment expansion is explicit and local to runtime configuration interpretation, separate from session-state interpolation. --- CHANGELOG.md | 6 ++ Cargo.lock | 2 +- Cargo.toml | 2 +- docs/configuration.md | 39 ++++++++- src/env_expand.rs | 196 ++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 1 + src/processes.rs | 140 ++++++++++++++++++++++++++++-- src/test_support.rs | 44 ++++++---- 8 files changed, 403 insertions(+), 27 deletions(-) create mode 100644 src/env_expand.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 497a40b..d4ab5f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to `devloop` will be recorded in this file. ## [Unreleased] +### Added +- Added shell-free parent-environment interpolation for process + command arguments, process environment values, and HTTP probe URLs, + so client configs can share values such as `CONTAINER_PORT` without + repo-local wrapper scripts. + ## [0.8.0] - 2026-04-08 ### Added diff --git a/Cargo.lock b/Cargo.lock index 86a1baf..8a679e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -235,7 +235,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "devloop" -version = "0.8.0" +version = "0.9.0" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index eeba7c9..fb56773 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "devloop" -version = "0.8.0" +version = "0.9.0" edition = "2024" [dependencies] diff --git a/docs/configuration.md b/docs/configuration.md index 4986bca..513c457 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -86,7 +86,7 @@ command = ["cargo", "run"] cwd = "." autostart = false restart = "always" -env = { PORT = "8080" } +env = { PORT = "$CONTAINER_PORT" } output = { inherit = true, body_style = "plain" } ``` @@ -102,6 +102,41 @@ output = { inherit = true, body_style = "plain" } - `liveness`: optional liveness probe. - `output`: inherited-output behavior and output-derived state rules. +### Environment interpolation + +Process `command` arguments, process `env` values, and HTTP probe URLs +can reference variables from the parent `devloop` environment: + +```toml +[process.server] +command = ["cargo", "run"] +env = { PORT = "$CONTAINER_PORT" } + +[process.server.readiness] +kind = "http" +url = "http://127.0.0.1:$CONTAINER_PORT/" + +[process.tunnel] +command = [ + "cloudflared", + "tunnel", + "--url", + "http://127.0.0.1:$CONTAINER_PORT", +] + +[process.chromium] +command = [ + "chromium-browser", + "--remote-debugging-port=9222", + "http://localhost:$CONTAINER_PORT", +] +``` + +Supported forms are `$NAME` and `${NAME}`. Use `$$` for a literal dollar +sign. Expansion is performed by `devloop` before spawning a process or +checking an HTTP probe; it does not invoke a shell. Missing or malformed +references fail loudly with the field and variable name. + ### Output config ```toml @@ -153,7 +188,7 @@ Rule keys: ```toml [process.server.readiness] kind = "http" -url = "http://127.0.0.1:8080/" +url = "http://127.0.0.1:$CONTAINER_PORT/" interval_ms = 500 timeout_ms = 30000 ``` diff --git a/src/env_expand.rs b/src/env_expand.rs new file mode 100644 index 0000000..4784322 --- /dev/null +++ b/src/env_expand.rs @@ -0,0 +1,196 @@ +use std::collections::BTreeMap; + +use anyhow::{Context, Result, anyhow}; + +pub(crate) fn expand_value(value: &str, field: &str) -> Result { + let mut rendered = String::with_capacity(value.len()); + let mut chars = value.char_indices().peekable(); + + while let Some((_, ch)) = chars.next() { + if ch != '$' { + rendered.push(ch); + continue; + } + + let Some((_, next)) = chars.peek().copied() else { + return Err(anyhow!("{field} contains trailing '$'")); + }; + + if next == '$' { + chars.next(); + rendered.push('$'); + continue; + } + + let name = parse_reference_name(field, next, &mut chars)?; + let replacement = std::env::var(&name) + .with_context(|| format!("{field} references missing environment variable '{name}'"))?; + rendered.push_str(&replacement); + } + + Ok(rendered) +} + +pub(crate) fn expand_vec(values: &[String], field: &str) -> Result> { + values + .iter() + .enumerate() + .map(|(index, value)| expand_value(value, &format!("{field}[{index}]"))) + .collect() +} + +pub(crate) fn expand_map( + values: &BTreeMap, + field: &str, +) -> Result> { + values + .iter() + .map(|(key, value)| Ok((key.clone(), expand_value(value, &format!("{field}.{key}"))?))) + .collect() +} + +fn parse_reference_name( + field: &str, + first: char, + chars: &mut std::iter::Peekable>, +) -> Result { + if first == '{' { + chars.next(); + let mut name = String::new(); + let mut closed = false; + for (_, candidate) in chars.by_ref() { + if candidate == '}' { + closed = true; + break; + } + name.push(candidate); + } + if !closed { + return Err(anyhow!("{field} contains unclosed environment reference")); + } + if !is_valid_env_name(&name) { + return Err(anyhow!( + "{field} contains invalid environment variable name '{name}'" + )); + } + return Ok(name); + } + + if is_env_name_start(first) { + let mut name = String::new(); + while let Some((_, candidate)) = chars.peek().copied() { + if !is_env_name_continue(candidate) { + break; + } + chars.next(); + name.push(candidate); + } + return Ok(name); + } + + Err(anyhow!( + "{field} contains invalid environment reference '${first}'" + )) +} + +fn is_valid_env_name(name: &str) -> bool { + let mut chars = name.chars(); + chars.next().is_some_and(is_env_name_start) && chars.all(is_env_name_continue) +} + +fn is_env_name_start(ch: char) -> bool { + ch == '_' || ch.is_ascii_alphabetic() +} + +fn is_env_name_continue(ch: char) -> bool { + is_env_name_start(ch) || ch.is_ascii_digit() +} + +#[cfg(test)] +mod tests { + use super::{expand_map, expand_value, expand_vec}; + use crate::test_support::EnvVarGuard; + use std::collections::BTreeMap; + + #[test] + fn expands_bare_environment_references() { + let _guard = EnvVarGuard::set("CONTAINER_PORT", Some("18080")); + + assert_eq!( + expand_value("http://127.0.0.1:$CONTAINER_PORT/", "probe.url").expect("expand value"), + "http://127.0.0.1:18080/" + ); + } + + #[test] + fn expands_braced_environment_references() { + let _guard = EnvVarGuard::set("CONTAINER_PORT", Some("18080")); + + assert_eq!( + expand_value("${CONTAINER_PORT}", "env.PORT").expect("expand value"), + "18080" + ); + } + + #[test] + fn keeps_literals_without_references() { + assert_eq!( + expand_value("http://127.0.0.1:8080/", "probe.url").expect("expand value"), + "http://127.0.0.1:8080/" + ); + } + + #[test] + fn escapes_literal_dollars() { + let _guard = EnvVarGuard::set("NAME", Some("value")); + + assert_eq!( + expand_value("cost=$$5 name=$NAME", "command[1]").expect("expand value"), + "cost=$5 name=value" + ); + } + + #[test] + fn reports_missing_environment_variables_with_context() { + let _guard = EnvVarGuard::set("MISSING_DEVLOOP_TEST_VAR", None); + let error = + expand_value("$MISSING_DEVLOOP_TEST_VAR", "command[1]").expect_err("missing var"); + + assert!(error.to_string().contains("command[1]")); + assert!(error.to_string().contains("MISSING_DEVLOOP_TEST_VAR")); + } + + #[test] + fn reports_malformed_references() { + let error = expand_value("${", "env.PORT").expect_err("malformed var"); + + assert!(error.to_string().contains("unclosed environment reference")); + } + + #[test] + fn expands_vectors_with_index_context() { + let _guard = EnvVarGuard::set("CONTAINER_PORT", Some("18080")); + let expanded = expand_vec( + &[ + "cloudflared".into(), + "http://127.0.0.1:$CONTAINER_PORT".into(), + ], + "command", + ) + .expect("expand vec"); + + assert_eq!(expanded, vec!["cloudflared", "http://127.0.0.1:18080"]); + } + + #[test] + fn expands_maps_with_key_context() { + let _guard = EnvVarGuard::set("CONTAINER_PORT", Some("18080")); + let expanded = expand_map( + &BTreeMap::from([("PORT".into(), "$CONTAINER_PORT".into())]), + "env", + ) + .expect("expand map"); + + assert_eq!(expanded["PORT"], "18080"); + } +} diff --git a/src/main.rs b/src/main.rs index 87cb103..d4ba9c6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod browser_reload; mod config; mod core; mod engine; +mod env_expand; mod external_events; mod output; mod processes; diff --git a/src/processes.rs b/src/processes.rs index 2c81327..2545e5b 100644 --- a/src/processes.rs +++ b/src/processes.rs @@ -18,6 +18,7 @@ use crate::config::{ ProcessSpec, }; use crate::core::{ProcessEffect, ProcessSupervisor}; +use crate::env_expand; use crate::external_events::{ExternalEventEnvironment, apply_external_event_env}; use crate::output::{ dim_start, format_output_prefix_with_style, should_colorize_output, style_reset, @@ -305,8 +306,16 @@ impl<'a> ProcessManager<'a> { continue; }; let now_ms = self.clock_start.elapsed().as_millis() as u64; - let healthy = match check_probe(&self.client, &process, liveness, state).await { - Ok(()) => true, + let healthy = match expand_probe_env(&process, liveness) { + Ok(liveness) => { + match check_probe(&self.client, &process, &liveness, state).await { + Ok(()) => true, + Err(error) => { + warn!("liveness probe failed for {}: {}", process, error); + false + } + } + } Err(error) => { warn!("liveness probe failed for {}: {}", process, error); false @@ -803,6 +812,7 @@ fn configure_command( cwd: PathBuf, context: CommandContext<'_>, ) -> Result { + let command = env_expand::expand_vec(command, "command")?; let Some(program) = command.first() else { return Err(anyhow!("command must not be empty")); }; @@ -810,7 +820,7 @@ fn configure_command( let mut cmd = Command::new(program); cmd.args(&command[1..]); cmd.current_dir(cwd); - let mut full_env = context.env.clone(); + let mut full_env = env_expand::expand_map(context.env, "env")?; apply_external_event_env(&mut full_env, context.external_event_env); apply_browser_reload_env(&mut full_env, context.browser_reload_env); cmd.envs(full_env); @@ -919,24 +929,51 @@ async fn wait_for_probe( probe: &ProbeSpec, state: &SessionState, ) -> Result<()> { + let expanded_probe = expand_probe_env(name, probe)?; let started = std::time::Instant::now(); - let timeout = match probe { + let timeout = match &expanded_probe { ProbeSpec::Http { timeout_ms, .. } | ProbeSpec::StateKey { timeout_ms, .. } => { Duration::from_millis(*timeout_ms) } }; - let interval = Duration::from_millis(probe.interval()); + let interval = Duration::from_millis(expanded_probe.interval()); loop { - if check_probe(client, name, probe, state).await.is_ok() { + if check_probe(client, name, &expanded_probe, state) + .await + .is_ok() + { return Ok(()); } if started.elapsed() >= timeout { - return Err(timeout_error(name, probe)); + return Err(timeout_error(name, &expanded_probe)); } sleep(interval).await; } } +fn expand_probe_env(process: &str, probe: &ProbeSpec) -> Result { + match probe { + ProbeSpec::Http { + url, + interval_ms, + timeout_ms, + } => Ok(ProbeSpec::Http { + url: env_expand::expand_value(url, &format!("process '{process}' http probe url"))?, + interval_ms: *interval_ms, + timeout_ms: *timeout_ms, + }), + ProbeSpec::StateKey { + key, + interval_ms, + timeout_ms, + } => Ok(ProbeSpec::StateKey { + key: key.clone(), + interval_ms: *interval_ms, + timeout_ms: *timeout_ms, + }), + } +} + async fn check_probe( client: &reqwest::Client, name: &str, @@ -984,7 +1021,7 @@ fn timeout_error(name: &str, probe: &ProbeSpec) -> anyhow::Error { mod tests { use super::*; use crate::config::{OutputExtract, ProbeSpec}; - use crate::test_support::RustLogGuard; + use crate::test_support::{EnvVarGuard, RustLogGuard}; use serde_json::Value; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; @@ -1384,6 +1421,73 @@ mod tests { assert_eq!(rust_log, "info,gcp_rust_blog=debug"); } + #[test] + fn configure_command_expands_parent_env_in_command_args_and_env_values() { + let _guard = EnvVarGuard::set("CONTAINER_PORT", Some("18080")); + let mut env = BTreeMap::new(); + env.insert("PORT".into(), "$CONTAINER_PORT".into()); + + let command = configure_command( + &[ + "cloudflared".into(), + "tunnel".into(), + "--url".into(), + "http://127.0.0.1:$CONTAINER_PORT".into(), + ], + PathBuf::from("/tmp"), + CommandContext { + env: &env, + external_event_env: None, + browser_reload_env: None, + root: Path::new("/tmp"), + state_path: Path::new("/tmp/state.json"), + changed_files: &[], + workflow: "startup", + }, + ) + .expect("configure command"); + + let args = command.as_std().get_args().collect::>(); + assert_eq!( + args, + vec![ + std::ffi::OsStr::new("tunnel"), + std::ffi::OsStr::new("--url"), + std::ffi::OsStr::new("http://127.0.0.1:18080") + ] + ); + let port = command + .as_std() + .get_envs() + .find(|(key, _)| *key == std::ffi::OsStr::new("PORT")) + .and_then(|(_, value)| value) + .expect("PORT should be set"); + assert_eq!(port, std::ffi::OsStr::new("18080")); + } + + #[test] + fn configure_command_reports_missing_env_references() { + let _guard = EnvVarGuard::set("MISSING_DEVLOOP_TEST_VAR", None); + + let error = configure_command( + &["echo".into(), "$MISSING_DEVLOOP_TEST_VAR".into()], + PathBuf::from("/tmp"), + CommandContext { + env: &BTreeMap::new(), + external_event_env: None, + browser_reload_env: None, + root: Path::new("/tmp"), + state_path: Path::new("/tmp/state.json"), + changed_files: &[], + workflow: "startup", + }, + ) + .expect_err("missing env should fail"); + + assert!(error.to_string().contains("command[1]")); + assert!(error.to_string().contains("MISSING_DEVLOOP_TEST_VAR")); + } + #[test] fn configure_command_injects_external_event_environment() { let env = BTreeMap::new(); @@ -1553,4 +1657,24 @@ mod tests { std::fs::remove_file(state_path).expect("cleanup state file"); } + + #[test] + fn expands_http_probe_urls_from_parent_env() { + let _guard = EnvVarGuard::set("CONTAINER_PORT", Some("18080")); + + let expanded = expand_probe_env( + "server", + &ProbeSpec::Http { + url: "http://127.0.0.1:$CONTAINER_PORT/".into(), + interval_ms: 100, + timeout_ms: 1000, + }, + ) + .expect("expand probe"); + + match expanded { + ProbeSpec::Http { url, .. } => assert_eq!(url, "http://127.0.0.1:18080/"), + ProbeSpec::StateKey { .. } => panic!("expected http probe"), + } + } } diff --git a/src/test_support.rs b/src/test_support.rs index 80751b5..d298d0e 100644 --- a/src/test_support.rs +++ b/src/test_support.rs @@ -1,43 +1,57 @@ use std::ffi::{OsStr, OsString}; use std::sync::{Mutex, MutexGuard, OnceLock}; -fn rust_log_lock() -> &'static Mutex<()> { +fn env_lock() -> &'static Mutex<()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())) } -pub(crate) struct RustLogGuard { +pub(crate) struct EnvVarGuard { _lock: MutexGuard<'static, ()>, + key: &'static str, original: Option, } -impl RustLogGuard { - pub(crate) fn set(value: Option<&str>) -> Self { - let lock = rust_log_lock().lock().expect("lock RUST_LOG test mutex"); - let original = std::env::var_os("RUST_LOG"); +impl EnvVarGuard { + pub(crate) fn set(key: &'static str, value: Option<&str>) -> Self { + let lock = env_lock().lock().expect("lock test env mutex"); + let original = std::env::var_os(key); match value { - Some(value) => set_test_env_var("RUST_LOG", value), - None => remove_test_env_var("RUST_LOG"), + Some(value) => set_test_env_var(key, value), + None => remove_test_env_var(key), } Self { _lock: lock, + key, original, } } } -impl Drop for RustLogGuard { +impl Drop for EnvVarGuard { fn drop(&mut self) { match &self.original { - Some(value) => set_test_env_var("RUST_LOG", value), - None => remove_test_env_var("RUST_LOG"), + Some(value) => set_test_env_var(self.key, value), + None => remove_test_env_var(self.key), + } + } +} + +pub(crate) struct RustLogGuard { + _guard: EnvVarGuard, +} + +impl RustLogGuard { + pub(crate) fn set(value: Option<&str>) -> Self { + Self { + _guard: EnvVarGuard::set("RUST_LOG", value), } } } fn set_test_env_var(key: &str, value: impl AsRef) { - // SAFETY: all test-time RUST_LOG mutation goes through the shared - // `rust_log_lock`, so no concurrent unit test can race on this + // SAFETY: all test-time environment mutation goes through the shared + // `env_lock`, so no concurrent unit test can race on this // process-global environment state. unsafe { std::env::set_var(key, value); @@ -45,8 +59,8 @@ fn set_test_env_var(key: &str, value: impl AsRef) { } fn remove_test_env_var(key: &str) { - // SAFETY: all test-time RUST_LOG mutation goes through the shared - // `rust_log_lock`, so removing the variable cannot race with another + // SAFETY: all test-time environment mutation goes through the shared + // `env_lock`, so removing the variable cannot race with another // unit test in this process. unsafe { std::env::remove_var(key); From d4548c6b45fd6642829b6e37a232750feb621691 Mon Sep 17 00:00:00 2001 From: Daniel Vianna <1708810+dmvianna@users.noreply.github.com> Date: Mon, 4 May 2026 14:44:27 +1000 Subject: [PATCH 3/4] Document environment interpolation Context: parent-environment interpolation is now supported in process command arguments, process env values, and HTTP probe URLs, but the behavior needed to be discoverable outside the implementation tests. Decision: add a README usage example near the managed-process workflow and document the runtime expansion rules in the behavior reference. Alternatives considered: keeping the detail only in the configuration reference was too easy to miss for users reading the overview or runtime semantics. Tradeoffs: this duplicates the supported forms in two docs, but keeps the overview and behavior reference independently useful. Architectural impact: no runtime changes; this makes the explicit environment boundary part of the documented config contract. --- .beads/interactions.jsonl | 2 ++ docs/behavior.md | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index e69de29..b204f01 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -0,0 +1,2 @@ +{"id":"int-7230a6ae","kind":"field_change","created_at":"2026-05-04T04:43:24.260954052Z","actor":"Daniel Vianna","issue_id":"devloop-hrb","extra":{"field":"status","new_value":"in_progress","old_value":"open"}} +{"id":"int-0600ed25","kind":"field_change","created_at":"2026-05-04T04:44:14.236762188Z","actor":"Daniel Vianna","issue_id":"devloop-hrb","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Documented in README and behavior reference"}} diff --git a/docs/behavior.md b/docs/behavior.md index 41f57b9..d4f8d65 100644 --- a/docs/behavior.md +++ b/docs/behavior.md @@ -108,6 +108,14 @@ Managed processes are long-running child commands. - Managed child processes inherit the ambient environment unless the process config explicitly overrides individual variables such as `env.RUST_LOG`. +- Before a process is spawned, `devloop` expands `$NAME` and `${NAME}` + references in process command arguments and configured process + environment values from its own parent environment. Use `$$` for a + literal dollar sign. +- HTTP readiness and liveness probe URLs use the same expansion when the + probe is checked. +- Missing or malformed environment references fail loudly with the + field name so the configuration error is visible. Liveness probes are checked on the configured interval while the process is running. If a liveness probe fails and the restart policy allows it, From f72b2864e2fdc7b1d29e203eed9bb20be541bd41 Mon Sep 17 00:00:00 2001 From: Daniel Vianna <1708810+dmvianna@users.noreply.github.com> Date: Mon, 4 May 2026 14:56:12 +1000 Subject: [PATCH 4/4] Prepare 0.9.0 changelog Context: the release automation reads notes from a dated CHANGELOG.md section that matches the pushed tag. Decision: move the environment interpolation note from Unreleased into a 0.9.0 section dated 2026-05-04 and close the release-prep bead. Alternatives considered: leaving the note under Unreleased would make the v0.9.0 release workflow fail to extract release notes. Tradeoffs: this makes the PR release-ready before merge, at the cost of recording the release date ahead of the tag push. Architectural impact: no runtime impact; this aligns the changelog with the tag-driven release contract. --- .beads/interactions.jsonl | 2 ++ CHANGELOG.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index b204f01..c36fce8 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -1,2 +1,4 @@ {"id":"int-7230a6ae","kind":"field_change","created_at":"2026-05-04T04:43:24.260954052Z","actor":"Daniel Vianna","issue_id":"devloop-hrb","extra":{"field":"status","new_value":"in_progress","old_value":"open"}} {"id":"int-0600ed25","kind":"field_change","created_at":"2026-05-04T04:44:14.236762188Z","actor":"Daniel Vianna","issue_id":"devloop-hrb","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Documented in README and behavior reference"}} +{"id":"int-76f6d530","kind":"field_change","created_at":"2026-05-04T04:54:41.658928411Z","actor":"Daniel Vianna","issue_id":"devloop-a2l","extra":{"field":"status","new_value":"in_progress","old_value":"open"}} +{"id":"int-a662fcf5","kind":"field_change","created_at":"2026-05-04T04:55:43.073552254Z","actor":"Daniel Vianna","issue_id":"devloop-a2l","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"0.9.0 changelog section prepared for release"}} diff --git a/CHANGELOG.md b/CHANGELOG.md index d4ab5f1..49e09b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to `devloop` will be recorded in this file. ## [Unreleased] +## [0.9.0] - 2026-05-04 + ### Added - Added shell-free parent-environment interpolation for process command arguments, process environment values, and HTTP probe URLs,