From e6339979de4408137e90dc0ed779c44d8d3b736d Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 23 Feb 2026 22:58:51 +0000 Subject: [PATCH] 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 --- ...oml_agent_config_without_server_restart.md | 23 ++++ frontend/src/api/client.ts | 8 +- frontend/src/components/AgentPanel.tsx | 12 +- frontend/src/components/Chat.tsx | 6 +- server/src/http/ws.rs | 40 ++++-- server/src/io/watcher.rs | 120 +++++++++++++++--- 6 files changed, 176 insertions(+), 33 deletions(-) create mode 100644 .story_kit/work/2_current/115_story_hot_reload_project_toml_agent_config_without_server_restart.md diff --git a/.story_kit/work/2_current/115_story_hot_reload_project_toml_agent_config_without_server_restart.md b/.story_kit/work/2_current/115_story_hot_reload_project_toml_agent_config_without_server_restart.md new file mode 100644 index 0000000..20847f3 --- /dev/null +++ b/.story_kit/work/2_current/115_story_hot_reload_project_toml_agent_config_without_server_restart.md @@ -0,0 +1,23 @@ +--- +name: "Hot-reload project.toml agent config without server restart" +--- + +# Story 115: Hot-reload project.toml agent config without server restart + +## User Story + +As a developer, I want changes to `.story_kit/project.toml` to be picked up automatically by the running server, so that I can update the agent roster without restarting the server. + +## Acceptance Criteria + +- [ ] When `.story_kit/project.toml` is saved on disk, the server detects the change within the debounce window (300 ms) and broadcasts an `agent_config_changed` WebSocket event to all connected clients +- [ ] The frontend `AgentPanel` automatically re-fetches and displays the updated agent roster upon receiving `agent_config_changed`, without any manual action +- [ ] `project.toml` changes inside worktree directories (paths containing `worktrees/`) are NOT broadcast +- [ ] Config file changes do NOT trigger a pipeline state refresh (only work-item events do) +- [ ] A helper `is_config_file(path, git_root)` correctly identifies the root-level `.story_kit/project.toml` (returns false for worktree copies) + +## Out of Scope + +- Watching for newly created `project.toml` (only file modification events) +- Validating the new config before broadcasting (parse errors are surfaced on next `get_agent_config` call) +- Reloading config into in-memory agent state (agents already read config from disk on each start) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index ba155eb..f4ca2b1 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -57,7 +57,9 @@ export type WsResponse = story_id: string; status: string; message: string; - }; + } + /** `.story_kit/project.toml` was modified; re-fetch the agent roster. */ + | { type: "agent_config_changed" }; export interface ProviderConfig { provider: string; @@ -273,6 +275,7 @@ export class ChatWebSocket { status: string, message: string, ) => void; + private onAgentConfigChanged?: () => void; private connected = false; private closeTimer?: number; private wsPath = DEFAULT_WS_PATH; @@ -322,6 +325,7 @@ export class ChatWebSocket { data.status, data.message, ); + if (data.type === "agent_config_changed") this.onAgentConfigChanged?.(); } catch (err) { this.onError?.(String(err)); } @@ -367,6 +371,7 @@ export class ChatWebSocket { status: string, message: string, ) => void; + onAgentConfigChanged?: () => void; }, wsPath = DEFAULT_WS_PATH, ) { @@ -378,6 +383,7 @@ export class ChatWebSocket { this.onPermissionRequest = handlers.onPermissionRequest; this.onActivity = handlers.onActivity; this.onReconciliationProgress = handlers.onReconciliationProgress; + this.onAgentConfigChanged = handlers.onAgentConfigChanged; this.wsPath = wsPath; this.shouldReconnect = true; diff --git a/frontend/src/components/AgentPanel.tsx b/frontend/src/components/AgentPanel.tsx index dbac329..6b9dc50 100644 --- a/frontend/src/components/AgentPanel.tsx +++ b/frontend/src/components/AgentPanel.tsx @@ -79,7 +79,12 @@ function agentKey(storyId: string, agentName: string): string { return `${storyId}:${agentName}`; } -export function AgentPanel() { +interface AgentPanelProps { + /** Increment this to trigger a re-fetch of the agent roster. */ + configVersion?: number; +} + +export function AgentPanel({ configVersion = 0 }: AgentPanelProps) { const { hiddenRosterAgents } = useLozengeFly(); const [agents, setAgents] = useState>({}); const [roster, setRoster] = useState([]); @@ -90,13 +95,16 @@ export function AgentPanel() { const [editingEditor, setEditingEditor] = useState(false); const cleanupRefs = useRef void>>({}); - // Load roster, existing agents, and editor preference on mount + // Re-fetch roster whenever configVersion changes (triggered by agent_config_changed WS event). useEffect(() => { agentsApi .getAgentConfig() .then(setRoster) .catch((err) => console.error("Failed to load agent config:", err)); + }, [configVersion]); + // Load existing agents and editor preference on mount + useEffect(() => { agentsApi .listAgents() .then((agentList) => { diff --git a/frontend/src/components/Chat.tsx b/frontend/src/components/Chat.tsx index 710501b..551e18d 100644 --- a/frontend/src/components/Chat.tsx +++ b/frontend/src/components/Chat.tsx @@ -69,6 +69,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { { id: string; storyId: string; status: string; message: string }[] >([]); const reconciliationEventIdRef = useRef(0); + const [agentConfigVersion, setAgentConfigVersion] = useState(0); const wsRef = useRef(null); const messagesEndRef = useRef(null); @@ -215,6 +216,9 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { }); } }, + onAgentConfigChanged: () => { + setAgentConfigVersion((v) => v + 1); + }, }); return () => { @@ -829,7 +833,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { }} > - + diff --git a/server/src/http/ws.rs b/server/src/http/ws.rs index 6668acd..6d8cdde 100644 --- a/server/src/http/ws.rs +++ b/server/src/http/ws.rs @@ -39,6 +39,7 @@ enum WsRequest { /// - `update` pushes the updated message history. /// - `error` reports a request or processing failure. /// - `work_item_changed` notifies that a `.story_kit/work/` file changed. +/// - `agent_config_changed` notifies that `.story_kit/project.toml` was modified. enum WsResponse { Token { content: String, @@ -62,13 +63,16 @@ enum WsResponse { action: String, commit_msg: String, }, - /// Full pipeline state pushed on connect and after every watcher event. + /// Full pipeline state pushed on connect and after every work-item watcher event. PipelineState { upcoming: Vec, current: Vec, qa: Vec, merge: Vec, }, + /// `.story_kit/project.toml` was modified; the frontend should re-fetch the + /// agent roster. Does NOT trigger a pipeline state refresh. + AgentConfigChanged, /// Claude Code is requesting user approval before executing a tool. PermissionRequest { request_id: String, @@ -89,13 +93,21 @@ enum WsResponse { }, } -impl From for WsResponse { +impl From for Option { fn from(e: WatcherEvent) -> Self { - WsResponse::WorkItemChanged { - stage: e.stage, - item_id: e.item_id, - action: e.action, - commit_msg: e.commit_msg, + match e { + WatcherEvent::WorkItem { + stage, + item_id, + action, + commit_msg, + } => Some(WsResponse::WorkItemChanged { + stage, + item_id, + action, + commit_msg, + }), + WatcherEvent::ConfigChanged => Some(WsResponse::AgentConfigChanged), } } } @@ -138,7 +150,8 @@ pub async fn ws_handler(ws: WebSocket, ctx: Data<&Arc>) -> impl poem } // Subscribe to filesystem watcher events and forward them to the client. - // After each watcher event, also push the updated pipeline state. + // After each work-item event, also push the updated pipeline state. + // Config-changed events are forwarded as-is without a pipeline refresh. let tx_watcher = tx.clone(); let ctx_watcher = ctx.clone(); let mut watcher_rx = ctx.watcher_tx.subscribe(); @@ -146,11 +159,16 @@ pub async fn ws_handler(ws: WebSocket, ctx: Data<&Arc>) -> impl poem loop { match watcher_rx.recv().await { Ok(evt) => { - if tx_watcher.send(evt.into()).is_err() { + let is_work_item = + matches!(evt, crate::io::watcher::WatcherEvent::WorkItem { .. }); + let ws_msg: Option = evt.into(); + if let Some(msg) = ws_msg && tx_watcher.send(msg).is_err() { break; } - // Push refreshed pipeline state after the change. - if let Ok(state) = load_pipeline_state(ctx_watcher.as_ref()) + // Only push refreshed pipeline state after work-item changes, + // not after config-file changes. + if is_work_item + && let Ok(state) = load_pipeline_state(ctx_watcher.as_ref()) && tx_watcher.send(state.into()).is_err() { break; diff --git a/server/src/io/watcher.rs b/server/src/io/watcher.rs index 3ffcf6c..ac2ac1e 100644 --- a/server/src/io/watcher.rs +++ b/server/src/io/watcher.rs @@ -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 = HashMap::new(); + // Whether a config file change is pending in the current debounce window. + let mut config_changed_pending = false; let mut deadline: Option = 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)); + } }