feat(story-115): hot-reload project.toml agent config without server restart

- Extend `WatcherEvent` to an enum with `WorkItem` and `ConfigChanged` variants
  so the watcher can distinguish between pipeline-file changes and config changes
- Watch `.story_kit/project.toml` at the project root (ignoring worktree copies)
  and broadcast `WatcherEvent::ConfigChanged` on modification
- Forward `agent_config_changed` WebSocket message to connected clients; skip
  pipeline state refresh for config-only events
- Add `is_config_file()` helper with unit tests covering root vs. worktree paths
- Accept `configVersion` prop in `AgentPanel` and re-fetch the agent roster
  whenever it increments
- Increment `agentConfigVersion` in `Chat` on receipt of `agent_config_changed`
  WS event via new `onAgentConfigChanged` handler in `ChatWebSocket`

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Dave
2026-02-23 22:58:51 +00:00
parent 662df66c13
commit e6339979de
6 changed files with 176 additions and 33 deletions

View File

@@ -1,9 +1,13 @@
//! Filesystem watcher for `.story_kit/work/`.
//! Filesystem watcher for `.story_kit/work/` and `.story_kit/project.toml`.
//!
//! Watches the work pipeline directories for file changes, infers the lifecycle
//! stage from the target directory name, auto-commits with a deterministic message,
//! and broadcasts a [`WatcherEvent`] to all connected WebSocket clients.
//!
//! Also watches `.story_kit/project.toml` for modifications and broadcasts
//! [`WatcherEvent::ConfigChanged`] so the frontend can reload the agent roster
//! without a server restart.
//!
//! # Debouncing
//! Events are buffered for 300 ms after the last activity. All changes within the
//! window are batched into a single `git add + commit`. This avoids double-commits
@@ -24,17 +28,37 @@ use std::sync::mpsc;
use std::time::{Duration, Instant};
use tokio::sync::broadcast;
/// A lifecycle event emitted by the filesystem watcher after auto-committing.
/// A lifecycle event emitted by the filesystem watcher.
#[derive(Clone, Debug, Serialize)]
pub struct WatcherEvent {
/// Pipeline stage directory (e.g. `"2_current"`, `"5_archived"`).
pub stage: String,
/// Work item ID (filename stem without extension, e.g. `"42_story_my_feature"`).
pub item_id: String,
/// Semantic action inferred from the stage (e.g. `"start"`, `"accept"`).
pub action: String,
/// The deterministic git commit message used (or that would have been used).
pub commit_msg: String,
#[serde(tag = "type", rename_all = "snake_case")]
pub enum WatcherEvent {
/// A work-pipeline file was created, modified, or deleted.
WorkItem {
/// Pipeline stage directory (e.g. `"2_current"`, `"5_archived"`).
stage: String,
/// Work item ID (filename stem without extension, e.g. `"42_story_my_feature"`).
item_id: String,
/// Semantic action inferred from the stage (e.g. `"start"`, `"accept"`).
action: String,
/// The deterministic git commit message used (or that would have been used).
commit_msg: String,
},
/// `.story_kit/project.toml` was modified at the project root (not inside a worktree).
ConfigChanged,
}
/// Return `true` if `path` is the root-level `.story_kit/project.toml`, i.e.
/// `{git_root}/.story_kit/project.toml`.
///
/// Returns `false` for paths inside worktree directories (paths containing
/// a `worktrees` component).
pub fn is_config_file(path: &Path, git_root: &Path) -> bool {
// Reject any path that passes through the worktrees directory.
if path.components().any(|c| c.as_os_str() == "worktrees") {
return false;
}
let expected = git_root.join(".story_kit").join("project.toml");
path == expected
}
/// Map a pipeline directory name to a (action, commit-message-prefix) pair.
@@ -161,7 +185,7 @@ fn flush_pending(
slog!("[watcher] skipped (already committed): {commit_msg}");
}
let stage = additions.first().map_or("unknown", |(_, s)| s);
let evt = WatcherEvent {
let evt = WatcherEvent::WorkItem {
stage: stage.to_string(),
item_id,
action: action.to_string(),
@@ -178,7 +202,8 @@ fn flush_pending(
/// Start the filesystem watcher on a dedicated OS thread.
///
/// `work_dir` — absolute path to `.story_kit/work/` (watched recursively).
/// `git_root` — project root (passed to `git` commands as cwd).
/// `git_root` — project root (passed to `git` commands as cwd, and used to
/// derive the config file path `.story_kit/project.toml`).
/// `event_tx` — broadcast sender; each connected WebSocket client holds a receiver.
pub fn start_watcher(
work_dir: PathBuf,
@@ -203,12 +228,22 @@ pub fn start_watcher(
return;
}
// Also watch .story_kit/project.toml for hot-reload of agent config.
let config_file = git_root.join(".story_kit").join("project.toml");
if config_file.exists()
&& let Err(e) = watcher.watch(&config_file, RecursiveMode::NonRecursive)
{
slog!("[watcher] failed to watch config file {}: {e}", config_file.display());
}
slog!("[watcher] watching {}", work_dir.display());
const DEBOUNCE: Duration = Duration::from_millis(300);
// Map path → stage for pending (uncommitted) changes.
// Map path → stage for pending (uncommitted) work-item changes.
let mut pending: HashMap<PathBuf, String> = HashMap::new();
// Whether a config file change is pending in the current debounce window.
let mut config_changed_pending = false;
let mut deadline: Option<Instant> = None;
loop {
@@ -229,7 +264,11 @@ pub fn start_watcher(
if is_relevant_kind {
for path in event.paths {
if let Some(stage) = stage_for_path(&path) {
if is_config_file(&path, &git_root) {
slog!("[watcher] config change detected: {}", path.display());
config_changed_pending = true;
deadline = Some(Instant::now() + DEBOUNCE);
} else if let Some(stage) = stage_for_path(&path) {
pending.insert(path, stage);
deadline = Some(Instant::now() + DEBOUNCE);
}
@@ -249,9 +288,16 @@ pub fn start_watcher(
}
};
if flush && !pending.is_empty() {
flush_pending(&pending, &git_root, &event_tx);
pending.clear();
if flush {
if !pending.is_empty() {
flush_pending(&pending, &git_root, &event_tx);
pending.clear();
}
if config_changed_pending {
slog!("[watcher] broadcasting agent_config_changed");
let _ = event_tx.send(WatcherEvent::ConfigChanged);
config_changed_pending = false;
}
deadline = None;
}
}
@@ -317,4 +363,42 @@ mod tests {
assert!(stage_metadata("unknown", "id").is_none());
}
#[test]
fn is_config_file_identifies_root_project_toml() {
let git_root = PathBuf::from("/proj");
let config = git_root.join(".story_kit").join("project.toml");
assert!(is_config_file(&config, &git_root));
}
#[test]
fn is_config_file_rejects_worktree_copies() {
let git_root = PathBuf::from("/proj");
// project.toml inside a worktree must NOT be treated as the root config.
let worktree_config = PathBuf::from(
"/proj/.story_kit/worktrees/42_story_foo/.story_kit/project.toml",
);
assert!(!is_config_file(&worktree_config, &git_root));
}
#[test]
fn is_config_file_rejects_other_files() {
let git_root = PathBuf::from("/proj");
// Random files must not match.
assert!(!is_config_file(
&PathBuf::from("/proj/.story_kit/work/2_current/42_story_foo.md"),
&git_root
));
assert!(!is_config_file(
&PathBuf::from("/proj/.story_kit/README.md"),
&git_root
));
}
#[test]
fn is_config_file_rejects_wrong_root() {
let git_root = PathBuf::from("/proj");
let other_root_config = PathBuf::from("/other/.story_kit/project.toml");
assert!(!is_config_file(&other_root_config, &git_root));
}
}