Fixed configuration-managed settings remaining active after their configuration plugin was removed (#892)
Some checks failed
Build and Release / Determine run mode (push) Has been cancelled
Build and Release / Read metadata (push) Has been cancelled
Build and Release / Sync Flatpak repo (push) Has been cancelled
Build and Release / Collect Flatpak artifacts (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Has been cancelled
Build and Release / Prepare & create release (push) Has been cancelled
Build and Release / Publish release (push) Has been cancelled

Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
This commit is contained in:
Peer Hogeterp 2026-08-06 20:59:53 +02:00 committed by GitHub
parent f085a87f5d
commit d1a6781ea6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 503 additions and 554 deletions

View File

@ -113,12 +113,12 @@ Plugins can configure:
- etc. - etc.
Configuration plugins provide three kinds of values: Configuration plugins provide three kinds of values:
- **Managed settings:** simple values such as booleans, numbers, strings, enums, lists, or sets handled through `ManagedConfiguration`. These values may be locked or used as organization defaults. - **Managed settings:** simple values such as booleans, numbers, strings, enums, lists, or sets handled through `ManagedConfiguration`. These values may be locked or used as organization defaults. Which configuration plugin owns a locked setting is persisted in `Data.ManagedLockedConfigurations`, and organization defaults are tracked in `Data.ManagedEditableDefaults`. Both are cleaned up generically by `ManagedConfiguration.CleanupLeftOverManagedConfigurations(...)` when the owning plugin is gone.
- **Managed configuration objects:** complex Lua tables that are persisted into `SettingsManager.ConfigurationData`, implement `IConfigurationObject`, and are cleaned up through `PluginConfigurationObject.CleanLeftOverConfigurationObjects(...)`. Examples include providers, profiles, chat templates, data sources, and document analysis policies. - **Managed configuration objects:** complex Lua tables that are persisted into `SettingsManager.ConfigurationData`, implement `IConfigurationObject`, and are cleaned up through `PluginConfigurationObject.CleanLeftOverConfigurationObjects(...)`. Examples include providers, profiles, chat templates, data sources, and document analysis policies.
- **Live plugin content:** complex Lua tables that implement `ILivePluginContent` and are read live from running plugins instead of being persisted to `ConfigurationData`. Examples include `MANDATORY_INFOS` and `INTRODUCTIONS`. If live plugin content creates persistent side data, add a dedicated cleanup path for that side data, like mandatory-info acceptances. - **Live plugin content:** complex Lua tables that implement `ILivePluginContent` and are read live from running plugins instead of being persisted to `ConfigurationData`. Examples include `MANDATORY_INFOS` and `INTRODUCTIONS`. If live plugin content creates persistent side data, add a dedicated cleanup path for that side data, like mandatory-info acceptances.
When adding configuration plugin capabilities: When adding configuration plugin capabilities:
- For managed settings, update the corresponding data class in `app/MindWork AI Studio/Settings/DataModel/` to call `ManagedConfiguration.Register(...)`, process the setting in `PluginConfiguration.TryProcessConfiguration`, and check for leftover managed configuration in `PluginFactory.Loading.LoadAll`. - For managed settings, update the corresponding data class in `app/MindWork AI Studio/Settings/DataModel/` to call `ManagedConfiguration.Register(...)` and process the setting in `PluginConfiguration.TryProcessConfiguration`. Cleaning up the setting when its configuration plugin was removed needs no extra step: `ManagedConfiguration.CleanupLeftOverManagedConfigurations(...)` iterates all registered settings. Do not add per-setting cleanup calls to `PluginFactory.Loading.LoadAll`.
- For managed configuration objects, update `PluginConfigurationObject.cs` and `PluginConfigurationObjectType.cs`, persist them in the appropriate `ConfigurationData` collection, and add cleanup via `PluginConfigurationObject.CleanLeftOverConfigurationObjects(...)`. - For managed configuration objects, update `PluginConfigurationObject.cs` and `PluginConfigurationObjectType.cs`, persist them in the appropriate `ConfigurationData` collection, and add cleanup via `PluginConfigurationObject.CleanLeftOverConfigurationObjects(...)`.
- For live plugin content, add a data type implementing `ILivePluginContent`, parse it in `PluginConfiguration`, expose it through `PluginFactory`, and add any required cleanup only for persistent side data. - For live plugin content, add a data type implementing `ILivePluginContent`, parse it in `PluginConfiguration`, expose it through `PluginFactory`, and add any required cleanup only for persistent side data.
- Always document the new capability in `app/MindWork AI Studio/Plugins/configuration/plugin.lua`. - Always document the new capability in `app/MindWork AI Studio/Plugins/configuration/plugin.lua`.

View File

@ -11,7 +11,7 @@ namespace AIStudio.Settings;
/// <typeparam name="TValue">The type of the configuration property value.</typeparam> /// <typeparam name="TValue">The type of the configuration property value.</typeparam>
public record ConfigMeta<TClass, TValue> : ConfigMetaBase public record ConfigMeta<TClass, TValue> : ConfigMetaBase
{ {
public ConfigMeta(Expression<Func<Data, TClass>> configSelection, Expression<Func<TClass, TValue>> propertyExpression) public ConfigMeta(Expression<Func<Data, TClass>> configSelection, Expression<Func<TClass, TValue>> propertyExpression) : base(SettingsManager.ToSettingName(propertyExpression))
{ {
this.ConfigSelection = configSelection; this.ConfigSelection = configSelection;
this.PropertyExpression = propertyExpression; this.PropertyExpression = propertyExpression;
@ -26,106 +26,17 @@ public record ConfigMeta<TClass, TValue> : ConfigMetaBase
/// The expression to select the property within the configuration class. /// The expression to select the property within the configuration class.
/// </summary> /// </summary>
private Expression<Func<TClass, TValue>> PropertyExpression { get; } private Expression<Func<TClass, TValue>> PropertyExpression { get; }
/// <summary>
/// Indicates whether the configuration is locked by a configuration plugin.
/// </summary>
public bool IsLocked { get; private set; }
/// <summary>
/// The ID of the plugin that locked this configuration.
/// </summary>
public Guid LockedByConfigPluginId { get; private set; }
/// <summary>
/// How this setting is managed by a configuration plugin, if at all.
/// </summary>
public ManagedConfigurationMode? ManagedMode { get; private set; }
/// <summary>
/// The ID of the plugin that currently provides an editable default value.
/// </summary>
public Guid EditableDefaultByConfigPluginId { get; private set; }
/// <summary> /// <summary>
/// The default value for the configuration property. This is used when resetting the property to its default state. /// The default value for the configuration property. This is used when resetting the property to its default state.
/// </summary> /// </summary>
public required TValue Default { get; init; } public required TValue Default { get; init; }
/// <summary>
/// Indicates whether a plugin contribution is available.
/// </summary>
public bool HasPluginContribution { get; private set; }
/// <summary> /// <summary>
/// The additive value contribution provided by a configuration plugin. /// The additive value contribution provided by a configuration plugin.
/// </summary> /// </summary>
public TValue PluginContribution { get; private set; } = default!; public TValue PluginContribution { get; private set; } = default!;
/// <summary>
/// The ID of the plugin that provided the additive value contribution.
/// </summary>
public Guid PluginContributionByConfigPluginId { get; private set; }
/// <summary>
/// Locks the configuration state, indicating that it is controlled by a specific plugin.
/// </summary>
/// <param name="pluginId">The ID of the plugin that is locking this configuration.</param>
public void LockConfiguration(Guid pluginId)
{
this.IsLocked = true;
this.LockedByConfigPluginId = pluginId;
this.ManagedMode = ManagedConfigurationMode.LOCKED;
this.EditableDefaultByConfigPluginId = Guid.Empty;
}
/// <summary>
/// Resets the locked state of the configuration, allowing it to be modified again.
/// This will also reset the property to its default value.
/// </summary>
public void ResetLockedConfiguration()
{
this.IsLocked = false;
this.LockedByConfigPluginId = Guid.Empty;
if (this.ManagedMode is ManagedConfigurationMode.LOCKED)
this.ManagedMode = null;
this.Reset();
}
/// <summary>
/// Unlocks the configuration state without changing the current value.
/// </summary>
public void UnlockConfiguration()
{
this.IsLocked = false;
this.LockedByConfigPluginId = Guid.Empty;
if (this.ManagedMode is ManagedConfigurationMode.LOCKED)
this.ManagedMode = null;
}
/// <summary>
/// Marks the setting as having an editable default provided by a configuration plugin.
/// </summary>
public void SetEditableDefaultConfiguration(Guid pluginId)
{
this.IsLocked = false;
this.LockedByConfigPluginId = Guid.Empty;
this.ManagedMode = ManagedConfigurationMode.EDITABLE_DEFAULT;
this.EditableDefaultByConfigPluginId = pluginId;
}
/// <summary>
/// Clears the editable-default state without changing the current value.
/// </summary>
public void ClearEditableDefaultConfiguration()
{
if (this.ManagedMode is ManagedConfigurationMode.EDITABLE_DEFAULT)
this.ManagedMode = null;
this.EditableDefaultByConfigPluginId = Guid.Empty;
}
/// <summary> /// <summary>
/// Stores an additive plugin contribution. /// Stores an additive plugin contribution.
/// </summary> /// </summary>
@ -136,20 +47,15 @@ public record ConfigMeta<TClass, TValue> : ConfigMetaBase
this.HasPluginContribution = true; this.HasPluginContribution = true;
} }
/// <summary> /// <inheritdoc/>
/// Clears the additive plugin contribution without changing the current value. public override void ClearPluginContribution()
/// </summary>
public void ClearPluginContribution()
{ {
this.PluginContribution = default!; this.PluginContribution = default!;
this.PluginContributionByConfigPluginId = Guid.Empty; base.ClearPluginContribution();
this.HasPluginContribution = false;
} }
/// <summary> /// <inheritdoc/>
/// Resets the configuration property to its default value. protected override void Reset()
/// </summary>
private void Reset()
{ {
var configInstance = this.ConfigSelection.Compile().Invoke(SettingsManagerAccess.ConfigurationData); var configInstance = this.ConfigSelection.Compile().Invoke(SettingsManagerAccess.ConfigurationData);
var memberExpression = this.PropertyExpression.GetMemberExpression(); var memberExpression = this.PropertyExpression.GetMemberExpression();

View File

@ -1,6 +1,148 @@
namespace AIStudio.Settings; namespace AIStudio.Settings;
public abstract record ConfigMetaBase : IConfig /// <summary>
/// The type-independent part of the configuration metadata: which configuration plugin manages
/// the setting, and in which way.
/// </summary>
/// <remarks>
/// The managed state lives here so that it can be processed without knowing the setting's type,
/// e.g. when cleaning up settings whose configuration plugin was removed.
/// </remarks>
public abstract record ConfigMetaBase(string SettingName) : IConfig
{ {
protected static SettingsManager SettingsManagerAccess => Program.SERVICE_PROVIDER.GetRequiredService<SettingsManager>(); protected static SettingsManager SettingsManagerAccess => Program.SERVICE_PROVIDER.GetRequiredService<SettingsManager>();
/// <summary>
/// The persisted name of the configuration setting.
/// </summary>
public string SettingName { get; } = SettingName;
/// <summary>
/// Indicates whether the configuration is locked by a configuration plugin.
/// </summary>
public bool IsLocked { get; private set; }
/// <summary>
/// The ID of the plugin that locked this configuration.
/// </summary>
public Guid LockedByConfigPluginId { get; private set; }
/// <summary>
/// How this setting is managed by a configuration plugin, if at all.
/// </summary>
public ManagedConfigurationMode? ManagedMode { get; private set; }
/// <summary>
/// The ID of the plugin that currently provides an editable default value.
/// </summary>
public Guid EditableDefaultByConfigPluginId { get; private set; }
/// <summary>
/// Indicates whether a plugin contribution is available.
/// </summary>
public bool HasPluginContribution { get; protected set; }
/// <summary>
/// The ID of the plugin that provided the additive value contribution.
/// </summary>
public Guid PluginContributionByConfigPluginId { get; protected set; }
/// <summary>
/// Locks the configuration state, indicating that it is controlled by a specific plugin.
/// </summary>
/// <param name="pluginId">The ID of the plugin that is locking this configuration.</param>
public void LockConfiguration(Guid pluginId)
{
this.IsLocked = true;
this.LockedByConfigPluginId = pluginId;
this.ManagedMode = ManagedConfigurationMode.LOCKED;
this.EditableDefaultByConfigPluginId = Guid.Empty;
SettingsManagerAccess.ConfigurationData.ManagedLockedConfigurations[this.SettingName] = pluginId;
}
/// <summary>
/// Restores persisted locked configuration metadata after settings were loaded.
/// </summary>
public void RestoreLockedConfiguration()
{
if (this.IsLocked || this.ManagedMode is not null)
return;
if (!SettingsManagerAccess.ConfigurationData.ManagedLockedConfigurations.TryGetValue(this.SettingName, out var pluginId) || pluginId == Guid.Empty)
return;
this.IsLocked = true;
this.LockedByConfigPluginId = pluginId;
this.ManagedMode = ManagedConfigurationMode.LOCKED;
this.EditableDefaultByConfigPluginId = Guid.Empty;
}
/// <summary>
/// Resets the locked state of the configuration, allowing it to be modified again.
/// This will also reset the property to its default value.
/// </summary>
public void ResetLockedConfiguration()
{
SettingsManagerAccess.ConfigurationData.ManagedLockedConfigurations.Remove(this.SettingName);
this.IsLocked = false;
this.LockedByConfigPluginId = Guid.Empty;
if (this.ManagedMode is ManagedConfigurationMode.LOCKED)
this.ManagedMode = null;
this.Reset();
}
/// <summary>
/// Unlocks the configuration state without changing the current value.
/// </summary>
public void UnlockConfiguration()
{
SettingsManagerAccess.ConfigurationData.ManagedLockedConfigurations.Remove(this.SettingName);
this.IsLocked = false;
this.LockedByConfigPluginId = Guid.Empty;
if (this.ManagedMode is ManagedConfigurationMode.LOCKED)
this.ManagedMode = null;
}
/// <summary>
/// Marks the setting as having an editable default provided by a configuration plugin.
/// </summary>
public void SetEditableDefaultConfiguration(Guid pluginId)
{
SettingsManagerAccess.ConfigurationData.ManagedLockedConfigurations.Remove(this.SettingName);
this.IsLocked = false;
this.LockedByConfigPluginId = Guid.Empty;
this.ManagedMode = ManagedConfigurationMode.EDITABLE_DEFAULT;
this.EditableDefaultByConfigPluginId = pluginId;
}
/// <summary>
/// Clears the editable-default state without changing the current value.
/// </summary>
public void ClearEditableDefaultConfiguration()
{
if (this.ManagedMode is ManagedConfigurationMode.EDITABLE_DEFAULT)
this.ManagedMode = null;
this.EditableDefaultByConfigPluginId = Guid.Empty;
}
/// <summary>
/// Clears the additive plugin contribution without changing the current value.
/// </summary>
public virtual void ClearPluginContribution()
{
this.PluginContributionByConfigPluginId = Guid.Empty;
this.HasPluginContribution = false;
}
/// <summary>
/// Resets the configuration property to its default value.
/// </summary>
protected abstract void Reset();
} }

View File

@ -63,6 +63,11 @@ public sealed class Data
/// </summary> /// </summary>
public Dictionary<string, ManagedEditableDefaultState> ManagedEditableDefaults { get; set; } = []; public Dictionary<string, ManagedEditableDefaultState> ManagedEditableDefaults { get; set; } = [];
/// <summary>
/// The configuration plugin that owns each locked managed setting.
/// </summary>
public Dictionary<string, Guid> ManagedLockedConfigurations { get; set; } = [];
/// <summary> /// <summary>
/// Cached audit results for assistant plugins. /// Cached audit results for assistant plugins.
/// </summary> /// </summary>

View File

@ -924,8 +924,8 @@ public static partial class ManagedConfiguration
// case only when the setting was locked and managed by the same configuration plugin. // case only when the setting was locked and managed by the same configuration plugin.
// //
// The other case, when the setting was locked and managed by a different configuration plugin, // The other case, when the setting was locked and managed by a different configuration plugin,
// is handled by the IsConfigurationLeftOver method, which checks if the configuration plugin // is handled by the CleanupLeftOverManagedConfigurations method, which checks if the configuration
// is still available. If it is not available, it resets the locked state of the // plugin is still available. If it is not available, it resets the locked state of the
// configuration setting, allowing it to be reconfigured by a different plugin or left unchanged. // configuration setting, allowing it to be reconfigured by a different plugin or left unchanged.
// //
configMeta.ResetLockedConfiguration(); configMeta.ResetLockedConfiguration();

View File

@ -19,10 +19,7 @@ public static partial class ManagedConfiguration
/// <typeparam name="TClass">The type of the configuration class.</typeparam> /// <typeparam name="TClass">The type of the configuration class.</typeparam>
/// <typeparam name="TValue">The type of the property within the configuration class.</typeparam> /// <typeparam name="TValue">The type of the property within the configuration class.</typeparam>
/// <returns>The default value.</returns> /// <returns>The default value.</returns>
public static TValue Register<TClass, TValue>( public static TValue Register<TClass, TValue>(Expression<Func<Data, TClass>>? configSelection, Expression<Func<TClass, TValue>> propertyExpression, TValue defaultValue)
Expression<Func<Data, TClass>>? configSelection,
Expression<Func<TClass, TValue>> propertyExpression,
TValue defaultValue)
where TValue : struct where TValue : struct
{ {
// When called from the JSON deserializer by using the standard constructor, // When called from the JSON deserializer by using the standard constructor,
@ -57,10 +54,7 @@ public static partial class ManagedConfiguration
/// <param name="defaultValue">The default value to use when the setting is not configured.</param> /// <param name="defaultValue">The default value to use when the setting is not configured.</param>
/// <typeparam name="TClass">The type of the configuration class.</typeparam> /// <typeparam name="TClass">The type of the configuration class.</typeparam>
/// <returns>The default value.</returns> /// <returns>The default value.</returns>
public static string Register<TClass>( public static string Register<TClass>(Expression<Func<Data, TClass>>? configSelection, Expression<Func<TClass, string>> propertyExpression, string defaultValue)
Expression<Func<Data, TClass>>? configSelection,
Expression<Func<TClass, string>> propertyExpression,
string defaultValue)
{ {
// When called from the JSON deserializer by using the standard constructor, // When called from the JSON deserializer by using the standard constructor,
// we ignore the register call and return the default value: // we ignore the register call and return the default value:
@ -95,10 +89,7 @@ public static partial class ManagedConfiguration
/// <typeparam name="TClass">The type of the configuration class.</typeparam> /// <typeparam name="TClass">The type of the configuration class.</typeparam>
/// <typeparam name="TValue">The type of the elements in the list within the configuration class.</typeparam> /// <typeparam name="TValue">The type of the elements in the list within the configuration class.</typeparam>
/// <returns>A list containing the default value.</returns> /// <returns>A list containing the default value.</returns>
public static List<TValue> Register<TClass, TValue>( public static List<TValue> Register<TClass, TValue>(Expression<Func<Data, TClass>>? configSelection, Expression<Func<TClass, IList<TValue>>> propertyExpression, TValue defaultValue)
Expression<Func<Data, TClass>>? configSelection,
Expression<Func<TClass, IList<TValue>>> propertyExpression,
TValue defaultValue)
{ {
// When called from the JSON deserializer by using the standard constructor, // When called from the JSON deserializer by using the standard constructor,
// we ignore the register call and return the default value: // we ignore the register call and return the default value:
@ -133,10 +124,7 @@ public static partial class ManagedConfiguration
/// <typeparam name="TClass">The type of the configuration class.</typeparam> /// <typeparam name="TClass">The type of the configuration class.</typeparam>
/// <typeparam name="TValue">The type of the elements within the property list.</typeparam> /// <typeparam name="TValue">The type of the elements within the property list.</typeparam>
/// <returns>The list of default values.</returns> /// <returns>The list of default values.</returns>
public static List<TValue> Register<TClass, TValue>( public static List<TValue> Register<TClass, TValue>(Expression<Func<Data, TClass>>? configSelection, Expression<Func<TClass, IList<TValue>>> propertyExpression, IList<TValue> defaultValues)
Expression<Func<Data, TClass>>? configSelection,
Expression<Func<TClass, IList<TValue>>> propertyExpression,
IList<TValue> defaultValues)
{ {
// When called from the JSON deserializer by using the standard constructor, // When called from the JSON deserializer by using the standard constructor,
// we ignore the register call and return the default value: // we ignore the register call and return the default value:
@ -170,10 +158,7 @@ public static partial class ManagedConfiguration
/// <typeparam name="TClass">The type of the configuration class.</typeparam> /// <typeparam name="TClass">The type of the configuration class.</typeparam>
/// <typeparam name="TValue">The type of the values within the set.</typeparam> /// <typeparam name="TValue">The type of the values within the set.</typeparam>
/// <returns>A set containing the default value.</returns> /// <returns>A set containing the default value.</returns>
public static HashSet<TValue> Register<TClass, TValue>( public static HashSet<TValue> Register<TClass, TValue>(Expression<Func<Data, TClass>>? configSelection, Expression<Func<TClass, ISet<TValue>>> propertyExpression, TValue defaultValue)
Expression<Func<Data, TClass>>? configSelection,
Expression<Func<TClass, ISet<TValue>>> propertyExpression,
TValue defaultValue)
{ {
// When called from the JSON deserializer by using the standard constructor, // When called from the JSON deserializer by using the standard constructor,
// we ignore the register call and return the default value: // we ignore the register call and return the default value:
@ -208,10 +193,7 @@ public static partial class ManagedConfiguration
/// <typeparam name="TClass">The type of the configuration class from which the property is selected.</typeparam> /// <typeparam name="TClass">The type of the configuration class from which the property is selected.</typeparam>
/// <typeparam name="TValue">The type of the elements in the collection associated with the configuration property.</typeparam> /// <typeparam name="TValue">The type of the elements in the collection associated with the configuration property.</typeparam>
/// <returns>A set containing the default values.</returns> /// <returns>A set containing the default values.</returns>
public static HashSet<TValue> Register<TClass, TValue>( public static HashSet<TValue> Register<TClass, TValue>(Expression<Func<Data, TClass>>? configSelection, Expression<Func<TClass, ISet<TValue>>> propertyExpression, IList<TValue> defaultValues)
Expression<Func<Data, TClass>>? configSelection,
Expression<Func<TClass, ISet<TValue>>> propertyExpression,
IList<TValue> defaultValues)
{ {
// When called from the JSON deserializer by using the standard constructor, // When called from the JSON deserializer by using the standard constructor,
// we ignore the register call and return the default value: // we ignore the register call and return the default value:
@ -246,10 +228,7 @@ public static partial class ManagedConfiguration
/// <typeparam name="TClass">The type of the configuration class from which the property is selected.</typeparam> /// <typeparam name="TClass">The type of the configuration class from which the property is selected.</typeparam>
/// <typeparam name="TDict">>The type of the dictionary within the configuration class.</typeparam> /// <typeparam name="TDict">>The type of the dictionary within the configuration class.</typeparam>
/// <returns>A dictionary containing the default values.</returns> /// <returns>A dictionary containing the default values.</returns>
public static TDict Register<TClass, TDict>( public static TDict Register<TClass, TDict>(Expression<Func<Data, TClass>>? configSelection, Expression<Func<TClass, IDictionary<string, string>>> propertyExpression, TDict defaultValues)
Expression<Func<Data, TClass>>? configSelection,
Expression<Func<TClass, IDictionary<string, string>>> propertyExpression,
TDict defaultValues)
where TDict : IDictionary<string, string>, new() where TDict : IDictionary<string, string>, new()
{ {
// When called from the JSON deserializer by using the standard constructor, // When called from the JSON deserializer by using the standard constructor,
@ -286,10 +265,7 @@ public static partial class ManagedConfiguration
/// <typeparam name="TKey">The enum type of the dictionary keys.</typeparam> /// <typeparam name="TKey">The enum type of the dictionary keys.</typeparam>
/// <typeparam name="TValue">The enum type of the dictionary values.</typeparam> /// <typeparam name="TValue">The enum type of the dictionary values.</typeparam>
/// <returns>A dictionary containing the default values.</returns> /// <returns>A dictionary containing the default values.</returns>
public static Dictionary<TKey, TValue> Register<TClass, TKey, TValue>( public static Dictionary<TKey, TValue> Register<TClass, TKey, TValue>(Expression<Func<Data, TClass>>? configSelection, Expression<Func<TClass, Dictionary<TKey, TValue>>> propertyExpression, Dictionary<TKey, TValue> defaultValues)
Expression<Func<Data, TClass>>? configSelection,
Expression<Func<TClass, Dictionary<TKey, TValue>>> propertyExpression,
Dictionary<TKey, TValue> defaultValues)
where TKey : struct, Enum where TKey : struct, Enum
where TValue : struct, Enum where TValue : struct, Enum
{ {

View File

@ -9,7 +9,10 @@ namespace AIStudio.Settings;
public static partial class ManagedConfiguration public static partial class ManagedConfiguration
{ {
private static readonly ConcurrentDictionary<string, IConfig> METADATA = new(); private static readonly ConcurrentDictionary<string, IConfig> METADATA = new();
private static SettingsManager SettingsManagerAccess => Program.SERVICE_PROVIDER.GetRequiredService<SettingsManager>(); private static SettingsManager SettingsManagerAccess => Program.SERVICE_PROVIDER.GetRequiredService<SettingsManager>();
private static ILogger Log => Program.LOGGER_FACTORY.CreateLogger(nameof(ManagedConfiguration));
/// <summary> /// <summary>
/// Attempts to retrieve the configuration metadata for a given configuration selection and /// Attempts to retrieve the configuration metadata for a given configuration selection and
@ -28,15 +31,13 @@ public static partial class ManagedConfiguration
/// <typeparam name="TClass">The type of the configuration class.</typeparam> /// <typeparam name="TClass">The type of the configuration class.</typeparam>
/// <typeparam name="TValue">The type of the property within the configuration class.</typeparam> /// <typeparam name="TValue">The type of the property within the configuration class.</typeparam>
/// <returns>True if the configuration metadata was found, otherwise false.</returns> /// <returns>True if the configuration metadata was found, otherwise false.</returns>
public static bool TryGet<TClass, TValue>( public static bool TryGet<TClass, TValue>(Expression<Func<Data, TClass>> configSelection, Expression<Func<TClass, TValue>> propertyExpression, out ConfigMeta<TClass, TValue> configMeta)
Expression<Func<Data, TClass>> configSelection,
Expression<Func<TClass, TValue>> propertyExpression,
out ConfigMeta<TClass, TValue> configMeta)
where TValue : Enum where TValue : Enum
{ {
var configPath = Path(configSelection, propertyExpression); var configPath = Path(configSelection, propertyExpression);
if (METADATA.TryGetValue(configPath, out var value) && value is ConfigMeta<TClass, TValue> meta) if (METADATA.TryGetValue(configPath, out var value) && value is ConfigMeta<TClass, TValue> meta)
{ {
meta.RestoreLockedConfiguration();
configMeta = meta; configMeta = meta;
return true; return true;
} }
@ -65,14 +66,12 @@ public static partial class ManagedConfiguration
/// if found.</param> /// if found.</param>
/// <typeparam name="TClass">The type of the configuration class.</typeparam> /// <typeparam name="TClass">The type of the configuration class.</typeparam>
/// <returns>True if the configuration metadata was found, otherwise false.</returns> /// <returns>True if the configuration metadata was found, otherwise false.</returns>
public static bool TryGet<TClass>( public static bool TryGet<TClass>(Expression<Func<Data, TClass>> configSelection, Expression<Func<TClass, string>> propertyExpression, out ConfigMeta<TClass, string> configMeta)
Expression<Func<Data, TClass>> configSelection,
Expression<Func<TClass, string>> propertyExpression,
out ConfigMeta<TClass, string> configMeta)
{ {
var configPath = Path(configSelection, propertyExpression); var configPath = Path(configSelection, propertyExpression);
if (METADATA.TryGetValue(configPath, out var value) && value is ConfigMeta<TClass, string> meta) if (METADATA.TryGetValue(configPath, out var value) && value is ConfigMeta<TClass, string> meta)
{ {
meta.RestoreLockedConfiguration();
configMeta = meta; configMeta = meta;
return true; return true;
} }
@ -104,16 +103,13 @@ public static partial class ManagedConfiguration
/// <returns>True if the configuration metadata was found, otherwise false.</returns> /// <returns>True if the configuration metadata was found, otherwise false.</returns>
// ReSharper disable MethodOverloadWithOptionalParameter // ReSharper disable MethodOverloadWithOptionalParameter
public static bool TryGet<TClass, TValue>( public static bool TryGet<TClass, TValue>(Expression<Func<Data, TClass>> configSelection, Expression<Func<TClass, TValue>> propertyExpression, out ConfigMeta<TClass, TValue> configMeta, ISpanParsable<TValue>? _ = null)
Expression<Func<Data, TClass>> configSelection,
Expression<Func<TClass, TValue>> propertyExpression,
out ConfigMeta<TClass, TValue> configMeta,
ISpanParsable<TValue>? _ = null)
where TValue : struct, ISpanParsable<TValue> where TValue : struct, ISpanParsable<TValue>
{ {
var configPath = Path(configSelection, propertyExpression); var configPath = Path(configSelection, propertyExpression);
if (METADATA.TryGetValue(configPath, out var value) && value is ConfigMeta<TClass, TValue> meta) if (METADATA.TryGetValue(configPath, out var value) && value is ConfigMeta<TClass, TValue> meta)
{ {
meta.RestoreLockedConfiguration();
configMeta = meta; configMeta = meta;
return true; return true;
} }
@ -143,14 +139,12 @@ public static partial class ManagedConfiguration
/// <typeparam name="TClass">The type of the configuration class.</typeparam> /// <typeparam name="TClass">The type of the configuration class.</typeparam>
/// <typeparam name="TValue">The type of the property within the configuration class.</typeparam> /// <typeparam name="TValue">The type of the property within the configuration class.</typeparam>
/// <returns>True if the configuration metadata was found, otherwise false.</returns> /// <returns>True if the configuration metadata was found, otherwise false.</returns>
public static bool TryGet<TClass, TValue>( public static bool TryGet<TClass, TValue>(Expression<Func<Data, TClass>> configSelection, Expression<Func<TClass, IList<TValue>>> propertyExpression, out ConfigMeta<TClass, IList<TValue>> configMeta)
Expression<Func<Data, TClass>> configSelection,
Expression<Func<TClass, IList<TValue>>> propertyExpression,
out ConfigMeta<TClass, IList<TValue>> configMeta)
{ {
var configPath = Path(configSelection, propertyExpression); var configPath = Path(configSelection, propertyExpression);
if (METADATA.TryGetValue(configPath, out var value) && value is ConfigMeta<TClass, IList<TValue>> meta) if (METADATA.TryGetValue(configPath, out var value) && value is ConfigMeta<TClass, IList<TValue>> meta)
{ {
meta.RestoreLockedConfiguration();
configMeta = meta; configMeta = meta;
return true; return true;
} }
@ -178,14 +172,12 @@ public static partial class ManagedConfiguration
/// <typeparam name="TClass">The type of the configuration class.</typeparam> /// <typeparam name="TClass">The type of the configuration class.</typeparam>
/// <typeparam name="TValue">The type of the property within the configuration class.</typeparam> /// <typeparam name="TValue">The type of the property within the configuration class.</typeparam>
/// <returns>True if the configuration metadata was found, otherwise false.</returns> /// <returns>True if the configuration metadata was found, otherwise false.</returns>
public static bool TryGet<TClass, TValue>( public static bool TryGet<TClass, TValue>(Expression<Func<Data, TClass>> configSelection, Expression<Func<TClass, ISet<TValue>>> propertyExpression, out ConfigMeta<TClass, ISet<TValue>> configMeta)
Expression<Func<Data, TClass>> configSelection,
Expression<Func<TClass, ISet<TValue>>> propertyExpression,
out ConfigMeta<TClass, ISet<TValue>> configMeta)
{ {
var configPath = Path(configSelection, propertyExpression); var configPath = Path(configSelection, propertyExpression);
if (METADATA.TryGetValue(configPath, out var value) && value is ConfigMeta<TClass, ISet<TValue>> meta) if (METADATA.TryGetValue(configPath, out var value) && value is ConfigMeta<TClass, ISet<TValue>> meta)
{ {
meta.RestoreLockedConfiguration();
configMeta = meta; configMeta = meta;
return true; return true;
} }
@ -212,14 +204,12 @@ public static partial class ManagedConfiguration
/// if found.</param> /// if found.</param>
/// <typeparam name="TClass">The type of the configuration class.</typeparam> /// <typeparam name="TClass">The type of the configuration class.</typeparam>
/// <returns>True if the configuration metadata was found, otherwise false.</returns> /// <returns>True if the configuration metadata was found, otherwise false.</returns>
public static bool TryGet<TClass>( public static bool TryGet<TClass>(Expression<Func<Data, TClass>> configSelection, Expression<Func<TClass, IDictionary<string, string>>> propertyExpression, out ConfigMeta<TClass, IDictionary<string, string>> configMeta)
Expression<Func<Data, TClass>> configSelection,
Expression<Func<TClass, IDictionary<string, string>>> propertyExpression,
out ConfigMeta<TClass, IDictionary<string, string>> configMeta)
{ {
var configPath = Path(configSelection, propertyExpression); var configPath = Path(configSelection, propertyExpression);
if (METADATA.TryGetValue(configPath, out var value) && value is ConfigMeta<TClass, IDictionary<string, string>> meta) if (METADATA.TryGetValue(configPath, out var value) && value is ConfigMeta<TClass, IDictionary<string, string>> meta)
{ {
meta.RestoreLockedConfiguration();
configMeta = meta; configMeta = meta;
return true; return true;
} }
@ -248,16 +238,14 @@ public static partial class ManagedConfiguration
/// <typeparam name="TKey">The enum type of the dictionary keys.</typeparam> /// <typeparam name="TKey">The enum type of the dictionary keys.</typeparam>
/// <typeparam name="TValue">The enum type of the dictionary values.</typeparam> /// <typeparam name="TValue">The enum type of the dictionary values.</typeparam>
/// <returns>True if the configuration metadata was found, otherwise false.</returns> /// <returns>True if the configuration metadata was found, otherwise false.</returns>
public static bool TryGet<TClass, TKey, TValue>( public static bool TryGet<TClass, TKey, TValue>(Expression<Func<Data, TClass>> configSelection, Expression<Func<TClass, Dictionary<TKey, TValue>>> propertyExpression, out ConfigMeta<TClass, Dictionary<TKey, TValue>> configMeta)
Expression<Func<Data, TClass>> configSelection,
Expression<Func<TClass, Dictionary<TKey, TValue>>> propertyExpression,
out ConfigMeta<TClass, Dictionary<TKey, TValue>> configMeta)
where TKey : struct, Enum where TKey : struct, Enum
where TValue : struct, Enum where TValue : struct, Enum
{ {
var configPath = Path(configSelection, propertyExpression); var configPath = Path(configSelection, propertyExpression);
if (METADATA.TryGetValue(configPath, out var value) && value is ConfigMeta<TClass, Dictionary<TKey, TValue>> meta) if (METADATA.TryGetValue(configPath, out var value) && value is ConfigMeta<TClass, Dictionary<TKey, TValue>> meta)
{ {
meta.RestoreLockedConfiguration();
configMeta = meta; configMeta = meta;
return true; return true;
} }
@ -270,211 +258,106 @@ public static partial class ManagedConfiguration
} }
/// <summary> /// <summary>
/// Checks if a configuration setting is left over from a configuration plugin that is no longer available. /// Removes all managed states whose configuration plugin is not available anymore.
/// If the configuration setting is locked and managed by a configuration plugin that is not available,
/// it resets the managed state of the configuration setting and returns true.
/// Otherwise, it returns false.
/// </summary> /// </summary>
/// <param name="configSelection">The expression to select the configuration class.</param> /// <remarks>
/// <param name="propertyExpression">The expression to select the property within the configuration class.</param> /// This covers every registered setting, regardless of its type: locked settings, editable
/// defaults, and additive plugin contributions. Settings do not need to be listed anywhere for
/// this cleanup to work, so adding a new managed setting cannot be forgotten here.<br/><br/>
/// A locked setting whose plugin is gone is reset to its default value. That is intended: the
/// value belonged to the organization, not to the user, and the user might not be able to
/// change it at all.
/// </remarks>
/// <param name="availablePlugins">The collection of available plugins to check against.</param> /// <param name="availablePlugins">The collection of available plugins to check against.</param>
/// <typeparam name="TClass">The type of the configuration class.</typeparam> /// <param name="deployedConfigPluginIds">
/// <typeparam name="TValue">The type of the property within the configuration class.</typeparam> /// The IDs of all configuration plugins which are deployed on this machine, including those which
/// <returns>True if the configuration setting is left over and was reset, otherwise false.</returns> /// could not be loaded. A deployed plugin was not removed, so its settings must stay untouched.
public static bool IsConfigurationLeftOver<TClass, TValue>( /// </param>
Expression<Func<Data, TClass>> configSelection, /// <returns>True when at least one setting was changed, otherwise false.</returns>
Expression<Func<TClass, TValue>> propertyExpression, public static bool CleanupLeftOverManagedConfigurations(IReadOnlyCollection<IAvailablePlugin> availablePlugins, IReadOnlySet<Guid> deployedConfigPluginIds)
IReadOnlyList<IAvailablePlugin> availablePlugins)
where TValue : Enum
{ {
if (!TryGet(configSelection, propertyExpression, out var configMeta)) var wasChanged = false;
return false; var registeredSettingNames = new HashSet<string>(StringComparer.Ordinal);
if (configMeta.LockedByConfigPluginId != Guid.Empty && configMeta.IsLocked) foreach (var config in METADATA.Values)
{ {
var plugin = availablePlugins.FirstOrDefault(x => x.Id == configMeta.LockedByConfigPluginId); if (config is not ConfigMetaBase configMeta)
if (plugin is null) continue;
registeredSettingNames.Add(configMeta.SettingName);
//
// Restore the persisted ownership first. Otherwise, we would not recognize a left-over
// lock when nobody has read this setting since the settings were loaded:
//
configMeta.RestoreLockedConfiguration();
// Check the locked state:
if (configMeta.IsLocked && configMeta.LockedByConfigPluginId != Guid.Empty && !IsPluginPresent(configMeta.LockedByConfigPluginId, availablePlugins, deployedConfigPluginIds))
{ {
Log.LogInformation($"Resetting the setting '{configMeta.SettingName}': it was locked by the configuration plugin '{configMeta.LockedByConfigPluginId}', which is not available anymore.");
configMeta.ResetLockedConfiguration(); configMeta.ResetLockedConfiguration();
return true; wasChanged = true;
}
// Check the editable default state:
if (CleanupEditableDefaultState(configMeta, availablePlugins, deployedConfigPluginIds))
wasChanged = true;
// Check the additive plugin contribution:
if (configMeta.HasPluginContribution && configMeta.PluginContributionByConfigPluginId != Guid.Empty && !IsPluginPresent(configMeta.PluginContributionByConfigPluginId, availablePlugins, deployedConfigPluginIds))
{
Log.LogInformation($"Clearing the plugin contribution for the setting '{configMeta.SettingName}': the configuration plugin '{configMeta.PluginContributionByConfigPluginId}' is not available anymore.");
configMeta.ClearPluginContribution();
wasChanged = true;
} }
} }
return CleanupEditableDefaultState(configMeta, SettingName(propertyExpression), availablePlugins); // Remove persisted states which belong to settings that do not exist anymore:
} if (RemoveUnknownManagedStates(registeredSettingNames))
wasChanged = true;
public static bool IsConfigurationLeftOver<TClass>( return wasChanged;
Expression<Func<Data, TClass>> configSelection,
Expression<Func<TClass, string>> propertyExpression,
IReadOnlyList<IAvailablePlugin> availablePlugins)
{
if (!TryGet(configSelection, propertyExpression, out var configMeta))
return false;
if (configMeta.LockedByConfigPluginId != Guid.Empty && configMeta.IsLocked)
{
var plugin = availablePlugins.FirstOrDefault(x => x.Id == configMeta.LockedByConfigPluginId);
if (plugin is null)
{
configMeta.ResetLockedConfiguration();
return true;
}
}
return CleanupEditableDefaultState(configMeta, SettingName(propertyExpression), availablePlugins);
}
// ReSharper disable MethodOverloadWithOptionalParameter
public static bool IsConfigurationLeftOver<TClass, TValue>(
Expression<Func<Data, TClass>> configSelection,
Expression<Func<TClass, TValue>> propertyExpression,
IReadOnlyList<IAvailablePlugin> availablePlugins,
ISpanParsable<TValue>? _ = null)
where TValue : struct, ISpanParsable<TValue>
{
if (!TryGet(configSelection, propertyExpression, out var configMeta))
return false;
if (configMeta.LockedByConfigPluginId != Guid.Empty && configMeta.IsLocked)
{
var plugin = availablePlugins.FirstOrDefault(x => x.Id == configMeta.LockedByConfigPluginId);
if (plugin is null)
{
configMeta.ResetLockedConfiguration();
return true;
}
}
return CleanupEditableDefaultState(configMeta, SettingName(propertyExpression), availablePlugins);
}
// ReSharper restore MethodOverloadWithOptionalParameter
public static bool IsConfigurationLeftOver<TClass, TValue>(
Expression<Func<Data, TClass>> configSelection,
Expression<Func<TClass, IList<TValue>>> propertyExpression,
IEnumerable<IAvailablePlugin> availablePlugins)
{
if (!TryGet(configSelection, propertyExpression, out var configMeta))
return false;
if (configMeta.ManagedMode is ManagedConfigurationMode.EDITABLE_DEFAULT)
return CleanupEditableDefaultState(configMeta, SettingName(propertyExpression), availablePlugins.ToList());
if (configMeta.LockedByConfigPluginId == Guid.Empty || !configMeta.IsLocked)
return false;
var plugin = availablePlugins.FirstOrDefault(x => x.Id == configMeta.LockedByConfigPluginId);
if (plugin is not null)
return false;
configMeta.ResetLockedConfiguration();
return true;
}
public static bool IsConfigurationLeftOver<TClass, TValue>(
Expression<Func<Data, TClass>> configSelection,
Expression<Func<TClass, ISet<TValue>>> propertyExpression,
IEnumerable<IAvailablePlugin> availablePlugins)
{
if (!TryGet(configSelection, propertyExpression, out var configMeta))
return false;
if (configMeta.LockedByConfigPluginId == Guid.Empty || !configMeta.IsLocked)
return false;
var plugin = availablePlugins.FirstOrDefault(x => x.Id == configMeta.LockedByConfigPluginId);
if (plugin is null)
{
configMeta.ResetLockedConfiguration();
return true;
}
return false;
} }
/// <summary> /// <summary>
/// Checks if a plugin contribution is left over from a configuration plugin that is no longer available. /// Checks whether a configuration plugin is still present on this machine.
/// If so, it clears the contribution and returns true.
/// </summary> /// </summary>
public static bool IsPluginContributionLeftOver<TClass, TValue>( /// <remarks>
Expression<Func<Data, TClass>> configSelection, /// A plugin counts as present when it was loaded, or when it is deployed but could not be loaded.
Expression<Func<TClass, ISet<TValue>>> propertyExpression, /// The latter matters for organizations: a broken configuration plugin is still in charge, so we
IEnumerable<IAvailablePlugin> availablePlugins) /// must not treat its settings as left over.
/// </remarks>
private static bool IsPluginPresent(Guid configPluginId, IReadOnlyCollection<IAvailablePlugin> availablePlugins, IReadOnlySet<Guid> deployedConfigPluginIds) => deployedConfigPluginIds.Contains(configPluginId) || availablePlugins.Any(x => x.Id == configPluginId);
/// <summary>
/// Removes persisted managed states which belong to settings that are not registered anymore.
/// </summary>
/// <remarks>
/// Without this, states of removed or renamed settings would stay in the settings file forever.
/// </remarks>
private static bool RemoveUnknownManagedStates(IReadOnlySet<string> registeredSettingNames)
{ {
if (!TryGet(configSelection, propertyExpression, out var configMeta)) var wasChanged = false;
return false; var configurationData = SettingsManagerAccess.ConfigurationData;
if (!configMeta.HasPluginContribution || configMeta.PluginContributionByConfigPluginId == Guid.Empty) foreach (var settingName in configurationData.ManagedLockedConfigurations.Keys.Where(x => !registeredSettingNames.Contains(x)).ToList())
return false;
var plugin = availablePlugins.FirstOrDefault(x => x.Id == configMeta.PluginContributionByConfigPluginId);
if (plugin is null)
{ {
configMeta.ClearPluginContribution(); Log.LogInformation($"Removing the persisted lock of the setting '{settingName}': this setting does not exist anymore.");
return true; configurationData.ManagedLockedConfigurations.Remove(settingName);
wasChanged = true;
} }
return false; foreach (var settingName in configurationData.ManagedEditableDefaults.Keys.Where(x => !registeredSettingNames.Contains(x)).ToList())
{
Log.LogInformation($"Removing the persisted editable default of the setting '{settingName}': this setting does not exist anymore.");
configurationData.ManagedEditableDefaults.Remove(settingName);
wasChanged = true;
}
return wasChanged;
} }
public static bool IsConfigurationLeftOver<TClass>(
Expression<Func<Data, TClass>> configSelection,
Expression<Func<TClass, IDictionary<string, string>>> propertyExpression,
IEnumerable<IAvailablePlugin> availablePlugins)
{
if (!TryGet(configSelection, propertyExpression, out var configMeta))
return false;
if (configMeta.LockedByConfigPluginId == Guid.Empty || !configMeta.IsLocked)
return false;
var plugin = availablePlugins.FirstOrDefault(x => x.Id == configMeta.LockedByConfigPluginId);
if (plugin is null)
{
configMeta.ResetLockedConfiguration();
return true;
}
return false;
}
public static bool IsConfigurationLeftOver<TClass, TKey, TValue>(
Expression<Func<Data, TClass>> configSelection,
Expression<Func<TClass, Dictionary<TKey, TValue>>> propertyExpression,
IEnumerable<IAvailablePlugin> availablePlugins)
where TKey : struct, Enum
where TValue : struct, Enum
{
if (!TryGet(configSelection, propertyExpression, out var configMeta))
return false;
if (configMeta.ManagedMode is ManagedConfigurationMode.EDITABLE_DEFAULT)
{
var plugin = availablePlugins.FirstOrDefault(x => x.Id == configMeta.EditableDefaultByConfigPluginId);
if (plugin is null)
{
configMeta.ClearEditableDefaultConfiguration();
ClearEditableDefaultState(SettingName(propertyExpression));
return true;
}
return false;
}
if (configMeta.LockedByConfigPluginId == Guid.Empty || !configMeta.IsLocked)
return false;
var lockedPlugin = availablePlugins.FirstOrDefault(x => x.Id == configMeta.LockedByConfigPluginId);
if (lockedPlugin is null)
{
configMeta.ResetLockedConfiguration();
return true;
}
return false;
}
private static string Path<TClass, TValue>(Expression<Func<Data, TClass>> configSelection, Expression<Func<TClass, TValue>> propertyExpression) private static string Path<TClass, TValue>(Expression<Func<Data, TClass>> configSelection, Expression<Func<TClass, TValue>> propertyExpression)
{ {
var className = typeof(TClass).Name; var className = typeof(TClass).Name;
@ -507,12 +390,9 @@ public static partial class ManagedConfiguration
private static bool ClearEditableDefaultState(string settingName) => SettingsManagerAccess.ConfigurationData.ManagedEditableDefaults.Remove(settingName); private static bool ClearEditableDefaultState(string settingName) => SettingsManagerAccess.ConfigurationData.ManagedEditableDefaults.Remove(settingName);
private static bool CleanupEditableDefaultState<TClass, TValue>( private static bool CleanupEditableDefaultState(ConfigMetaBase configMeta, IReadOnlyCollection<IAvailablePlugin> availablePlugins, IReadOnlySet<Guid> deployedConfigPluginIds)
ConfigMeta<TClass, TValue> configMeta,
string settingName,
IReadOnlyList<IAvailablePlugin> availablePlugins)
{ {
if (!TryGetEditableDefaultState(settingName, out var editableDefaultState)) if (!TryGetEditableDefaultState(configMeta.SettingName, out var editableDefaultState))
{ {
if (configMeta.ManagedMode is not ManagedConfigurationMode.EDITABLE_DEFAULT) if (configMeta.ManagedMode is not ManagedConfigurationMode.EDITABLE_DEFAULT)
return false; return false;
@ -521,11 +401,11 @@ public static partial class ManagedConfiguration
return true; return true;
} }
var plugin = availablePlugins.FirstOrDefault(x => x.Id == editableDefaultState.ConfigPluginId); if (IsPluginPresent(editableDefaultState.ConfigPluginId, availablePlugins, deployedConfigPluginIds))
if (plugin is not null)
return false; return false;
Log.LogInformation($"Clearing the editable default of the setting '{configMeta.SettingName}': the configuration plugin '{editableDefaultState.ConfigPluginId}' is not available anymore.");
configMeta.ClearEditableDefaultConfiguration(); configMeta.ClearEditableDefaultConfiguration();
return ClearEditableDefaultState(settingName); return ClearEditableDefaultState(configMeta.SettingName);
} }
} }

View File

@ -255,6 +255,11 @@ public sealed record PluginConfigurationObject
/// <param name="configObjectType">The type of configuration object to process.</param> /// <param name="configObjectType">The type of configuration object to process.</param>
/// <param name="configObjectSelection">A selection expression to retrieve the configuration objects from the main configuration.</param> /// <param name="configObjectSelection">A selection expression to retrieve the configuration objects from the main configuration.</param>
/// <param name="availablePlugins">A list of currently available plugins.</param> /// <param name="availablePlugins">A list of currently available plugins.</param>
/// <param name="deployedConfigPluginIds">
/// The IDs of all configuration plugins which are deployed on this machine, including those which
/// could not be loaded. Objects of a deployed plugin are never removed, because the plugin was not
/// removed either.
/// </param>
/// <param name="configObjectList">A list of all existing configuration objects.</param> /// <param name="configObjectList">A list of all existing configuration objects.</param>
/// <param name="secretStoreType">An optional parameter specifying the type of secret store to use for deleting associated API keys from the OS keyring, if applicable.</param> /// <param name="secretStoreType">An optional parameter specifying the type of secret store to use for deleting associated API keys from the OS keyring, if applicable.</param>
/// <param name="deleteSecret">When true, delete the associated non-API-key secret from the OS keyring.</param> /// <param name="deleteSecret">When true, delete the associated non-API-key secret from the OS keyring.</param>
@ -263,6 +268,7 @@ public sealed record PluginConfigurationObject
PluginConfigurationObjectType configObjectType, PluginConfigurationObjectType configObjectType,
Expression<Func<Data, List<TClass>>> configObjectSelection, Expression<Func<Data, List<TClass>>> configObjectSelection,
IList<IAvailablePlugin> availablePlugins, IList<IAvailablePlugin> availablePlugins,
IReadOnlySet<Guid> deployedConfigPluginIds,
IList<PluginConfigurationObject> configObjectList, IList<PluginConfigurationObject> configObjectList,
SecretStoreType? secretStoreType = null, SecretStoreType? secretStoreType = null,
bool deleteSecret = false) where TClass : IConfigurationObject bool deleteSecret = false) where TClass : IConfigurationObject
@ -281,7 +287,17 @@ public sealed record PluginConfigurationObject
var configObjectSourcePluginId = configuredObject.EnterpriseConfigurationPluginId; var configObjectSourcePluginId = configuredObject.EnterpriseConfigurationPluginId;
if(configObjectSourcePluginId == Guid.Empty) if(configObjectSourcePluginId == Guid.Empty)
continue; continue;
//
// Is the source plugin deployed, but could not be loaded? Then we must not touch any of
// its objects. The plugin was not removed, it is broken: it might be invalid Lua code,
// a missing `plugin.lua`, or an incomplete download. Removing the objects would delete
// the organization's providers and data sources, including their secrets, although the
// organization still manages this AI Studio instance:
//
if(deployedConfigPluginIds.Contains(configObjectSourcePluginId) && availablePlugins.All(plugin => plugin.Id != configObjectSourcePluginId))
continue;
// Is the source plugin still available? If not, we can be pretty sure that this configuration object is left // Is the source plugin still available? If not, we can be pretty sure that this configuration object is left
// over and should be removed: // over and should be removed:
var templateSourcePlugin = availablePlugins.FirstOrDefault(plugin => plugin.Id == configObjectSourcePluginId); var templateSourcePlugin = availablePlugins.FirstOrDefault(plugin => plugin.Id == configObjectSourcePluginId);

View File

@ -1,5 +1,7 @@
using System.Linq.Expressions;
using System.Text; using System.Text;
using AIStudio.Settings; using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.PluginSystem.Assistants; using AIStudio.Tools.PluginSystem.Assistants;
using Lua; using Lua;
using Lua.Standard; using Lua.Standard;
@ -44,19 +46,23 @@ public static partial class PluginFactory
try try
{ {
LOG.LogInformation("Start loading plugins."); LOG.LogInformation("Start loading plugins.");
if (!Directory.Exists(PLUGINS_ROOT))
{ //
LOG.LogInformation("No plugins found."); // Without the plugins directory, we cannot load or start any plugin. Still, we must not
return; // stop here: the clean-up at the end of this method has to run. Otherwise, settings which
} // a configuration plugin has locked would stay locked forever.
//
var pluginsDirectoryExists = Directory.Exists(PLUGINS_ROOT);
if (!pluginsDirectoryExists)
LOG.LogWarning("No plugins found. Checking for left-over configurations of removed configuration plugins.");
AVAILABLE_PLUGINS.Clear(); AVAILABLE_PLUGINS.Clear();
// //
// The easiest way to load all plugins is to find all `plugin.lua` files and load them. // The easiest way to load all plugins is to find all `plugin.lua` files and load them.
// By convention, each plugin is enforced to have a `plugin.lua` file. // By convention, each plugin is enforced to have a `plugin.lua` file.
// //
var pluginMainFiles = Directory.EnumerateFiles(PLUGINS_ROOT, "plugin.lua", SearchOption.AllDirectories); IEnumerable<string> pluginMainFiles = pluginsDirectoryExists ? Directory.EnumerateFiles(PLUGINS_ROOT, "plugin.lua", SearchOption.AllDirectories) : [];
foreach (var pluginMainFile in pluginMainFiles) foreach (var pluginMainFile in pluginMainFiles)
{ {
try try
@ -149,8 +155,11 @@ public static partial class PluginFactory
} }
// Start or restart all plugins: // Start or restart all plugins:
var configObjects = await RestartAllPlugins(cancellationToken); if (pluginsDirectoryExists)
configObjectList.AddRange(configObjects); {
var configObjects = await RestartAllPlugins(cancellationToken);
configObjectList.AddRange(configObjects);
}
} }
finally finally
{ {
@ -166,218 +175,56 @@ public static partial class PluginFactory
// ========================================================= // =========================================================
// //
//
// Configuration plugins which are deployed but could not be loaded count as present: they
// were not removed, so everything they manage must stay as it is. Otherwise, one broken
// configuration plugin would wipe the entire organization configuration:
//
var deployedConfigPluginIds = GetDeployedConfigPluginIds();
var unloadedConfigPluginIds = deployedConfigPluginIds.Where(x => AVAILABLE_PLUGINS.All(plugin => plugin.Id != x)).ToList();
foreach (var unloadedConfigPluginId in unloadedConfigPluginIds)
LOG.LogWarning($"The configuration plugin '{unloadedConfigPluginId}' is deployed, but was not loaded. Everything it manages stays unchanged, because the plugin was not removed. Please check the errors above and fix the plugin.");
// Check LLM providers: // Check LLM providers:
var wasConfigurationChanged = await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.LLM_PROVIDER, x => x.Providers, AVAILABLE_PLUGINS, configObjectList, SecretStoreType.LLM_PROVIDER); var wasConfigurationChanged = await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.LLM_PROVIDER, x => x.Providers, AVAILABLE_PLUGINS, deployedConfigPluginIds, configObjectList, SecretStoreType.LLM_PROVIDER);
// Check transcription providers: // Check transcription providers:
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER, x => x.TranscriptionProviders, AVAILABLE_PLUGINS, configObjectList, SecretStoreType.TRANSCRIPTION_PROVIDER)) if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER, x => x.TranscriptionProviders, AVAILABLE_PLUGINS, deployedConfigPluginIds, configObjectList, SecretStoreType.TRANSCRIPTION_PROVIDER))
wasConfigurationChanged = true; wasConfigurationChanged = true;
// Check embedding providers: // Check embedding providers:
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.EMBEDDING_PROVIDER, x => x.EmbeddingProviders, AVAILABLE_PLUGINS, configObjectList, SecretStoreType.EMBEDDING_PROVIDER)) if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.EMBEDDING_PROVIDER, x => x.EmbeddingProviders, AVAILABLE_PLUGINS, deployedConfigPluginIds, configObjectList, SecretStoreType.EMBEDDING_PROVIDER))
wasConfigurationChanged = true; wasConfigurationChanged = true;
// Check data sources: // Check data sources:
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.DATA_SOURCE, x => x.DataSources, AVAILABLE_PLUGINS, configObjectList, SecretStoreType.DATA_SOURCE, deleteSecret: true)) if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.DATA_SOURCE, x => x.DataSources, AVAILABLE_PLUGINS, deployedConfigPluginIds, configObjectList, SecretStoreType.DATA_SOURCE, deleteSecret: true))
wasConfigurationChanged = true; wasConfigurationChanged = true;
// Check chat templates: // Check chat templates:
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.CHAT_TEMPLATE, x => x.ChatTemplates, AVAILABLE_PLUGINS, configObjectList)) if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.CHAT_TEMPLATE, x => x.ChatTemplates, AVAILABLE_PLUGINS, deployedConfigPluginIds, configObjectList))
wasConfigurationChanged = true; wasConfigurationChanged = true;
// Check profiles: // Check profiles:
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.PROFILE, x => x.Profiles, AVAILABLE_PLUGINS, configObjectList)) if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.PROFILE, x => x.Profiles, AVAILABLE_PLUGINS, deployedConfigPluginIds, configObjectList))
wasConfigurationChanged = true; wasConfigurationChanged = true;
// Check document analysis policies: // Check document analysis policies:
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY, x => x.DocumentAnalysis.Policies, AVAILABLE_PLUGINS, configObjectList)) if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY, x => x.DocumentAnalysis.Policies, AVAILABLE_PLUGINS, deployedConfigPluginIds, configObjectList))
wasConfigurationChanged = true; wasConfigurationChanged = true;
// Check left-over mandatory info acceptances: // Check left-over mandatory info acceptances:
if (SettingsManagerAccess.ConfigurationData.MandatoryInformation.RemoveLeftOverAcceptances(GetMandatoryInfos())) if (SettingsManagerAccess.ConfigurationData.MandatoryInformation.RemoveLeftOverAcceptances(GetMandatoryInfos()))
wasConfigurationChanged = true; wasConfigurationChanged = true;
// Check for a preselected provider: // Check all managed settings, i.e. settings which a configuration plugin can lock,
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.PreselectedProvider, AVAILABLE_PLUGINS)) // provide as an editable default, or contribute to:
if(ManagedConfiguration.CleanupLeftOverManagedConfigurations(AVAILABLE_PLUGINS, deployedConfigPluginIds))
wasConfigurationChanged = true; wasConfigurationChanged = true;
// Check for a preselected profile: // Compatibility shim, see documentation/compatibility-shims/2026-08-orphaned-config-locks.md (remove after 2027-08-06):
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.PreselectedProfile, AVAILABLE_PLUGINS)) if (RepairLegacyConfigOnlySettings(unloadedConfigPluginIds.Count > 0))
wasConfigurationChanged = true; wasConfigurationChanged = true;
// Check for preselected chat options:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.Chat, x => x.PreselectOptions, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.Chat, x => x.PreselectedProvider, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.Chat, x => x.PreselectedProfile, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.Chat, x => x.PreselectedChatTemplate, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.Chat, x => x.PreselectedDataSourcesDisabled, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.Chat, x => x.PreselectedDataSourcesAutomaticSelection, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.Chat, x => x.PreselectedDataSourcesAutomaticValidation, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.Chat, x => x.PreselectedDataSourceIds, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.Chat, x => x.SendToChatDataSourceBehavior, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check for the update interval:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.UpdateInterval, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check for the update installation method:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.UpdateInstallation, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check for the start page:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.StartPage, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check for the built-in introduction visibility:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.ShowIntroduction, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check for the quick start guide visibility:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.ShowQuickStartGuide, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check for the last changelog visibility:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.ShowLastChangelog, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check for the vision panel visibility:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.ShowVision, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check for users allowed to added providers:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.AllowUserToAddProvider, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check for the plugin import permission:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.AllowUserToImportPlugins, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check for the plugin sharing permission:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.AllowUserToSharePlugins, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check for admin settings visibility:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.ShowAdminSettings, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check for preview visibility:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.PreviewVisibility, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check for enabled preview features:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.EnabledPreviewFeatures, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
if(ManagedConfiguration.IsPluginContributionLeftOver(x => x.App, x => x.EnabledPreviewFeatures, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check for the transcription provider:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.UseTranscriptionProvider, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check for hidden assistants:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.HiddenAssistants, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check for the voice recording shortcut:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.ShortcutVoiceRecording, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check for the external HTTP client timeout:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.HttpClientTimeoutSeconds, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check for custom root certificates for external HTTP requests:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.ExternalHttpCustomRootCertificatesEnabled, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.ExternalHttpCustomRootCertificateBundlePath, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.ExternalHttpCustomRootCertificateAllowedHosts, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check provider confidence settings:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.Confidence, x => x.EnforceGlobalMinimumConfidence, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.Confidence, x => x.GlobalMinimumConfidence, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.Confidence, x => x.ShowProviderConfidence, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.Confidence, x => x.ConfidenceScheme, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.Confidence, x => x.CustomConfidenceScheme, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check data source security settings:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.DataSourceSecurity, x => x.TrustedProviderIds, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check data source selection agent settings:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.AgentDataSourceSelection, x => x.PreselectAgentOptions, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.AgentDataSourceSelection, x => x.PreselectedAgentProvider, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check retrieval context validation agent settings:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.AgentRetrievalContextValidation, x => x.EnableRetrievalContextValidation, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.AgentRetrievalContextValidation, x => x.PreselectAgentOptions, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.AgentRetrievalContextValidation, x => x.PreselectedAgentProvider, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.AgentRetrievalContextValidation, x => x.NumParallelValidations, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check if audit is required before it can be activated
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.AssistantPluginAudit, x => x.RequireAuditBeforeActivation, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Register new preselected provider for the security audit
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.AssistantPluginAudit, x => x.PreselectedAgentProvider, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Change the minimum required audit level that is required for the allowance of assistants
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.AssistantPluginAudit, x => x.MinimumLevel, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check if external plugins are strictly forbidden, when the minimum audit level is fell below
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.AssistantPluginAudit, x => x.BlockActivationBelowMinimum, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check if security audits are invoked automatically and transparent for the user
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.AssistantPluginAudit, x => x.AutomaticallyAuditAssistants, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check enterprise-managed assistant plugin approvals
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.AssistantPluginAudit, x => x.EnterpriseApprovedPlugins, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
if (wasConfigurationChanged) if (wasConfigurationChanged)
{ {
await SettingsManagerAccess.StoreSettings(); await SettingsManagerAccess.StoreSettings();
@ -385,6 +232,38 @@ public static partial class PluginFactory
} }
} }
/// <summary>
/// Determines the IDs of all configuration plugins which are deployed on this machine.
/// </summary>
/// <remarks>
/// We read these IDs from the file system instead of taking them from the loaded plugins. A
/// configuration plugin might be present but not loadable, e.g. due to invalid Lua code, a
/// missing `plugin.lua`, or an incomplete download. Such a plugin still manages this AI Studio
/// instance, so we must not treat its settings as left over. Configuration plugins deployed by a
/// configuration server live in a directory named after their ID, which is the only information
/// left when the plugin itself cannot be read.
/// </remarks>
private static HashSet<Guid> GetDeployedConfigPluginIds()
{
var deployedConfigPluginIds = new HashSet<Guid>();
if (!Directory.Exists(CONFIGURATION_PLUGINS_ROOT))
return deployedConfigPluginIds;
foreach (var configPluginDirectory in Directory.EnumerateDirectories(CONFIGURATION_PLUGINS_ROOT))
{
if (!Guid.TryParse(Path.GetFileName(configPluginDirectory), out var configPluginId) || configPluginId == Guid.Empty)
continue;
// An empty directory is a left-over of a removed plugin, not a deployed plugin:
if (!Directory.EnumerateFileSystemEntries(configPluginDirectory).Any())
continue;
deployedConfigPluginIds.Add(configPluginId);
}
return deployedConfigPluginIds;
}
/// <param name="pluginPath">The directory the plugin is located in, or null when the code has no directory yet.</param> /// <param name="pluginPath">The directory the plugin is located in, or null when the code has no directory yet.</param>
/// <param name="code">The Lua code of the plugin's main file.</param> /// <param name="code">The Lua code of the plugin's main file.</param>
/// <param name="cancellationToken">Cancellation token for running the Lua code.</param> /// <param name="cancellationToken">Cancellation token for running the Lua code.</param>
@ -460,4 +339,111 @@ public static partial class PluginFactory
return new NoPlugin("This plugin type is not supported yet. Please try again with a future version of AI Studio."); return new NoPlugin("This plugin type is not supported yet. Please try again with a future version of AI Studio.");
} }
} }
//
// =========================================================
// Compatibility shim. Please read the related document
// before you change anything here:
//
// documentation/compatibility-shims/2026-08-orphaned-config-locks.md
//
// Remove after 2027-08-06. Everything from here down to the
// end of this file belongs to the shim and can be deleted
// in one piece.
// =========================================================
//
/// <summary>
/// Repairs settings that were configured by a configuration plugin which was removed before
/// AI Studio started to persist the configuration ownership.
/// </summary>
/// <remarks>
/// All settings listed here share two properties: a configuration plugin can set them, and
/// there is no user interface to change them back. Therefore, any value that differs from the
/// default must originate from a configuration plugin. When such a setting is not managed
/// anymore, its plugin is gone and we restore the default value.<br/><br/>
/// This is only valid as long as none of these settings gets a user interface. When you add
/// one, remove the setting from this method and from the shim's document.
/// </remarks>
/// <param name="hasUnloadedConfigPlugins" >
/// True when at least one configuration plugin is deployed but could not be loaded. In that case,
/// we cannot tell whether a value comes from that plugin or from a removed one, so we repair
/// nothing at all.
/// </param>
/// <returns>True when at least one setting was repaired, otherwise false.</returns>
private static bool RepairLegacyConfigOnlySettings(bool hasUnloadedConfigPlugins)
{
if (hasUnloadedConfigPlugins)
{
LOG.LogWarning("Skipping the repair of configuration-only settings: at least one configuration plugin is deployed, but could not be loaded. We try again the next time AI Studio starts.");
return false;
}
var data = SettingsManagerAccess.ConfigurationData;
var wasRepaired = false;
// Settings which are enabled by default and which only a configuration plugin can switch off:
wasRepaired |= RepairLegacyConfigOnlyFlag(x => x.App, x => x.ShowIntroduction, data.App.ShowIntroduction);
wasRepaired |= RepairLegacyConfigOnlyFlag(x => x.App, x => x.ShowQuickStartGuide, data.App.ShowQuickStartGuide);
wasRepaired |= RepairLegacyConfigOnlyFlag(x => x.App, x => x.ShowLastChangelog, data.App.ShowLastChangelog);
wasRepaired |= RepairLegacyConfigOnlyFlag(x => x.App, x => x.ShowVision, data.App.ShowVision);
wasRepaired |= RepairLegacyConfigOnlyFlag(x => x.App, x => x.AllowUserToAddProvider, data.App.AllowUserToAddProvider);
wasRepaired |= RepairLegacyConfigOnlyFlag(x => x.App, x => x.AllowUserToImportPlugins, data.App.AllowUserToImportPlugins);
wasRepaired |= RepairLegacyConfigOnlyFlag(x => x.App, x => x.AllowUserToSharePlugins, data.App.AllowUserToSharePlugins);
// Collections which stay empty unless a configuration plugin fills them:
wasRepaired |= RepairLegacyConfigOnlyCollection(x => x.App, x => x.HiddenAssistants, data.App.HiddenAssistants.Count);
wasRepaired |= RepairLegacyConfigOnlyCollection(x => x.DataSourceSecurity, x => x.TrustedProviderIds, data.DataSourceSecurity.TrustedProviderIds.Count);
wasRepaired |= RepairLegacyConfigOnlyCollection(x => x.AssistantPluginAudit, x => x.EnterpriseApprovedPlugins, data.AssistantPluginAudit.EnterpriseApprovedPlugins.Count);
return wasRepaired;
}
/// <summary>
/// Restores the default of a boolean setting when it is switched off without being managed.
/// </summary>
private static bool RepairLegacyConfigOnlyFlag<TClass>(Expression<Func<Data, TClass>> configSelection, Expression<Func<TClass, bool>> propertyExpression, bool currentValue)
{
if (currentValue)
return false;
if (!ManagedConfiguration.TryGet(configSelection, propertyExpression, out var configMeta) || configMeta.ManagedMode is not null)
return false;
LOG.LogWarning($"Repairing the setting '{configMeta.SettingName}': it was switched off by a configuration plugin which is not available anymore.");
configMeta.ResetLockedConfiguration();
return true;
}
/// <summary>
/// Clears a set-based setting when it contains entries without being managed.
/// </summary>
private static bool RepairLegacyConfigOnlyCollection<TClass, TValue>(Expression<Func<Data, TClass>> configSelection, Expression<Func<TClass, ISet<TValue>>> propertyExpression, int currentCount)
{
if (currentCount is 0)
return false;
if (!ManagedConfiguration.TryGet(configSelection, propertyExpression, out var configMeta) || configMeta.ManagedMode is not null)
return false;
LOG.LogWarning($"Repairing the setting '{configMeta.SettingName}': it was filled by a configuration plugin which is not available anymore.");
configMeta.ResetLockedConfiguration();
return true;
}
/// <summary>
/// Clears a list-based setting when it contains entries without being managed.
/// </summary>
private static bool RepairLegacyConfigOnlyCollection<TClass, TValue>(Expression<Func<Data, TClass>> configSelection, Expression<Func<TClass, IList<TValue>>> propertyExpression, int currentCount)
{
if (currentCount is 0)
return false;
if (!ManagedConfiguration.TryGet(configSelection, propertyExpression, out var configMeta) || configMeta.ManagedMode is not null)
return false;
LOG.LogWarning($"Repairing the setting '{configMeta.SettingName}': it was filled by a configuration plugin which is not available anymore.");
configMeta.ResetLockedConfiguration();
return true;
}
} }

