mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-12 18:18:10 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86db50729e |
No files matched your search
@@ -182,7 +182,7 @@ impl PluginDiscoveryContext {
|
||||
super::discovery::default_workspace_plugins_dir(workspace),
|
||||
];
|
||||
roots.extend(self.builtin_plugin_dirs.iter().cloned());
|
||||
super::discovery::PluginCatalogStamp::capture(roots)
|
||||
super::discovery::PluginCatalogStamp::capture(roots, &self.state_path)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,20 +27,40 @@ pub struct DiscoveryConfig {
|
||||
pub state_path: PathBuf,
|
||||
}
|
||||
|
||||
/// Cheap on-disk catalog fingerprint: plugin-bundle directories only.
|
||||
/// Cheap on-disk catalog fingerprint: plugin-bundle directories plus the
|
||||
/// content of the plugin state file.
|
||||
///
|
||||
/// Used to nudge `/plugin reload` when a bundle appears, disappears, or is
|
||||
/// rewritten without consulting process environment or auto-applying trust.
|
||||
/// `state.json` writes are ignored so enable/trust does not look like a
|
||||
/// catalog change.
|
||||
/// Used to apply or nudge a reload when a bundle appears, disappears, or is
|
||||
/// rewritten, or when another process installs, enables or disables a plugin.
|
||||
/// The state file is compared by content digest, never by mtime, so a rewrite
|
||||
/// with identical bytes is not a change and a same-second rewrite is.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct PluginCatalogStamp {
|
||||
entries: BTreeMap<PathBuf, Option<SystemTime>>,
|
||||
state: Option<[u8; 32]>,
|
||||
}
|
||||
|
||||
const MAX_STAMPED_FILE_BYTES: u64 = 8 * 1024 * 1024;
|
||||
|
||||
/// SHA-256 of a small file's bytes, or `None` when it is absent, unreadable or
|
||||
/// larger than the stamp bound.
|
||||
#[must_use]
|
||||
pub fn file_content_digest(path: &Path) -> Option<[u8; 32]> {
|
||||
use std::io::Read;
|
||||
let file = fs::File::open(path).ok()?;
|
||||
let mut bytes = Vec::new();
|
||||
file.take(MAX_STAMPED_FILE_BYTES + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.ok()?;
|
||||
if bytes.len() as u64 > MAX_STAMPED_FILE_BYTES {
|
||||
return None;
|
||||
}
|
||||
Some(Sha256::digest(&bytes).into())
|
||||
}
|
||||
|
||||
impl PluginCatalogStamp {
|
||||
#[must_use]
|
||||
pub fn capture(roots: impl IntoIterator<Item = PathBuf>) -> Self {
|
||||
pub fn capture(roots: impl IntoIterator<Item = PathBuf>, state_path: &Path) -> Self {
|
||||
let mut entries = BTreeMap::new();
|
||||
for root in roots {
|
||||
let Ok(read) = fs::read_dir(&root) else {
|
||||
@@ -57,7 +77,14 @@ impl PluginCatalogStamp {
|
||||
entries.insert(path, metadata.modified().ok());
|
||||
}
|
||||
}
|
||||
Self { entries }
|
||||
Self {
|
||||
entries,
|
||||
state: file_content_digest(state_path),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn refresh_state(&mut self, state_path: &Path) {
|
||||
self.state = file_content_digest(state_path);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,11 +27,41 @@ pub mod test_fixture;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub use context::{HostEnvironment, PluginDiscoveryContext};
|
||||
pub use discovery::PluginCatalogStamp;
|
||||
pub use registry::PluginRegistry;
|
||||
|
||||
pub const PLUGIN_RELOAD_NUDGE: &str = "Plugins changed on disk. Run /plugin reload to apply.";
|
||||
pub const PLUGIN_RELOAD_NUDGE: &str =
|
||||
"Plugins changed on disk. They apply when the session is idle, or run /plugin reload.";
|
||||
|
||||
pub const PLUGIN_PROVIDER_NEW_SESSION_NOTICE: &str =
|
||||
"Plugin provider changes apply in a new session.";
|
||||
|
||||
fn active_provider_declarations(
|
||||
registry: &PluginRegistry,
|
||||
) -> BTreeMap<String, (String, Vec<String>)> {
|
||||
registry
|
||||
.active_plugins()
|
||||
.into_iter()
|
||||
.filter(|plugin| !plugin.manifest.providers.is_empty())
|
||||
.map(|plugin| {
|
||||
(
|
||||
plugin.id.as_str().to_string(),
|
||||
(
|
||||
plugin.content_hash.clone(),
|
||||
plugin.manifest.providers.keys().cloned().collect(),
|
||||
),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn provider_declarations_changed(before: &PluginRegistry, after: &PluginRegistry) -> bool {
|
||||
active_provider_declarations(before) != active_provider_declarations(after)
|
||||
}
|
||||
|
||||
/// Returns the reload nudge once per distinct on-disk catalog stamp.
|
||||
#[must_use]
|
||||
|
||||
@@ -1326,6 +1326,7 @@ impl PluginRegistry {
|
||||
let mut next = load_state_unlocked(path)?;
|
||||
mutate(&mut next)?;
|
||||
save_state(path, &next)?;
|
||||
self.catalog_stamp.refresh_state(path);
|
||||
self.state = next;
|
||||
// Runtime selection rechecks the persisted review through its own
|
||||
// read lock. Publish the completed state transaction before deriving
|
||||
|
||||
@@ -35,6 +35,26 @@ fn write_named_plugin(config: &DiscoveryConfig, name: &str, extra: &str) -> Path
|
||||
plugin
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_stamp_changes_when_only_state_bytes_change() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config = config(tmp.path());
|
||||
write_plugin(&config, "");
|
||||
let registry = discover_with_config(&config);
|
||||
assert!(!registry.on_disk_catalog_changed());
|
||||
|
||||
std::fs::create_dir_all(config.state_path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&config.state_path, "{\"version\":1}").unwrap();
|
||||
assert!(registry.on_disk_catalog_changed());
|
||||
|
||||
let reloaded = registry.rediscover_for_workspace(&config.workspace);
|
||||
assert!(!reloaded.on_disk_catalog_changed());
|
||||
std::fs::write(&config.state_path, "{\"version\":1}").unwrap();
|
||||
assert!(!reloaded.on_disk_catalog_changed());
|
||||
std::fs::write(&config.state_path, "{\"version\":2}").unwrap();
|
||||
assert!(reloaded.on_disk_catalog_changed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn on_disk_catalog_change_nudges_reload_once_until_rediscover() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -1736,6 +1736,7 @@ pub struct App {
|
||||
pub plugin_reload_nudge_stamp: Option<codewhale_plugins::PluginCatalogStamp>,
|
||||
/// Last idle catalog fingerprint poll, so disk changes can surface between turns.
|
||||
pub last_plugin_catalog_poll: Option<Instant>,
|
||||
pub mcp_config_watch: Option<crate::tui::plugin_suggestions::McpConfigWatch>,
|
||||
/// Live composer plugin CTA (debounce + one match, never auto-install).
|
||||
pub plugin_cta: crate::tui::plugin_suggestions::PluginCtaState,
|
||||
pub model: String,
|
||||
|
||||
@@ -858,6 +858,7 @@ impl App {
|
||||
context_pressure_warning_dismissed: None,
|
||||
plugin_reload_nudge_stamp: None,
|
||||
last_plugin_catalog_poll: None,
|
||||
mcp_config_watch: None,
|
||||
plugin_cta: crate::tui::plugin_suggestions::PluginCtaState::from_settings(&settings),
|
||||
model,
|
||||
provider_models,
|
||||
|
||||
@@ -31,6 +31,22 @@ use codewhale_plugins::recommend::{
|
||||
|
||||
const CATALOG_POLL_INTERVAL: Duration = Duration::from_secs(2);
|
||||
|
||||
pub const MCP_CONFIG_NUDGE: &str =
|
||||
"MCP servers changed on disk. They apply when the session is idle, or run /mcp reload.";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ExternalCatalogChange {
|
||||
pub plugins: bool,
|
||||
pub mcp: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct McpConfigWatch {
|
||||
pub digest: Option<[u8; 32]>,
|
||||
pub generation: u64,
|
||||
pub notified: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PluginCtaPhase {
|
||||
Hidden,
|
||||
@@ -124,24 +140,63 @@ impl App {
|
||||
true
|
||||
}
|
||||
|
||||
/// Cheap idle poll so on-disk plugin changes can surface between turns,
|
||||
/// not only on send. Fingerprints directories; never auto-reloads.
|
||||
pub fn maybe_poll_plugin_catalog_idle(&mut self) {
|
||||
pub fn poll_external_catalog_changes(
|
||||
&mut self,
|
||||
engine_busy: bool,
|
||||
) -> Option<ExternalCatalogChange> {
|
||||
let now = Instant::now();
|
||||
if self
|
||||
.last_plugin_catalog_poll
|
||||
.is_some_and(|seen| now.duration_since(seen) < CATALOG_POLL_INTERVAL)
|
||||
{
|
||||
return;
|
||||
return None;
|
||||
}
|
||||
self.last_plugin_catalog_poll = Some(now);
|
||||
if let Some(message) = codewhale_plugins::plugin_reload_nudge(
|
||||
self.plugin_registry.as_ref(),
|
||||
&mut self.plugin_reload_nudge_stamp,
|
||||
) {
|
||||
self.push_status_toast(message, StatusToastLevel::Warning, Some(8_000));
|
||||
self.needs_redraw = true;
|
||||
let digest = codewhale_plugins::discovery::file_content_digest(&self.mcp_config_path);
|
||||
let generation = self.mcp_snapshot_generation;
|
||||
let watch = self.mcp_config_watch.get_or_insert(McpConfigWatch {
|
||||
digest,
|
||||
generation,
|
||||
notified: false,
|
||||
});
|
||||
if watch.generation != generation {
|
||||
*watch = McpConfigWatch {
|
||||
digest,
|
||||
generation,
|
||||
notified: false,
|
||||
};
|
||||
}
|
||||
let mcp = watch.digest != digest;
|
||||
let mcp_unannounced = mcp && !std::mem::replace(&mut watch.notified, true);
|
||||
let plugins = self.plugin_registry.on_disk_catalog_changed();
|
||||
if !plugins && !mcp {
|
||||
return None;
|
||||
}
|
||||
if engine_busy || self.pending_fresh_session.is_some() {
|
||||
if plugins
|
||||
&& let Some(message) = codewhale_plugins::plugin_reload_nudge(
|
||||
self.plugin_registry.as_ref(),
|
||||
&mut self.plugin_reload_nudge_stamp,
|
||||
)
|
||||
{
|
||||
self.push_status_toast(message, StatusToastLevel::Warning, Some(8_000));
|
||||
self.needs_redraw = true;
|
||||
}
|
||||
if mcp_unannounced {
|
||||
self.push_status_toast(MCP_CONFIG_NUDGE, StatusToastLevel::Warning, Some(8_000));
|
||||
self.needs_redraw = true;
|
||||
}
|
||||
return None;
|
||||
}
|
||||
Some(ExternalCatalogChange { plugins, mcp })
|
||||
}
|
||||
|
||||
pub fn mark_external_catalog_applied(&mut self) {
|
||||
self.mcp_config_watch = Some(McpConfigWatch {
|
||||
digest: codewhale_plugins::discovery::file_content_digest(&self.mcp_config_path),
|
||||
generation: self.mcp_snapshot_generation,
|
||||
notified: false,
|
||||
});
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
@@ -417,12 +472,53 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_state_and_mcp_writes_apply_when_idle_and_toast_while_busy() {
|
||||
let _lock = crate::test_support::lock_test_env();
|
||||
let (mut app, _root, _home) = app_with_supabase_plugin();
|
||||
assert_eq!(app.poll_external_catalog_changes(false), None);
|
||||
|
||||
let state_path = app.plugin_registry.state_path().unwrap().to_path_buf();
|
||||
fs::create_dir_all(state_path.parent().unwrap()).unwrap();
|
||||
fs::write(&state_path, "{\"version\":1,\"plugins\":{}}").unwrap();
|
||||
fs::write(&app.mcp_config_path, "{\"servers\":{}}").unwrap();
|
||||
|
||||
app.last_plugin_catalog_poll = None;
|
||||
assert_eq!(app.poll_external_catalog_changes(true), None);
|
||||
let toasts = app
|
||||
.status_toasts
|
||||
.iter()
|
||||
.map(|toast| toast.text.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(toasts.iter().any(|text| text.contains("Plugins changed")));
|
||||
assert!(
|
||||
toasts
|
||||
.iter()
|
||||
.any(|text| text.contains("MCP servers changed"))
|
||||
);
|
||||
|
||||
app.last_plugin_catalog_poll = None;
|
||||
assert_eq!(
|
||||
app.poll_external_catalog_changes(false),
|
||||
Some(ExternalCatalogChange {
|
||||
plugins: true,
|
||||
mcp: true
|
||||
})
|
||||
);
|
||||
app.mark_external_catalog_applied();
|
||||
app.plugin_registry = app
|
||||
.plugin_registry
|
||||
.rediscover_for_workspace(&app.workspace.clone());
|
||||
app.last_plugin_catalog_poll = None;
|
||||
assert_eq!(app.poll_external_catalog_changes(false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_a_matching_draft_never_shows_a_row() {
|
||||
let _lock = crate::test_support::lock_test_env();
|
||||
let (mut app, _root, _home) = app_with_supabase_plugin();
|
||||
app.input = "add supabase auth to login".to_string();
|
||||
app.maybe_poll_plugin_catalog_idle();
|
||||
let _ = app.poll_external_catalog_changes(false);
|
||||
assert!(!app.plugin_cta.phase.is_visible());
|
||||
assert_eq!(app.plugin_cta_row_height(), 0);
|
||||
}
|
||||
|
||||
@@ -981,7 +981,9 @@ pub(crate) use compaction_flow::*;
|
||||
pub(crate) use provider_setup::*;
|
||||
mod dispatch;
|
||||
mod dispatch_prepare;
|
||||
mod external_catalog;
|
||||
pub(crate) use dispatch_prepare::*;
|
||||
pub(crate) use external_catalog::*;
|
||||
pub(crate) mod fatal_signal_guard;
|
||||
// #6169: runtime half of the foreground-ownership contract — restore on stop,
|
||||
// rebuild on continue. Sits next to the fatal guard because both write the same
|
||||
|
||||
@@ -2640,7 +2640,17 @@ pub(crate) async fn run_event_loop(
|
||||
// #1830/#2317: service any already-arrived terminal keys before a
|
||||
// potentially long engine batch so composer/modal input stays live.
|
||||
collect_pending_terminal_events(&terminal_input, &mut pending_terminal_events)?;
|
||||
app.maybe_poll_plugin_catalog_idle();
|
||||
if let Some(change) = app.poll_external_catalog_changes(mcp_engine_busy(app)) {
|
||||
apply_external_catalog_change(
|
||||
terminal,
|
||||
app,
|
||||
&mut engine_handle,
|
||||
&task_manager,
|
||||
config,
|
||||
change,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if drain_remote_control_events(app, config, &engine_handle).await? {
|
||||
app.needs_redraw = true;
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
use super::*;
|
||||
use crate::tui::plugin_suggestions::ExternalCatalogChange;
|
||||
|
||||
fn skill_delta(before: &[(String, String)], after: &[(String, String)]) -> String {
|
||||
let names = |skills: &[(String, String)]| {
|
||||
skills
|
||||
.iter()
|
||||
.map(|(name, _)| name.clone())
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
};
|
||||
let (before, after) = (names(before), names(after));
|
||||
let added = after.difference(&before).cloned().collect::<Vec<_>>();
|
||||
let removed = before.difference(&after).cloned().collect::<Vec<_>>();
|
||||
let mut parts = Vec::new();
|
||||
if !added.is_empty() {
|
||||
parts.push(format!("skills added: {}", added.join(", ")));
|
||||
}
|
||||
if !removed.is_empty() {
|
||||
parts.push(format!("skills removed: {}", removed.join(", ")));
|
||||
}
|
||||
parts.join("; ")
|
||||
}
|
||||
|
||||
pub(crate) async fn apply_external_catalog_change(
|
||||
terminal: &mut AppTerminal,
|
||||
app: &mut App,
|
||||
engine_handle: &mut EngineHandle,
|
||||
task_manager: &SharedTaskManager,
|
||||
config: &mut Config,
|
||||
change: ExternalCatalogChange,
|
||||
) -> Result<()> {
|
||||
app.mark_external_catalog_applied();
|
||||
if change.plugins {
|
||||
let before = app.plugin_registry.clone();
|
||||
let skills_before = app.cached_skills.clone();
|
||||
let workspace = app.workspace.clone();
|
||||
app.plugin_registry = before.rediscover_for_workspace(&workspace);
|
||||
app.refresh_skill_cache();
|
||||
let mut message = String::from("Plugins changed outside this session and were applied");
|
||||
let delta = skill_delta(&skills_before, &app.cached_skills);
|
||||
if !delta.is_empty() {
|
||||
message.push_str(&format!(" ({delta})"));
|
||||
}
|
||||
message.push('.');
|
||||
if codewhale_plugins::provider_declarations_changed(
|
||||
before.as_ref(),
|
||||
app.plugin_registry.as_ref(),
|
||||
) {
|
||||
message.push(' ');
|
||||
message.push_str(codewhale_plugins::PLUGIN_PROVIDER_NEW_SESSION_NOTICE);
|
||||
}
|
||||
let result = commands::CommandResult::with_message_and_action(
|
||||
message,
|
||||
AppAction::PluginRegistryChanged,
|
||||
);
|
||||
apply_command_result(terminal, app, engine_handle, task_manager, config, result).await?;
|
||||
}
|
||||
if change.mcp {
|
||||
let extensions_was_open = app.view_stack.extensions_is_top();
|
||||
handle_mcp_ui_action(
|
||||
app,
|
||||
engine_handle,
|
||||
config,
|
||||
crate::tui::app::McpUiAction::Reload,
|
||||
)
|
||||
.await;
|
||||
if !extensions_was_open && app.view_stack.extensions_is_top() {
|
||||
app.view_stack.pop();
|
||||
}
|
||||
}
|
||||
app.needs_redraw = true;
|
||||
Ok(())
|
||||
}
|
||||
@@ -740,7 +740,7 @@ fn mcp_scoped_config_path(
|
||||
|
||||
/// Whether a turn (or its compaction work) owns the engine. The engine
|
||||
/// services ops only between turns, so an op sent now waits for that turn.
|
||||
fn mcp_engine_busy(app: &App) -> bool {
|
||||
pub(super) fn mcp_engine_busy(app: &App) -> bool {
|
||||
app.is_loading
|
||||
|| app.dispatch_in_flight
|
||||
|| matches!(app.runtime_turn_status.as_deref(), Some("in_progress"))
|
||||
|
||||
Reference in new issue
Block a user