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
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,24 @@ 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
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

Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "devloop"
version = "0.3.0"
version = "0.4.0"
edition = "2024"

[dependencies]
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Docs

- [Behavior Reference](behavior.md)
- [Configuration Reference](configuration.md)

This directory holds detailed reference material for `devloop`.
Expand Down
169 changes: 169 additions & 0 deletions docs/behavior.md
Original file line number Diff line number Diff line change
@@ -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.<name>.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
31 changes: 30 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -133,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"
```
Expand All @@ -142,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.
Expand Down
58 changes: 58 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,8 @@ pub struct HookSpec {
pub cwd: Option<PathBuf>,
#[serde(default)]
pub env: BTreeMap<String, String>,
#[serde(default = "default_hook_output_config")]
pub output: HookOutputConfig,
pub capture: Option<CaptureMode>,
pub state_key: Option<String>,
}
Expand All @@ -319,13 +321,34 @@ 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"));
}
Ok(())
}
}

#[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 {
Expand Down Expand Up @@ -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::*;
Expand Down Expand Up @@ -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);
}
}
Loading
Loading