View File

@ -6,8 +6,10 @@
- Added the option to import plugins by dropping a plugin archive onto the plugin page. - Added the option to import plugins by dropping a plugin archive onto the plugin page.
- Added the dedicated file extension `.mwplugin` for plugin archives. - Added the dedicated file extension `.mwplugin` for plugin archives.
- Added an option for organizations to disable importing, sharing, and exporting plugins. - Added an option for organizations to disable importing, sharing, and exporting plugins.
- Improved how your organization's configuration behaves when a configuration plugin is present but cannot be loaded, e.g. because of an error in the plugin. Such a plugin still manages your app, so its settings, providers, data sources, profiles, and chat templates now stay in place instead of being removed.
- Fixed reset buttons in assistants. As you may have noticed in the Document Analysis Assistant, resetting it could leave content from the previous analysis visible. Reset buttons now clear previous results completely. - Fixed reset buttons in assistants. As you may have noticed in the Document Analysis Assistant, resetting it could leave content from the previous analysis visible. Reset buttons now clear previous results completely.
- Fixed dropping files after you closed a dialog that accepts files itself. Such a dialog takes over dropped files while it is open, but never handed that role back when you closed it. Afterwards, the chat and the assistants silently ignored dropped files until you switched to another page. Each time you opened such a dialog again, the problem got worse. - Fixed dropping files after you closed a dialog that accepts files itself. Such a dialog takes over dropped files while it is open, but never handed that role back when you closed it. Afterwards, the chat and the assistants silently ignored dropped files until you switched to another page. Each time you opened such a dialog again, the problem got worse.
- Fixed configuration-managed settings remaining active after their configuration plugin was removed.
- Fixed the integrated code editor to keep errors and other issues in plugin code visible in the footer while scrolling. - Fixed the integrated code editor to keep errors and other issues in plugin code visible in the footer while scrolling.
- Fixed the trusted badge so you can now see at a glance which models are trusted. It is shown consistently for self-hosted models and models from trusted providers. - Fixed the trusted badge so you can now see at a glance which models are trusted. It is shown consistently for self-hosted models and models from trusted providers.
- Upgraded dependencies to their latest versions to improve security and stability. - Upgraded dependencies to their latest versions to improve security and stability.

