Improve plugin source modeling and runtime dedup (#414)

This commit is contained in:
Gregory Schier
2026-03-01 16:30:43 -08:00
committed by GitHub
parent 2d99e26f19
commit 2ca51125a4
13 changed files with 210 additions and 44 deletions

View File

@@ -1 +1,2 @@
- Tag safety: app releases use `v*` tags and CLI releases use `yaak-cli-*` tags; always confirm which one is requested before retagging. - Tag safety: app releases use `v*` tags and CLI releases use `yaak-cli-*` tags; always confirm which one is requested before retagging.
- Do not commit, push, or tag without explicit approval

View File

@@ -38,7 +38,7 @@ use yaak_mac_window::AppHandleMacWindowExt;
use yaak_models::models::{ use yaak_models::models::{
AnyModel, CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent, AnyModel, CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent,
GrpcEventType, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseState, Plugin, GrpcEventType, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseState, Plugin,
Workspace, WorkspaceMeta, PluginSource, Workspace, WorkspaceMeta,
}; };
use yaak_models::util::{BatchUpsertResult, UpdateSource, get_workspace_export_resources}; use yaak_models::util::{BatchUpsertResult, UpdateSource, get_workspace_export_resources};
use yaak_plugins::events::{ use yaak_plugins::events::{
@@ -1354,7 +1354,13 @@ async fn cmd_install_plugin<R: Runtime>(
window: WebviewWindow<R>, window: WebviewWindow<R>,
) -> YaakResult<Plugin> { ) -> YaakResult<Plugin> {
let plugin = app_handle.db().upsert_plugin( let plugin = app_handle.db().upsert_plugin(
&Plugin { directory: directory.into(), url, enabled: true, ..Default::default() }, &Plugin {
directory: directory.into(),
url,
enabled: true,
source: PluginSource::Filesystem,
..Default::default()
},
&UpdateSource::from_window_label(window.label()), &UpdateSource::from_window_label(window.label()),
)?; )?;

View File

@@ -15,6 +15,7 @@ use yaak_models::error::Result;
use yaak_models::models::{AnyModel, GraphQlIntrospection, GrpcEvent, Settings, WebsocketEvent}; use yaak_models::models::{AnyModel, GraphQlIntrospection, GrpcEvent, Settings, WebsocketEvent};
use yaak_models::query_manager::QueryManager; use yaak_models::query_manager::QueryManager;
use yaak_models::util::UpdateSource; use yaak_models::util::UpdateSource;
use yaak_plugins::manager::PluginManager;
const MODEL_CHANGES_RETENTION_HOURS: i64 = 1; const MODEL_CHANGES_RETENTION_HOURS: i64 = 1;
const MODEL_CHANGES_POLL_INTERVAL_MS: u64 = 1000; const MODEL_CHANGES_POLL_INTERVAL_MS: u64 = 1000;
@@ -255,23 +256,32 @@ pub(crate) fn models_upsert_graphql_introspection<R: Runtime>(
} }
#[tauri::command] #[tauri::command]
pub(crate) fn models_workspace_models<R: Runtime>( pub(crate) async fn models_workspace_models<R: Runtime>(
window: WebviewWindow<R>, window: WebviewWindow<R>,
workspace_id: Option<&str>, workspace_id: Option<&str>,
plugin_manager: State<'_, PluginManager>,
) -> Result<String> { ) -> Result<String> {
let db = window.db();
let mut l: Vec<AnyModel> = Vec::new(); let mut l: Vec<AnyModel> = Vec::new();
// Add the settings // Add the global models
l.push(db.get_settings().into()); {
let db = window.db();
l.push(db.get_settings().into());
l.append(&mut db.list_workspaces()?.into_iter().map(Into::into).collect());
l.append(&mut db.list_key_values()?.into_iter().map(Into::into).collect());
}
// Add global models let plugins = {
l.append(&mut db.list_workspaces()?.into_iter().map(Into::into).collect()); let db = window.db();
l.append(&mut db.list_key_values()?.into_iter().map(Into::into).collect()); db.list_plugins()?
l.append(&mut db.list_plugins()?.into_iter().map(Into::into).collect()); };
let plugins = plugin_manager.resolve_plugins_for_runtime_from_db(plugins).await;
l.append(&mut plugins.into_iter().map(Into::into).collect());
// Add the workspace children // Add the workspace children
if let Some(wid) = workspace_id { if let Some(wid) = workspace_id {
let db = window.db();
l.append(&mut db.list_cookie_jars(wid)?.into_iter().map(Into::into).collect()); l.append(&mut db.list_cookie_jars(wid)?.into_iter().map(Into::into).collect());
l.append(&mut db.list_environments_ensure_base(wid)?.into_iter().map(Into::into).collect()); l.append(&mut db.list_environments_ensure_base(wid)?.into_iter().map(Into::into).collect());
l.append(&mut db.list_folders(wid)?.into_iter().map(Into::into).collect()); l.append(&mut db.list_folders(wid)?.into_iter().map(Into::into).collect());

View File

@@ -67,7 +67,9 @@ export type ParentAuthentication = { authentication: Record<string, any>, authen
export type ParentHeaders = { headers: Array<HttpRequestHeader>, }; export type ParentHeaders = { headers: Array<HttpRequestHeader>, };
export type Plugin = { model: "plugin", id: string, createdAt: string, updatedAt: string, checkedAt: string | null, directory: string, enabled: boolean, url: string | null, }; export type Plugin = { model: "plugin", id: string, createdAt: string, updatedAt: string, checkedAt: string | null, directory: string, enabled: boolean, url: string | null, source: PluginSource, };
export type PluginSource = "bundled" | "filesystem" | "registry";
export type PluginKeyValue = { model: "plugin_key_value", createdAt: string, updatedAt: string, pluginName: string, key: string, value: string, }; export type PluginKeyValue = { model: "plugin_key_value", createdAt: string, updatedAt: string, pluginName: string, key: string, value: string, };

View File

@@ -0,0 +1,33 @@
ALTER TABLE plugins
ADD COLUMN source TEXT DEFAULT 'filesystem' NOT NULL;
-- Existing registry installs have a URL; classify them first.
UPDATE plugins
SET source = 'registry'
WHERE url IS NOT NULL;
-- Best-effort bundled backfill for legacy rows.
UPDATE plugins
SET source = 'bundled'
WHERE source = 'filesystem'
AND (
-- Normalize separators so this also works for Windows paths.
replace(directory, '\', '/') LIKE '%/vendored/plugins/%'
OR replace(directory, '\', '/') LIKE '%/vendored-plugins/%'
);
-- Keep one row per exact directory before adding uniqueness.
-- Tie-break by recency.
WITH ranked AS (SELECT id,
ROW_NUMBER() OVER (
PARTITION BY directory
ORDER BY updated_at DESC,
created_at DESC
) AS row_num
FROM plugins)
DELETE
FROM plugins
WHERE id IN (SELECT id FROM ranked WHERE row_num > 1);
CREATE UNIQUE INDEX IF NOT EXISTS idx_plugins_directory_unique
ON plugins (directory);

View File

@@ -2074,6 +2074,46 @@ pub struct Plugin {
pub directory: String, pub directory: String,
pub enabled: bool, pub enabled: bool,
pub url: Option<String>, pub url: Option<String>,
pub source: PluginSource,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "gen_models.ts")]
pub enum PluginSource {
Bundled,
Filesystem,
Registry,
}
impl FromStr for PluginSource {
type Err = crate::error::Error;
fn from_str(s: &str) -> Result<Self> {
match s {
"bundled" => Ok(Self::Bundled),
"filesystem" => Ok(Self::Filesystem),
"registry" => Ok(Self::Registry),
_ => Ok(Self::default()),
}
}
}
impl Display for PluginSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let str = match self {
PluginSource::Bundled => "bundled".to_string(),
PluginSource::Filesystem => "filesystem".to_string(),
PluginSource::Registry => "registry".to_string(),
};
write!(f, "{}", str)
}
}
impl Default for PluginSource {
fn default() -> Self {
Self::Filesystem
}
} }
impl UpsertModelInfo for Plugin { impl UpsertModelInfo for Plugin {
@@ -2109,6 +2149,7 @@ impl UpsertModelInfo for Plugin {
(Directory, self.directory.into()), (Directory, self.directory.into()),
(Url, self.url.into()), (Url, self.url.into()),
(Enabled, self.enabled.into()), (Enabled, self.enabled.into()),
(Source, self.source.to_string().into()),
]) ])
} }
@@ -2119,6 +2160,7 @@ impl UpsertModelInfo for Plugin {
PluginIden::Directory, PluginIden::Directory,
PluginIden::Url, PluginIden::Url,
PluginIden::Enabled, PluginIden::Enabled,
PluginIden::Source,
] ]
} }
@@ -2135,6 +2177,7 @@ impl UpsertModelInfo for Plugin {
url: row.get("url")?, url: row.get("url")?,
directory: row.get("directory")?, directory: row.get("directory")?,
enabled: row.get("enabled")?, enabled: row.get("enabled")?,
source: PluginSource::from_str(row.get::<_, String>("source")?.as_str()).unwrap(),
}) })
} }
} }

