Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
881 lines
37 KiB
Rust
881 lines
37 KiB
Rust
use std::{
|
|
collections::{HashMap, HashSet},
|
|
path::{Path, PathBuf},
|
|
};
|
|
|
|
use super::{
|
|
subscribers::{
|
|
HomeSkillSubscriber, ProjectSkillSubscriber, SkillRepositoryMessage, SymlinkSkillSubscriber,
|
|
},
|
|
utils::{
|
|
find_skill_directories_in_tree, is_home_provider_path, is_home_skill_directory,
|
|
is_skill_file, read_skills_from_directories,
|
|
},
|
|
};
|
|
use watcher::{BulkFilesystemWatcherEvent, HomeDirectoryWatcher, HomeDirectoryWatcherEvent};
|
|
|
|
use crate::galaxy_managed_paths_watcher::{
|
|
filter_repository_update_by_prefix, galaxy_managed_skill_dirs, GalaxyManagedPathsWatcher,
|
|
GalaxyManagedPathsWatcherEvent,
|
|
};
|
|
use crate::server::datetime_ext::DateTimeExt;
|
|
use ai::skills::{
|
|
home_skills_path, parse_skill, ParsedSkill, SkillProvider, SKILL_PROVIDER_DEFINITIONS,
|
|
};
|
|
use async_channel::Sender;
|
|
use chrono::{DateTime, Duration, Utc};
|
|
use galaxyui::{AppContext, Entity, ModelContext, ModelHandle, SingletonEntity};
|
|
use repo_metadata::{
|
|
repositories::{DetectedRepositories, RepoDetectionSource},
|
|
repository::{Repository, SubscriberId},
|
|
DirectoryWatcher, RepoMetadataModel, RepositoryUpdate,
|
|
};
|
|
|
|
#[derive(Debug, PartialEq)]
|
|
pub enum SkillWatcherEvent {
|
|
SkillsAdded { skills: Vec<ParsedSkill> },
|
|
SkillsDeleted { paths: Vec<PathBuf> },
|
|
}
|
|
|
|
// When a new directory is detected by file watchers, we queue it to be scanned for skills later.
|
|
// These are processed when the file tree is updated.
|
|
// If a directory is left unprocessed for too long, we will drop it.
|
|
#[derive(Clone)]
|
|
pub struct QueuedProjectDirectoryCreation {
|
|
pub path: PathBuf,
|
|
pub timestamp: DateTime<Utc>,
|
|
}
|
|
|
|
pub struct SkillWatcher {
|
|
// Channel for sending repository messages from subscribers.
|
|
repository_message_tx: Sender<SkillRepositoryMessage>,
|
|
/// Repos we've registered file watchers for (to prevent duplicate subscriptions).
|
|
watched_repos: HashSet<PathBuf>,
|
|
queued_project_directory_creations: Vec<QueuedProjectDirectoryCreation>,
|
|
watcher_event_tx: Sender<SkillWatcherEvent>,
|
|
/// Tracks watchers on home provider directories (e.g. ~/.agents, ~/.claude) so they
|
|
/// can be cleaned up when the directory is deleted.
|
|
home_provider_watchers: HashMap<PathBuf, (ModelHandle<Repository>, SubscriberId)>,
|
|
/// Maps canonical (resolved) SKILL.md paths → set of original symlink-based paths.
|
|
/// Multiple symlinks can resolve to the same canonical file, so we track all of them.
|
|
/// Used to detect changes to the real files behind symlinked skill directories
|
|
/// on platforms where the OS-level watcher (e.g. FSEvents on macOS) does not
|
|
/// follow symlinks.
|
|
symlink_canonical_to_originals: HashMap<PathBuf, HashSet<PathBuf>>,
|
|
/// Watchers for resolved symlink target directories, keyed by canonical
|
|
/// parent directory. Used both as a dedup guard (skip if already watching)
|
|
/// and to hold the subscriber info for error rollback.
|
|
symlink_target_watchers: HashMap<PathBuf, (ModelHandle<Repository>, SubscriberId)>,
|
|
}
|
|
|
|
impl SkillWatcher {
|
|
/// Synchronously reads skills from the given repo paths.
|
|
/// Requires file trees to already be built (i.e. `RepositoryUpdated` has fired).
|
|
/// Returns the parsed skills; the caller is responsible for feeding them into
|
|
/// `SkillManager::handle_skills_added`.
|
|
pub fn read_skills_for_repos(repo_paths: &[PathBuf], ctx: &AppContext) -> Vec<ParsedSkill> {
|
|
let repo_metadata = RepoMetadataModel::as_ref(ctx);
|
|
let skill_dirs: Vec<PathBuf> = repo_paths
|
|
.iter()
|
|
.flat_map(|repo_path| find_skill_directories_in_tree(repo_path, repo_metadata, ctx))
|
|
.collect();
|
|
|
|
read_skills_from_directories(skill_dirs)
|
|
}
|
|
|
|
pub fn new(ctx: &mut ModelContext<Self>, watcher_event_tx: Sender<SkillWatcherEvent>) -> Self {
|
|
Self::new_internal(ctx, watcher_event_tx, dirs::home_dir())
|
|
}
|
|
|
|
/// Test-only constructor that skips home-directory watching so tests are not
|
|
/// polluted by real skills present on the developer's machine.
|
|
#[cfg(test)]
|
|
pub fn new_for_testing(
|
|
ctx: &mut ModelContext<Self>,
|
|
watcher_event_tx: Sender<SkillWatcherEvent>,
|
|
) -> Self {
|
|
Self::new_internal(ctx, watcher_event_tx, None)
|
|
}
|
|
|
|
fn new_internal(
|
|
ctx: &mut ModelContext<Self>,
|
|
watcher_event_tx: Sender<SkillWatcherEvent>,
|
|
home_dir: Option<PathBuf>,
|
|
) -> Self {
|
|
// Create channel for receiving repository messages (scans and updates)
|
|
let (repository_message_tx, repository_message_rx) = async_channel::unbounded();
|
|
|
|
// Subscribe to repository messages for both projects and home directory
|
|
// When a message is received, handle_message is used to dispatch the message to the appropriate handler
|
|
ctx.spawn_stream_local(
|
|
repository_message_rx,
|
|
|me, message, ctx| {
|
|
me.handle_message(message, ctx);
|
|
},
|
|
|_, _| {}, // No cleanup needed when stream ends
|
|
);
|
|
|
|
if home_dir.is_some() {
|
|
ctx.subscribe_to_model(
|
|
&HomeDirectoryWatcher::handle(ctx),
|
|
|me, event, ctx| match event {
|
|
HomeDirectoryWatcherEvent::HomeFilesChanged(event) => {
|
|
me.handle_home_files_changed(event, ctx);
|
|
}
|
|
},
|
|
);
|
|
ctx.subscribe_to_model(&GalaxyManagedPathsWatcher::handle(ctx), |me, event, ctx| {
|
|
me.handle_warp_managed_paths_event(event, ctx);
|
|
});
|
|
}
|
|
|
|
// Subscribe to home directory skills via DirectoryWatcher.
|
|
//
|
|
// We watch each skills "parent directory" under the home directory (e.g., `~/.agents`,
|
|
// `~/.claude`) rather than the entire home directory, to reduce watch overhead.
|
|
//
|
|
// Note: This will not create watchers for provider directories that haven't been created yet.
|
|
// We use a separate HomeDirectoryWatcher to detect when those are created and start watching them after they are created.
|
|
let mut home_provider_watchers = HashMap::new();
|
|
if let Some(home_path) = home_dir {
|
|
Self::spawn_read_skills_from_directories(galaxy_managed_skill_dirs(), ctx);
|
|
let skills_parent_paths: HashSet<PathBuf> = SKILL_PROVIDER_DEFINITIONS
|
|
.iter()
|
|
.filter(|provider| provider.provider != SkillProvider::Warp)
|
|
.filter_map(|provider| {
|
|
home_skills_path(provider.provider)
|
|
.and_then(|skills_path| skills_path.parent().map(Path::to_path_buf))
|
|
})
|
|
.filter(|parent| parent.starts_with(&home_path))
|
|
.collect();
|
|
|
|
for parent_path in skills_parent_paths {
|
|
Self::watch_home_provider_path(
|
|
&parent_path,
|
|
&repository_message_tx,
|
|
&mut home_provider_watchers,
|
|
ctx,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Two subscriptions handle different aspects of skill loading:
|
|
//
|
|
// 1. RepositoryMetadataEvent::RepositoryUpdated - Loads initial skills from the file tree.
|
|
// This fires after the tree is built, so we can query it for skill directories.
|
|
//
|
|
// 2. DetectedRepositoriesEvent::DetectedGitRepo - Sets up file watchers for incremental
|
|
// updates (add/delete/move). This handles changes after initial load.
|
|
//
|
|
// The order of these events doesn't matter - both are idempotent and serve different purposes.
|
|
ctx.subscribe_to_model(&RepoMetadataModel::handle(ctx), |me, event, ctx| {
|
|
use repo_metadata::wrapper_model::RepoMetadataEvent;
|
|
use repo_metadata::RepositoryIdentifier;
|
|
match event {
|
|
RepoMetadataEvent::RepositoryUpdated {
|
|
id: RepositoryIdentifier::Local(path),
|
|
} => {
|
|
if let Some(local_path) = path.to_local_path() {
|
|
me.watch_repo(local_path.clone(), ctx);
|
|
me.scan_repository_for_skills(&local_path, ctx);
|
|
}
|
|
}
|
|
RepoMetadataEvent::FileTreeEntryUpdated { .. } => {
|
|
me.handle_queued_project_directory_creations(ctx);
|
|
}
|
|
RepoMetadataEvent::RepositoryUpdated { .. }
|
|
| RepoMetadataEvent::RepositoryRemoved { .. }
|
|
| RepoMetadataEvent::FileTreeUpdated { .. }
|
|
| RepoMetadataEvent::UpdatingRepositoryFailed { .. }
|
|
| RepoMetadataEvent::IncrementalUpdateReady { .. } => {}
|
|
}
|
|
});
|
|
|
|
// Subscribe to DetectedRepositories to watch repos registered via CloudEnvironmentPrep.
|
|
// This fires when AgentDriver calls prepare_environment (for any run with a configured
|
|
// environment, Warp-hosted or self-hosted). The CloudEnvironmentPrep source filter means
|
|
// this is a no-op on local runs where no environment is configured.
|
|
ctx.subscribe_to_model(&DetectedRepositories::handle(ctx), |me, event, ctx| {
|
|
use repo_metadata::repositories::DetectedRepositoriesEvent;
|
|
match event {
|
|
DetectedRepositoriesEvent::DetectedGitRepo { source, .. }
|
|
if *source == RepoDetectionSource::CloudEnvironmentPrep =>
|
|
{
|
|
// The repo root is already registered in DirectoryWatcher by the time
|
|
// this event fires. Extract its path from the repository handle.
|
|
let DetectedRepositoriesEvent::DetectedGitRepo { repository, .. } = event;
|
|
let repo_path = repository.as_ref(ctx).root_dir().to_local_path_lossy();
|
|
me.watch_repo(repo_path.clone(), ctx);
|
|
me.scan_repository_for_skills(&repo_path, ctx);
|
|
}
|
|
DetectedRepositoriesEvent::DetectedGitRepo { .. } => {}
|
|
}
|
|
});
|
|
|
|
Self {
|
|
repository_message_tx,
|
|
watched_repos: HashSet::new(),
|
|
queued_project_directory_creations: Vec::new(),
|
|
watcher_event_tx,
|
|
home_provider_watchers,
|
|
symlink_canonical_to_originals: HashMap::new(),
|
|
symlink_target_watchers: HashMap::new(),
|
|
}
|
|
}
|
|
|
|
/// Register a project root path to watch for skill file changes.
|
|
fn watch_repo(&mut self, repo_path: PathBuf, ctx: &mut ModelContext<Self>) {
|
|
if self.watched_repos.contains(&repo_path) {
|
|
return;
|
|
}
|
|
|
|
// Get the repository handle from DetectedRepositories.
|
|
if let Some(repo_handle) =
|
|
DetectedRepositories::as_ref(ctx).get_watched_repo_for_path(&repo_path, ctx)
|
|
{
|
|
// Optimistically add the repository to the set of watched repositories to prevent duplicate subscriptions
|
|
self.watched_repos.insert(repo_path.clone());
|
|
|
|
let subscriber = Box::new(ProjectSkillSubscriber {
|
|
message_tx: self.repository_message_tx.clone(),
|
|
});
|
|
|
|
let start = repo_handle.update(ctx, |repo, ctx| repo.start_watching(subscriber, ctx));
|
|
ctx.spawn(start.registration_future, move |me, res, ctx| {
|
|
if let Err(err) = res {
|
|
log::warn!("Failed to start watching project skills directory: {err}");
|
|
me.watched_repos.remove(&repo_path);
|
|
repo_handle.update(ctx, |repo, ctx| {
|
|
repo.stop_watching(start.subscriber_id, ctx)
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Scans a repository for skills using the LocalRepoMetadataModel tree.
|
|
/// This is called when RepositoryMetadataEvent::RepositoryUpdated fires.
|
|
fn scan_repository_for_skills(&mut self, repo_path: &Path, ctx: &mut ModelContext<Self>) {
|
|
let repo_metadata = RepoMetadataModel::as_ref(ctx);
|
|
|
|
// Find all skill directories in the tree
|
|
let skill_dirs = find_skill_directories_in_tree(repo_path, repo_metadata, ctx);
|
|
if skill_dirs.is_empty() {
|
|
return;
|
|
}
|
|
Self::spawn_read_skills_from_directories(skill_dirs, ctx);
|
|
}
|
|
|
|
fn spawn_read_skills_from_directories(
|
|
skill_dirs: impl IntoIterator<Item = PathBuf>,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
let skill_dirs: Vec<_> = skill_dirs.into_iter().collect();
|
|
if skill_dirs.is_empty() {
|
|
return;
|
|
}
|
|
|
|
ctx.spawn(
|
|
async move { read_skills_from_directories(skill_dirs) },
|
|
move |me, skills, ctx| {
|
|
if !skills.is_empty() {
|
|
me.register_symlink_watches(&skills, ctx);
|
|
let _ = me
|
|
.watcher_event_tx
|
|
.try_send(SkillWatcherEvent::SkillsAdded { skills });
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
fn handle_message(&mut self, message: SkillRepositoryMessage, ctx: &mut ModelContext<Self>) {
|
|
match message {
|
|
SkillRepositoryMessage::HomeInitialScan { skills } => {
|
|
if skills.is_empty() {
|
|
return;
|
|
}
|
|
|
|
self.register_symlink_watches(&skills, ctx);
|
|
let _ = self
|
|
.watcher_event_tx
|
|
.try_send(SkillWatcherEvent::SkillsAdded { skills });
|
|
}
|
|
SkillRepositoryMessage::RepositoryUpdate { update } => {
|
|
self.handle_repository_update(&update, ctx);
|
|
}
|
|
SkillRepositoryMessage::SymlinkTargetUpdate { update } => {
|
|
self.handle_symlink_target_update(&update, ctx);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn handle_repository_update(
|
|
&mut self,
|
|
update: &RepositoryUpdate,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
let mut queued_project_directories = HashSet::new();
|
|
let mut home_path_additions = HashSet::new();
|
|
let mut deleted_paths = Vec::new();
|
|
|
|
// Process deleted files
|
|
for target_file in &update.deleted {
|
|
deleted_paths.push(target_file.path.clone());
|
|
}
|
|
|
|
// Process moved files
|
|
for (to_target, from_target) in &update.moved {
|
|
deleted_paths.push(from_target.path.clone());
|
|
let to_target_path = to_target.path.clone();
|
|
|
|
if is_skill_file(&to_target_path) {
|
|
// read the skill from the file system
|
|
let skill = parse_skill(&to_target_path);
|
|
if let Ok(skill) = skill {
|
|
self.register_symlink_watches(std::slice::from_ref(&skill), ctx);
|
|
let _ = self
|
|
.watcher_event_tx
|
|
.try_send(SkillWatcherEvent::SkillsAdded {
|
|
skills: vec![skill],
|
|
});
|
|
}
|
|
} else {
|
|
let repo_path = self.get_watched_repo_path(&to_target.path);
|
|
if let Some(repo_path) = repo_path {
|
|
if to_target.path.is_dir() {
|
|
queued_project_directories.insert(repo_path);
|
|
}
|
|
} else {
|
|
home_path_additions.insert(to_target.path.clone());
|
|
}
|
|
}
|
|
}
|
|
|
|
// Process added or modified files
|
|
for target_file in update.added_or_modified() {
|
|
let target_file_path = target_file.path.clone();
|
|
if is_skill_file(&target_file_path) {
|
|
// read the skill from the file system
|
|
ctx.spawn(
|
|
async move { parse_skill(&target_file_path) },
|
|
move |me, skill, ctx| {
|
|
if let Ok(skill) = skill {
|
|
me.register_symlink_watches(std::slice::from_ref(&skill), ctx);
|
|
let _ = me
|
|
.watcher_event_tx
|
|
.try_send(SkillWatcherEvent::SkillsAdded {
|
|
skills: vec![skill],
|
|
});
|
|
}
|
|
},
|
|
);
|
|
} else if target_file.path.is_symlink()
|
|
&& target_file.path.is_dir()
|
|
&& target_file.path.join("SKILL.md").exists()
|
|
{
|
|
// Newly created symlinked skill directory — read the skill directly
|
|
// rather than waiting for the queued directory reprocessing cycle.
|
|
let skill_file_path = target_file.path.join("SKILL.md");
|
|
ctx.spawn(
|
|
async move { parse_skill(&skill_file_path) },
|
|
move |me, skill, ctx| {
|
|
if let Ok(skill) = skill {
|
|
me.register_symlink_watches(std::slice::from_ref(&skill), ctx);
|
|
let _ = me
|
|
.watcher_event_tx
|
|
.try_send(SkillWatcherEvent::SkillsAdded {
|
|
skills: vec![skill],
|
|
});
|
|
}
|
|
},
|
|
);
|
|
} else {
|
|
let repo_path = self.get_watched_repo_path(&target_file.path);
|
|
if let Some(repo_path) = repo_path {
|
|
if target_file.path.is_dir() {
|
|
queued_project_directories.insert(repo_path);
|
|
}
|
|
} else {
|
|
home_path_additions.insert(target_file.path.clone());
|
|
}
|
|
}
|
|
}
|
|
|
|
// Read home directory skills in a batch
|
|
let home_skill_directories: HashSet<PathBuf> = home_path_additions
|
|
.into_iter()
|
|
.filter_map(|path| {
|
|
// Conditions for potentially being a valid home directory skill or containing skills:
|
|
// 1. The path is a home directory skill file
|
|
// 2. The path is a home directory skill directory
|
|
// 3. The path is a provider path itself under the home directory
|
|
// We don't need to check #1 because we already checked if this is a skill file
|
|
if is_home_skill_directory(&path) {
|
|
let parent_directory = path.parent();
|
|
parent_directory.map(|parent_directory| parent_directory.to_path_buf())
|
|
} else if is_home_provider_path(&path) {
|
|
Some(path)
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect();
|
|
if !home_skill_directories.is_empty() {
|
|
ctx.spawn(
|
|
async move { read_skills_from_directories(home_skill_directories) },
|
|
move |me, skills, ctx| {
|
|
if !skills.is_empty() {
|
|
me.register_symlink_watches(&skills, ctx);
|
|
let _ = me
|
|
.watcher_event_tx
|
|
.try_send(SkillWatcherEvent::SkillsAdded { skills });
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
// Process deleted paths in a batch
|
|
if !deleted_paths.is_empty() {
|
|
self.cleanup_symlink_watches(&deleted_paths);
|
|
let _ = self
|
|
.watcher_event_tx
|
|
.try_send(SkillWatcherEvent::SkillsDeleted {
|
|
paths: deleted_paths,
|
|
});
|
|
}
|
|
|
|
// Queue project directory creations for later processing since the file tree is not yet updated
|
|
self.queued_project_directory_creations
|
|
.extend(queued_project_directories.into_iter().map(|path| {
|
|
QueuedProjectDirectoryCreation {
|
|
path,
|
|
timestamp: DateTime::now().into(),
|
|
}
|
|
}));
|
|
}
|
|
|
|
fn handle_queued_project_directory_creations(&mut self, ctx: &mut ModelContext<Self>) {
|
|
let mut queued_by_repo_path: HashMap<PathBuf, Vec<QueuedProjectDirectoryCreation>> =
|
|
HashMap::new();
|
|
|
|
for queued_project_directory_creation in &self.queued_project_directory_creations {
|
|
let repo_path = self.get_watched_repo_path(&queued_project_directory_creation.path);
|
|
if let Some(repo_path) = repo_path {
|
|
queued_by_repo_path
|
|
.entry(repo_path)
|
|
.or_default()
|
|
.push(queued_project_directory_creation.clone());
|
|
}
|
|
}
|
|
|
|
let mut queued_project_directory_creations_to_requeue: Vec<QueuedProjectDirectoryCreation> =
|
|
Vec::new();
|
|
let mut skill_dirs_to_read: HashSet<PathBuf> = HashSet::new();
|
|
|
|
for (repo_path, queued_project_directory_creations) in queued_by_repo_path {
|
|
// Find all skill directories in the repository
|
|
let repo_metadata = RepoMetadataModel::as_ref(ctx);
|
|
let skill_dirs = find_skill_directories_in_tree(&repo_path, repo_metadata, ctx);
|
|
if skill_dirs.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
for queued_project_directory_creation in queued_project_directory_creations {
|
|
let relevant_skill_dirs = skill_dirs
|
|
.iter()
|
|
.filter(|skill_dir| {
|
|
// If the skill_dir is the child of the new directory, we need to read it again
|
|
// E.g. new dir is /repo/frontend/feature and skill dir is /repo/frontend/feature/.agents/skills
|
|
// If the new directory is a child of the skill dir, we need to read it again
|
|
// E.g. skill_dir is /repo/frontend/.agents/skills and new dir is /repo/frontend/.agents/skills/skill-name
|
|
skill_dir.starts_with(&queued_project_directory_creation.path)
|
|
|| queued_project_directory_creation
|
|
.path
|
|
.starts_with(skill_dir)
|
|
})
|
|
.collect::<Vec<&PathBuf>>();
|
|
|
|
// If the file tree doesn't have the newly created directory, we should requeue it for when the file tree is updated again
|
|
if relevant_skill_dirs.is_empty() {
|
|
// If 10s after the initial directory creation, the file tree still doesn't have the directory, we will give up and not requeue it
|
|
let elapsed = DateTime::now()
|
|
.signed_duration_since(queued_project_directory_creation.timestamp);
|
|
if elapsed < Duration::seconds(10) {
|
|
queued_project_directory_creations_to_requeue
|
|
.push(queued_project_directory_creation.clone());
|
|
}
|
|
} else {
|
|
skill_dirs_to_read.extend(relevant_skill_dirs.into_iter().cloned());
|
|
}
|
|
}
|
|
}
|
|
|
|
ctx.spawn(
|
|
async move { read_skills_from_directories(skill_dirs_to_read) },
|
|
move |me, skills, ctx| {
|
|
if !skills.is_empty() {
|
|
me.register_symlink_watches(&skills, ctx);
|
|
let _ = me
|
|
.watcher_event_tx
|
|
.try_send(SkillWatcherEvent::SkillsAdded { skills });
|
|
}
|
|
},
|
|
);
|
|
|
|
// Requeue project directory creations that could not be processed immediately
|
|
self.queued_project_directory_creations = queued_project_directory_creations_to_requeue;
|
|
}
|
|
|
|
/// Cleans up symlink canonical→original mappings for deleted skill paths.
|
|
///
|
|
/// The subscriber and `DirectoryWatcher` entry for the canonical directory
|
|
/// are intentionally kept alive so that if the symlink is re-created later,
|
|
/// the event still reaches `handle_symlink_target_update` and is handled
|
|
/// as a new symlink skill.
|
|
fn cleanup_symlink_watches(&mut self, deleted_paths: &[PathBuf]) {
|
|
let mut empty_canonicals = Vec::new();
|
|
|
|
for (canonical, originals) in &mut self.symlink_canonical_to_originals {
|
|
originals.retain(|original| {
|
|
!deleted_paths
|
|
.iter()
|
|
.any(|deleted| original.starts_with(deleted) || original == deleted)
|
|
});
|
|
if originals.is_empty() {
|
|
empty_canonicals.push(canonical.clone());
|
|
}
|
|
}
|
|
|
|
for canonical_path in empty_canonicals {
|
|
self.symlink_canonical_to_originals.remove(&canonical_path);
|
|
}
|
|
}
|
|
|
|
/// For each loaded skill, check whether it lives behind a symlink. If so,
|
|
/// resolve the canonical path and register a watch on the target directory
|
|
/// via `DirectoryWatcher` so that modifications to the real file are detected.
|
|
fn register_symlink_watches(&mut self, skills: &[ParsedSkill], ctx: &mut ModelContext<Self>) {
|
|
for skill in skills {
|
|
let original_path = &skill.path;
|
|
let Ok(canonical_path) = dunce::canonicalize(original_path) else {
|
|
continue;
|
|
};
|
|
if canonical_path == *original_path {
|
|
continue; // Not a symlink
|
|
}
|
|
|
|
self.symlink_canonical_to_originals
|
|
.entry(canonical_path.clone())
|
|
.or_default()
|
|
.insert(original_path.clone());
|
|
|
|
let Some(canonical_dir) = canonical_path.parent() else {
|
|
continue;
|
|
};
|
|
let canonical_dir = canonical_dir.to_path_buf();
|
|
if self.symlink_target_watchers.contains_key(&canonical_dir) {
|
|
continue; // Already watched
|
|
}
|
|
|
|
let Ok(std_dir_path) =
|
|
galaxy_util::standardized_path::StandardizedPath::from_local_canonicalized(
|
|
&canonical_dir,
|
|
)
|
|
else {
|
|
continue;
|
|
};
|
|
|
|
let dir_display = canonical_dir.display().to_string();
|
|
let repo_handle = match DirectoryWatcher::handle(ctx)
|
|
.update(ctx, |watcher, ctx| watcher.add_directory(std_dir_path, ctx))
|
|
{
|
|
Ok(handle) => handle,
|
|
Err(err) => {
|
|
log::warn!(
|
|
"Failed to register symlink target directory {dir_display} for watching: {err}"
|
|
);
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let subscriber = Box::new(SymlinkSkillSubscriber {
|
|
message_tx: self.repository_message_tx.clone(),
|
|
});
|
|
let start = repo_handle.update(ctx, |repo, ctx| repo.start_watching(subscriber, ctx));
|
|
let subscriber_id = start.subscriber_id;
|
|
self.symlink_target_watchers
|
|
.insert(canonical_dir.clone(), (repo_handle.clone(), subscriber_id));
|
|
|
|
ctx.spawn(start.registration_future, move |me, res, ctx| {
|
|
if let Err(err) = res {
|
|
log::warn!(
|
|
"Failed to start watching symlink target directory {dir_display}: {err}"
|
|
);
|
|
me.symlink_target_watchers.remove(&canonical_dir);
|
|
repo_handle.update(ctx, |repo, ctx| {
|
|
repo.stop_watching(subscriber_id, ctx);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Handle file changes detected in a resolved symlink target directory.
|
|
/// Maps canonical paths back to their original symlink-based skill paths
|
|
/// and re-reads the affected skills.
|
|
fn handle_symlink_target_update(
|
|
&mut self,
|
|
update: &RepositoryUpdate,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
// When the real file behind a symlink is deleted, emit SkillsDeleted
|
|
// so the SkillManager removes the stale entry.
|
|
let deleted_original_paths: Vec<PathBuf> = update
|
|
.deleted
|
|
.iter()
|
|
.flat_map(|target_file| {
|
|
// Exact canonical match
|
|
let exact = self
|
|
.symlink_canonical_to_originals
|
|
.get(&target_file.path)
|
|
.into_iter()
|
|
.flatten()
|
|
.cloned();
|
|
// Also match when a parent directory of the canonical path is deleted
|
|
let ancestor = self
|
|
.symlink_canonical_to_originals
|
|
.iter()
|
|
.filter(|(canonical, _)| canonical.starts_with(&target_file.path))
|
|
.flat_map(|(_, originals)| originals.iter().cloned());
|
|
exact.chain(ancestor)
|
|
})
|
|
.collect();
|
|
|
|
if !deleted_original_paths.is_empty() {
|
|
self.cleanup_symlink_watches(&deleted_original_paths);
|
|
let _ = self
|
|
.watcher_event_tx
|
|
.try_send(SkillWatcherEvent::SkillsDeleted {
|
|
paths: deleted_original_paths,
|
|
});
|
|
}
|
|
|
|
for target_file in update.added_or_modified() {
|
|
if let Some(original_paths) = self.symlink_canonical_to_originals.get(&target_file.path)
|
|
{
|
|
for original_path in original_paths.clone() {
|
|
ctx.spawn(
|
|
async move { parse_skill(&original_path) },
|
|
|me, skill, _| {
|
|
if let Ok(skill) = skill {
|
|
let _ =
|
|
me.watcher_event_tx
|
|
.try_send(SkillWatcherEvent::SkillsAdded {
|
|
skills: vec![skill],
|
|
});
|
|
}
|
|
},
|
|
);
|
|
}
|
|
} else if target_file.path.is_symlink()
|
|
&& target_file.path.is_dir()
|
|
&& target_file.path.join("SKILL.md").exists()
|
|
{
|
|
// A symlink skill directory was (re-)created. The event routed here
|
|
// because the DirectoryWatcher entry for the canonical target still
|
|
// exists from a previous registration. Parse the skill and re-register.
|
|
let skill_file_path = target_file.path.join("SKILL.md");
|
|
ctx.spawn(
|
|
async move { parse_skill(&skill_file_path) },
|
|
move |me, skill, ctx| {
|
|
if let Ok(skill) = skill {
|
|
me.register_symlink_watches(std::slice::from_ref(&skill), ctx);
|
|
let _ = me
|
|
.watcher_event_tx
|
|
.try_send(SkillWatcherEvent::SkillsAdded {
|
|
skills: vec![skill],
|
|
});
|
|
}
|
|
},
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Given a path, return the path of the watched repository, if any.
|
|
fn get_watched_repo_path(&self, path: &Path) -> Option<PathBuf> {
|
|
self.watched_repos
|
|
.iter()
|
|
.find(|repo_path| path.starts_with(repo_path))
|
|
.cloned()
|
|
}
|
|
|
|
/// Handle changes to top-level files in the home directory.
|
|
/// For skills, these are newly created provider directories
|
|
fn handle_home_files_changed(
|
|
&mut self,
|
|
event: &BulkFilesystemWatcherEvent,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
let mut deleted_paths = Vec::new();
|
|
let mut added_paths = Vec::new();
|
|
|
|
let provider_root_paths: HashSet<String> = SKILL_PROVIDER_DEFINITIONS
|
|
.iter()
|
|
.filter(|provider| provider.provider != SkillProvider::Warp)
|
|
.filter_map(|provider| {
|
|
let component = provider.skills_path.components().next();
|
|
component.map(|component| component.as_os_str().to_string_lossy().to_string())
|
|
})
|
|
.collect();
|
|
|
|
// Process deleted files
|
|
for target_file in event.deleted.iter() {
|
|
let file_name = target_file
|
|
.file_name()
|
|
.unwrap_or_default()
|
|
.to_string_lossy()
|
|
.to_string();
|
|
if provider_root_paths.contains(&file_name) {
|
|
deleted_paths.push(target_file.clone());
|
|
}
|
|
}
|
|
|
|
// Process moved files
|
|
for (to_target, from_target) in event.moved.iter() {
|
|
let from_file_name = from_target
|
|
.file_name()
|
|
.unwrap_or_default()
|
|
.to_string_lossy()
|
|
.to_string();
|
|
if provider_root_paths.contains(&from_file_name) {
|
|
deleted_paths.push(from_target.clone());
|
|
}
|
|
let to_file_name = to_target
|
|
.file_name()
|
|
.unwrap_or_default()
|
|
.to_string_lossy()
|
|
.to_string();
|
|
if provider_root_paths.contains(&to_file_name) {
|
|
added_paths.push(to_target.clone());
|
|
}
|
|
}
|
|
|
|
// Process added files
|
|
// We don't care about modified files because that doesn't affect existing watchers
|
|
for target_file in event.added.iter() {
|
|
let file_name = target_file
|
|
.file_name()
|
|
.unwrap_or_default()
|
|
.to_string_lossy()
|
|
.to_string();
|
|
if provider_root_paths.contains(&file_name) {
|
|
added_paths.push(target_file.clone());
|
|
}
|
|
}
|
|
|
|
// Clean up directory watchers for deleted provider paths.
|
|
for deleted_path in &deleted_paths {
|
|
if let Some((repo_handle, subscriber_id)) =
|
|
self.home_provider_watchers.remove(deleted_path)
|
|
{
|
|
repo_handle.update(ctx, |repo, ctx| {
|
|
repo.stop_watching(subscriber_id, ctx);
|
|
});
|
|
}
|
|
}
|
|
|
|
if !deleted_paths.is_empty() {
|
|
let _ = self
|
|
.watcher_event_tx
|
|
.try_send(SkillWatcherEvent::SkillsDeleted {
|
|
paths: deleted_paths,
|
|
});
|
|
}
|
|
|
|
for added_path in added_paths {
|
|
// For each newly added provider root path, add a watcher for it
|
|
Self::watch_home_provider_path(
|
|
&added_path,
|
|
&self.repository_message_tx,
|
|
&mut self.home_provider_watchers,
|
|
ctx,
|
|
);
|
|
}
|
|
}
|
|
|
|
fn handle_warp_managed_paths_event(
|
|
&mut self,
|
|
event: &GalaxyManagedPathsWatcherEvent,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
let GalaxyManagedPathsWatcherEvent::FilesChanged(update) = event;
|
|
for skill_dir in galaxy_managed_skill_dirs() {
|
|
if let Some(filtered_update) = filter_repository_update_by_prefix(update, &skill_dir) {
|
|
self.handle_repository_update(&filtered_update, ctx);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Watch a provider path in the home directory (e.g. ~/.agents), storing the handle
|
|
/// and subscriber ID in `home_provider_watchers` so the watcher can be cleaned up
|
|
/// when the directory is deleted.
|
|
fn watch_home_provider_path(
|
|
path: &Path,
|
|
repository_message_tx: &Sender<SkillRepositoryMessage>,
|
|
home_provider_watchers: &mut HashMap<PathBuf, (ModelHandle<Repository>, SubscriberId)>,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
let Ok(std_path) =
|
|
galaxy_util::standardized_path::StandardizedPath::from_local_canonicalized(path)
|
|
else {
|
|
return;
|
|
};
|
|
|
|
let subscriber = Box::new(HomeSkillSubscriber {
|
|
message_tx: repository_message_tx.clone(),
|
|
});
|
|
|
|
let parent_path_display = std_path.to_string();
|
|
let repo_handle = match DirectoryWatcher::handle(ctx)
|
|
.update(ctx, |watcher, ctx| watcher.add_directory(std_path, ctx))
|
|
{
|
|
Ok(handle) => handle,
|
|
Err(err) => {
|
|
log::warn!(
|
|
"Failed to register home skills directory {parent_path_display} for watching: {err}"
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
|
|
let start = repo_handle.update(ctx, |repo, ctx| repo.start_watching(subscriber, ctx));
|
|
let subscriber_id = start.subscriber_id;
|
|
|
|
// Store the watcher so it can be cleaned up if the directory is deleted.
|
|
home_provider_watchers.insert(path.to_path_buf(), (repo_handle.clone(), subscriber_id));
|
|
|
|
let path_owned = path.to_path_buf();
|
|
ctx.spawn(start.registration_future, move |me, res, ctx| {
|
|
if let Err(err) = res {
|
|
log::warn!(
|
|
"Failed to start watching home skills directory {parent_path_display}: {err}"
|
|
);
|
|
// Remove the stored watcher since registration failed.
|
|
me.home_provider_watchers.remove(&path_owned);
|
|
repo_handle.update(ctx, |repo, ctx| {
|
|
repo.stop_watching(subscriber_id, ctx);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
impl Entity for SkillWatcher {
|
|
type Event = SkillWatcherEvent;
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "skill_watcher_tests.rs"]
|
|
mod skill_watcher_tests;
|