View File

@ -0,0 +1,36 @@
# Orphaned Configuration Locks
- Status: Active
- Introduced: 2026-08-06
- Remove after: 2027-08-06
- Code references:
- `app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs` (`RepairLegacyConfigOnlySettings`, `RepairLegacyConfigOnlyFlag`, `RepairLegacyConfigOnlyCollection`)
## User Impact
Until this release, AI Studio persisted the value a configuration plugin had set, but not the information which plugin owned that value. After a restart, the ownership was lost. When the configuration plugin was removed in the meantime, the cleanup in `PluginFactory.LoadAll` could not recognize the value as left over, so it stayed active forever.
For most settings, this was an inconvenience only, because users can change them in the settings dialog. For settings without any user interface, it was a dead end: hidden assistants stayed hidden, adding providers stayed disabled, and the home page panels stayed switched off. The only workaround was to edit the settings file by hand.
Installations that lost the ownership this way cannot be repaired by the new persistence alone, because the missing information cannot be reconstructed. They need this one-time repair.
## Compatibility Behavior
At the end of `PluginFactory.LoadAll`, AI Studio checks a fixed list of settings. A setting is repaired when it is not managed by any configuration plugin at that moment and still holds a value that only a configuration plugin could have produced:
- `DataApp.ShowIntroduction`, `DataApp.ShowQuickStartGuide`, `DataApp.ShowLastChangelog`, `DataApp.ShowVision`, `DataApp.AllowUserToAddProvider`, `DataApp.AllowUserToImportPlugins`, `DataApp.AllowUserToSharePlugins`: enabled by default, so a disabled value is repaired.
- `DataApp.HiddenAssistants`, `DataSourceSecuritySettings.TrustedProviderIds`, `DataAssistantPluginAudit.EnterpriseApprovedPlugins`: empty by default, so a filled collection is repaired.
Repairing means restoring the default value. Each repair is logged as a warning.
Nothing is repaired at all while a configuration plugin is deployed but could not be loaded, e.g. because of invalid Lua code. In that situation, we cannot tell whether a value comes from that plugin or from a removed one, so the repair is postponed to the next start.
The check runs on every start, not once. This is safe because none of these settings has a user interface that writes to it, so a non-default value can only originate from a configuration plugin. This is the load-bearing assumption of the whole shim: as soon as one of these settings gets a user interface, the shim would overwrite the user's choice on every start. In that case, remove the setting from `RepairLegacyConfigOnlySettings` and from the list above.
Settings that a configuration plugin can lock but that users can change themselves are deliberately not part of this list. Their owner is persisted from this release on, and the regular left-over cleanup handles them.
## Removal Checklist
- Remove `RepairLegacyConfigOnlySettings`, `RepairLegacyConfigOnlyFlag`, and `RepairLegacyConfigOnlyCollection` from `PluginFactory.Loading.cs`, including the call and the comment in `LoadAll`.
- Update this document's status to `Removed`.
- No changelog entry is needed, because removing the shim is not user-visible.