View File

@@ -26,6 +26,10 @@ impl<'a> DbContext<'a> {
} }
pub fn upsert_plugin(&self, plugin: &Plugin, source: &UpdateSource) -> Result<Plugin> { pub fn upsert_plugin(&self, plugin: &Plugin, source: &UpdateSource) -> Result<Plugin> {
self.upsert(plugin, source) let mut plugin_to_upsert = plugin.clone();
if let Some(existing) = self.get_plugin_by_directory(&plugin.directory) {
plugin_to_upsert.id = existing.id;
}
self.upsert(&plugin_to_upsert, source)
} }
} }

View File

@@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize};
use std::path::Path; use std::path::Path;
use std::str::FromStr; use std::str::FromStr;
use ts_rs::TS; use ts_rs::TS;
use yaak_models::models::Plugin; use yaak_models::models::{Plugin, PluginSource};
/// Get plugin info from the registry. /// Get plugin info from the registry.
pub async fn get_plugin( pub async fn get_plugin(
@@ -58,7 +58,7 @@ pub async fn check_plugin_updates(
) -> Result<PluginUpdatesResponse> { ) -> Result<PluginUpdatesResponse> {
let name_versions: Vec<PluginNameVersion> = plugins let name_versions: Vec<PluginNameVersion> = plugins
.into_iter() .into_iter()
.filter(|p| p.url.is_some()) // Only check plugins with URLs (from registry) .filter(|p| matches!(p.source, PluginSource::Registry)) // Only check registry-installed plugins
.filter_map(|p| match get_plugin_meta(&Path::new(&p.directory)) { .filter_map(|p| match get_plugin_meta(&Path::new(&p.directory)) {
Ok(m) => Some(PluginNameVersion { name: m.name, version: m.version }), Ok(m) => Some(PluginNameVersion { name: m.name, version: m.version }),
Err(e) => { Err(e) => {

View File

@@ -9,7 +9,7 @@ use log::info;
use std::fs::{create_dir_all, remove_dir_all}; use std::fs::{create_dir_all, remove_dir_all};
use std::io::Cursor; use std::io::Cursor;
use std::sync::Arc; use std::sync::Arc;
use yaak_models::models::Plugin; use yaak_models::models::{Plugin, PluginSource};
use yaak_models::query_manager::QueryManager; use yaak_models::query_manager::QueryManager;
use yaak_models::util::UpdateSource; use yaak_models::util::UpdateSource;
@@ -78,6 +78,7 @@ pub async fn download_and_install(
directory: plugin_dir_str.clone(), directory: plugin_dir_str.clone(),
enabled: true, enabled: true,
url: Some(plugin_version.url.clone()), url: Some(plugin_version.url.clone()),
source: PluginSource::Registry,
..Default::default() ..Default::default()
}, },
&UpdateSource::Background, &UpdateSource::Background,

View File

@@ -21,9 +21,10 @@ use crate::events::{
use crate::native_template_functions::{template_function_keyring, template_function_secure}; use crate::native_template_functions::{template_function_keyring, template_function_secure};
use crate::nodejs::start_nodejs_plugin_runtime; use crate::nodejs::start_nodejs_plugin_runtime;
use crate::plugin_handle::PluginHandle; use crate::plugin_handle::PluginHandle;
use crate::plugin_meta::get_plugin_meta;
use crate::server_ws::PluginRuntimeServerWebsocket; use crate::server_ws::PluginRuntimeServerWebsocket;
use log::{error, info, warn}; use log::{error, info, warn};
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
use std::env; use std::env;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
@@ -33,7 +34,7 @@ use tokio::net::TcpListener;
use tokio::sync::mpsc::error::TrySendError; use tokio::sync::mpsc::error::TrySendError;
use tokio::sync::{Mutex, mpsc, oneshot}; use tokio::sync::{Mutex, mpsc, oneshot};
use tokio::time::{Instant, timeout}; use tokio::time::{Instant, timeout};
use yaak_models::models::Plugin; use yaak_models::models::{Plugin, PluginSource};
use yaak_models::query_manager::QueryManager; use yaak_models::query_manager::QueryManager;
use yaak_models::util::{UpdateSource, generate_id}; use yaak_models::util::{UpdateSource, generate_id};
use yaak_templates::error::Error::RenderError; use yaak_templates::error::Error::RenderError;
@@ -162,13 +163,14 @@ impl PluginManager {
let bundled_dirs = plugin_manager.list_bundled_plugin_dirs().await?; let bundled_dirs = plugin_manager.list_bundled_plugin_dirs().await?;
let db = query_manager.connect(); let db = query_manager.connect();
for dir in bundled_dirs { for dir in &bundled_dirs {
if db.get_plugin_by_directory(&dir).is_none() { if db.get_plugin_by_directory(dir).is_none() {
db.upsert_plugin( db.upsert_plugin(
&Plugin { &Plugin {
directory: dir, directory: dir.clone(),
enabled: true, enabled: true,
url: None, url: None,
source: PluginSource::Bundled,
..Default::default() ..Default::default()
}, },
&UpdateSource::Background, &UpdateSource::Background,
@@ -213,6 +215,57 @@ impl PluginManager {
read_plugins_dir(&plugins_dir).await read_plugins_dir(&plugins_dir).await
} }
pub async fn resolve_plugins_for_runtime_from_db(&self, plugins: Vec<Plugin>) -> Vec<Plugin> {
let bundled_dirs = match self.list_bundled_plugin_dirs().await {
Ok(dirs) => dirs,
Err(err) => {
warn!("Failed to read bundled plugin dirs for resolution: {err:?}");
Vec::new()
}
};
self.resolve_plugins_for_runtime(plugins, bundled_dirs)
}
/// Resolve the plugin set for the current runtime instance.
///
/// Rules:
/// - Drop bundled rows that are not present in this instance's bundled directory list.
/// - Deduplicate by plugin metadata name (fallback to directory key when metadata is unreadable).
/// - Prefer sources in this order: filesystem > registry > bundled.
/// - For same-source conflicts, prefer the most recently installed row (`created_at`).
fn resolve_plugins_for_runtime(
&self,
plugins: Vec<Plugin>,
bundled_dirs: Vec<String>,
) -> Vec<Plugin> {
let bundled_dir_set: HashSet<String> = bundled_dirs.into_iter().collect();
let mut selected: HashMap<String, Plugin> = HashMap::new();
for plugin in plugins {
if matches!(plugin.source, PluginSource::Bundled)
&& !bundled_dir_set.contains(&plugin.directory)
{
continue;
}
let key = match get_plugin_meta(Path::new(&plugin.directory)) {
Ok(meta) => meta.name,
Err(_) => format!("__dir__{}", plugin.directory),
};
match selected.get(&key) {
Some(existing) if !prefer_plugin(&plugin, existing) => {}
_ => {
selected.insert(key, plugin);
}
}
}
let mut resolved = selected.into_values().collect::<Vec<_>>();
resolved.sort_by(|a, b| b.created_at.cmp(&a.created_at));
resolved
}
pub async fn uninstall(&self, plugin_context: &PluginContext, dir: &str) -> Result<()> { pub async fn uninstall(&self, plugin_context: &PluginContext, dir: &str) -> Result<()> {
let plugin = self.get_plugin_by_dir(dir).await.ok_or(PluginNotFoundErr(dir.to_string()))?; let plugin = self.get_plugin_by_dir(dir).await.ok_or(PluginNotFoundErr(dir.to_string()))?;
self.remove_plugin(plugin_context, &plugin).await self.remove_plugin(plugin_context, &plugin).await
@@ -287,7 +340,8 @@ impl PluginManager {
Ok(()) Ok(())
} }
/// Initialize all plugins from the provided list. /// Initialize all plugins from the provided DB list.
/// Plugin candidates are resolved for this runtime instance before initialization.
/// Returns a list of (plugin_directory, error_message) for any plugins that failed to initialize. /// Returns a list of (plugin_directory, error_message) for any plugins that failed to initialize.
pub async fn initialize_all_plugins( pub async fn initialize_all_plugins(
&self, &self,
@@ -297,15 +351,18 @@ impl PluginManager {
info!("Initializing all plugins"); info!("Initializing all plugins");
let start = Instant::now(); let start = Instant::now();
let mut errors = Vec::new(); let mut errors = Vec::new();
let plugins = self.resolve_plugins_for_runtime_from_db(plugins).await;
// Rebuild runtime handles from scratch to avoid stale/duplicate handles.
let existing_handles = { self.plugin_handles.lock().await.clone() };
for plugin_handle in existing_handles {
if let Err(e) = self.remove_plugin(plugin_context, &plugin_handle).await {
error!("Failed to remove plugin {} {e:?}", plugin_handle.dir);
errors.push((plugin_handle.dir.clone(), e.to_string()));
}
}
for plugin in plugins { for plugin in plugins {
// First remove the plugin if it exists and is enabled
if let Some(plugin_handle) = self.get_plugin_by_dir(&plugin.directory).await {
if let Err(e) = self.remove_plugin(plugin_context, &plugin_handle).await {
error!("Failed to remove plugin {} {e:?}", plugin.directory);
continue;
}
}
if let Err(e) = self.add_plugin(plugin_context, &plugin).await { if let Err(e) = self.add_plugin(plugin_context, &plugin).await {
warn!("Failed to add plugin {} {e:?}", plugin.directory); warn!("Failed to add plugin {} {e:?}", plugin.directory);
errors.push((plugin.directory.clone(), e.to_string())); errors.push((plugin.directory.clone(), e.to_string()));
@@ -1063,6 +1120,24 @@ impl PluginManager {
} }
} }
fn source_priority(source: &PluginSource) -> i32 {
match source {
PluginSource::Filesystem => 3,
PluginSource::Registry => 2,
PluginSource::Bundled => 1,
}
}
fn prefer_plugin(candidate: &Plugin, existing: &Plugin) -> bool {
let candidate_priority = source_priority(&candidate.source);
let existing_priority = source_priority(&existing.source);
if candidate_priority != existing_priority {
return candidate_priority > existing_priority;
}
candidate.created_at > existing.created_at
}
async fn read_plugins_dir(dir: &PathBuf) -> Result<Vec<String>> { async fn read_plugins_dir(dir: &PathBuf) -> Result<Vec<String>> {
let mut result = read_dir(dir).await?; let mut result = read_dir(dir).await?;
let mut dirs: Vec<String> = vec![]; let mut dirs: Vec<String> = vec![];

View File

@@ -1,5 +1,7 @@
{ {
"name": "@yaak/template-function-timestamp", "name": "@yaak/template-function-timestamp",
"displayName": "Timestamp Template Functions",
"description": "Template functions for dealing with timestamps",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"scripts": { "scripts": {

View File

@@ -15,7 +15,6 @@ import { useDebouncedValue } from '../../hooks/useDebouncedValue';
import { useInstallPlugin } from '../../hooks/useInstallPlugin'; import { useInstallPlugin } from '../../hooks/useInstallPlugin';
import { usePluginInfo } from '../../hooks/usePluginInfo'; import { usePluginInfo } from '../../hooks/usePluginInfo';
import { usePluginsKey, useRefreshPlugins } from '../../hooks/usePlugins'; import { usePluginsKey, useRefreshPlugins } from '../../hooks/usePlugins';
import { appInfo } from '../../lib/appInfo';
import { showConfirmDelete } from '../../lib/confirm'; import { showConfirmDelete } from '../../lib/confirm';
import { minPromiseMillis } from '../../lib/minPromiseMillis'; import { minPromiseMillis } from '../../lib/minPromiseMillis';
import { Button } from '../core/Button'; import { Button } from '../core/Button';
@@ -33,16 +32,6 @@ import { TabContent, Tabs } from '../core/Tabs/Tabs';
import { EmptyStateText } from '../EmptyStateText'; import { EmptyStateText } from '../EmptyStateText';
import { SelectFile } from '../SelectFile'; import { SelectFile } from '../SelectFile';
function isPluginBundled(plugin: Plugin, vendoredPluginDir: string): boolean {
const normalizedDir = plugin.directory.replace(/\\/g, '/');
const normalizedVendoredDir = vendoredPluginDir.replace(/\\/g, '/');
return (
normalizedDir.includes(normalizedVendoredDir) ||
normalizedDir.includes('vendored/plugins') ||
normalizedDir.includes('/plugins/')
);
}
interface SettingsPluginsProps { interface SettingsPluginsProps {
defaultSubtab?: string; defaultSubtab?: string;
} }
@@ -50,8 +39,8 @@ interface SettingsPluginsProps {
export function SettingsPlugins({ defaultSubtab }: SettingsPluginsProps) { export function SettingsPlugins({ defaultSubtab }: SettingsPluginsProps) {
const [directory, setDirectory] = useState<string | null>(null); const [directory, setDirectory] = useState<string | null>(null);
const plugins = useAtomValue(pluginsAtom); const plugins = useAtomValue(pluginsAtom);
const bundledPlugins = plugins.filter((p) => isPluginBundled(p, appInfo.vendoredPluginDir)); const bundledPlugins = plugins.filter((p) => p.source === 'bundled');
const installedPlugins = plugins.filter((p) => !isPluginBundled(p, appInfo.vendoredPluginDir)); const installedPlugins = plugins.filter((p) => p.source !== 'bundled');
const createPlugin = useInstallPlugin(); const createPlugin = useInstallPlugin();
const refreshPlugins = useRefreshPlugins(); const refreshPlugins = useRefreshPlugins();
return ( return (

View File

@@ -896,9 +896,9 @@ function MenuItem({
}; };
const rightSlot = item.submenu ? ( const rightSlot = item.submenu ? (
<Icon icon="chevron_right" color='secondary' /> <Icon icon="chevron_right" color="secondary" />
) : ( ) : (
(item.rightSlot ?? <Hotkey action={item.hotKeyAction ?? null} />) (item.rightSlot ?? <Hotkey variant="text" action={item.hotKeyAction ?? null} />)
); );
return ( return (
@@ -937,7 +937,7 @@ function MenuItem({
)} )}
{...props} {...props}
> >
<div className={classNames('truncate')}>{item.label}</div> <div className={classNames('truncate min-w-[5rem]')}>{item.label}</div>
</Button> </Button>
); );
} }