From be90d2335b541ed6fdfb4068b75fdccb9bdb54a0 Mon Sep 17 00:00:00 2001 From: Daniel Vianna <1708810+dmvianna@users.noreply.github.com> Date: Wed, 25 Mar 2026 18:37:26 +1100 Subject: [PATCH 1/4] Reapply dim styling after ANSI colors --- CHANGELOG.md | 3 +++ docs/configuration.md | 4 +++- src/processes.rs | 19 +++++++++++++++++-- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bbd06d..c48fbb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ All notable changes to `devloop` will be recorded in this file. ### Fixed - Preserved UTF-8 multibyte characters in inherited subprocess output so watch tools render units such as `μs` correctly. +- Reapplied dim styling after child ANSI SGR sequences when + `output.body_style = "dim"` so colored subprocess logs can still + recede visually without losing their tint entirely. ## [0.3.0] - 2026-03-25 diff --git a/docs/configuration.md b/docs/configuration.md index 8ec0059..d27a820 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -82,7 +82,9 @@ output = { - `plain`: preserve the process body text as-is, including native ANSI colors when present. - `dim`: dim non-control body text so `devloop` engine logs stand out - more strongly. + more strongly. When subprocesses emit ANSI SGR color sequences, + `devloop` reapplies dim after each sequence so the original tint is + preserved as much as the terminal allows. Use `plain` when subprocess color or exact body rendering matters. Use `dim` when you want inherited process output to recede visually. diff --git a/src/processes.rs b/src/processes.rs index 206096e..d0d8a8d 100644 --- a/src/processes.rs +++ b/src/processes.rs @@ -556,7 +556,11 @@ fn render_output_byte( if matches!(byte, 0x40..=0x7e) { render_state.ansi_escape_state = AnsiEscapeState::None; } - return (byte as char).to_string(); + let mut text = (byte as char).to_string(); + if byte == b'm' && matches!(body_style, OutputBodyStyle::Dim) { + text.push_str(dim_start(colorize)); + } + return text; } AnsiEscapeState::None => {} } @@ -962,7 +966,18 @@ mod tests { ] .concat(); - assert_eq!(rendered, "\u{1b}[34mD"); + assert_eq!(rendered, "\u{1b}[34m\u{1b}[2mD"); + } + + #[test] + fn render_output_byte_reapplies_dim_after_reset_sequence() { + let mut render_state = OutputRenderState::new(); + let rendered = [0x1b_u8, b'[', b'0', b'm'] + .into_iter() + .map(|byte| render_output_byte(byte, true, OutputBodyStyle::Dim, &mut render_state)) + .collect::(); + + assert_eq!(rendered, "\u{1b}[0m\u{1b}[2m"); } #[test] From 2a15c848c867b645490b3a87054e4d02ce75b6e3 Mon Sep 17 00:00:00 2001 From: Daniel Vianna <1708810+dmvianna@users.noreply.github.com> Date: Wed, 25 Mar 2026 19:29:54 +1100 Subject: [PATCH 2/4] Dim hook output and expand docs --- CHANGELOG.md | 6 ++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 3 + docs/README.md | 1 + docs/behavior.md | 169 ++++++++++++++++++++++++++++++++++++++++++ docs/configuration.md | 27 +++++++ src/config.rs | 58 +++++++++++++++ src/processes.rs | 120 +++++++++++++++++++++++++++++- 9 files changed, 384 insertions(+), 4 deletions(-) create mode 100644 docs/behavior.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c48fbb7..e4606f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,17 @@ All notable changes to `devloop` will be recorded in this file. ## [Unreleased] +## [0.4.0] - 2026-03-25 + ### Added - Source-labeled managed process output so mixed logs show which configured process and executable emitted each line. - Stable per-process label colors and dimmed managed-process bodies so `devloop` workflow and engine logs stand out by contrast. +- Source-labeled hook stdout and stderr with dimmed bodies by default so + short-lived helper commands remain visible without dominating the main + process logs. +- Detailed runtime behavior reference under [`docs/behavior.md`](docs/behavior.md). ### Fixed - Preserved UTF-8 multibyte characters in inherited subprocess output diff --git a/Cargo.lock b/Cargo.lock index 3af4d32..e4f906f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -183,7 +183,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "devloop" -version = "0.3.0" +version = "0.4.0" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index 4c338a4..ea4f495 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "devloop" -version = "0.3.0" +version = "0.4.0" edition = "2024" [dependencies] diff --git a/README.md b/README.md index 20eaf76..3489627 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,9 @@ That renders inherited lines with the process name and executable, for example `[tunnel cloudflared] ...`, using ANSI color when stdout is a terminal and `NO_COLOR` is not set. +For the runtime behavior reference, see +[`docs/behavior.md`](docs/behavior.md). + For the full configuration reference, see [`docs/configuration.md`](docs/configuration.md). diff --git a/docs/README.md b/docs/README.md index 37961a2..aacde2c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,5 +1,6 @@ # Docs +- [Behavior Reference](behavior.md) - [Configuration Reference](configuration.md) This directory holds detailed reference material for `devloop`. diff --git a/docs/behavior.md b/docs/behavior.md new file mode 100644 index 0000000..3dafd50 --- /dev/null +++ b/docs/behavior.md @@ -0,0 +1,169 @@ +# Behavior Reference + +This document describes how `devloop` behaves at runtime beyond the +schema in [`configuration.md`](configuration.md). + +## Startup + +When `devloop run` starts, it: + +1. loads and validates `devloop.toml` +2. resolves `root`, `state_file`, command paths, and relative working + directories +3. loads the session state file into memory +4. starts any processes with `autostart = true` +5. runs each workflow named in `startup_workflows` in order +6. starts watching the configured `root` + +The in-memory session state is authoritative for the running process. +Edits made directly to the JSON file while `devloop` is running are not +merged back into the live session. + +## Watching and debounce + +`devloop` watches the configured `root` recursively. + +- Only relevant file-system events are considered. +- Events are batched for `debounce_ms`. +- Matching changes are grouped by workflow name before execution. +- Each workflow receives the set of changed relative paths that matched + it during the debounce window. + +If multiple watch groups map to the same workflow, their matched paths +are merged for that workflow run. + +## Workflow execution + +Workflows run step by step, in order. + +- A step must finish successfully before the next one begins. +- `run_workflow` executes another named workflow inline. +- Recursive workflow graphs are rejected at config-validation time. +- `write_state` renders `{{state_key}}` templates against the current + in-memory session state. +- `log` also renders templates against the current session state before + emitting output. + +If any step fails, the workflow fails immediately and `devloop run` +returns an error. + +## Processes + +Managed processes are long-running child commands. + +- `start_process` is a no-op if the named process is already running. +- `restart_process` stops the child, then starts it again. +- `wait_for_process` waits on the configured readiness probe, not just + on successful spawning. +- `restart = "always"` restarts a child after any exit unless + `devloop` is shutting down. +- `restart = "on_failure"` restarts only after unsuccessful exit. +- `restart = "never"` never restarts automatically. + +Liveness probes are checked on the configured interval while the process +is running. If a liveness probe fails and the restart policy allows it, +the process is restarted. + +## Hooks + +Hooks are one-shot commands executed inside workflows. + +- Hooks run to completion before the workflow continues. +- Hook stdout and stderr are captured fully, then rendered with a source + label if `hook..output.inherit` is enabled. +- Hook output defaults to `body_style = "dim"` so helper-command output + is visible but visually secondary. +- Hook capture is independent of hook output rendering. +- `capture = "text"` trims stdout and stores it in `state_key`. +- `capture = "json"` parses stdout as a JSON object and merges it into + the session state. +- A non-zero hook exit status fails the workflow after any captured + stdout and stderr have been rendered. + +## Output rendering + +`devloop` uses a line-oriented, pipe-based output model. + +- Terminal-native subprocess UIs are a non-goal. +- Child stdout is forwarded to `devloop` stdout. +- Child stderr is forwarded to `devloop` stderr. +- `devloop` engine and process logs are emitted through `tracing`. +- Managed-process and hook output is source-labeled with the configured + name and executable. +- When output color is enabled, labels are colorized per source. + +### Color rules + +Colorized output is enabled when stdout is a terminal and `NO_COLOR` is +not set. + +- `body_style = "plain"` preserves subprocess body text as-is. +- `body_style = "dim"` dims the inherited body text. +- When a subprocess emits ANSI SGR color sequences while `body_style = + "dim"`, `devloop` reapplies dim after each SGR sequence so the + original tint is preserved as much as the terminal allows. + +### Carriage returns and line boundaries + +`devloop` prefers visibility over terminal redraw semantics. + +- `\r` is treated as a visible line boundary. +- `\r\n` does not double-print. +- Output is buffered by line before each write so prefixes do not split + mid-line. +- UTF-8 multibyte sequences are buffered before decoding so characters + such as `μ` survive inherited output rendering. + +This is meant for readable supervised logs, not PTY emulation. + +## Output-derived state + +Long-running processes can write values into session state by matching +their inherited output against configured rules. + +- Rules run on the raw output stream, line by line. +- Regex extraction uses the configured `capture_group`. +- `url_token` extracts the first token that looks like a + `trycloudflare.com` URL. +- State keys configured in output rules are cleared before the process + starts. + +This is how a process such as `cloudflared` can publish a readiness +value without wrapper scripts. + +## Readiness and liveness probes + +HTTP probes succeed on an HTTP success status. + +State-key probes succeed when the referenced session-state key exists +and is not empty after trimming. + +These probe types are used both for workflow waiting and for ongoing +liveness checks. + +## Session state + +Session state is shared across the running engine, workflows, hooks, and +output-derived updates. + +- `root` is written into session state when the engine starts. +- `last_workflow` and `last_changed_files` are updated for top-level + workflow runs triggered by watches or startup execution. +- Nested `run_workflow` calls reuse the same session state without + overwriting the top-level change context. + +## Shutdown + +On `ctrl-c`, `devloop`: + +1. marks itself as shutting down +2. stops all managed processes +3. suppresses further automatic restarts +4. exits + +## Known non-goals + +- PTY emulation +- full-screen terminal UIs +- byte-exact reconstruction of combined stdout and stderr ordering after + a child has already split output across file descriptors diff --git a/docs/configuration.md b/docs/configuration.md index d27a820..066c1e7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -135,6 +135,7 @@ Hooks are narrow one-shot commands invoked from workflows. [hook.current_post_slug] command = ["./scripts/current-post-slug.sh"] cwd = "." +output = { inherit = true, body_style = "dim" } capture = "text" state_key = "current_post_slug" ``` @@ -144,15 +145,41 @@ state_key = "current_post_slug" - `command`: command and arguments. Required. - `cwd`: working directory. - `env`: extra environment variables. +- `output`: inherited-output behavior for hook stdout and stderr. - `capture`: one of `ignore`, `text`, or `json`. - `state_key`: required for `capture = "text"`. +### Hook output config + +```toml +output = { inherit = true, body_style = "dim" } +``` + +- `inherit`: whether hook stdout and stderr should be forwarded by + `devloop`. Default: `true`. +- `body_style`: visual treatment for forwarded hook body text. Default: + `dim`. + +Hooks default to dimmed inherited output because they are typically +short-lived helper commands whose output is useful context but not the +primary long-running log stream. + ### Hook capture modes - `ignore`: discard stdout. - `text`: write trimmed stdout into `state_key`. - `json`: parse stdout as a JSON object and merge it into session state. +Hook forwarding and capture are separate behaviors: + +- inherited hook output is shown with a source label if `output.inherit` + is enabled +- `capture = "text"` stores trimmed stdout in `state_key` +- `capture = "json"` parses stdout and merges the result into session + state +- failed hooks return an error after any captured stdout and stderr have + been rendered + ## Workflows Workflows are ordered steps. diff --git a/src/config.rs b/src/config.rs index df09b21..a557825 100644 --- a/src/config.rs +++ b/src/config.rs @@ -310,6 +310,8 @@ pub struct HookSpec { pub cwd: Option, #[serde(default)] pub env: BTreeMap, + #[serde(default = "default_hook_output_config")] + pub output: HookOutputConfig, pub capture: Option, pub state_key: Option, } @@ -319,6 +321,7 @@ impl HookSpec { if self.command.is_empty() { return Err(anyhow!("hook command must not be empty")); } + self.output.validate()?; if matches!(self.capture, Some(CaptureMode::Text)) && self.state_key.is_none() { return Err(anyhow!("text capture requires state_key")); } @@ -326,6 +329,26 @@ impl HookSpec { } } +#[derive(Debug, Clone, Deserialize)] +pub struct HookOutputConfig { + #[serde(default = "default_true")] + pub inherit: bool, + #[serde(default = "default_hook_body_style")] + pub body_style: OutputBodyStyle, +} + +impl Default for HookOutputConfig { + fn default() -> Self { + default_hook_output_config() + } +} + +impl HookOutputConfig { + fn validate(&self) -> Result<()> { + Ok(()) + } +} + #[derive(Debug, Clone, Copy, Deserialize)] #[serde(rename_all = "snake_case")] pub enum CaptureMode { @@ -459,6 +482,17 @@ fn default_capture_group() -> usize { 1 } +fn default_hook_body_style() -> OutputBodyStyle { + OutputBodyStyle::Dim +} + +fn default_hook_output_config() -> HookOutputConfig { + HookOutputConfig { + inherit: true, + body_style: OutputBodyStyle::Dim, + } +} + #[cfg(test)] mod tests { use super::*; @@ -546,4 +580,28 @@ mod tests { assert_eq!(config.body_style, OutputBodyStyle::Dim); } + + #[test] + fn hook_output_defaults_to_dimmed_inherited_output() { + let config: HookOutputConfig = toml::from_str("").expect("parse hook output config"); + + assert!(config.inherit); + assert_eq!(config.body_style, OutputBodyStyle::Dim); + } + + #[test] + fn hook_spec_defaults_to_dimmed_inherited_output() { + let hook: HookSpec = toml::from_str("command = [\"echo\", \"ok\"]").expect("parse hook"); + + assert!(hook.output.inherit); + assert_eq!(hook.output.body_style, OutputBodyStyle::Dim); + } + + #[test] + fn hook_output_parses_plain_body_style_override() { + let config: HookOutputConfig = + toml::from_str("body_style = \"plain\"").expect("parse hook output config"); + + assert_eq!(config.body_style, OutputBodyStyle::Plain); + } } diff --git a/src/processes.rs b/src/processes.rs index d0d8a8d..947e384 100644 --- a/src/processes.rs +++ b/src/processes.rs @@ -13,8 +13,8 @@ use tokio::time::sleep; use tracing::{info, warn}; use crate::config::{ - Config, HookSpec, OutputBodyStyle, OutputExtract, OutputRule, ProbeSpec, ProcessSpec, - RestartPolicy, + Config, HookOutputConfig, HookSpec, OutputBodyStyle, OutputExtract, OutputRule, ProbeSpec, + ProcessSpec, RestartPolicy, }; use crate::output::{dim_start, format_output_prefix, should_colorize_output, style_reset}; use crate::state::SessionState; @@ -118,6 +118,9 @@ impl<'a> ProcessManager<'a> { .output() .await .with_context(|| format!("failed to run hook '{name}'"))?; + let source_label = process_output_source_label(name, &spec.command); + self.render_hook_output(&source_label, &spec.output, &output.stdout, &output.stderr) + .await; if !output.status.success() { return Err(anyhow!( "hook '{name}' failed with status {}", @@ -257,6 +260,46 @@ impl<'a> ProcessManager<'a> { pub fn initiate_shutdown(&mut self) { self.shutting_down = true; } + + async fn render_hook_output( + &self, + source_label: &str, + output: &HookOutputConfig, + stdout: &[u8], + stderr: &[u8], + ) { + if !output.inherit { + return; + } + + if let Err(error) = write_captured_output( + &OutputSink::Stdout(self.stdout.clone()), + source_label, + stdout, + output.body_style, + ) + .await + { + warn!( + "failed to write hook stdout for {}: {}", + source_label, error + ); + } + + if let Err(error) = write_captured_output( + &OutputSink::Stderr(self.stderr.clone()), + source_label, + stderr, + output.body_style, + ) + .await + { + warn!( + "failed to write hook stderr for {}: {}", + source_label, error + ); + } + } } #[derive(Clone)] @@ -448,6 +491,49 @@ async fn flush_rendered_output( } } +async fn write_captured_output( + output: &OutputSink, + source_label: &str, + bytes: &[u8], + body_style: OutputBodyStyle, +) -> std::io::Result<()> { + match output { + OutputSink::Stdout(writer) => { + write_captured_output_to_writer(writer, source_label, bytes, body_style).await + } + OutputSink::Stderr(writer) => { + write_captured_output_to_writer(writer, source_label, bytes, body_style).await + } + } +} + +async fn write_captured_output_to_writer( + writer: &Arc>, + source_label: &str, + bytes: &[u8], + body_style: OutputBodyStyle, +) -> std::io::Result<()> +where + W: AsyncWriteExt + Unpin + Send, +{ + let colorize = should_colorize_output(); + let mut render_state = OutputRenderState::default(); + + for &byte in bytes { + write_output_byte_to_writer( + writer, + source_label, + byte, + colorize, + body_style, + &mut render_state, + ) + .await?; + } + + flush_rendered_output_to_writer(writer, &mut render_state, false).await +} + async fn write_output_byte_to_writer( writer: &Arc>, source_label: &str, @@ -1162,6 +1248,36 @@ mod tests { assert!(rendered.ends_with("\u{1b}[2malpha\u{1b}[0m\n")); } + #[tokio::test] + async fn write_captured_output_dims_hook_body_by_default() { + let (writer, mut reader) = tokio::io::duplex(256); + let writer = Arc::new(Mutex::new(writer)); + + write_captured_output_to_writer( + &writer, + "build_css build-css.sh", + b"Done in 73ms\n", + OutputBodyStyle::Dim, + ) + .await + .expect("write captured output"); + + drop(writer); + + let mut rendered = String::new(); + reader + .read_to_string(&mut rendered) + .await + .expect("read rendered output"); + + assert!(rendered.contains("[build_css build-css.sh]")); + if should_colorize_output() { + assert!(rendered.contains("\u{1b}[2mDone in 73ms\u{1b}[0m")); + } else { + assert!(rendered.ends_with("Done in 73ms\n")); + } + } + #[test] fn process_output_source_label_uses_process_name_and_executable() { let label = process_output_source_label( From 18e42e40fe3201a1d5ee4ae79d3610ae9d658a78 Mon Sep 17 00:00:00 2001 From: Daniel Vianna <1708810+dmvianna@users.noreply.github.com> Date: Wed, 25 Mar 2026 19:31:25 +1100 Subject: [PATCH 3/4] Simplify hook output writer path --- src/processes.rs | 36 ++++++------------------------------ 1 file changed, 6 insertions(+), 30 deletions(-) diff --git a/src/processes.rs b/src/processes.rs index 947e384..9cb5516 100644 --- a/src/processes.rs +++ b/src/processes.rs @@ -272,13 +272,9 @@ impl<'a> ProcessManager<'a> { return; } - if let Err(error) = write_captured_output( - &OutputSink::Stdout(self.stdout.clone()), - source_label, - stdout, - output.body_style, - ) - .await + if let Err(error) = + write_captured_output_to_writer(&self.stdout, source_label, stdout, output.body_style) + .await { warn!( "failed to write hook stdout for {}: {}", @@ -286,13 +282,9 @@ impl<'a> ProcessManager<'a> { ); } - if let Err(error) = write_captured_output( - &OutputSink::Stderr(self.stderr.clone()), - source_label, - stderr, - output.body_style, - ) - .await + if let Err(error) = + write_captured_output_to_writer(&self.stderr, source_label, stderr, output.body_style) + .await { warn!( "failed to write hook stderr for {}: {}", @@ -491,22 +483,6 @@ async fn flush_rendered_output( } } -async fn write_captured_output( - output: &OutputSink, - source_label: &str, - bytes: &[u8], - body_style: OutputBodyStyle, -) -> std::io::Result<()> { - match output { - OutputSink::Stdout(writer) => { - write_captured_output_to_writer(writer, source_label, bytes, body_style).await - } - OutputSink::Stderr(writer) => { - write_captured_output_to_writer(writer, source_label, bytes, body_style).await - } - } -} - async fn write_captured_output_to_writer( writer: &Arc>, source_label: &str, From bc936872879d56acd55624f6bfe196d0edecd621 Mon Sep 17 00:00:00 2001 From: Daniel Vianna <1708810+dmvianna@users.noreply.github.com> Date: Wed, 25 Mar 2026 19:34:25 +1100 Subject: [PATCH 4/4] Clarify changelog requirement in AGENTS --- AGENTS.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 194bacf..aae145c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,7 +44,8 @@ without hard-coding knowledge of any one repository. ## Documentation expectations - Keep `README.md` focused on current goals and how to run the tool. - Keep `PLAN.md` aligned with the next reviewable milestones. -- Record new functionality in `CHANGELOG.md` as part of the same change. +- User-visible functionality changes and behavior changes must update + `CHANGELOG.md` as part of the same change before commit. - `devloop` uses semantic versioning. Update versions intentionally to match the scope of delivered changes. - Record follow-up work in `bd`, not as free-form TODO comments.