diff --git a/memex/Memex.Portal.Shared/MemexConfiguration.cs b/memex/Memex.Portal.Shared/MemexConfiguration.cs index 5942139bee..30438f1768 100644 --- a/memex/Memex.Portal.Shared/MemexConfiguration.cs +++ b/memex/Memex.Portal.Shared/MemexConfiguration.cs @@ -480,6 +480,12 @@ public TBuilder ConfigureMemexMesh(IConfiguration configuration, bool isDevelopm // import — no regression. Default Helm sets ["Doc","Agent","Provider","Harness","Skill"]. var syncPartitions = features.StaticRepoSync.Partitions .ToHashSet(StringComparer.OrdinalIgnoreCase); + // AI content is served as a UNIT: if the config names ANY AI partition, serve them ALL + // (Agent/Provider/Harness/Skill), so an incomplete list can't leave Skill (or a future AI + // content type) in-memory while the rest go to the DB — and AddAI's per-type serve-from-DB + // gating stays consistent with the static-repo import. See MeshWeaver.AI/AiContentSources. + if (syncPartitions.Overlaps(AiContentSources.ContentPartitions)) + syncPartitions.UnionWith(AiContentSources.ContentPartitions); IReadOnlySet serveFromPartition = syncPartitions; MeshBuilder mb = builder diff --git a/memex/Memex.Portal.Shared/StaticRepoSyncExtensions.cs b/memex/Memex.Portal.Shared/StaticRepoSyncExtensions.cs index 4d8877fd7a..d9df592db3 100644 --- a/memex/Memex.Portal.Shared/StaticRepoSyncExtensions.cs +++ b/memex/Memex.Portal.Shared/StaticRepoSyncExtensions.cs @@ -37,16 +37,16 @@ public static TBuilder AddStaticRepoSync(this TBuilder builder, { if (serveFromPartition.Contains("Doc")) services.AddSingleton(); - if (serveFromPartition.Contains("Agent")) - services.AddSingleton(); - // The model catalog (providers + models + policy) imports into the top-level "Provider" - // partition. Honour the legacy "Model" partition name too for backwards-compatible configs. - if (serveFromPartition.Contains("Provider") || serveFromPartition.Contains("Model")) - services.AddSingleton(); - if (serveFromPartition.Contains("Harness")) - services.AddSingleton(); - if (serveFromPartition.Contains("Skill")) - services.AddSingleton(); + + // AI content (Agent / Provider / Harness / Skill) is ONE bundle: register every built-in + // AI source together whenever any AI partition is served, so a config that names only some + // of them can NEVER silently drop one (the recurring "Skill was never imported" bug). The + // set + the bundle live next to the sources in MeshWeaver.AI — adding a new AI content type + // there auto-joins the import; there is no per-partition allow-list here to forget. (The + // expand-to-the-whole-bundle also happens in MemexConfiguration so AddAI's serve-from-DB + // gating stays consistent with the import.) + if (serveFromPartition.Overlaps(AiContentSources.ContentPartitions)) + services.AddBuiltInAiContentSources(); // Runs after the PG schema-provisioning hosted service (registered earlier by // AddPartitionedPostgreSqlPersistence) — hosted services start in registration order. diff --git a/src/MeshWeaver.AI/AgentStaticRepoSource.cs b/src/MeshWeaver.AI/AgentStaticRepoSource.cs index da8c039211..a9881707b5 100644 --- a/src/MeshWeaver.AI/AgentStaticRepoSource.cs +++ b/src/MeshWeaver.AI/AgentStaticRepoSource.cs @@ -38,6 +38,7 @@ public IReadOnlyList EnumerateSourceNodes() => public MeshNode? PartitionRoot => new("Agent") { Name = "Agents", + Icon = "/static/NodeTypeIcons/sparkle.svg", NodeType = "Space", State = MeshNodeState.Active, Content = new MarkdownContent diff --git a/src/MeshWeaver.AI/AiContentSources.cs b/src/MeshWeaver.AI/AiContentSources.cs new file mode 100644 index 0000000000..36d76d71f9 --- /dev/null +++ b/src/MeshWeaver.AI/AiContentSources.cs @@ -0,0 +1,46 @@ +using System.Collections.Immutable; +using MeshWeaver.Mesh; +using Microsoft.Extensions.DependencyInjection; + +namespace MeshWeaver.AI; + +/// +/// The built-in AI content partitions and their static-repo import sources, treated as ONE bundle. +/// AI content — Agents, model Providers, Harnesses, Skills — is seeded from these sources on boot. +/// Bundling them is the cure for the recurring "Skill partition was never imported" bug: there is no +/// per-partition allow-list to hand-maintain, so a new built-in AI content type can't be silently +/// left un-imported by forgetting to name its partition. This is the single source of truth shared by +/// AddAI's serve-from-DB gating and the portal's static-repo import wiring. +/// +/// Pinned by AiContentSourcesTest: every defined in +/// MeshWeaver.AI MUST be in — adding a fifth source +/// without bundling it fails the test. +/// +public static class AiContentSources +{ + /// + /// The partitions whose content is the built-in AI catalog: Agent, Provider, + /// Harness, Skill. Served from the DB (and imported) as a UNIT — never partially. + /// + public static readonly ImmutableHashSet ContentPartitions = + ImmutableHashSet.Create( + StringComparer.OrdinalIgnoreCase, + "Agent", // AgentStaticRepoSource.Partition + ModelProviderNodeType.RootNamespace, // "Provider" + HarnessNodeType.RootNamespace, // "Harness" + SkillNodeType.RootNamespace); // "Skill" + + /// + /// Registers EVERY built-in AI (Agent, Model/Provider, Harness, + /// Skill) as one bundle. The portal calls this whenever AI content is served from the DB, so the + /// import set can never drift from or silently drop a partition. + /// + public static IServiceCollection AddBuiltInAiContentSources(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/src/MeshWeaver.AI/ModelStaticRepoSource.cs b/src/MeshWeaver.AI/ModelStaticRepoSource.cs index e4068ca2c9..06d32b9429 100644 --- a/src/MeshWeaver.AI/ModelStaticRepoSource.cs +++ b/src/MeshWeaver.AI/ModelStaticRepoSource.cs @@ -39,6 +39,7 @@ public IReadOnlyList EnumerateSourceNodes() => public MeshNode? PartitionRoot => new(ModelProviderNodeType.RootNamespace) { Name = "Providers", + Icon = "/static/NodeTypeIcons/database.svg", NodeType = "Space", State = MeshNodeState.Active, Content = new MarkdownContent diff --git a/src/MeshWeaver.AI/SkillStaticRepoSource.cs b/src/MeshWeaver.AI/SkillStaticRepoSource.cs index a2d14acdfc..d5f6e71b3a 100644 --- a/src/MeshWeaver.AI/SkillStaticRepoSource.cs +++ b/src/MeshWeaver.AI/SkillStaticRepoSource.cs @@ -35,6 +35,7 @@ public IReadOnlyList EnumerateSourceNodes() => public MeshNode? PartitionRoot => new(SkillNodeType.RootNamespace) { Name = "Skills", + Icon = "/static/NodeTypeIcons/sparkle.svg", NodeType = "Space", State = MeshNodeState.Active, Content = new MarkdownContent diff --git a/test/MeshWeaver.AI.Test/AiContentSourcesTest.cs b/test/MeshWeaver.AI.Test/AiContentSourcesTest.cs new file mode 100644 index 0000000000..41c29e4391 --- /dev/null +++ b/test/MeshWeaver.AI.Test/AiContentSourcesTest.cs @@ -0,0 +1,45 @@ +using System.Linq; +using MeshWeaver.Mesh; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace MeshWeaver.AI.Test; + +/// +/// The ratchet behind the recurring "Skill partition was never imported" bug: every built-in AI +/// defined in MeshWeaver.AI MUST be in the +/// bundle. Adding a new AI content type +/// (a fifth partition) without bundling it fails this test — so it can never again be silently left +/// un-imported by an incomplete per-partition list. +/// +public class AiContentSourcesTest +{ + [Fact] + public void AddBuiltInAiContentSources_bundles_every_AI_static_repo_source() + { + // Reflect over EVERY concrete IStaticRepoSource in the MeshWeaver.AI assembly. + var allAiSources = typeof(AiContentSources).Assembly.GetTypes() + .Where(t => t is { IsAbstract: false, IsClass: true } + && typeof(IStaticRepoSource).IsAssignableFrom(t)) + .ToHashSet(); + allAiSources.Should().NotBeEmpty("MeshWeaver.AI defines built-in static-repo content sources"); + + var registered = new ServiceCollection() + .AddBuiltInAiContentSources() + .Where(sd => sd.ServiceType == typeof(IStaticRepoSource)) + .Select(sd => sd.ImplementationType!) + .ToHashSet(); + + var unbundled = allAiSources.Except(registered).OrderBy(t => t.Name).ToList(); + unbundled.Should().BeEmpty( + "AddBuiltInAiContentSources must register EVERY IStaticRepoSource in MeshWeaver.AI — " + + "bundling them is what stops a new AI partition (Skill, …) from being silently un-imported"); + } + + [Fact] + public void ContentPartitions_cover_the_four_AI_content_partitions() + { + AiContentSources.ContentPartitions.OrderBy(p => p, System.StringComparer.Ordinal) + .Should().Equal("Agent", "Harness", "Provider", "Skill"); + } +}