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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/target
/.devloop/
/examples/*/state.json
**/.#*

Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ All notable changes to `devloop` will be recorded in this file.

## [Unreleased]

## [0.10.0] - 2026-07-23

### Added

- `devloop run` now persists a unique session log under the state-file
directory's `logs/` subdirectory (by default `.devloop/logs/`), including
engine, process, and hook output even when terminal inheritance is disabled.

## [0.9.3] - 2026-07-22

### Changed
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.9.3"
version = "0.10.0"
edition = "2024"

[dependencies]
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,12 @@ The session state file is owned by `devloop` while it is running.
External edits to that file are not merged back into the live session;
restart the supervisor if you need to seed a different initial state.

Each `devloop run` also writes a durable, per-session log beside that
state file. With the default state-file location, logs live under
`.devloop/logs/`; add `.devloop/` to the client repository's `.gitignore`.
The [Behavior Reference](docs/behavior.md#session-logs) defines what the log
captures and how `devloop` handles it.

## Example use case

Used as the primary local development workflow for
Expand Down
4 changes: 3 additions & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,6 @@

This directory holds detailed reference material for `devloop`.
Keep the top-level `README.md` focused on purpose, installation, and the
main development loop; put configuration detail here.
main development loop; put configuration detail here. Session-log behaviour
is documented in the Behavior Reference, and its path follows the state-file
configuration in the Configuration Reference.
20 changes: 20 additions & 0 deletions docs/behavior.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,26 @@ server for browser listeners.
first.
- When output color is enabled, labels are colorized per source.

### Session logs

Every `devloop run` also persists a per-session log under the `logs/`
directory beside its state file. With the default state file, that is
`.devloop/logs/`. `devloop` reports the selected path at startup.

The persistent log contains `tracing` output and labeled managed-process and
hook output. It records that child output even when `output.inherit` hides it
from the terminal. `devloop` creates the directory and log before starting the
runtime; a creation failure stops startup rather than running without durable
evidence.

Devloop ignores the active session-log file when classifying watch events, so
broad patterns such as `**/*` do not retrigger workflows on that log write.
Other files in the same `logs/` directory remain normal watched files.

`devloop` does not rotate or delete session logs. The client owns retention;
add `.devloop/` to the client repository's `.gitignore` when using the default
state-file location.

### Color rules

Colorized output is enabled when stdout is a terminal and `NO_COLOR` is
Expand Down
10 changes: 10 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,16 @@ startup_workflows = ["startup"]
- `startup_workflows`: workflows to run after autostart processes have
been started.

## State and session logs

Each `devloop run` creates a unique durable log beside the state file, under
`<state-file-parent>/logs/`. The default layout is therefore
`<root>/.devloop/logs/`. Add `.devloop/` to the client repository's
`.gitignore`; `devloop` owns both the state and log files there. Session logs
need no configuration and are created only by `devloop run`, not by
`devloop validate` or `devloop docs`. See [Behavior Reference](behavior.md)
for capture, failure, and retention rules.

Optional watcher backend config:

```toml
Expand Down
148 changes: 135 additions & 13 deletions src/engine.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
Expand All @@ -21,10 +21,12 @@ use crate::config::{CompiledWatchGroup, CompiledWatchTarget, Config, LogStyle, W
use crate::core::{RuntimeEffect, RuntimeEvent, RuntimeMachine, WorkflowEffect, WorkflowMachine};
use crate::external_events::{ExternalEventMessage, ExternalEventServer};
use crate::processes::ProcessManager;
use crate::session_log::SessionLog;
use crate::state::SessionState;

pub struct Engine {
config: Config,
session_log: SessionLog,
}

trait WorkflowEffectAdapter {
Expand Down Expand Up @@ -79,8 +81,11 @@ struct LiveRuntimeAdapter<'a, 'b> {
}

impl Engine {
pub fn new(config: Config) -> Self {
Self { config }
pub fn new(config: Config, session_log: SessionLog) -> Self {
Self {
config,
session_log,
}
}

pub async fn run(self) -> Result<()> {
Expand All @@ -90,16 +95,24 @@ impl Engine {
.clone()
.ok_or_else(|| anyhow!("state file missing after config load"))?,
)?;
let mut processes = ProcessManager::new(&self.config);
let mut processes =
ProcessManager::new(&self.config).with_session_log(self.session_log.clone());
let watch_groups = self.config.compiled_watchers()?;
let watched_targets = self.config.compiled_watch_targets();
let ignored_watch_paths = vec![self.session_log.path().to_path_buf()];
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let (external_event_tx, mut external_event_rx) = tokio::sync::mpsc::unbounded_channel();
let tx_watcher = tx.clone();
let watcher_ignored_paths = ignored_watch_paths.clone();
let watcher_shutdown = Arc::new(AtomicBool::new(false));
let watcher_shutdown_callback = watcher_shutdown.clone();
let mut watcher = create_watcher(&self.config, move |result| {
forward_watcher_event(&tx_watcher, &watcher_shutdown_callback, result);
forward_watcher_event(
&tx_watcher,
&watcher_shutdown_callback,
result,
&watcher_ignored_paths,
);
})?;
let mut maintain_tick = tokio::time::interval(Duration::from_secs(1));
let mut runtime = RuntimeMachine::new(&self.config);
Expand Down Expand Up @@ -158,8 +171,12 @@ impl Engine {
tokio::time::sleep_until(deadline).await;
}
}, if watch_deadline.is_some() => {
let workflows =
classify_events(&self.config.root, &watch_groups, &pending_watch_events);
let workflows = classify_events(
&self.config.root,
&watch_groups,
&pending_watch_events,
&ignored_watch_paths,
);
pending_watch_events.clear();
watch_deadline = None;
if !workflows.is_empty() {
Expand Down Expand Up @@ -425,8 +442,19 @@ async fn execute_runtime_effects<A: RuntimeEffectAdapter>(
fn forward_watcher_event(
tx: &tokio::sync::mpsc::UnboundedSender<notify::Result<Event>>,
shutting_down: &AtomicBool,
result: notify::Result<Event>,
mut result: notify::Result<Event>,
ignored_paths: &[PathBuf],
) {
if let Ok(event) = &mut result {
event.paths.retain(|path| {
!ignored_paths
.iter()
.any(|ignored| path_is_equivalent_to_ignored_path(path, ignored))
});
if event.paths.is_empty() {
return;
}
}
if let Err(error) = tx.send(result)
&& !shutting_down.load(Ordering::Relaxed)
{
Expand Down Expand Up @@ -600,13 +628,20 @@ fn classify_events(
root: &Path,
watch_groups: &[CompiledWatchGroup],
events: &[Event],
ignored_paths: &[PathBuf],
) -> BTreeMap<String, Vec<String>> {
let mut grouped: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for event in events {
if !is_relevant_event(&event.kind) {
continue;
}
for path in &event.paths {
if ignored_paths
.iter()
.any(|ignored_path| path_is_equivalent_to_ignored_path(path, ignored_path))
{
continue;
}
let Some(relative) = relativize_event_path(root, path) else {
continue;
};
Expand All @@ -632,11 +667,36 @@ fn relativize_event_path<'a>(root: &'a Path, path: &'a Path) -> Option<&'a Path>
.or_else(|| strip_private_prefix_variant(root, path))
}

fn path_is_equivalent_to_ignored_path(path: &Path, ignored_path: &Path) -> bool {
if path == ignored_path {
return true;
}
if let Some(private_path) = private_path_variant(ignored_path)
&& path == private_path
{
return true;
}
if let Some(public_path) = public_path_variant(ignored_path)
&& path == public_path
{
return true;
}
false
}

fn strip_private_prefix_variant<'a>(root: &'a Path, path: &'a Path) -> Option<&'a Path> {
let private_root = Path::new("/private").join(root.strip_prefix("/").ok()?);
let private_root = private_path_variant(root)?;
path.strip_prefix(&private_root).ok()
}

fn private_path_variant(path: &Path) -> Option<PathBuf> {
Some(Path::new("/private").join(path.strip_prefix("/").ok()?))
}

fn public_path_variant(path: &Path) -> Option<PathBuf> {
Some(Path::new("/").join(path.strip_prefix("/private").ok()?))
}

fn normalize_path(path: &Path) -> String {
path.components()
.map(|component| component.as_os_str().to_string_lossy())
Expand Down Expand Up @@ -697,11 +757,52 @@ mod tests {
attrs: Default::default(),
},
];
let grouped = classify_events(&root, &groups, &events);
let grouped = classify_events(&root, &groups, &events, &[]);
assert_eq!(grouped["server"], vec!["src/main.rs"]);
assert_eq!(grouped["content"], vec!["content/posts/example.md"]);
}

#[test]
fn classify_events_ignores_active_session_log_file_even_for_broad_patterns() {
let root = PathBuf::from("/tmp/example");
let groups = vec![CompiledWatchGroup::for_test(&["**/*"], "all").expect("watch group")];
let ignored_paths = vec![root.join(".devloop/logs/session-1.log")];
let events = vec![
Event {
kind: EventKind::Modify(ModifyKind::Any),
paths: vec![root.join(".devloop/logs/session-1.log")],
attrs: Default::default(),
},
Event {
kind: EventKind::Modify(ModifyKind::Any),
paths: vec![root.join(".devloop/logs/user-owned.log")],
attrs: Default::default(),
},
];

let grouped = classify_events(&root, &groups, &events, &ignored_paths);

assert_eq!(grouped["all"], vec![".devloop/logs/user-owned.log"]);
}

#[test]
fn classify_events_ignores_private_path_variant_of_active_session_log_file() {
let root = PathBuf::from("/tmp/example");
let groups = vec![CompiledWatchGroup::for_test(&["**/*"], "all").expect("watch group")];
let ignored_paths = vec![root.join(".devloop/logs/session-1.log")];
let events = vec![Event {
kind: EventKind::Modify(ModifyKind::Any),
paths: vec![PathBuf::from(
"/private/tmp/example/.devloop/logs/session-1.log",
)],
attrs: Default::default(),
}];

let grouped = classify_events(&root, &groups, &events, &ignored_paths);

assert!(grouped.is_empty());
}

#[test]
fn resolve_watch_registration_keeps_existing_poll_file_exact() {
let dir = tempdir().expect("tempdir");
Expand Down Expand Up @@ -838,7 +939,7 @@ mod tests {
attrs: Default::default(),
}];

let grouped = classify_events(&root, &groups, &events);
let grouped = classify_events(&root, &groups, &events, &[]);

assert_eq!(grouped["content"], vec!["watched.txt"]);
}
Expand Down Expand Up @@ -866,7 +967,7 @@ mod tests {
},
];

let grouped = classify_events(&root, &groups, &events);
let grouped = classify_events(&root, &groups, &events, &[]);

assert_eq!(grouped["css"], vec!["tailwind.css"]);
}
Expand All @@ -882,7 +983,7 @@ mod tests {
attrs: Default::default(),
}];

let grouped = classify_events(&root, &groups, &events);
let grouped = classify_events(&root, &groups, &events, &[]);

assert!(grouped.is_empty());
}
Expand Down Expand Up @@ -1645,9 +1746,30 @@ mod tests {
paths: vec![PathBuf::from("content/layout.html")],
attrs: Default::default(),
}),
&[],
);
}

#[test]
fn forward_watcher_event_drops_ignored_paths_before_queueing() {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let shutdown = AtomicBool::new(false);
let ignored_paths = vec![PathBuf::from("/tmp/example/.devloop/logs/session-1.log")];

forward_watcher_event(
&tx,
&shutdown,
Ok(Event {
kind: EventKind::Modify(ModifyKind::Any),
paths: vec![PathBuf::from("/tmp/example/.devloop/logs/session-1.log")],
attrs: Default::default(),
}),
&ignored_paths,
);

assert!(rx.try_recv().is_err());
}

#[tokio::test]
async fn runtime_machine_does_not_run_observed_workflow_when_hook_state_is_unchanged() {
let mut config = Config {
Expand Down
Loading