From 93f27d34ff933a4fee849e6015e7a8aa9ec01e9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Thu, 16 Apr 2026 21:04:59 +0200 Subject: [PATCH 01/50] fix: resolve relative paths against main node on satellite pages CurrentNamespace now returns PrimaryPath (the main node) instead of the raw resolved address. Chat input, autocomplete, attachments, and creatable- types loading all key off CurrentNamespace; on a thread URL like /PartnerRe/AIConsulting/_Thread/abc, they were treating the satellite path as the namespace, so @content/foo and @../sibling refs failed to route. Also folds in two related satellite fixes already in the working tree: - PathUtils.ResolveRelativePath strips _Thread/_Comment/_Activity segments from the base path before applying ../ traversal - ThreadMessageLayoutAreas emits absolute hrefs for agent-emitted @refs in rendered messages (so a reader can interpret them without knowing the enclosing thread's context) Adds three NavigationServiceTest cases covering satellite vs regular nodes and creatable-type loading on a satellite page. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/MeshWeaver.AI/ThreadMessageLayoutAreas.cs | 4 +- .../NavigationService.cs | 7 +- src/MeshWeaver.Markdown/PathUtils.cs | 25 ++++- .../NavigationServiceTest.cs | 100 ++++++++++++++++++ 4 files changed, 130 insertions(+), 6 deletions(-) diff --git a/src/MeshWeaver.AI/ThreadMessageLayoutAreas.cs b/src/MeshWeaver.AI/ThreadMessageLayoutAreas.cs index 1e07c28ca..57d60ae58 100644 --- a/src/MeshWeaver.AI/ThreadMessageLayoutAreas.cs +++ b/src/MeshWeaver.AI/ThreadMessageLayoutAreas.cs @@ -426,8 +426,8 @@ private static string ConvertReferencesToLinks(string text) // Don't convert email addresses if (path.Contains('@')) return match.Value; - // Use @prefix in href — LinkUrlCleanupExtension will strip @ and resolve - return $"[`@{path}`](@{path})"; + // Emit absolute href — @references from agents are always full paths + return $"[`@{path}`](/{path})"; }); } diff --git a/src/MeshWeaver.Hosting.Blazor/NavigationService.cs b/src/MeshWeaver.Hosting.Blazor/NavigationService.cs index 480a3af25..29e016a1d 100644 --- a/src/MeshWeaver.Hosting.Blazor/NavigationService.cs +++ b/src/MeshWeaver.Hosting.Blazor/NavigationService.cs @@ -191,7 +191,10 @@ private async Task ProcessResolvedPathAsync(string path, AddressResolution resol }; _context = context; - CurrentNamespace = context.Namespace; + // On satellite pages (thread/comment/activity), CurrentNamespace points at the + // main node — callers that resolve relative paths, autocomplete, attachments, + // and chat context all need the primary node, not the `_Thread/...` sub-address. + CurrentNamespace = context.PrimaryPath; // Track navigation activity for "Recently Viewed" if (node != null) @@ -200,7 +203,7 @@ private async Task ProcessResolvedPathAsync(string path, AddressResolution resol OnNavigationContextChanged?.Invoke(context); // Load creatable types in background when namespace changes - var currentNodePath = context.Namespace ?? ""; + var currentNodePath = context.PrimaryPath ?? ""; if (currentNodePath != _lastLoadedNodePath) { _ = LoadCreatableTypesAsync(currentNodePath); diff --git a/src/MeshWeaver.Markdown/PathUtils.cs b/src/MeshWeaver.Markdown/PathUtils.cs index fc60be7a6..1c517c0f6 100644 --- a/src/MeshWeaver.Markdown/PathUtils.cs +++ b/src/MeshWeaver.Markdown/PathUtils.cs @@ -10,6 +10,9 @@ public static class PathUtils /// /// Resolves a relative path against the current node path. /// Returns the path unchanged if it's already absolute, external, or an anchor. + /// Satellite partitions (path segments starting with '_', e.g., _Thread, _Comment) + /// are stripped from the base path so that links in satellite content resolve + /// relative to the main entity, not the satellite node itself. /// /// The path to resolve (may be relative or absolute) /// The full path of the current node (e.g., "Doc/Architecture") @@ -21,8 +24,10 @@ public static string ResolveRelativePath(string path, string? currentNodePath) if (string.IsNullOrEmpty(currentNodePath)) return path; - // Handle ../ segments (go up to parent) - var basePath = currentNodePath; + // Strip satellite partitions: segments starting with '_' (e.g., _Thread, _Comment) + // and everything after them. Links in satellite content should resolve relative + // to the main entity, not the satellite path. + var basePath = StripSatellitePartition(currentNodePath); while (path.StartsWith("../")) { var lastSlash = basePath.LastIndexOf('/'); @@ -39,4 +44,20 @@ public static string ResolveRelativePath(string path, string? currentNodePath) return string.IsNullOrEmpty(basePath) ? path : $"{basePath}/{path}"; } + + /// + /// Strips the first satellite partition segment (starting with '_') and everything + /// after it from a path. E.g., "Org/Project/_Thread/slug/msgId" → "Org/Project". + /// Returns the path unchanged if no satellite partition is found. + /// + internal static string StripSatellitePartition(string path) + { + var segments = path.Split('/'); + for (var i = 0; i < segments.Length; i++) + { + if (segments[i].StartsWith('_')) + return i == 0 ? "" : string.Join('/', segments[..i]); + } + return path; + } } diff --git a/test/MeshWeaver.Hosting.Blazor.Test/NavigationServiceTest.cs b/test/MeshWeaver.Hosting.Blazor.Test/NavigationServiceTest.cs index 229e00b22..565f8b266 100644 --- a/test/MeshWeaver.Hosting.Blazor.Test/NavigationServiceTest.cs +++ b/test/MeshWeaver.Hosting.Blazor.Test/NavigationServiceTest.cs @@ -479,6 +479,97 @@ public async Task Dispose_UnsubscribesFromLocationChanged() #endregion + #region Satellite Node Tests + + [Fact] + public async Task OnLocationChanged_SatelliteNode_CurrentNamespacePointsAtMainNode() + { + // User browses to a thread under PartnerRe/AIConsulting. The thread node's MainNode + // points back at the parent that owns it, so CurrentNamespace — which downstream + // chat/autocomplete/attachment code uses to resolve relative paths — must surface + // the main node, not the satellite path. + var service = CreateService(); + const string SatellitePath = "PartnerRe/AIConsulting/_Thread/abc-123"; + const string MainNode = "PartnerRe/AIConsulting"; + + _pathResolver.ResolvePathAsync(Arg.Any()) + .Returns(new AddressResolution(SatellitePath, null)); + + var threadNode = new MeshNode("abc-123", "PartnerRe/AIConsulting/_Thread") + { + NodeType = "Thread", + MainNode = MainNode + }; + _meshQuery.QueryAsync(Arg.Any(), Arg.Any()) + .Returns(ToAsyncObjects(threadNode)); + + await service.InitializeAsync(); + + service.CurrentNamespace.Should().Be(MainNode); + service.Context!.Namespace.Should().Be(SatellitePath); + service.Context.PrimaryPath.Should().Be(MainNode); + service.Context.IsSatellite.Should().BeTrue(); + } + + [Fact] + public async Task OnLocationChanged_RegularNode_CurrentNamespaceMatchesNamespace() + { + // For a non-satellite node, CurrentNamespace and Namespace are the same. + // PrimaryPath falls back to Namespace when Node is null, so this also covers + // the no-node-found path (existing tests rely on this fallback). + var service = CreateService(); + _pathResolver.ResolvePathAsync(Arg.Any()) + .Returns(new AddressResolution("PartnerRe/AIConsulting", null)); + + var mainNode = new MeshNode("AIConsulting", "PartnerRe") + { + NodeType = "Group" + // MainNode defaults to Path → "PartnerRe/AIConsulting" + }; + _meshQuery.QueryAsync(Arg.Any(), Arg.Any()) + .Returns(ToAsyncObjects(mainNode)); + + await service.InitializeAsync(); + + service.CurrentNamespace.Should().Be("PartnerRe/AIConsulting"); + service.Context!.PrimaryPath.Should().Be("PartnerRe/AIConsulting"); + service.Context.IsSatellite.Should().BeFalse(); + } + + [Fact] + public async Task OnLocationChanged_SatelliteNode_LoadsCreatableTypesForMainNode() + { + // The creatable-types background load also keys off PrimaryPath so menus on + // satellite pages reflect what can be created on the parent node. + var service = CreateService(); + _pathResolver.ResolvePathAsync(Arg.Any()) + .Returns(new AddressResolution("PartnerRe/AIConsulting/_Thread/abc-123", null)); + + var threadNode = new MeshNode("abc-123", "PartnerRe/AIConsulting/_Thread") + { + NodeType = "Thread", + MainNode = "PartnerRe/AIConsulting" + }; + _meshQuery.QueryAsync(Arg.Any(), Arg.Any()) + .Returns(ToAsyncObjects(threadNode)); + + _nodeTypeService + .GetCreatableTypesAsync("PartnerRe/AIConsulting", Arg.Any()) + .Returns(ToAsyncEnumerable(new CreatableTypeInfo("PartnerRe/AIConsulting/Story"))); + + CreatableTypesSnapshot? lastSnapshot = null; + service.CreatableTypes.Subscribe(s => lastSnapshot = s); + + await service.InitializeAsync(); + await Task.Delay(150, TestContext.Current.CancellationToken); + + _nodeTypeService.Received().GetCreatableTypesAsync( + "PartnerRe/AIConsulting", Arg.Any()); + lastSnapshot!.Items.Should().Contain(t => t.NodeTypePath == "PartnerRe/AIConsulting/Story"); + } + + #endregion + #region Helper Methods private static async IAsyncEnumerable ToAsyncEnumerable(params CreatableTypeInfo[] items) @@ -490,6 +581,15 @@ private static async IAsyncEnumerable ToAsyncEnumerable(param await Task.CompletedTask; } + private static async IAsyncEnumerable ToAsyncObjects(params object[] items) + { + foreach (var item in items) + { + yield return item; + } + await Task.CompletedTask; + } + private static async IAsyncEnumerable ToAsyncEnumerableWithDelay( params CreatableTypeInfo[] items) { From d85922dc089ecb6027edf5c06fce43fa1371bee6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Thu, 16 Apr 2026 21:24:18 +0200 Subject: [PATCH 02/50] test: lock in content slash-format + spaced-filename behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds four GetDataRequest tests that exercise the user-reported scenario: - content/ in default collection - content/ - content// - content/ on a hub with no provider — must return error, not hang All four pass against the current monolith handler, so the prod symptom (10s AwaitResponse timeout against PartnerRe/AIConsulting) is not a handler bug. The tests now form a regression net so any future change to the content-resolver path that breaks slash format or spaces fails locally before it ships. Also folds in: - ThreadMessageLayoutAreas: avoid `//path` when an agent emits an already- absolute path - ToolStatusFormatterTest: update expectation to the new absolute-href format introduced in the previous commit Co-Authored-By: Claude Opus 4.7 (1M context) --- src/MeshWeaver.AI/ThreadMessageLayoutAreas.cs | 3 +- .../UnifiedContentAccessTest.cs | 145 ++++++++++++++++++ .../ToolStatusFormatterTest.cs | 4 +- 3 files changed, 149 insertions(+), 3 deletions(-) diff --git a/src/MeshWeaver.AI/ThreadMessageLayoutAreas.cs b/src/MeshWeaver.AI/ThreadMessageLayoutAreas.cs index 57d60ae58..aeec7823d 100644 --- a/src/MeshWeaver.AI/ThreadMessageLayoutAreas.cs +++ b/src/MeshWeaver.AI/ThreadMessageLayoutAreas.cs @@ -427,7 +427,8 @@ private static string ConvertReferencesToLinks(string text) if (path.Contains('@')) return match.Value; // Emit absolute href — @references from agents are always full paths - return $"[`@{path}`](/{path})"; + var href = path.StartsWith('/') ? path : $"/{path}"; + return $"[`@{path}`]({href})"; }); } diff --git a/test/MeshWeaver.AI.Test/UnifiedContentAccessTest.cs b/test/MeshWeaver.AI.Test/UnifiedContentAccessTest.cs index e88af7b91..31adec85d 100644 --- a/test/MeshWeaver.AI.Test/UnifiedContentAccessTest.cs +++ b/test/MeshWeaver.AI.Test/UnifiedContentAccessTest.cs @@ -4,6 +4,7 @@ using System.IO; using System.Linq; using System.Reactive.Linq; +using System.Threading; using System.Threading.Tasks; using FluentAssertions; using MeshWeaver.ContentCollections; @@ -430,6 +431,150 @@ public async Task GetDataRequest_UnifiedReference_LayoutAreas_ReturnsAreaDefinit #endregion + #region Slash-format and spaced-filename repro (PartnerRe / .docx symptom) + + // The prod symptom was: AI agent calls Get("@/PartnerRe/AIConsulting/content/Diskussion Thomas Final Report.docx"). + // MeshOperations.TryResolveUnifiedPathAsync splits the path into addressPart="PartnerRe/AIConsulting" + // and remainder="content/Diskussion Thomas Final Report.docx", then posts + // GetDataRequest(new UnifiedReference("content/Diskussion Thomas Final Report.docx")) + // to the address. The user observed a 10-second AwaitResponse timeout — symptom said + // "no response received". These tests pin down whether the GetDataRequest handler returns at + // all for the slash-format default-collection lookup, with and without spaces. + + [Fact] + public async Task GetDataRequest_ContentSlashFormat_FileInDefaultCollection_Responds() + { + var testDir = Path.Combine(Path.GetTempPath(), "MeshWeaverTest_SlashDefault_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(testDir); + await File.WriteAllTextAsync(Path.Combine(testDir, "report.txt"), "default-collection slash format", + TestContext.Current.CancellationToken); + + try + { + var host = GetHostWithFileProvider(testDir, defaultCollection: true); + var client = GetClient(); + + // Slash format with NO collection segment — what the agent actually emits. + var response = await client.AwaitResponse( + new GetDataRequest(new UnifiedReference("content/report.txt")), + o => o.WithTarget(CreateHostAddress()), + TestContext.Current.CancellationToken); + + response.Message.Error.Should().BeNull(); + (response.Message.Data as string).Should().Contain("default-collection slash format"); + } + finally + { + if (Directory.Exists(testDir)) Directory.Delete(testDir, true); + } + } + + [Fact] + public async Task GetDataRequest_ContentSlashFormat_SpacedFilename_Responds() + { + var testDir = Path.Combine(Path.GetTempPath(), "MeshWeaverTest_SlashSpaces_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(testDir); + const string Spaced = "Diskussion Thomas Final Report.txt"; + await File.WriteAllTextAsync(Path.Combine(testDir, Spaced), "spaced default-collection content", + TestContext.Current.CancellationToken); + + try + { + var host = GetHostWithFileProvider(testDir, defaultCollection: true); + var client = GetClient(); + + var response = await client.AwaitResponse( + new GetDataRequest(new UnifiedReference($"content/{Spaced}")), + o => o.WithTarget(CreateHostAddress()), + TestContext.Current.CancellationToken); + + response.Message.Error.Should().BeNull(); + (response.Message.Data as string).Should().Contain("spaced default-collection content"); + } + finally + { + if (Directory.Exists(testDir)) Directory.Delete(testDir, true); + } + } + + [Fact] + public async Task GetDataRequest_ContentSlashFormat_NamedCollection_SpacedFilename_Responds() + { + var testDir = Path.Combine(Path.GetTempPath(), "MeshWeaverTest_SlashNamedSpaces_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(testDir); + const string Spaced = "Input Markus Apr 15.txt"; + await File.WriteAllTextAsync(Path.Combine(testDir, Spaced), "named-collection spaced content", + TestContext.Current.CancellationToken); + + try + { + var host = GetHostWithFileProvider(testDir, defaultCollection: false); + var client = GetClient(); + + // Slash format WITH collection segment + spaces. + var response = await client.AwaitResponse( + new GetDataRequest(new UnifiedReference($"content/TestFiles/{Spaced}")), + o => o.WithTarget(CreateHostAddress()), + TestContext.Current.CancellationToken); + + response.Message.Error.Should().BeNull(); + (response.Message.Data as string).Should().Contain("named-collection spaced content"); + } + finally + { + if (Directory.Exists(testDir)) Directory.Delete(testDir, true); + } + } + + [Fact] + public async Task GetDataRequest_ContentSlashFormat_MissingDefaultCollection_ReturnsErrorNotTimeout() + { + // The prod hub for /PartnerRe/AIConsulting may not have AddContentCollections() registered + // under the default "content" name. The handler must return a clear error response — not + // hang and force AwaitResponse to time out. + GetHost(); // baseline host with NO file content provider configured + var client = GetClient(); + + // Bound the wait so a hang fails fast (as opposed to the test running forever). + using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + cts.CancelAfter(TimeSpan.FromSeconds(5)); + + var act = async () => await client.AwaitResponse( + new GetDataRequest(new UnifiedReference("content/Some File.docx")), + o => o.WithTarget(CreateHostAddress()), + cts.Token); + + var response = await act(); + response.Message.Error.Should().NotBeNullOrEmpty( + "the handler must respond with an error rather than letting AwaitResponse time out"); + } + + private IMessageHub GetHostWithFileProvider(string testDir, bool defaultCollection) + { + // For default-collection scenarios we register the collection as "content"; for named-collection + // scenarios we register it as "TestFiles". The default-content scenario also wires the legacy + // ContentProvider hook so `data:` lookups can still resolve files (matches existing tests). + if (hostWithFileProvider != null && currentTestDir == testDir) return hostWithFileProvider; + currentTestDir = testDir; + var collectionName = defaultCollection ? "content" : "TestFiles"; + hostWithFileProvider = Mesh.GetHostedHub(CreateHostAddress(), config => config + .AddFileSystemContentCollection(collectionName, _ => testDir) + .AddData(data => data + .AddSource(source => source + .WithType(t => t + .WithInitialData(_ => Task.FromResult(new List + { + new() { Id = TestPricingId, Name = "Test Pricing", Status = "Active" } + }.AsEnumerable())))) + .WithDefaultDataReference(workspace => + workspace.GetObservable().Select(p => p.OrderBy(x => x.Id).FirstOrDefault())) + .WithContentProvider(collectionName)) + .AddLayout(layout => layout.WithView("TestArea", TestAreaView))); + return hostWithFileProvider; + } + + #endregion + #region Test Configuration protected override MessageHubConfiguration ConfigureHost(MessageHubConfiguration configuration) diff --git a/test/MeshWeaver.Threading.Test/ToolStatusFormatterTest.cs b/test/MeshWeaver.Threading.Test/ToolStatusFormatterTest.cs index 1ff6b1055..2e7c7c603 100644 --- a/test/MeshWeaver.Threading.Test/ToolStatusFormatterTest.cs +++ b/test/MeshWeaver.Threading.Test/ToolStatusFormatterTest.cs @@ -135,8 +135,8 @@ public void ConvertReferences_InlinePathBecomesLink() method.Should().NotBeNull(); var result = (string)method!.Invoke(null, ["Check out @User/rbuergi/agents-comparison for details"])!; - // Should produce markdown link with @prefix in href for LinkUrlCleanupExtension to resolve - result.Should().Contain("[`@User/rbuergi/agents-comparison`](@User/rbuergi/agents-comparison)"); + // Should produce markdown link with absolute href (leading /) + result.Should().Contain("[`@User/rbuergi/agents-comparison`](/User/rbuergi/agents-comparison)"); } [Fact] From 13b890e07c8226fdcabdbc53eae01b689b430758 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Thu, 16 Apr 2026 21:39:05 +0200 Subject: [PATCH 03/50] fix: strip embedded quotes from agent-emitted paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents (and the autocomplete UI) wrap spaced filenames in double quotes to "protect" them, producing paths like @/PartnerRe/AIConsulting/content/"Diskussion Thomas Final Report.docx". ResolvePath previously only stripped a wrapping quote pair; embedded quotes around a single segment survived and the file lookup went after a literally-quoted name — returning "not found" or hanging on the prod hub waiting for a routing response that never came. Fix: drop every double quote from the path. Mesh paths don't contain quotes legitimately, so this is safe regardless of position. Two new repro tests (Get_AbsolutePath_QuotedSpacedFilename and Get_AbsolutePath_QuotesAroundContentSegment) replicate the exact prod shape — both fail on main, both pass with this change. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/MeshWeaver.AI/MeshOperations.cs | 18 +++-- .../MeshPluginContentAccessTest.cs | 68 +++++++++++++++++++ 2 files changed, 82 insertions(+), 4 deletions(-) diff --git a/src/MeshWeaver.AI/MeshOperations.cs b/src/MeshWeaver.AI/MeshOperations.cs index 91df54a00..66e14004c 100644 --- a/src/MeshWeaver.AI/MeshOperations.cs +++ b/src/MeshWeaver.AI/MeshOperations.cs @@ -37,13 +37,23 @@ public MeshOperations(IMessageHub hub) } /// - /// Resolves @ prefix and quotes from path. Example: @graph/org1 -> graph/org1, "@content/My File.md" -> content/My File.md + /// Resolves @ prefix and quotes from path. Examples: + /// @graph/org1 → graph/org1 + /// "@content/My File.md" → content/My File.md (surrounding quotes) + /// @/Org/content/"My File.docx" → /Org/content/My File.docx (embedded around filename) + /// @/Org/"content/My File.docx" → /Org/content/My File.docx (embedded around segment) + /// Models often emit segment-quoted paths to "protect" spaced filenames; those quotes + /// are not legal mesh-path characters, so we strip every double quote regardless of + /// position. Without this, the file lookup goes after a literally-quoted name. /// public static string ResolvePath(string path) { - // Strip surrounding quotes (autocomplete wraps spaced paths in quotes) - if (path.Length >= 2 && path[0] == '"' && path[^1] == '"') - path = path[1..^1]; + if (string.IsNullOrEmpty(path)) + return path; + + // Drop every double quote — mesh paths never contain them legitimately. + if (path.Contains('"')) + path = path.Replace("\"", string.Empty); if (path.StartsWith("@")) return path[1..]; diff --git a/test/MeshWeaver.AI.Test/MeshPluginContentAccessTest.cs b/test/MeshWeaver.AI.Test/MeshPluginContentAccessTest.cs index 5ab864b44..7d637bcc2 100644 --- a/test/MeshWeaver.AI.Test/MeshPluginContentAccessTest.cs +++ b/test/MeshWeaver.AI.Test/MeshPluginContentAccessTest.cs @@ -200,6 +200,74 @@ await NodeFactory.CreateNodeAsync( response.Message.Error.Should().BeNull(); } + /// + /// Repro for the prod symptom: agent emits an absolute path where the SPACED filename + /// portion is wrapped in double quotes (e.g. content/"My File.docx"). The full path + /// looks like @/Org/Sub/content/"Diskussion Thomas Final Report.docx". + /// MeshOperations.ResolvePath only strips SURROUNDING quotes, so embedded quotes + /// survive, the address part is parsed correctly but the file lookup goes after a + /// quoted-literal filename that doesn't exist. + /// + [Fact] + public async Task Get_AbsolutePath_QuotedSpacedFilename_ReturnsFileContent() + { + var nodePath = $"ContentTest_{_testId}_Q"; + var contentDir = Path.Combine(ContentBasePath, nodePath); + Directory.CreateDirectory(contentDir); + const string SpacedFile = "Diskussion Thomas Final Report.txt"; + await File.WriteAllTextAsync(Path.Combine(contentDir, SpacedFile), + "the actual report content", TestContext.Current.CancellationToken); + + await NodeFactory.CreateNodeAsync( + new MeshNode(nodePath) { Name = "Quoted Test", NodeType = "Markdown" }, + TestContext.Current.CancellationToken); + + var plugin = new MeshPlugin(Mesh, new MockAgentChat()); + + // Exact prod-shape path: absolute (@/) + spaced filename wrapped in quotes. + var path = $"@/{nodePath}/content/\"{SpacedFile}\""; + var result = await plugin.Get(path); + + Output.WriteLine($"Path: {path}"); + Output.WriteLine($"Result: {result}"); + + result.Should().NotStartWith("Error"); + result.Should().NotStartWith("Not found"); + result.Should().Contain("the actual report content"); + } + + /// + /// Same as above but quotes wrap the WHOLE relative portion after content/ — + /// i.e. content/"name" vs "content/name" — both shapes appear in agent output. + /// + [Fact] + public async Task Get_AbsolutePath_QuotesAroundContentSegment_ReturnsFileContent() + { + var nodePath = $"ContentTest_{_testId}_Q2"; + var contentDir = Path.Combine(ContentBasePath, nodePath); + Directory.CreateDirectory(contentDir); + const string SpacedFile = "Input Markus Apr 15.txt"; + await File.WriteAllTextAsync(Path.Combine(contentDir, SpacedFile), + "markus input notes", TestContext.Current.CancellationToken); + + await NodeFactory.CreateNodeAsync( + new MeshNode(nodePath) { Name = "Markus Test", NodeType = "Markdown" }, + TestContext.Current.CancellationToken); + + var plugin = new MeshPlugin(Mesh, new MockAgentChat()); + + // Quote wraps "content/" together rather than just . + var path = $"@/{nodePath}/\"content/{SpacedFile}\""; + var result = await plugin.Get(path); + + Output.WriteLine($"Path: {path}"); + Output.WriteLine($"Result: {result}"); + + result.Should().NotStartWith("Error"); + result.Should().NotStartWith("Not found"); + result.Should().Contain("markus input notes"); + } + private class MockAgentChat : IAgentChat { public AgentContext? Context { get; set; } From 6c1a217c2c7737ab0c29d960849eb43e0ca78a68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Thu, 16 Apr 2026 21:45:39 +0200 Subject: [PATCH 04/50] test: add PathUtils satellite partition tests and content access tolerance tests Adds 25 unit tests for relative link resolution in satellite contexts (_Thread, _Comment, _Tracking) plus end-to-end LinkUrlCleanupExtension pipeline tests. Also adds tolerance matrix tests for spaced-filename content access via MeshPlugin. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../MeshPluginContentAccessTest.cs | 137 +++++++++++++ .../MeshWeaver.Markdown.Test/PathUtilsTest.cs | 185 ++++++++++++++++++ 2 files changed, 322 insertions(+) create mode 100644 test/MeshWeaver.Markdown.Test/PathUtilsTest.cs diff --git a/test/MeshWeaver.AI.Test/MeshPluginContentAccessTest.cs b/test/MeshWeaver.AI.Test/MeshPluginContentAccessTest.cs index 7d637bcc2..6fd9d514c 100644 --- a/test/MeshWeaver.AI.Test/MeshPluginContentAccessTest.cs +++ b/test/MeshWeaver.AI.Test/MeshPluginContentAccessTest.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Threading; using System.Threading.Tasks; using FluentAssertions; @@ -10,6 +11,7 @@ using MeshWeaver.AI.Persistence; using MeshWeaver.ContentCollections; using MeshWeaver.Data; +using MeshWeaver.Data.Completion; using MeshWeaver.Graph; using MeshWeaver.Graph.Configuration; using MeshWeaver.Hosting.Monolith; @@ -268,6 +270,141 @@ await NodeFactory.CreateNodeAsync( result.Should().Contain("markus input notes"); } + /// + /// Tolerance matrix — every shape we've actually observed an agent or autocomplete + /// emit for the SAME spaced file. They must all return the file content. Keep this + /// table extended with new shapes the agents come up with in the wild. + /// {NODE} is replaced with the per-test node path so the parameterization stays + /// readable; {FILE} is the spaced filename. Both sit under a per-test temp dir. + /// + [Theory] + [InlineData("@/{NODE}/content/{FILE}", "no quotes, absolute")] + [InlineData("\"@/{NODE}/content/{FILE}\"", "wrapping quotes around the whole reference")] + [InlineData("@/{NODE}/content/\"{FILE}\"", "quotes around filename only")] + [InlineData("@/{NODE}/\"content/{FILE}\"", "quotes around content/filename together")] + [InlineData("@\"/{NODE}/content/{FILE}\"", "quote right after @")] + [InlineData("@\"{NODE}/content/{FILE}\"", "quote after @, no leading slash")] + [InlineData("@/{NODE}/content/{FILE} ", "trailing whitespace")] + [InlineData(" @/{NODE}/content/{FILE}", "leading whitespace")] + [InlineData("@/{NODE}/content/'{FILE}'", "single quotes around filename")] + public async Task Get_AgentEmittedShapes_AllReturnFileContent(string template, string description) + { + var nodePath = $"ContentTest_{_testId}_{Math.Abs(template.GetHashCode()):X}"; + var contentDir = Path.Combine(ContentBasePath, nodePath); + Directory.CreateDirectory(contentDir); + const string SpacedFile = "Diskussion Thomas Final Report.txt"; + const string Body = "agent-shape tolerance body"; + await File.WriteAllTextAsync(Path.Combine(contentDir, SpacedFile), Body, + TestContext.Current.CancellationToken); + + await NodeFactory.CreateNodeAsync( + new MeshNode(nodePath) { Name = "Tolerance", NodeType = "Markdown" }, + TestContext.Current.CancellationToken); + + var path = template.Replace("{NODE}", nodePath).Replace("{FILE}", SpacedFile); + Output.WriteLine($"[{description}] path: {path}"); + + var plugin = new MeshPlugin(Mesh, new MockAgentChat()); + var result = await plugin.Get(path); + Output.WriteLine($" result: {result}"); + + result.Should().Contain(Body, $"shape '{description}' should resolve to the file"); + } + + /// + /// Yet another shape an agent (or model) sometimes emits: @"content/some path" — the + /// quote sits right after @ and wraps everything that follows. Same fix applies + /// (strip every quote), this just nails the regression. + /// + [Fact] + public async Task Get_AbsolutePath_QuoteAfterAtSign_ReturnsFileContent() + { + var nodePath = $"ContentTest_{_testId}_Q3"; + var contentDir = Path.Combine(ContentBasePath, nodePath); + Directory.CreateDirectory(contentDir); + const string SpacedFile = "some fucking thing.txt"; + await File.WriteAllTextAsync(Path.Combine(contentDir, SpacedFile), + "the contents", TestContext.Current.CancellationToken); + + await NodeFactory.CreateNodeAsync( + new MeshNode(nodePath) { Name = "QAfterAt", NodeType = "Markdown" }, + TestContext.Current.CancellationToken); + + var plugin = new MeshPlugin(Mesh, new MockAgentChat()); + var path = $"@\"/{nodePath}/content/{SpacedFile}\""; + var result = await plugin.Get(path); + + Output.WriteLine($"Path: {path}"); + Output.WriteLine($"Result: {result}"); + + result.Should().NotStartWith("Error"); + result.Should().NotStartWith("Not found"); + result.Should().Contain("the contents"); + } + + /// + /// End-to-end repro: typing lowercase "markus" against the node hub's autocomplete + /// must return the spaced file "Input Markus Apr 15.txt", and the InsertText that + /// the autocomplete suggests must round-trip through MeshPlugin.Get and return the + /// file content. This exercises the full chat pipeline: case-insensitive fuzzy + /// match → quoted-reference InsertText → ResolvePath → file lookup. + /// + [Fact] + public async Task Autocomplete_RoundTrip_LowercaseQuery_QuotedInsertText_GetsContent() + { + var nodePath = $"ContentTest_{_testId}_RT"; + var contentDir = Path.Combine(ContentBasePath, nodePath); + Directory.CreateDirectory(contentDir); + const string SpacedFile = "Input Markus Apr 15.txt"; + const string FileContent = "the input markus body"; + await File.WriteAllTextAsync(Path.Combine(contentDir, SpacedFile), FileContent, + TestContext.Current.CancellationToken); + + await NodeFactory.CreateNodeAsync( + new MeshNode(nodePath) { Name = "Round Trip", NodeType = "Markdown" }, + TestContext.Current.CancellationToken); + + var client = GetClient(); + + // Step 1 — autocomplete, lowercase, treats node as the chat context. + using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + cts.CancelAfter(TimeSpan.FromSeconds(10)); + var acResponse = await client.AwaitResponse( + new AutocompleteRequest("@markus", nodePath), + o => o.WithTarget(new Address(nodePath)), + cts.Token); + + Output.WriteLine($"Autocomplete items: {acResponse.Message.Items.Count}"); + foreach (var item in acResponse.Message.Items) + Output.WriteLine($" - Label={item.Label} | InsertText={item.InsertText}"); + + var match = acResponse.Message.Items.FirstOrDefault(i => + i.Label != null && i.Label.Contains(SpacedFile, StringComparison.Ordinal)); + match.Should().NotBeNull("lowercase 'markus' should fuzzy-match 'Input Markus Apr 15.txt'"); + match!.InsertText.Should().NotBeNullOrEmpty(); + + // The InsertText for a spaced filename is wrapped in quotes by FormatInsertText. + match.InsertText.Should().Contain("\"", "spaced filenames are quoted in the InsertText"); + + // Step 2 — feed the InsertText (verbatim, including quotes) into MeshPlugin.Get, + // pretending the agent received it via attachment. Strip trailing whitespace the + // way the chat input would when treating it as an @reference. + var insertedRef = match.InsertText.TrimEnd(); + var plugin = new MeshPlugin(Mesh, new MockAgentChat()); + var contextResolved = MeshOperations.ResolveContextPath( + new MockAgentChat { Context = new AgentContext { Address = new Address(nodePath), Context = nodePath } }, + insertedRef); + Output.WriteLine($"Inserted ref: {insertedRef}"); + Output.WriteLine($"Context-resolved: {contextResolved}"); + + var result = await plugin.Get(contextResolved); + Output.WriteLine($"Get result: {result}"); + + result.Should().NotStartWith("Error"); + result.Should().NotStartWith("Not found"); + result.Should().Contain(FileContent); + } + private class MockAgentChat : IAgentChat { public AgentContext? Context { get; set; } diff --git a/test/MeshWeaver.Markdown.Test/PathUtilsTest.cs b/test/MeshWeaver.Markdown.Test/PathUtilsTest.cs new file mode 100644 index 000000000..ffbe6863c --- /dev/null +++ b/test/MeshWeaver.Markdown.Test/PathUtilsTest.cs @@ -0,0 +1,185 @@ +using FluentAssertions; +using Markdig; +using Xunit; + +namespace MeshWeaver.Markdown.Test; + +/// +/// Tests for : relative path resolution and satellite partition stripping. +/// Satellite partitions (segments starting with '_', e.g., _Thread, _Comment) are stripped +/// so that links in satellite content resolve relative to the main entity. +/// +public class PathUtilsTest +{ + // ---------- Satellite partition stripping (tested via ResolveRelativePath) ---------- + + [Theory] + [InlineData("X", "A/B/_Thread/slug/msg", "A/B/X")] + [InlineData("X", "A/B/_Comment/abc123", "A/B/X")] + [InlineData("X", "A/B/_Tracking/change1", "A/B/X")] + [InlineData("X", "A/_Thread/slug", "A/X")] + [InlineData("X", "_Thread/slug/msg", "X")] + [InlineData("X", "A/B/C", "A/B/C/X")] // no satellite — unchanged + public void ResolveRelativePath_StripsSatellitePartitions(string path, string basePath, string expected) + => PathUtils.ResolveRelativePath(path, basePath).Should().Be(expected); + + // ---------- ResolveRelativePath with satellite partitions ---------- + + [Fact] + public void ResolveRelativePath_ThreadContext_ResolvesRelativeToMainEntity() + { + // A relative link "FinalReport" in a thread message should resolve + // to the main entity's namespace, not the thread path. + var result = PathUtils.ResolveRelativePath( + "FinalReport", + "PartnerRe/AIConsulting/_Thread/we-will-now-work-on-97af/d75effc1"); + + result.Should().Be("PartnerRe/AIConsulting/FinalReport"); + } + + [Fact] + public void ResolveRelativePath_CommentContext_ResolvesRelativeToMainEntity() + { + var result = PathUtils.ResolveRelativePath( + "Appendix", + "Doc/Architecture/_Comment/abc123"); + + result.Should().Be("Doc/Architecture/Appendix"); + } + + [Fact] + public void ResolveRelativePath_ParentTraversal_InThreadContext() + { + // "../OtherProject" from PartnerRe/AIConsulting/_Thread/... should go up from AIConsulting + var result = PathUtils.ResolveRelativePath( + "../OtherProject", + "PartnerRe/AIConsulting/_Thread/slug/msgId"); + + result.Should().Be("PartnerRe/OtherProject"); + } + + [Fact] + public void ResolveRelativePath_DotSlash_InThreadContext() + { + var result = PathUtils.ResolveRelativePath( + "./FinalReport", + "PartnerRe/AIConsulting/_Thread/slug/msgId"); + + result.Should().Be("PartnerRe/AIConsulting/FinalReport"); + } + + // ---------- ResolveRelativePath (non-satellite, existing behavior) ---------- + + [Fact] + public void ResolveRelativePath_NormalContext_ResolvesRelatively() + { + var result = PathUtils.ResolveRelativePath( + "DataModeling", + "Doc/Architecture"); + + result.Should().Be("Doc/Architecture/DataModeling"); + } + + [Fact] + public void ResolveRelativePath_ParentTraversal_NormalContext() + { + var result = PathUtils.ResolveRelativePath( + "../DataMesh/NodeTypes", + "Doc/Architecture/BusinessRules"); + + result.Should().Be("Doc/Architecture/DataMesh/NodeTypes"); + } + + [Theory] + [InlineData("/absolute/path")] + [InlineData("https://example.com")] + [InlineData("#anchor")] + [InlineData("mailto:test@example.com")] + public void ResolveRelativePath_SkipsNonRelativePaths(string path) + => PathUtils.ResolveRelativePath(path, "Doc/Architecture").Should().Be(path); + + [Theory] + [InlineData(null)] + [InlineData("")] + public void ResolveRelativePath_NullOrEmptyBase_ReturnsPathUnchanged(string? basePath) + => PathUtils.ResolveRelativePath("FinalReport", basePath).Should().Be("FinalReport"); + + [Theory] + [InlineData(null)] + [InlineData("")] + public void ResolveRelativePath_NullOrEmptyPath_ReturnsAsIs(string? path) + => PathUtils.ResolveRelativePath(path!, "Doc/Architecture").Should().Be(path!); + + // ---------- End-to-end: LinkUrlCleanupExtension in thread context ---------- + + [Fact] + public void LinkCleanup_ThreadContext_RelativeLinkResolvesToMainEntity() + { + // Simulate rendering markdown in a thread message bubble. + // The currentNodePath is the thread message's full path. + var threadMsgPath = "PartnerRe/AIConsulting/_Thread/thread-slug/msgId"; + var pipeline = new MarkdownPipelineBuilder() + .Use(new LinkUrlCleanupExtension(threadMsgPath)) + .Build(); + + var html = Markdig.Markdown.ToHtml("[Final Report](FinalReport)", pipeline); + + html.Should().Contain("href=\"/PartnerRe/AIConsulting/FinalReport\"", + "relative link in thread should resolve to main entity path"); + } + + [Fact] + public void LinkCleanup_ThreadContext_AbsoluteLinkUnchanged() + { + var threadMsgPath = "PartnerRe/AIConsulting/_Thread/slug/msgId"; + var pipeline = new MarkdownPipelineBuilder() + .Use(new LinkUrlCleanupExtension(threadMsgPath)) + .Build(); + + var html = Markdig.Markdown.ToHtml("[Report](/PartnerRe/AIConsulting/FinalReport)", pipeline); + + html.Should().Contain("href=\"/PartnerRe/AIConsulting/FinalReport\"", + "absolute links should remain unchanged"); + } + + [Fact] + public void LinkCleanup_ThreadContext_AtPrefixedLinkResolvesToMainEntity() + { + var threadMsgPath = "PartnerRe/AIConsulting/_Thread/slug/msgId"; + var pipeline = new MarkdownPipelineBuilder() + .Use(new LinkUrlCleanupExtension(threadMsgPath)) + .Build(); + + // @SiblingDoc — after stripping '@', should resolve relative to main entity + var html = Markdig.Markdown.ToHtml("[doc](@SiblingDoc)", pipeline); + + html.Should().Contain("href=\"/PartnerRe/AIConsulting/SiblingDoc\"", + "@-prefixed relative link in thread should resolve to main entity path"); + } + + [Fact] + public void LinkCleanup_ThreadContext_ExternalLinkUnchanged() + { + var threadMsgPath = "Org/Project/_Thread/slug/msgId"; + var pipeline = new MarkdownPipelineBuilder() + .Use(new LinkUrlCleanupExtension(threadMsgPath)) + .Build(); + + var html = Markdig.Markdown.ToHtml("[Google](https://google.com)", pipeline); + + html.Should().Contain("href=\"https://google.com\""); + } + + [Fact] + public void LinkCleanup_NonThreadContext_RelativeLinkResolvesNormally() + { + // Normal (non-satellite) context should still work as before. + var pipeline = new MarkdownPipelineBuilder() + .Use(new LinkUrlCleanupExtension("Doc/Architecture")) + .Build(); + + var html = Markdig.Markdown.ToHtml("[Data](DataModeling)", pipeline); + + html.Should().Contain("href=\"/Doc/Architecture/DataModeling\""); + } +} From 6d5f13b013d8dd62bac52fe6bed1bc1b0aaf6f6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Thu, 16 Apr 2026 21:47:29 +0200 Subject: [PATCH 05/50] fix: also strip single quotes and trim whitespace in agent-emitted paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the quote-stripping ResolvePath fix to handle: - Single quotes wrapping a segment ('My File.docx') - Surrounding/leading/trailing whitespace Adds a Theory tolerance matrix (Get_AgentEmittedShapes_AllReturnFileContent) covering 9 path shapes observed from agents and autocomplete: no quotes / wrapping double quotes / inner double quotes around filename / inner double quotes around content/file segment / quote after @ / quote after @ no leading slash / trailing whitespace / leading whitespace / single quotes around filename Also adds an autocomplete round-trip test: type lowercase "markus" → AutocompleteRequest to node hub → ContentAutocompleteProvider returns quoted InsertText for spaced filename → feed that InsertText back through MeshPlugin.Get → returns file content All 18 MeshPluginContentAccessTest cases now pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/MeshWeaver.AI/MeshOperations.cs | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/src/MeshWeaver.AI/MeshOperations.cs b/src/MeshWeaver.AI/MeshOperations.cs index 66e14004c..3bb37e489 100644 --- a/src/MeshWeaver.AI/MeshOperations.cs +++ b/src/MeshWeaver.AI/MeshOperations.cs @@ -37,23 +37,28 @@ public MeshOperations(IMessageHub hub) } /// - /// Resolves @ prefix and quotes from path. Examples: - /// @graph/org1 → graph/org1 - /// "@content/My File.md" → content/My File.md (surrounding quotes) - /// @/Org/content/"My File.docx" → /Org/content/My File.docx (embedded around filename) - /// @/Org/"content/My File.docx" → /Org/content/My File.docx (embedded around segment) - /// Models often emit segment-quoted paths to "protect" spaced filenames; those quotes - /// are not legal mesh-path characters, so we strip every double quote regardless of - /// position. Without this, the file lookup goes after a literally-quoted name. + /// Resolves @ prefix and normalises agent-emitted formatting noise. + /// Models / autocomplete frequently wrap spaced filenames in quotes ("foo bar.docx", + /// 'foo bar.docx'), put quotes around different segments, or include surrounding + /// whitespace. None of those characters are legal mesh-path content, so we strip + /// them regardless of position. Examples: + /// @graph/org1 → graph/org1 + /// "@content/My File.md" → content/My File.md + /// @/Org/content/"My File.docx" → /Org/content/My File.docx + /// @/Org/"content/My File.docx" → /Org/content/My File.docx + /// @"/Org/content/My File.docx" → /Org/content/My File.docx + /// @/Org/content/'My File.docx' → /Org/content/My File.docx + /// " @/Org/content/My File.docx " → /Org/content/My File.docx /// public static string ResolvePath(string path) { if (string.IsNullOrEmpty(path)) return path; - // Drop every double quote — mesh paths never contain them legitimately. - if (path.Contains('"')) - path = path.Replace("\"", string.Empty); + // Strip surrounding/inner whitespace and quote characters in one pass. + path = path.Trim(); + if (path.IndexOfAny(['"', '\'']) >= 0) + path = path.Replace("\"", string.Empty).Replace("'", string.Empty); if (path.StartsWith("@")) return path[1..]; From dbcf85a1f3207c5a098d8e806a2dbbc7e8c6d261 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Thu, 16 Apr 2026 22:12:54 +0200 Subject: [PATCH 06/50] fix: dedup duplicate chat submissions within a 500ms window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ThreadChatView.SubmitMessageCore force-releases the submission handler immediately after Submit so the input stays enabled for queueing. Without a guard, a double-click or Enter+Send race re-entered TryBeginSubmit with the same text and was wrongly accepted — two user cells were created and the server watcher dispatched two execution rounds ("Generating response" appearing twice in the UI). Fix: text-based debounce in TryBeginSubmit. Same text submitted within 500ms of the previous accepted submission is rejected; different text goes through (queueing UX preserved). Two new tests: - DoubleClick_SameTextWithinDebounce_RejectsSecondSubmission — fails on main - ForceRelease_ThenDifferentText_SecondSubmissionAccepted — verifies queueing still works All 17 ChatSubmissionHandler tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Components/ChatSubmissionHandler.cs | 19 ++++++++- .../ChatSubmissionHandlerTest.cs | 42 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/MeshWeaver.Blazor/Components/ChatSubmissionHandler.cs b/src/MeshWeaver.Blazor/Components/ChatSubmissionHandler.cs index 82c6c1608..e4a885394 100644 --- a/src/MeshWeaver.Blazor/Components/ChatSubmissionHandler.cs +++ b/src/MeshWeaver.Blazor/Components/ChatSubmissionHandler.cs @@ -16,9 +16,12 @@ public enum SubmissionState } private readonly TimeSpan _timeout; + private readonly TimeSpan _dedupWindow; + private readonly Func _now; private readonly Func _scheduleTimeout; private IDisposable? _timeoutDisposable; private bool _disposed; + private DateTime? _lastAcceptedAt; /// /// Current state of the submission handler. @@ -47,10 +50,14 @@ public enum SubmissionState /// Optional scheduler for testing. If null, uses Task.Delay. public ChatSubmissionHandler( TimeSpan? timeout = null, - Func? scheduleTimeout = null) + Func? scheduleTimeout = null, + TimeSpan? dedupWindow = null, + Func? now = null) { _timeout = timeout ?? TimeSpan.FromSeconds(30); _scheduleTimeout = scheduleTimeout ?? DefaultScheduleTimeout; + _dedupWindow = dedupWindow ?? TimeSpan.FromMilliseconds(500); + _now = now ?? (() => DateTime.UtcNow); } /// @@ -70,8 +77,18 @@ public bool TryBeginSubmit(string? text) if (State != SubmissionState.Idle) return false; + // Debounce: ThreadChatView force-releases immediately after Submit so the input stays + // enabled for queueing. Without this guard, a double-click / Enter+Send race produces + // two user cells, and the server watcher then dispatches two execution rounds. + // Dedup is text-based: a real second message (different text) goes through. + if (LastSubmittedText == text + && _lastAcceptedAt.HasValue + && (_now() - _lastAcceptedAt.Value) < _dedupWindow) + return false; + State = SubmissionState.Submitting; LastSubmittedText = text; + _lastAcceptedAt = _now(); SubmissionCount++; return true; diff --git a/test/MeshWeaver.Layout.Test/ChatSubmissionHandlerTest.cs b/test/MeshWeaver.Layout.Test/ChatSubmissionHandlerTest.cs index c788c2708..3d8dfe416 100644 --- a/test/MeshWeaver.Layout.Test/ChatSubmissionHandlerTest.cs +++ b/test/MeshWeaver.Layout.Test/ChatSubmissionHandlerTest.cs @@ -260,6 +260,48 @@ public void StateTracking_IsAccurate() Assert.Equal(2, handler.SubmissionCount); } + /// + /// Reproduces the prod "twice generating response" symptom: ThreadChatView.SubmitMessageCore + /// calls ForceRelease immediately after Submit so the input stays enabled for queueing. + /// A double-click (or Enter+button race) then re-enters TryBeginSubmit with the SAME text + /// — the state is already Idle, so the second call wrongly succeeds, the second user cell + /// is created, and the server watcher dispatches a second round. + /// + [Fact] + public void DoubleClick_SameTextWithinDebounce_RejectsSecondSubmission() + { + var (handler, _) = CreateWithControllableTimeout(); + + // 1st click: accepted + Assert.True(handler.TryBeginSubmit("Hello")); + Assert.Equal(1, handler.SubmissionCount); + + // SubmitMessageCore force-releases immediately so the user can keep typing. + handler.ForceRelease(); + + // 2nd click of the same text within the dedup window — must be rejected. + Assert.False(handler.TryBeginSubmit("Hello"), + "duplicate Send within the debounce window should be ignored"); + Assert.Equal(1, handler.SubmissionCount); + } + + /// + /// Genuinely different text after force-release must still go through — that's the + /// queueing UX (user types another message while the previous is processing). + /// + [Fact] + public void ForceRelease_ThenDifferentText_SecondSubmissionAccepted() + { + var (handler, _) = CreateWithControllableTimeout(); + + Assert.True(handler.TryBeginSubmit("Hello")); + handler.ForceRelease(); + + Assert.True(handler.TryBeginSubmit("How are you"), + "different text after force-release is a real second submission, must go through"); + Assert.Equal(2, handler.SubmissionCount); + } + [Fact] public void ConcurrentSubmitAttempts_OnlyOneSucceeds() { From d51db7cef414a3d1c8b9edc4a2d07a8ae9436af0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Sat, 18 Apr 2026 10:56:06 +0200 Subject: [PATCH 07/50] fix: hold watcher guard until response cell is created MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server watcher's reentrancy guard was released the moment the subscription handler returned — but DispatchRound only POSTS the CreateNodeRequest and registers a callback; the IsExecuting=true commit lands later, inside the callback. The window between handler exit and that commit was wide enough for the user-cell creation emit to re-fire the watcher, find IsExecuting still false + the same unprocessed user message, and dispatch a SECOND round — producing a duplicate response cell ("Generating response" appearing twice in the UI). Fix: defer Interlocked.Exchange(ref dispatching, 0) until DispatchRound's RegisterCallback runs (success or failure), via an onCompleted callback hook. Subsequent watcher emits during the in-flight dispatch see dispatching=1 and skip; once the response cell exists and IsExecuting is true, the guard drops back to idle but the IsExecuting check now blocks new dispatches. Test: Submit_SingleSubmit_ProducesExactlyOneResponseCell asserts ONE submit produces exactly one user + one response cell on the thread and exactly one assistant cell node. Existing 8/8 tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/MeshWeaver.AI/ThreadSubmission.cs | 25 +++++++-- .../ThreadSubmissionIntegrationTest.cs | 56 +++++++++++++++++++ 2 files changed, 75 insertions(+), 6 deletions(-) diff --git a/src/MeshWeaver.AI/ThreadSubmission.cs b/src/MeshWeaver.AI/ThreadSubmission.cs index aa367e59a..f74bdfc5b 100644 --- a/src/MeshWeaver.AI/ThreadSubmission.cs +++ b/src/MeshWeaver.AI/ThreadSubmission.cs @@ -594,6 +594,7 @@ public static IDisposable InstallServerWatcher(IMessageHub threadHub) if (Interlocked.CompareExchange(ref dispatching, 1, 0) != 0) return; + var releaseGuard = true; try { var threadNode = nodes.FirstOrDefault(n => n.Path == threadPath); @@ -602,15 +603,19 @@ public static IDisposable InstallServerWatcher(IMessageHub threadHub) // Queue-don't-cancel: if the thread is executing, do nothing. The queued // user messages stay in UserMessageIds; as soon as IsExecuting flips to // false (current round completed naturally), we dispatch the next round. - // This matches Claude Code / Anthropic's recommended pattern — the Messages - // API doesn't support mid-stream injection and cancelling during a tool_use - // produces orphaned blocks that need synthetic tool_result recovery. if (thread.IsExecuting) return; var dispatch = ThreadSubmission.PlanNextRound(thread); if (dispatch is null) return; - DispatchRound(threadHub, threadNode, dispatch, logger); + // Hold the reentrancy guard until the response cell is created and + // IsExecuting=true is committed. Otherwise the user-cell creation emit (or + // a back-to-back AppendUserMessageRequest) re-fires the watcher before the + // commit lands, sees IsExecuting=false + the same unprocessed messages, and + // dispatches a SECOND round → duplicate response cell. + releaseGuard = false; + DispatchRound(threadHub, threadNode, dispatch, logger, + onCompleted: () => Interlocked.Exchange(ref dispatching, 0)); } catch (Exception ex) { @@ -618,7 +623,7 @@ public static IDisposable InstallServerWatcher(IMessageHub threadHub) } finally { - Interlocked.Exchange(ref dispatching, 0); + if (releaseGuard) Interlocked.Exchange(ref dispatching, 0); } }); @@ -634,7 +639,8 @@ private static void DispatchRound( IMessageHub hub, MeshNode threadNode, RoundDispatch dispatch, - ILogger? logger) + ILogger? logger, + Action? onCompleted = null) { var threadPath = hub.Address.Path; var responseMsgId = dispatch.ResponseMessageId; @@ -677,6 +683,7 @@ private static void DispatchRound( { logger?.LogWarning("[ThreadSubmission] Post of CreateNodeRequest returned null for response cell {ResponseMsgId} on {ThreadPath}", responseMsgId, threadPath); + onCompleted?.Invoke(); return; } @@ -687,6 +694,7 @@ private static void DispatchRound( var err = (response as IMessageDelivery)?.Message.Error ?? "unknown"; logger?.LogWarning("[ThreadSubmission] Response cell creation failed for {ResponseMsgId} on {ThreadPath}: {Error}", responseMsgId, threadPath, err); + onCompleted?.Invoke(); return response; } @@ -725,6 +733,11 @@ private static void DispatchRound( new UpdateThreadMessageContent { Text = "Allocating agent..." }, o => o.WithTarget(new Address(responsePath))); + // The watcher's reentrancy guard is held by the caller until this point — release + // it now that IsExecuting=true is committed. Subsequent watcher emits will see the + // executing flag and skip until this round completes. + onCompleted?.Invoke(); + // Step 3: post to _Exec hosted hub — actual agent streaming runs there. var executionHub = hub.GetHostedHub( new Address($"{hub.Address}/_Exec"), diff --git a/test/MeshWeaver.AI.Test/ThreadSubmissionIntegrationTest.cs b/test/MeshWeaver.AI.Test/ThreadSubmissionIntegrationTest.cs index 961de3d9b..8c4edd1bd 100644 --- a/test/MeshWeaver.AI.Test/ThreadSubmissionIntegrationTest.cs +++ b/test/MeshWeaver.AI.Test/ThreadSubmissionIntegrationTest.cs @@ -394,6 +394,62 @@ await WaitForThreadAsync( final.Messages.Should().HaveCount(4); } + // ─── Single submit must produce exactly one response cell ─── + + /// + /// Repro for the prod symptom: ONE submit produces TWO "Generating response" rounds. + /// Hypothesis: the user-cell creation emits a workspace stream event that re-fires the + /// server watcher BEFORE DispatchRound's IsExecuting=true commit lands. The watcher sees + /// IsExecuting=false + the user msg still unprocessed, dispatches a second round, second + /// response cell is created. + /// + [Fact] + public async Task Submit_SingleSubmit_ProducesExactlyOneResponseCell() + { + var ct = TestContext.Current.CancellationToken; + var threadPath = await SeedEmptyThreadAsync(ct); + var client = GetClient(); + + ThreadSubmission.Submit(new SubmitContext + { + Hub = client, + ThreadPath = threadPath, + UserText = "exactly once", + CreatedBy = "rbuergi@systemorph.com", + AuthorName = "Tester" + }); + + // Wait for the round to settle. + var settled = await WaitForThreadAsync( + threadPath, + t => !t.IsExecuting && t.IngestedMessageIds.Count == 1, + timeoutMs: 10_000, ct); + + // Give any racing second-dispatch a chance to land. + await Task.Delay(500, ct); + + var final = await ReadThreadAsync(threadPath, ct); + + // The thread should record exactly: [user, response]. If a second round dispatched, + // Messages would contain a second response cell id. + final.Messages.Should().HaveCount(2, + $"one submit must produce exactly one user + one response cell, got Messages=[{string.Join(",", final.Messages)}]"); + final.IngestedMessageIds.Should().HaveCount(1); + final.UserMessageIds.Should().HaveCount(1); + + // Cross-check at the node level: count actual ThreadMessage assistant cells. + var msgNodes = new List(); + await foreach (var n in MeshQuery.QueryAsync( + $"namespace:{threadPath} nodeType:{ThreadMessageNodeType.NodeType}", null, ct)) + msgNodes.Add(n); + var responseCells = msgNodes + .Where(n => (n.Content as ThreadMessage)?.Role == "assistant") + .ToList(); + responseCells.Should().HaveCount(1, + $"exactly one response cell node should exist, got {responseCells.Count}: " + + string.Join(",", responseCells.Select(c => c.Id))); + } + // ─── Helpers ─── private async Task SeedEmptyThreadAsync(CancellationToken ct) From 77eed5a0d67d015b30ac68c8b08b2b660a6bbf5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Sat, 18 Apr 2026 11:36:21 +0200 Subject: [PATCH 08/50] feat: add OAuth discovery and authorization server for MCP endpoint Re-add MCP SDK's AddMcp() for RFC 9728 OAuth resource metadata discovery, fixing the root cause of the previous revert (ForwardAuthenticate = "Bearer" hardcoded in SDK constructor takes priority over ForwardDefaultSelector). Fix: set ForwardAuthenticate = null, use ForwardDefaultSelector to route Bearer tokens to ApiToken handler and cookie sessions to Cookie scheme. Add minimal OAuth authorization server (/connect/authorize, /connect/token, /.well-known/oauth-authorization-server) implementing authorization code flow with PKCE. Issues mw_ API tokens as access tokens, reusing existing ApiTokenService infrastructure. Enables claude.ai Connectors support. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Authentication/OAuthCodeStore.cs | 121 +++++++++++++ .../Authentication/OAuthConnectController.cs | 159 ++++++++++++++++++ .../Memex.Portal.Shared.csproj | 1 + .../Memex.Portal.Shared/MemexConfiguration.cs | 61 ++++++- 4 files changed, 337 insertions(+), 5 deletions(-) create mode 100644 memex/Memex.Portal.Shared/Authentication/OAuthCodeStore.cs create mode 100644 memex/Memex.Portal.Shared/Authentication/OAuthConnectController.cs diff --git a/memex/Memex.Portal.Shared/Authentication/OAuthCodeStore.cs b/memex/Memex.Portal.Shared/Authentication/OAuthCodeStore.cs new file mode 100644 index 000000000..0e2dadb0f --- /dev/null +++ b/memex/Memex.Portal.Shared/Authentication/OAuthCodeStore.cs @@ -0,0 +1,121 @@ +using System.Collections.Concurrent; +using System.Security.Cryptography; +using System.Text; + +namespace Memex.Portal.Shared.Authentication; + +/// +/// In-memory store for OAuth authorization codes with PKCE support. +/// Codes expire after 5 minutes and are single-use (consumed on exchange). +/// Uses ConcurrentDictionary for thread-safe mutation (per CLAUDE.md exception). +/// +internal class OAuthCodeStore +{ + private readonly ConcurrentDictionary _codes = new(); + private static readonly TimeSpan CodeLifetime = TimeSpan.FromMinutes(5); + + /// + /// Generates a new authorization code and stores it with the given parameters. + /// + public string GenerateCode( + string userId, + string userName, + string userEmail, + string clientId, + string redirectUri, + string? codeChallenge, + string? codeChallengeMethod) + { + // Clean up expired codes opportunistically + CleanupExpired(); + + var code = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)) + .Replace("+", "-").Replace("/", "_").TrimEnd('='); + + var entry = new AuthorizationCode + { + Code = code, + UserId = userId, + UserName = userName, + UserEmail = userEmail, + ClientId = clientId, + RedirectUri = redirectUri, + CodeChallenge = codeChallenge, + CodeChallengeMethod = codeChallengeMethod, + CreatedAt = DateTimeOffset.UtcNow, + }; + + _codes[code] = entry; + return code; + } + + /// + /// Exchanges an authorization code for the stored entry. + /// Returns null if the code is invalid, expired, or already consumed. + /// Validates PKCE code_verifier if a code_challenge was stored. + /// + public AuthorizationCode? ExchangeCode(string code, string clientId, string redirectUri, string? codeVerifier) + { + if (!_codes.TryRemove(code, out var entry)) + return null; + + // Check expiry + if (DateTimeOffset.UtcNow - entry.CreatedAt > CodeLifetime) + return null; + + // Validate client_id and redirect_uri match + if (!string.Equals(entry.ClientId, clientId, StringComparison.Ordinal)) + return null; + if (!string.Equals(entry.RedirectUri, redirectUri, StringComparison.Ordinal)) + return null; + + // Validate PKCE + if (!string.IsNullOrEmpty(entry.CodeChallenge)) + { + if (string.IsNullOrEmpty(codeVerifier)) + return null; + + if (!VerifyPkce(codeVerifier, entry.CodeChallenge, entry.CodeChallengeMethod)) + return null; + } + + return entry; + } + + private static bool VerifyPkce(string codeVerifier, string codeChallenge, string? method) + { + if (string.Equals(method, "S256", StringComparison.OrdinalIgnoreCase)) + { + var hash = SHA256.HashData(Encoding.ASCII.GetBytes(codeVerifier)); + var computed = Convert.ToBase64String(hash) + .Replace("+", "-").Replace("/", "_").TrimEnd('='); + return string.Equals(computed, codeChallenge, StringComparison.Ordinal); + } + + // plain method (or no method specified) + return string.Equals(codeVerifier, codeChallenge, StringComparison.Ordinal); + } + + private void CleanupExpired() + { + var cutoff = DateTimeOffset.UtcNow - CodeLifetime; + foreach (var kvp in _codes) + { + if (kvp.Value.CreatedAt < cutoff) + _codes.TryRemove(kvp.Key, out _); + } + } +} + +internal record AuthorizationCode +{ + public required string Code { get; init; } + public required string UserId { get; init; } + public required string UserName { get; init; } + public required string UserEmail { get; init; } + public required string ClientId { get; init; } + public required string RedirectUri { get; init; } + public string? CodeChallenge { get; init; } + public string? CodeChallengeMethod { get; init; } + public DateTimeOffset CreatedAt { get; init; } +} diff --git a/memex/Memex.Portal.Shared/Authentication/OAuthConnectController.cs b/memex/Memex.Portal.Shared/Authentication/OAuthConnectController.cs new file mode 100644 index 000000000..c2f12aa05 --- /dev/null +++ b/memex/Memex.Portal.Shared/Authentication/OAuthConnectController.cs @@ -0,0 +1,159 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Memex.Portal.Shared.Authentication; + +/// +/// Minimal OAuth 2.0 authorization server for MCP clients (claude.ai Connectors, Claude Desktop). +/// Implements authorization code flow with PKCE. Issues mw_ API tokens as access tokens, +/// reusing the existing ApiTokenService infrastructure. +/// +[ApiController] +public class OAuthConnectController( + IServiceProvider serviceProvider, + ILogger logger) : ControllerBase +{ + private OAuthCodeStore CodeStore => serviceProvider.GetRequiredService(); + private ApiTokenService TokenService => serviceProvider.GetRequiredService(); + + /// + /// RFC 8414 — OAuth Authorization Server Metadata. + /// MCP clients discover this via the authorization_servers URL from the protected resource metadata. + /// + [HttpGet("/.well-known/oauth-authorization-server")] + [AllowAnonymous] + public IActionResult GetServerMetadata() + { + var origin = $"{Request.Scheme}://{Request.Host}"; + return Ok(new + { + issuer = $"{origin}/connect", + authorization_endpoint = $"{origin}/connect/authorize", + token_endpoint = $"{origin}/connect/token", + response_types_supported = new[] { "code" }, + grant_types_supported = new[] { "authorization_code" }, + code_challenge_methods_supported = new[] { "S256" }, + }); + } + + /// + /// OAuth Authorization Endpoint — redirects authenticated users to the client's redirect_uri + /// with an authorization code. Unauthenticated users are sent to /login first. + /// + [HttpGet("connect/authorize")] + public IActionResult Authorize( + [FromQuery] string response_type, + [FromQuery] string client_id, + [FromQuery] string redirect_uri, + [FromQuery] string? state, + [FromQuery] string? scope, + [FromQuery] string? code_challenge, + [FromQuery] string? code_challenge_method) + { + if (response_type != "code") + return BadRequest(new { error = "unsupported_response_type" }); + + if (string.IsNullOrEmpty(client_id) || string.IsNullOrEmpty(redirect_uri)) + return BadRequest(new { error = "invalid_request", error_description = "client_id and redirect_uri are required" }); + + // If user is not authenticated, redirect to login with return URL + if (User?.Identity?.IsAuthenticated != true) + { + var authorizeUrl = $"{Request.Scheme}://{Request.Host}{Request.Path}{Request.QueryString}"; + var loginUrl = $"/login?returnUrl={Uri.EscapeDataString(authorizeUrl)}"; + return Redirect(loginUrl); + } + + // Extract user identity from cookie claims + var email = User.FindFirstValue(ClaimTypes.Email) + ?? User.FindFirstValue("email") + ?? User.FindFirstValue("preferred_username") + ?? ""; + var name = User.FindFirstValue(ClaimTypes.Name) + ?? User.FindFirstValue("name") + ?? email; + var userId = User.FindFirstValue("preferred_username") + ?? email; + + if (string.IsNullOrEmpty(email)) + return BadRequest(new { error = "invalid_request", error_description = "Unable to determine user identity" }); + + // Generate authorization code + var code = CodeStore.GenerateCode( + userId: userId, + userName: name, + userEmail: email, + clientId: client_id, + redirectUri: redirect_uri, + codeChallenge: code_challenge, + codeChallengeMethod: code_challenge_method); + + logger.LogInformation("Issued OAuth authorization code for user {Email}, client {ClientId}", email, client_id); + + // Redirect to client with code (and state if provided) + var callbackUrl = string.IsNullOrEmpty(state) + ? $"{redirect_uri}?code={Uri.EscapeDataString(code)}" + : $"{redirect_uri}?code={Uri.EscapeDataString(code)}&state={Uri.EscapeDataString(state)}"; + + return Redirect(callbackUrl); + } + + /// + /// OAuth Token Endpoint — exchanges an authorization code for an API token. + /// The issued token is a standard mw_ API token, indistinguishable from manually created ones. + /// + [HttpPost("connect/token")] + [AllowAnonymous] + public async Task ExchangeToken([FromForm] TokenRequest request) + { + if (request.grant_type != "authorization_code") + return BadRequest(new { error = "unsupported_grant_type" }); + + if (string.IsNullOrEmpty(request.code) || string.IsNullOrEmpty(request.client_id) || string.IsNullOrEmpty(request.redirect_uri)) + return BadRequest(new { error = "invalid_request" }); + + var entry = CodeStore.ExchangeCode( + request.code, + request.client_id, + request.redirect_uri, + request.code_verifier); + + if (entry == null) + { + logger.LogWarning("OAuth token exchange failed: invalid or expired code for client {ClientId}", request.client_id); + return BadRequest(new { error = "invalid_grant" }); + } + + // Create an mw_ API token via the existing token service + var (rawToken, _) = await TokenService.CreateTokenAsync( + userId: entry.UserId, + userName: entry.UserName, + userEmail: entry.UserEmail, + label: $"OAuth: {request.client_id}", + expiresAt: DateTimeOffset.UtcNow.AddDays(30)); + + logger.LogInformation("Issued OAuth access token for user {Email}, client {ClientId}", entry.UserEmail, request.client_id); + + return Ok(new + { + access_token = rawToken, + token_type = "Bearer", + expires_in = (int)TimeSpan.FromDays(30).TotalSeconds, + }); + } +} + +/// +/// Binds the form-encoded token request body. +/// +public class TokenRequest +{ + public string grant_type { get; set; } = ""; + public string? code { get; set; } + public string? client_id { get; set; } + public string? redirect_uri { get; set; } + public string? code_verifier { get; set; } +} diff --git a/memex/Memex.Portal.Shared/Memex.Portal.Shared.csproj b/memex/Memex.Portal.Shared/Memex.Portal.Shared.csproj index 271494c04..74f5b3aa5 100644 --- a/memex/Memex.Portal.Shared/Memex.Portal.Shared.csproj +++ b/memex/Memex.Portal.Shared/Memex.Portal.Shared.csproj @@ -31,6 +31,7 @@ + diff --git a/memex/Memex.Portal.Shared/MemexConfiguration.cs b/memex/Memex.Portal.Shared/MemexConfiguration.cs index 267713855..4dba3abe8 100644 --- a/memex/Memex.Portal.Shared/MemexConfiguration.cs +++ b/memex/Memex.Portal.Shared/MemexConfiguration.cs @@ -42,6 +42,8 @@ using Microsoft.Extensions.Logging; using Microsoft.Identity.Web; using Microsoft.Identity.Web.UI; +using ModelContextProtocol.AspNetCore.Authentication; +using ModelContextProtocol.Authentication; using PortalAuthOptions = MeshWeaver.Blazor.Portal.Authentication.AuthenticationOptions; namespace Memex.Portal.Shared; @@ -117,8 +119,9 @@ public static void ConfigureMemexServices(this WebApplicationBuilder builder) services.Configure( builder.Configuration.GetSection("Styles")); - // Register API token service for MCP bearer auth + // Register API token service for MCP bearer auth and OAuth code store services.AddSingleton(); + services.AddSingleton(); // Configure authentication var authSection = builder.Configuration.GetSection(PortalAuthOptions.SectionName); @@ -163,7 +166,8 @@ public static void ConfigureMemexServices(this WebApplicationBuilder builder) .AddMicrosoftIdentityWebApp(entraIdConfig); services.AddAuthentication() .AddScheme( - ApiTokenAuthenticationHandler.SchemeName, _ => { }); + ApiTokenAuthenticationHandler.SchemeName, _ => { }) + .AddMcp(ConfigureMcpResourceMetadata); services.AddControllersWithViews() .AddMicrosoftIdentityUI(); } @@ -196,20 +200,67 @@ public static void ConfigureMemexServices(this WebApplicationBuilder builder) // Add API token auth scheme for MCP bearer authentication authBuilder.AddScheme( - ApiTokenAuthenticationHandler.SchemeName, _ => { }); + ApiTokenAuthenticationHandler.SchemeName, _ => { }) + .AddMcp(ConfigureMcpResourceMetadata); } - // Add authorization with McpAuth policy (ApiToken scheme only — no cookie redirects for API clients) + // Add authorization with McpAuth policy (MCP scheme forwards to ApiToken or Cookie) services.AddAuthorization(options => { options.AddPolicy("McpAuth", policy => { - policy.AddAuthenticationSchemes(ApiTokenAuthenticationHandler.SchemeName); + policy.AddAuthenticationSchemes(McpAuthenticationDefaults.AuthenticationScheme); policy.RequireAuthenticatedUser(); }); }); } + /// + /// Configures the MCP authentication scheme with OAuth resource metadata discovery + /// and request-based forwarding to the appropriate authentication handler. + /// + private static void ConfigureMcpResourceMetadata(McpAuthenticationOptions options) + { + // CRITICAL: SDK constructor sets ForwardAuthenticate = "Bearer" which takes + // priority over ForwardDefaultSelector in ASP.NET Core's ResolveTarget(). + // Clear it so our selector works. + options.ForwardAuthenticate = null; + + // Route Bearer tokens to ApiToken handler, everything else to Cookie + options.ForwardDefaultSelector = ctx => + { + var authHeader = ctx.Request.Headers.Authorization.ToString(); + if (!string.IsNullOrEmpty(authHeader) && + authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + return ApiTokenAuthenticationHandler.SchemeName; + return CookieAuthenticationDefaults.AuthenticationScheme; + }; + + // Fallback resource metadata (overridden per-request by Events) + options.ResourceMetadata = new ProtectedResourceMetadata + { + BearerMethodsSupported = { "header" }, + ScopesSupported = { "mcp" }, + }; + + options.Events = new McpAuthenticationEvents + { + OnResourceMetadataRequest = ctx => + { + var req = ctx.HttpContext.Request; + var origin = $"{req.Scheme}://{req.Host}"; + ctx.ResourceMetadata = new ProtectedResourceMetadata + { + Resource = $"{origin}/mcp", + BearerMethodsSupported = { "header" }, + ScopesSupported = { "mcp" }, + AuthorizationServers = { $"{origin}/connect" }, + }; + return Task.CompletedTask; + } + }; + } + extension(TBuilder builder) where TBuilder : MeshBuilder { /// From 61639163b82336c335b220c343533c89247076e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Sat, 18 Apr 2026 12:22:00 +0200 Subject: [PATCH 09/50] fix: stabilize thread chat submission, embed sub-thread streams, add per-message metadata + thread header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eliminates the "duplicate response cell", "Renderer has been disposed", and "sub-thread streaming deadlocks" failures. Pipeline: - Atomic single-write submission via new ThreadInput.AppendUserInput (testable, Blazor-free); client posts one AppendUserMessageRequest instead of CreateNodeRequest + AppendUserMessageRequest. Handler runs ThreadInput on the thread hub for one local UpdateMeshNode patch. - Watcher subscribes to MeshNodeReference (not collection-wide), holds the reentrancy guard until IsExecuting=true is observed back, materialises user satellites server-side from a new Thread.PendingUserMessages map, and rechecks idempotency inside the guard before dispatching. - BlazorView gates its DataBind subscription callbacks on a _viewDisposed flag, before scheduling AND after the sync-context dispatch — kills late-callback "Renderer has been disposed" errors. Sub-thread streaming (no awaits on parent): - ThreadMessageLayoutAreas.Overview replaces the meshService.QueryAsync ToListAsync() deadlock with a reactive subscription that emits embedded LayoutAreaControls pointing at each delegation's Streaming area. The parent never reads sub-thread streams. GUI enhancements: - New token + CompletedAt fields on ThreadMessage, captured from Microsoft.Extensions.AI UsageContent during streaming. Per-message metadata row on assistant cells (timestamp · model · duration · tokens). - New Header layout area: parent-thread back-link (path-derived for delegations) + aggregated UpdatedNodes summary linking to the existing VersionLayoutArea compare URL. Rendered above the message list. Cancellation: queue-don't-cancel confirmed; explicit Cancel button preserved. Mid-iteration drain of PendingUserMessages within a single agent turn deferred — would require bypassing Microsoft.Extensions.AI's auto-tool-invocation and rebuilding the loop manually. Tests: AI.Test 294/294, Threading.Test 104/104. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/MeshWeaver.AI/Thread.cs | 34 +- src/MeshWeaver.AI/ThreadExecution.cs | 53 +- src/MeshWeaver.AI/ThreadInput.cs | 100 +++ src/MeshWeaver.AI/ThreadLayoutAreas.cs | 143 ++++ src/MeshWeaver.AI/ThreadMessageLayoutAreas.cs | 134 +++- src/MeshWeaver.AI/ThreadNodeType.cs | 7 + src/MeshWeaver.AI/ThreadSubmission.cs | 695 ++++++++---------- .../UpdateThreadMessageContent.cs | 8 + .../Chat/ThreadChatView.razor | 7 + .../Chat/ThreadChatView.razor.cs | 15 + src/MeshWeaver.Blazor/BlazorView.razor.cs | 30 +- .../Components/ChatSubmissionHandler.cs | 12 +- 12 files changed, 814 insertions(+), 424 deletions(-) create mode 100644 src/MeshWeaver.AI/ThreadInput.cs diff --git a/src/MeshWeaver.AI/Thread.cs b/src/MeshWeaver.AI/Thread.cs index 98e59d9c2..628c117da 100644 --- a/src/MeshWeaver.AI/Thread.cs +++ b/src/MeshWeaver.AI/Thread.cs @@ -135,17 +135,26 @@ public record Thread /// public DateTime? ExecutionStartedAt { get; init; } - /// - /// Streaming text buffer — transient, never persisted. - /// Used only in-memory during active execution for the status bar preview. - /// /// /// Pending user message text — set at thread creation to auto-start execution. /// When the thread grain activates and sees this, it immediately starts streaming. /// Cleared after execution starts. + /// Legacy: still used by the auto-execute-on-creation path. New submissions + /// from the GUI populate instead. /// public string? PendingUserMessage { get; init; } + /// + /// User messages submitted by the client but not yet ingested into a round. + /// Keyed by user message id. The server-side submission watcher creates + /// satellite ThreadMessage cells from these entries and clears them once + /// the round is dispatched. Lets us do the entire submission as a single + /// atomic stream.Update on this thread node — no separate + /// CreateNodeRequest, no AppendUserMessageRequest. + /// + public ImmutableDictionary PendingUserMessages { get; init; } + = ImmutableDictionary.Empty; + /// Agent name for pending execution. public string? PendingAgentName { get; init; } @@ -264,4 +273,21 @@ public record ThreadMessage /// The server watcher truncates the thread after this id and re-ingests. /// public bool IsResubmit { get; init; } + + /// + /// Token usage reported by the model provider. Populated for AgentResponse cells + /// when the streaming finishes. Null while streaming or when the provider didn't + /// report usage (e.g., some local models). Sum of + + /// may differ from if the + /// provider includes cached / reasoning tokens. + /// + public int? InputTokens { get; init; } + public int? OutputTokens { get; init; } + public int? TotalTokens { get; init; } + + /// + /// Wall-clock time when the assistant response finished streaming. Null while + /// streaming. CompletedAt - Timestamp is the per-message duration. + /// + public DateTime? CompletedAt { get; init; } } diff --git a/src/MeshWeaver.AI/ThreadExecution.cs b/src/MeshWeaver.AI/ThreadExecution.cs index 644118ed0..d134992d0 100644 --- a/src/MeshWeaver.AI/ThreadExecution.cs +++ b/src/MeshWeaver.AI/ThreadExecution.cs @@ -429,11 +429,15 @@ void RespondWithError(string error) /// Async handler on the _Exec hosted hub. /// Prepares agent and await-streams the response. /// Uses UpdateMeshNode on a remote stream to push text to the response node. - /// - /// - /// Fully reactive execution handler — zero await, zero QueryAsync. - /// Subscribes to chatClient.Initialize() observable, then runs streaming in the callback. - /// The AI API streaming (GetStreamingResponseAsync) runs via hub.InvokeAsync for async I/O. + /// + /// User input received while a round is in progress is held in + /// . The submission watcher dispatches + /// a NEW round (with its own response cell) as soon as this one completes — so + /// follow-up typed input is naturally queued without cancelling the current + /// model turn. Mid-iteration drain (injecting new user input into the same + /// response without round-boundary tear-down) would require manually orchestrating + /// the tool loop instead of relying on Microsoft.Extensions.AI's auto-invocation; + /// that's intentionally NOT done here. /// internal static IMessageDelivery ExecuteMessageAsync( IMessageHub hub, @@ -690,6 +694,9 @@ void UpdateThreadExecution(Func mutate) var ct = executionCts.Token; var responseText = new StringBuilder(); capturedResponseText = responseText; + int? inputTokens = null; + int? outputTokens = null; + int? totalTokens = null; try { logger.LogInformation("[ThreadExec] STREAMING_LOOP_ENTRY: {Time:HH:mm:ss.fff} threadPath={ThreadPath} (on thread pool)", DateTime.UtcNow, threadPath); @@ -735,6 +742,18 @@ void UpdateThreadExecution(Func mutate) }); } } + else if (content is UsageContent usage) + { + // Aggregate token usage across stream chunks. Providers vary — + // some report once at the end, others on every chunk; sum either way. + var d = usage.Details; + if (d?.InputTokenCount is { } it) + inputTokens = (inputTokens ?? 0) + (int)it; + if (d?.OutputTokenCount is { } ot) + outputTokens = (outputTokens ?? 0) + (int)ot; + if (d?.TotalTokenCount is { } tt) + totalTokens = (totalTokens ?? 0) + (int)tt; + } else if (content is FunctionResultContent functionResult) { logger.LogDebug("[ThreadExec] TOOL_RESULT: {Time:HH:mm:ss.fff} callId={CallId}, success={Success}, resultLen={Length}", @@ -808,13 +827,27 @@ void UpdateThreadExecution(Func mutate) } } - // Final update — aggregate node changes (merges sub-thread changes with min/max versions) + // Final update — aggregate node changes (merges sub-thread changes with min/max versions), + // include token usage + completion timestamp so the cell can show duration / tokens. var aggregatedChanges = AggregateNodeChanges(nodeChangeLog); - logger.LogInformation("[ThreadExec] EXECUTION_COMPLETE: {Time:HH:mm:ss.fff} threadPath={ThreadPath}, responseLength={Length}, toolCalls={ToolCalls}", - DateTime.UtcNow, threadPath, responseText.Length, toolCallLog.Count); + if (totalTokens is null && (inputTokens.HasValue || outputTokens.HasValue)) + totalTokens = (inputTokens ?? 0) + (outputTokens ?? 0); + logger.LogInformation("[ThreadExec] EXECUTION_COMPLETE: {Time:HH:mm:ss.fff} threadPath={ThreadPath}, responseLength={Length}, toolCalls={ToolCalls}, tokens={In}/{Out}/{Total}", + DateTime.UtcNow, threadPath, responseText.Length, toolCallLog.Count, + inputTokens, outputTokens, totalTokens); var finalText = responseText.ToString(); - PushToResponseMessage(finalText, toolCallLog, aggregatedChanges, - request.AgentName, request.ModelName); + parentHub.Post(new UpdateThreadMessageContent + { + Text = finalText, + ToolCalls = toolCallLog, + UpdatedNodes = aggregatedChanges, + AgentName = request.AgentName, + ModelName = request.ModelName, + InputTokens = inputTokens, + OutputTokens = outputTokens, + TotalTokens = totalTokens, + CompletedAt = DateTime.UtcNow + }, o => o.WithTarget(new Address(responsePath))); // Clear streaming state UpdateThreadExecution(t => t with { diff --git a/src/MeshWeaver.AI/ThreadInput.cs b/src/MeshWeaver.AI/ThreadInput.cs new file mode 100644 index 000000000..9f1fd94cd --- /dev/null +++ b/src/MeshWeaver.AI/ThreadInput.cs @@ -0,0 +1,100 @@ +using MeshWeaver.Data; +using MeshWeaver.Graph; +using MeshWeaver.Mesh; +using MeshWeaver.Messaging; +using MeshThread = MeshWeaver.AI.Thread; + +namespace MeshWeaver.AI; + +/// +/// Testable, Blazor-free helpers for appending user input into a thread. +/// +/// The whole submission is one atomic workspace.UpdateMeshNode on the +/// thread node — adding the new id to UserMessageIds and stashing the +/// message payload in . The server +/// watcher creates the satellite cell and dispatches the next round. +/// +/// This replaces the legacy two-message dance (CreateNodeRequest + +/// AppendUserMessageRequest), eliminating the duplicate-dispatch races caused +/// by interleaved fire-and-forget posts. +/// +public static class ThreadInput +{ + private static string NewId() => Guid.NewGuid().ToString("N")[..8]; + + /// + /// Pure: builds a user record. No I/O. + /// + public static ThreadMessage CreateUserMessage( + string text, + string? createdBy = null, + string? authorName = null, + string? agentName = null, + string? modelName = null, + string? contextPath = null, + IReadOnlyList? attachments = null) => + new() + { + Role = "user", + Text = text, + AuthorName = authorName, + CreatedBy = createdBy, + AgentName = agentName, + ModelName = modelName, + ContextPath = contextPath, + Attachments = attachments, + Timestamp = DateTime.UtcNow, + Type = ThreadMessageType.ExecutedInput + }; + + /// + /// Atomically appends a user message to via a + /// single workspace.UpdateMeshNode on the thread's MeshNode. Returns + /// the generated message id. The server-side submission watcher creates the + /// satellite cell from and + /// dispatches the next round. + /// + public static string AppendUserInput( + IWorkspace workspace, + string threadPath, + ThreadMessage message) + { + if (string.IsNullOrEmpty(threadPath)) + throw new ArgumentException("threadPath is required", nameof(threadPath)); + ArgumentNullException.ThrowIfNull(workspace); + ArgumentNullException.ThrowIfNull(message); + + var msgId = NewId(); + + // Update the single MeshNode in this hub's data source. Using the no-address overload + // (FirstOrDefault) avoids a pre-existing path-vs-id key mismatch in the address-aware + // overload. This call expects to run on the thread's own hub (e.g., from the + // AppendUserMessageRequest handler) where there's exactly one node in the collection. + workspace.UpdateMeshNode(node => + { + var thread = node.Content as MeshThread ?? new MeshThread(); + var msgs = thread.Messages.Contains(msgId) + ? thread.Messages + : thread.Messages.Add(msgId); + var userIds = thread.UserMessageIds.Contains(msgId) + ? thread.UserMessageIds + : thread.UserMessageIds.Add(msgId); + var pending = thread.PendingUserMessages.SetItem(msgId, message); + return node with + { + Content = thread with + { + Messages = msgs, + UserMessageIds = userIds, + PendingUserMessages = pending, + PendingAgentName = message.AgentName ?? thread.PendingAgentName, + PendingModelName = message.ModelName ?? thread.PendingModelName, + PendingContextPath = message.ContextPath ?? thread.PendingContextPath, + PendingAttachments = message.Attachments ?? thread.PendingAttachments + } + }; + }); + + return msgId; + } +} diff --git a/src/MeshWeaver.AI/ThreadLayoutAreas.cs b/src/MeshWeaver.AI/ThreadLayoutAreas.cs index 6c870330f..22068f0e3 100644 --- a/src/MeshWeaver.AI/ThreadLayoutAreas.cs +++ b/src/MeshWeaver.AI/ThreadLayoutAreas.cs @@ -45,6 +45,7 @@ public static MessageHubConfiguration AddThreadLayoutAreas(this MessageHubConfig .WithView(ThreadNodeType.ThreadChatArea, ThreadChatView) .WithView(ThreadNodeType.StreamingArea, StreamingView) .WithView(ThreadNodeType.HistoryArea, HistoryView) + .WithView(ThreadNodeType.HeaderArea, HeaderView) .WithView(MeshNodeLayoutAreas.ThumbnailArea, Thumbnail) .WithView(MeshNodeLayoutAreas.ThreadsArea, ThreadsCatalog)); @@ -518,4 +519,146 @@ private static UiControl ThreadsView(LayoutAreaHost host, RenderingContext _) .WithRenderMode(MeshSearchRenderMode.Flat) .WithCreateNodeType("Thread"); } + + /// + /// Header area shown above the chat. Renders, when applicable: + /// • A back-link to the parent thread (if this is a delegation sub-thread — + /// detected by path nesting under another thread's response message id). + /// • A summary of nodes modified during this thread's runs, aggregated across + /// every entry, with version-before/ + /// version-after. Each entry links to the node's Versions area where the + /// existing compare/restore UI lives. + /// Pure subscription on the thread MeshNode; no awaits, no QueryAsync. + /// + public static IObservable HeaderView(LayoutAreaHost host, RenderingContext _) + { + var threadPath = host.Hub.Address.ToString(); + var parentLink = TryBuildParentLink(threadPath); + + var stream = host.Workspace.GetStream(new MeshNodeReference()); + if (stream is null) + return Observable.Return(parentLink); + + // Walk this thread's message ids, fetch each satellite via GetDataRequest, accumulate + // their UpdatedNodes, then render the aggregated summary alongside the parent link. + return stream + .Select(change => (change.Value?.Content as MeshThread)?.Messages ?? ImmutableList.Empty) + .Select(ids => (ids, key: string.Join("|", ids))) + .DistinctUntilChanged(p => p.key) + .Select(p => CollectUpdatedNodes(host.Hub, threadPath, p.ids)) + .Switch() + .Select(updates => BuildHeader(parentLink, updates)); + } + + /// + /// Walks , requests each satellite ThreadMessage via + /// GetDataRequest (Post + RegisterCallback wrapped as an Observable), accumulates + /// their UpdatedNodes, and emits the aggregated list once all responses arrive. + /// + private static IObservable> CollectUpdatedNodes( + IMessageHub hub, string threadPath, ImmutableList messageIds) + { + if (messageIds.IsEmpty) return Observable.Return(ImmutableList.Empty); + + var subjects = messageIds.Select(id => + { + var subject = new System.Reactive.Subjects.AsyncSubject>(); + var del = hub.Post(new GetDataRequest(new MeshNodeReference()), + o => o.WithTarget(new Address($"{threadPath}/{id}"))); + if (del is null) + { + subject.OnNext(ImmutableList.Empty); + subject.OnCompleted(); + } + else + { + hub.RegisterCallback((IMessageDelivery)del, resp => + { + var msg = resp is IMessageDelivery gdr + ? (gdr.Message.Data as MeshNode)?.Content as ThreadMessage + : null; + subject.OnNext(msg?.UpdatedNodes ?? ImmutableList.Empty); + subject.OnCompleted(); + return resp; + }); + } + return subject.AsObservable(); + }).ToList(); + + return Observable.CombineLatest(subjects) + .Select(parts => ThreadExecution.AggregateNodeChanges( + parts.SelectMany(p => p).ToImmutableList())) + .Take(1) + .Timeout(TimeSpan.FromSeconds(5)) + .Catch, Exception>(_ => + Observable.Return(ImmutableList.Empty)); + } + + private static UiControl? TryBuildParentLink(string threadPath) + { + // Sub-thread paths nest under a parent response message: + // {parentThreadPath}/{parentResponseMsgId}/{thisThreadId} + // If we can find a ".../<8-hex-id>/" pattern we treat this as a delegation. + var segments = threadPath.Split('/'); + if (segments.Length < 3) return null; + var parentMsgId = segments[^2]; + if (parentMsgId.Length != 8) return null; + var parentThreadPath = string.Join('/', segments[..^2]); + if (string.IsNullOrEmpty(parentThreadPath)) return null; + + var encoded = System.Web.HttpUtility.HtmlEncode(parentThreadPath); + var html = + $"" + + $" Delegated from {encoded}"; + return Controls.Html(html); + } + + private static UiControl? BuildHeader(UiControl? parentLink, ImmutableList updates) + { + if (parentLink is null && updates.IsEmpty) return null; + + var stack = Controls.Stack + .WithStyle("gap:6px; padding:8px 12px; margin-bottom:8px; " + + "background:var(--neutral-layer-1); border:1px solid var(--neutral-stroke-rest); " + + "border-radius:8px;"); + + if (parentLink is not null) + stack = stack.WithView(parentLink); + + if (!updates.IsEmpty) + { + var sb = new System.Text.StringBuilder(); + sb.Append("
"); + sb.Append("
Modified nodes
"); + foreach (var entry in updates) + { + var path = System.Web.HttpUtility.HtmlEncode(entry.Path); + var versionLabel = (entry.VersionBefore, entry.VersionAfter) switch + { + (null, { } v) => $"new \u2192 v{v}", + ({ } v, null) => $"v{v} \u2192 deleted", + ({ } a, { } b) when a == b => $"v{b}", + ({ } a, { } b) => $"v{a} \u2192 v{b}", + _ => entry.Operation ?? "" + }; + // Link to the node's Versions area (existing compare/restore view). + // Append from/to as query params so VersionLayoutArea can deep-link the + // compare view if it knows how to honour them; otherwise a no-op. + var queryParts = new List(); + if (entry.VersionBefore.HasValue) queryParts.Add($"from={entry.VersionBefore.Value}"); + if (entry.VersionAfter.HasValue) queryParts.Add($"to={entry.VersionAfter.Value}"); + var qs = queryParts.Count > 0 ? "?" + string.Join("&", queryParts) : ""; + sb.Append( + $"
" + + $"{path}" + + $"{versionLabel}
"); + } + sb.Append("
"); + stack = stack.WithView(Controls.Html(sb.ToString())); + } + + return stack; + } } diff --git a/src/MeshWeaver.AI/ThreadMessageLayoutAreas.cs b/src/MeshWeaver.AI/ThreadMessageLayoutAreas.cs index aeec7823d..29b7ddf48 100644 --- a/src/MeshWeaver.AI/ThreadMessageLayoutAreas.cs +++ b/src/MeshWeaver.AI/ThreadMessageLayoutAreas.cs @@ -127,7 +127,11 @@ private static IMessageDelivery HandleUpdateContent( ToolCalls = msg.ToolCalls ?? current.ToolCalls, UpdatedNodes = msg.UpdatedNodes ?? current.UpdatedNodes, AgentName = msg.AgentName ?? current.AgentName, - ModelName = msg.ModelName ?? current.ModelName + ModelName = msg.ModelName ?? current.ModelName, + InputTokens = msg.InputTokens ?? current.InputTokens, + OutputTokens = msg.OutputTokens ?? current.OutputTokens, + TotalTokens = msg.TotalTokens ?? current.TotalTokens, + CompletedAt = msg.CompletedAt ?? current.CompletedAt } }; }); @@ -352,27 +356,54 @@ private static UiControl BuildMessageOverview( .WithStyle(isUser ? "align-items: flex-end;" : "") .WithView(bubble); - // For assistant messages: show delegation sub-threads as clickable links + // Reactive metadata row for assistant cells: model · duration · tokens. + // Re-renders whenever CompletedAt or token fields change on the underlying message. if (!isUser) { - var messagePath = $"{threadPath}/{messageId}"; container = container.WithView((h, c) => { - var meshService = h.Hub.ServiceProvider.GetService(); - if (meshService == null) return Observable.Return(null); + var stream = h.Workspace.GetStream(new MeshNodeReference()); + if (stream is null) return Observable.Return(null); + + return stream + .Select(change => change.Value?.Content as ThreadMessage) + .Where(m => m is not null) + .Select(m => ( + Started: m!.Timestamp, + Completed: m.CompletedAt, + Model: m.ModelName, + In: m.InputTokens, + Out: m.OutputTokens, + Total: m.TotalTokens)) + .DistinctUntilChanged() + .Select(meta => BuildAssistantMetaRow(meta.Started, meta.Completed, meta.Model, + meta.In, meta.Out, meta.Total)); + }); + } - return Observable.FromAsync(async () => - { - try - { - var subs = await meshService - .QueryAsync($"namespace:{messagePath} nodeType:{ThreadNodeType.NodeType}") - .ToListAsync(); - if (subs.Count == 0) return (UiControl?)null; - return (UiControl?)BuildDelegationLinks(subs); - } - catch { return (UiControl?)null; } - }); + // For assistant messages: embed each delegation sub-thread as a live LayoutAreaControl + // pointing at the sub-thread's compact Streaming area. The Blazor renderer opens its + // own subscription against the sub-thread hub — parent execution never awaits the + // sub-thread's stream. Re-emits whenever the message's ToolCalls list changes (a new + // delegation appears, or its DelegationPath gets stamped after the call returns). + if (!isUser) + { + container = container.WithView((h, c) => + { + var stream = h.Workspace.GetStream(new MeshNodeReference()); + if (stream is null) return Observable.Return(null); + + return stream + .Select(change => (change.Value?.Content as ThreadMessage)?.ToolCalls + ?? System.Collections.Immutable.ImmutableList.Empty) + .Select(tcs => tcs + .Where(tc => !string.IsNullOrEmpty(tc.DelegationPath)) + .Select(tc => tc.DelegationPath!) + .Distinct() + .ToList()) + .Select(paths => (paths, key: string.Join("|", paths))) + .DistinctUntilChanged(p => p.key) + .Select(p => BuildEmbeddedSubThreadAreas(p.paths)); }); } @@ -381,27 +412,66 @@ private static UiControl BuildMessageOverview( } /// - /// Builds simple navigation links for delegation sub-threads. - /// Each sub-thread is rendered as a clickable link showing its name. + /// Builds the muted one-line metadata row shown below an assistant cell: + /// HH:mm:ss · model · 1.8s · 1,247 in / 392 out (1,639 total). Returns + /// null when there's nothing to show (e.g. response still streaming and no model + /// yet known). /// - private static UiControl BuildDelegationLinks(IReadOnlyList subThreads) + private static UiControl? BuildAssistantMetaRow( + DateTime started, DateTime? completed, string? model, + int? input, int? output, int? total) { - var sb = new System.Text.StringBuilder(); - sb.Append("
"); - - foreach (var st in subThreads) + var parts = new List(); + parts.Add(started.ToLocalTime().ToString("HH:mm:ss")); + if (!string.IsNullOrEmpty(model)) + parts.Add(System.Web.HttpUtility.HtmlEncode(model)); + if (completed.HasValue) + { + var dur = completed.Value - started; + parts.Add(dur.TotalSeconds < 1 + ? $"{dur.TotalMilliseconds:F0}ms" + : $"{dur.TotalSeconds:F1}s"); + } + if (input.HasValue || output.HasValue || total.HasValue) { - var name = System.Web.HttpUtility.HtmlEncode( - st.Name?.Length > 80 ? st.Name[..77] + "..." : st.Name ?? st.Id); - var href = $"/{st.Path}"; + var inS = input?.ToString("N0") ?? "?"; + var outS = output?.ToString("N0") ?? "?"; + var totS = total?.ToString("N0"); + var tokens = totS is null + ? $"{inS} in / {outS} out" + : $"{inS} in / {outS} out ({totS} total)"; + parts.Add(tokens); + } + if (parts.Count == 0) return null; + + var line = string.Join(" · ", parts); + return Controls.Html( + $"
{line}
"); + } + + /// + /// Builds an embedded stack of s pointing at the + /// sub-thread's compact Streaming view. Each control opens its own + /// subscription against the sub-thread hub — the parent's execution loop never + /// reads or awaits the sub-thread's stream. Returns null when there are no + /// delegations. + /// + private static UiControl? BuildEmbeddedSubThreadAreas(IReadOnlyList subThreadPaths) + { + if (subThreadPaths.Count == 0) return null; - sb.Append($"" + - $" {name}"); + var stack = Controls.Stack + .WithStyle("gap: 6px; margin: 6px 0 4px 8px; padding-left: 8px; " + + "border-left: 2px solid var(--accent-fill-rest);"); + + foreach (var path in subThreadPaths) + { + var area = new LayoutAreaControl(path, new LayoutAreaReference("Streaming")) + .WithSpinnerType(SpinnerType.Skeleton); + stack = stack.WithView(area); } - sb.Append("
"); - return Controls.Html(sb.ToString()); + return stack; } /// diff --git a/src/MeshWeaver.AI/ThreadNodeType.cs b/src/MeshWeaver.AI/ThreadNodeType.cs index e82720c69..61d5303b9 100644 --- a/src/MeshWeaver.AI/ThreadNodeType.cs +++ b/src/MeshWeaver.AI/ThreadNodeType.cs @@ -46,6 +46,13 @@ public static class ThreadNodeType /// public const string HistoryArea = "History"; + /// + /// Layout area shown above the chat: parent-thread origin link (when this thread + /// is a delegation), aggregated list of nodes modified by this thread's execution + /// with version-before / version-after, and click-through to the version compare view. + /// + public const string HeaderArea = "Header"; + /// /// Generates a human-readable speaking ID from message text. /// Takes the first few words, lowercases, replaces non-alphanumeric with hyphens, diff --git a/src/MeshWeaver.AI/ThreadSubmission.cs b/src/MeshWeaver.AI/ThreadSubmission.cs index f74bdfc5b..b9e599379 100644 --- a/src/MeshWeaver.AI/ThreadSubmission.cs +++ b/src/MeshWeaver.AI/ThreadSubmission.cs @@ -75,18 +75,113 @@ public static ImmutableList FindUnprocessedUserMessages(MeshThread threa // ═════════════════════════════════════════════════════════════════════ /// - /// Submits a user message into an existing thread. Fire-and-forget; the caller - /// observes the new user cell appear through the thread's remote MeshNode stream. + /// Submits a user message into an existing thread. Posts a single + /// to the thread hub — the handler + /// runs locally (one atomic + /// workspace.UpdateMeshNode), and the server watcher then creates the + /// satellite cell and dispatches the round. No separate CreateNodeRequest from + /// the client — that was the duplicate-dispatch source in the legacy flow. /// public static void Submit(SubmitContext ctx) - => ThreadSubmissionClient.Submit(ctx); + { + if (string.IsNullOrEmpty(ctx.ThreadPath)) + { + ctx.OnError?.Invoke("Submit requires ThreadPath. Use CreateThreadAndSubmit for new threads."); + return; + } + + var delivery = ctx.Hub.Post( + new AppendUserMessageRequest + { + ThreadPath = ctx.ThreadPath!, + UserMessageId = Guid.NewGuid().ToString("N")[..8], // ignored by handler — kept for back-compat shape + UserText = ctx.UserText, + AgentName = ctx.AgentName, + ModelName = ctx.ModelName, + ContextPath = ctx.ContextPath, + Attachments = ctx.Attachments + }, + o => o.WithTarget(new Address(ctx.ThreadPath!))); + + if (delivery == null) + { + ctx.OnError?.Invoke("Hub.Post returned null"); + return; + } + + ctx.Hub.RegisterCallback((IMessageDelivery)delivery, response => + { + if (response is IMessageDelivery { Message.Success: false } fail) + ctx.OnError?.Invoke($"Submit failed: {fail.Message.Error ?? "unknown"}"); + return response; + }); + } /// - /// Creates a new thread node and submits the first user message. - /// fires when the thread node is confirmed so the caller can navigate immediately. + /// Creates a new thread node, then submits the first user message via + /// on the new thread. + /// fires when the thread is confirmed. /// public static void CreateThreadAndSubmit(SubmitContext ctx) - => ThreadSubmissionClient.CreateThreadAndSubmit(ctx); + { + if (string.IsNullOrEmpty(ctx.Namespace)) + { + ctx.OnError?.Invoke("CreateThreadAndSubmit requires Namespace."); + return; + } + + var threadNode = ThreadNodeType.BuildThreadNode(ctx.Namespace!, ctx.UserText, ctx.CreatedBy); + var fallbackPath = threadNode.Path!; + + var delivery = ctx.Hub.Post( + new CreateNodeRequest(threadNode), + o => o.WithTarget(new Address(ctx.Namespace!))); + + if (delivery == null) + { + ctx.OnError?.Invoke("Hub.Post returned null"); + return; + } + + ctx.Hub.RegisterCallback((IMessageDelivery)delivery, response => + { + if (response is not IMessageDelivery { Message.Success: true } cnr) + { + var err = (response as IMessageDelivery)?.Message.Error ?? "unknown"; + ctx.OnError?.Invoke($"Thread creation failed: {err}"); + return response; + } + + var createdNode = cnr.Message.Node ?? threadNode; + var createdPath = createdNode.Path ?? fallbackPath; + ctx.OnThreadCreated?.Invoke(createdNode); + + var append = ctx.Hub.Post( + new AppendUserMessageRequest + { + ThreadPath = createdPath, + UserMessageId = Guid.NewGuid().ToString("N")[..8], + UserText = ctx.UserText, + AgentName = ctx.AgentName, + ModelName = ctx.ModelName, + ContextPath = ctx.ContextPath, + Attachments = ctx.Attachments + }, + o => o.WithTarget(new Address(createdPath))); + + if (append != null) + { + ctx.Hub.RegisterCallback((IMessageDelivery)append, appendResp => + { + if (appendResp is IMessageDelivery { Message.Success: false } fail) + ctx.OnError?.Invoke($"Append after thread create failed: {fail.Message.Error ?? "unknown"}"); + return appendResp; + }); + } + + return response; + }); + } /// /// Resubmits an existing user message: truncates Messages and IngestedMessageIds @@ -94,7 +189,37 @@ public static void CreateThreadAndSubmit(SubmitContext ctx) /// creates a new output cell. /// public static void Resubmit(ResubmitContext ctx) - => ThreadSubmissionClient.Resubmit(ctx); + { + if (string.IsNullOrEmpty(ctx.ThreadPath) || string.IsNullOrEmpty(ctx.UserMessageIdToReplay)) + { + ctx.OnError?.Invoke("Resubmit requires ThreadPath and UserMessageIdToReplay."); + return; + } + + var delivery = ctx.Hub.Post( + new ResubmitUserMessageRequest + { + ThreadPath = ctx.ThreadPath, + UserMessageId = ctx.UserMessageIdToReplay, + NewUserText = ctx.NewUserText, + AgentName = ctx.AgentName, + ModelName = ctx.ModelName + }, + o => o.WithTarget(new Address(ctx.ThreadPath))); + + if (delivery == null) + { + ctx.OnError?.Invoke("Hub.Post returned null"); + return; + } + + ctx.Hub.RegisterCallback((IMessageDelivery)delivery, response => + { + if (response is IMessageDelivery { Message.Success: false } fail) + ctx.OnError?.Invoke($"Resubmit failed: {fail.Message.Error ?? "unknown"}"); + return response; + }); + } // ═════════════════════════════════════════════════════════════════════ // Server-side API — invoked from thread hub initialization @@ -113,40 +238,36 @@ public static IDisposable InstallServerWatcher(IMessageHub threadHub) // ═════════════════════════════════════════════════════════════════════ /// - /// Thread-hub handler: registers a new user message id on the thread, stores Pending*, - /// and lets the watcher dispatch. Runs on the thread hub's scheduler — only one - /// AppendUserMessageRequest is processed at a time, so the state update is atomic - /// and patch-safe. + /// Thread-hub handler kept as a back-compat shim: re-routes legacy + /// through the new + /// path. New callers should write directly to the thread's MeshNode via ThreadInput + /// instead of posting this request. /// public static IMessageDelivery HandleAppendUserMessage( IMessageHub hub, IMessageDelivery delivery) { var req = delivery.Message; - hub.GetWorkspace().UpdateMeshNode(node => + try { - var t = node.Content as MeshThread ?? new MeshThread(); - var msgs = t.Messages.Contains(req.UserMessageId) ? t.Messages : t.Messages.Add(req.UserMessageId); - var userIds = t.UserMessageIds.Contains(req.UserMessageId) ? t.UserMessageIds : t.UserMessageIds.Add(req.UserMessageId); - // Accumulate queued text into PendingUserMessage. DispatchRound reads and clears it. - var pending = string.IsNullOrEmpty(t.PendingUserMessage) - ? req.UserText - : $"{t.PendingUserMessage}\n\n---\n\n{req.UserText}"; - return node with - { - Content = t with - { - Messages = msgs, - UserMessageIds = userIds, - PendingUserMessage = pending, - PendingAgentName = req.AgentName ?? t.PendingAgentName, - PendingModelName = req.ModelName ?? t.PendingModelName, - PendingContextPath = req.ContextPath ?? t.PendingContextPath, - PendingAttachments = req.Attachments?.ToImmutableList() ?? t.PendingAttachments - } - }; - }); - hub.Post(new AppendUserMessageResponse { Success = true }, o => o.ResponseFor(delivery)); + var msg = ThreadInput.CreateUserMessage( + req.UserText, + createdBy: delivery.AccessContext?.ObjectId, + authorName: null, + agentName: req.AgentName, + modelName: req.ModelName, + contextPath: req.ContextPath, + attachments: req.Attachments); + // Note: this shim ignores req.UserMessageId — the new flow allocates its own. + // Tests + the legacy client posted the id eagerly; the new flow only uses + // server-allocated ids so we don't honour the request's id here. + ThreadInput.AppendUserInput(hub.GetWorkspace(), req.ThreadPath, msg); + hub.Post(new AppendUserMessageResponse { Success = true }, o => o.ResponseFor(delivery)); + } + catch (Exception ex) + { + hub.Post(new AppendUserMessageResponse { Success = false, Error = ex.Message }, o => o.ResponseFor(delivery)); + } return delivery.Processed(); } @@ -340,235 +461,6 @@ public sealed record RoundDispatch( string? ContextPath, IReadOnlyList? Attachments); -/// -/// Client-side submission logic. All methods are void / fire-and-forget. -/// The client only posts CreateNodeRequest — the server watcher does all Thread-state bookkeeping -/// (append to Messages/UserMessageIds, set Pending*, dispatch). This avoids remote-stream write -/// races that produce out-of-bounds JSON patches. -/// -internal static class ThreadSubmissionClient -{ - private static string NewId() => Guid.NewGuid().ToString("N")[..8]; - - public static void Submit(SubmitContext ctx) - { - if (string.IsNullOrEmpty(ctx.ThreadPath)) - { - ctx.OnError?.Invoke("Submit requires ThreadPath. Use CreateThreadAndSubmit for new threads."); - return; - } - - var userMsgId = NewId(); - var threadAddr = new Address(ctx.ThreadPath); - var userCell = BuildUserCell(userMsgId, ctx.ThreadPath, ctx); - - void ReportFailure(string reason) - { - PostFailureRecord(ctx.Hub, ctx.ThreadPath!, userMsgId, ctx.UserText, reason); - ctx.OnError?.Invoke(reason); - } - - // 1) Create the user cell. - var createDelivery = ctx.Hub.Post( - new CreateNodeRequest(userCell), - o => o.WithTarget(threadAddr)); - - if (createDelivery != null) - { - ctx.Hub.RegisterCallback((IMessageDelivery)createDelivery, response => - { - if (response is IMessageDelivery { Message.Success: false } fail) - ReportFailure($"User cell creation failed: {fail.Message.Error ?? "unknown"}"); - return response; - }); - } - - // 2) Tell the thread hub to register the id and queue it. - var appendDelivery = ctx.Hub.Post( - new AppendUserMessageRequest - { - ThreadPath = ctx.ThreadPath, - UserMessageId = userMsgId, - UserText = ctx.UserText, - AgentName = ctx.AgentName, - ModelName = ctx.ModelName, - ContextPath = ctx.ContextPath, - Attachments = ctx.Attachments - }, - o => o.WithTarget(threadAddr)); - - if (appendDelivery != null) - { - ctx.Hub.RegisterCallback((IMessageDelivery)appendDelivery, response => - { - if (response is IMessageDelivery { Message.Success: false } fail) - ReportFailure($"Append failed: {fail.Message.Error ?? "unknown"}"); - return response; - }); - } - } - - public static void CreateThreadAndSubmit(SubmitContext ctx) - { - if (string.IsNullOrEmpty(ctx.Namespace)) - { - ctx.OnError?.Invoke("CreateThreadAndSubmit requires Namespace."); - return; - } - - var userMsgId = NewId(); - - // Build an empty thread node. The server watcher will populate Messages/UserMessageIds - // once the user cell is created. - var threadNode = ThreadNodeType.BuildThreadNode(ctx.Namespace, ctx.UserText, ctx.CreatedBy); - var threadPath = threadNode.Path!; - var userCell = BuildUserCell(userMsgId, threadPath, ctx); - - var delivery = ctx.Hub.Post( - new CreateNodeRequest(threadNode), - o => o.WithTarget(new Address(ctx.Namespace))); - - if (delivery == null) - { - ctx.OnError?.Invoke("Hub.Post returned null"); - return; - } - - ctx.Hub.RegisterCallback((IMessageDelivery)delivery, response => - { - if (response is not IMessageDelivery { Message.Success: true } cnr) - { - var err = (response as IMessageDelivery)?.Message.Error ?? "unknown"; - ctx.OnError?.Invoke($"Thread creation failed: {err}"); - return response; - } - - var createdNode = cnr.Message.Node ?? threadNode; - var createdPath = createdNode.Path ?? threadPath; - ctx.OnThreadCreated?.Invoke(createdNode); - - var threadAddr = new Address(createdPath); - - // Create the user cell on the new thread. - var cellDelivery = ctx.Hub.Post( - new CreateNodeRequest(userCell), - o => o.WithTarget(threadAddr)); - - if (cellDelivery is not null) - { - ctx.Hub.RegisterCallback((IMessageDelivery)cellDelivery, cellResp => - { - if (cellResp is IMessageDelivery { Message.Success: false } cellFail) - ctx.OnError?.Invoke($"User cell creation failed: {cellFail.Message.Error ?? "unknown"}"); - return cellResp; - }); - } - - // Tell the thread hub to register the id + queue it. - var appendDelivery = ctx.Hub.Post( - new AppendUserMessageRequest - { - ThreadPath = createdPath, - UserMessageId = userMsgId, - UserText = ctx.UserText, - AgentName = ctx.AgentName, - ModelName = ctx.ModelName, - ContextPath = ctx.ContextPath, - Attachments = ctx.Attachments - }, - o => o.WithTarget(threadAddr)); - - if (appendDelivery is not null) - { - ctx.Hub.RegisterCallback((IMessageDelivery)appendDelivery, appendResp => - { - if (appendResp is IMessageDelivery { Message.Success: false } fail) - ctx.OnError?.Invoke($"Append failed: {fail.Message.Error ?? "unknown"}"); - return appendResp; - }); - } - - return response; - }); - } - - public static void Resubmit(ResubmitContext ctx) - { - if (string.IsNullOrEmpty(ctx.ThreadPath) || string.IsNullOrEmpty(ctx.UserMessageIdToReplay)) - { - ctx.OnError?.Invoke("Resubmit requires ThreadPath and UserMessageIdToReplay."); - return; - } - - var delivery = ctx.Hub.Post( - new ResubmitUserMessageRequest - { - ThreadPath = ctx.ThreadPath, - UserMessageId = ctx.UserMessageIdToReplay, - NewUserText = ctx.NewUserText, - AgentName = ctx.AgentName, - ModelName = ctx.ModelName - }, - o => o.WithTarget(new Address(ctx.ThreadPath))); - - if (delivery == null) - { - ctx.OnError?.Invoke("Hub.Post returned null"); - return; - } - - ctx.Hub.RegisterCallback((IMessageDelivery)delivery, response => - { - if (response is IMessageDelivery { Message.Success: false } fail) - ctx.OnError?.Invoke($"Resubmit failed: {fail.Message.Error ?? "unknown"}"); - return response; - }); - } - - /// - /// Fire-and-forget post of a so the thread - /// shows the failure as an error response cell. If this post also fails, we've exhausted - /// recovery — swallow silently (the OnError callback is still invoked separately). - /// - private static void PostFailureRecord( - IMessageHub hub, string threadPath, string userMsgId, string userText, string error) - { - try - { - hub.Post( - new RecordSubmissionFailureRequest - { - ThreadPath = threadPath, - UserMessageId = userMsgId, - UserText = userText, - ErrorMessage = error - }, - o => o.WithTarget(new Address(threadPath))); - } - catch { /* swallow — caller's OnError will still fire */ } - } - - private static MeshNode BuildUserCell(string userMsgId, string threadPath, SubmitContext ctx) - => new(userMsgId, threadPath) - { - NodeType = ThreadMessageNodeType.NodeType, - MainNode = ctx.ContextPath ?? threadPath, - Content = new ThreadMessage - { - Role = "user", - AuthorName = ctx.AuthorName, - Text = ctx.UserText, - Timestamp = DateTime.UtcNow, - Type = ThreadMessageType.ExecutedInput, - CreatedBy = ctx.CreatedBy, - AgentName = ctx.AgentName, - ModelName = ctx.ModelName, - ContextPath = ctx.ContextPath, - Attachments = ctx.Attachments - } - }; -} - /// /// Server-side watcher: observes thread state changes and dispatches execution rounds. /// Installed once on thread hub initialization. Non-blocking; uses only Post + RegisterCallback @@ -583,39 +475,42 @@ public static IDisposable InstallServerWatcher(IMessageHub threadHub) var threadPath = threadHub.Address.Path; // Reentrancy guard: 0=idle, 1=dispatching. - // Combined with the thread's IsExecuting flag, prevents double-dispatch - // between "start dispatching" and "IsExecuting=true visible on stream". + // Held until IsExecuting=true is observed back through the same stream, so a + // re-emission triggered by our own response-cell write or PendingUserMessages + // patch can't double-dispatch. var dispatching = 0; - var sub = workspace.GetStream() - ?.Subscribe(nodes => + // Subscribe to this thread's own MeshNode (via MeshNodeReference) instead of the + // collection-wide stream — fewer wakeups, and the patches we observe are exactly + // the writes against this thread. + var sub = workspace.GetStream(new MeshNodeReference()) + ?.Subscribe(change => { - if (nodes == null) return; + var threadNode = change.Value; + if (threadNode?.Content is not MeshThread thread) return; + + // IsExecuting=true is visible — we held the guard waiting for this commit. + if (thread.IsExecuting && dispatching == 1) + { + Interlocked.Exchange(ref dispatching, 0); + return; + } + if (thread.IsExecuting) return; + if (Interlocked.CompareExchange(ref dispatching, 1, 0) != 0) return; var releaseGuard = true; try { - var threadNode = nodes.FirstOrDefault(n => n.Path == threadPath); - if (threadNode?.Content is not MeshThread thread) return; - - // Queue-don't-cancel: if the thread is executing, do nothing. The queued - // user messages stay in UserMessageIds; as soon as IsExecuting flips to - // false (current round completed naturally), we dispatch the next round. - if (thread.IsExecuting) return; - var dispatch = ThreadSubmission.PlanNextRound(thread); if (dispatch is null) return; - // Hold the reentrancy guard until the response cell is created and - // IsExecuting=true is committed. Otherwise the user-cell creation emit (or - // a back-to-back AppendUserMessageRequest) re-fires the watcher before the - // commit lands, sees IsExecuting=false + the same unprocessed messages, and - // dispatches a SECOND round → duplicate response cell. + // Hold the guard. It will be released when we observe IsExecuting=true + // back on this same stream above (or on hard failure inside DispatchRound). releaseGuard = false; DispatchRound(threadHub, threadNode, dispatch, logger, - onCompleted: () => Interlocked.Exchange(ref dispatching, 0)); + onFailure: () => Interlocked.Exchange(ref dispatching, 0)); } catch (Exception ex) { @@ -634,13 +529,17 @@ public static IDisposable InstallServerWatcher(IMessageHub threadHub) /// Creates the output cell, writes the committed round to the thread node, and /// fires off agent execution on the _Exec hosted hub. Non-blocking — all /// Hub.Post + RegisterCallback; the workspace write is a synchronous fire-and-forget. + /// + /// Step 0 (new): for each unprocessed user id present in , + /// create the satellite ThreadMessage cell. The client only writes the thread node; + /// the server materializes the per-message satellite nodes here. /// private static void DispatchRound( IMessageHub hub, MeshNode threadNode, RoundDispatch dispatch, ILogger? logger, - Action? onCompleted = null) + Action? onFailure = null) { var threadPath = hub.Address.Path; var responseMsgId = dispatch.ResponseMessageId; @@ -648,10 +547,6 @@ private static void DispatchRound( var thread = threadNode.Content as MeshThread ?? new MeshThread(); var mainEntity = threadNode.MainNode ?? dispatch.ContextPath ?? threadPath; - // PendingUserMessage contains the concatenated text of all user messages queued by - // AppendUserMessageRequest handlers since the last dispatch. - var combinedUserText = thread.PendingUserMessage ?? ""; - var accessService = hub.ServiceProvider.GetService(); var userCtx = accessService?.Context ?? accessService?.CircuitContext; if (userCtx is null && !string.IsNullOrEmpty(thread.CreatedBy)) @@ -659,107 +554,165 @@ private static void DispatchRound( userCtx = new AccessContext { ObjectId = thread.CreatedBy, Name = thread.CreatedBy }; } - // Step 1: create the assistant output cell (CreateNodeRequest → RegisterCallback). - var responseCell = new MeshNode(responseMsgId, threadPath) - { - NodeType = ThreadMessageNodeType.NodeType, - MainNode = mainEntity, - Content = new ThreadMessage - { - Role = "assistant", - Text = "", - Timestamp = DateTime.UtcNow, - Type = ThreadMessageType.AgentResponse, - AgentName = dispatch.AgentName, - ModelName = dispatch.ModelName - } - }; + var meshService = hub.ServiceProvider.GetRequiredService(); - var createDelivery = hub.Post( - new CreateNodeRequest(responseCell), - o => userCtx != null ? o.WithAccessContext(userCtx).WithTarget(hub.Address) : o.WithTarget(hub.Address)); + // Step 0: materialize user satellite cells from PendingUserMessages. + // Only ids present in dispatch.UserMessageIds AND PendingUserMessages need creation + // here — legacy paths (PendingUserMessage string) create cells elsewhere. + var pendingForRound = dispatch.UserMessageIds + .Where(id => thread.PendingUserMessages.ContainsKey(id)) + .Select(id => (Id: id, Msg: thread.PendingUserMessages[id])) + .ToImmutableList(); - if (createDelivery == null) - { - logger?.LogWarning("[ThreadSubmission] Post of CreateNodeRequest returned null for response cell {ResponseMsgId} on {ThreadPath}", - responseMsgId, threadPath); - onCompleted?.Invoke(); - return; - } + var combinedUserText = pendingForRound.Count > 0 + ? string.Join("\n\n---\n\n", pendingForRound.Select(p => p.Msg.Text)) + : (thread.PendingUserMessage ?? ""); - hub.RegisterCallback((IMessageDelivery)createDelivery, response => + void AfterUserCellsReady() { - if (response is not IMessageDelivery { Message.Success: true }) + // Step 1: create the assistant output cell (CreateNodeRequest → RegisterCallback). + var responseCell = new MeshNode(responseMsgId, threadPath) { - var err = (response as IMessageDelivery)?.Message.Error ?? "unknown"; - logger?.LogWarning("[ThreadSubmission] Response cell creation failed for {ResponseMsgId} on {ThreadPath}: {Error}", - responseMsgId, threadPath, err); - onCompleted?.Invoke(); - return response; + NodeType = ThreadMessageNodeType.NodeType, + MainNode = mainEntity, + Content = new ThreadMessage + { + Role = "assistant", + Text = "", + Timestamp = DateTime.UtcNow, + Type = ThreadMessageType.AgentResponse, + AgentName = dispatch.AgentName, + ModelName = dispatch.ModelName + } + }; + + var createDelivery = hub.Post( + new CreateNodeRequest(responseCell), + o => userCtx != null + ? o.WithAccessContext(userCtx).WithTarget(hub.Address) + : o.WithTarget(hub.Address)); + + if (createDelivery == null) + { + logger?.LogWarning("[ThreadSubmission] Post of CreateNodeRequest returned null for response cell {ResponseMsgId} on {ThreadPath}", + responseMsgId, threadPath); + onFailure?.Invoke(); + return; } - // Step 2: commit the round to the thread state (one atomic UpdateMeshNode). - hub.GetWorkspace().UpdateMeshNode(node => + hub.RegisterCallback((IMessageDelivery)createDelivery, response => { - var t = node.Content as MeshThread ?? new MeshThread(); - var msgs = t.Messages.Contains(responseMsgId) ? t.Messages : t.Messages.Add(responseMsgId); - var ingested = t.IngestedMessageIds; - foreach (var uid in dispatch.UserMessageIds) + if (response is not IMessageDelivery { Message.Success: true }) { - if (!ingested.Contains(uid)) - ingested = ingested.Add(uid); + var err = (response as IMessageDelivery)?.Message.Error ?? "unknown"; + logger?.LogWarning("[ThreadSubmission] Response cell creation failed for {ResponseMsgId} on {ThreadPath}: {Error}", + responseMsgId, threadPath, err); + onFailure?.Invoke(); + return response; } - return node with + + // Step 2: commit the round to the thread state (one atomic UpdateMeshNode). + // Idempotency: if IsExecuting is already true (rare race with another emit), + // do nothing — the previous dispatch holds the round. + hub.GetWorkspace().UpdateMeshNode(node => { - Content = t with + var t = node.Content as MeshThread ?? new MeshThread(); + if (t.IsExecuting) return node; + + var msgs = t.Messages.Contains(responseMsgId) ? t.Messages : t.Messages.Add(responseMsgId); + var ingested = t.IngestedMessageIds; + foreach (var uid in dispatch.UserMessageIds) + if (!ingested.Contains(uid)) ingested = ingested.Add(uid); + + // Drop consumed PendingUserMessages entries — their satellites now exist. + var pending = t.PendingUserMessages; + foreach (var (uid, _) in pendingForRound) + pending = pending.Remove(uid); + + return node with { - Messages = msgs, - IngestedMessageIds = ingested, - IsExecuting = true, - ActiveMessageId = responseMsgId, - ExecutionStartedAt = DateTime.UtcNow, - TokensUsed = 0, - ExecutionStatus = null, - // Clear PendingUserMessage — the round's text is already captured in combinedUserText. - // Next AppendUserMessageRequest starts accumulating fresh for the next round. - PendingUserMessage = null, - PendingContextPath = dispatch.ContextPath, - PendingAttachments = dispatch.Attachments?.ToImmutableList() - } - }; - }); + Content = t with + { + Messages = msgs, + IngestedMessageIds = ingested, + IsExecuting = true, + ActiveMessageId = responseMsgId, + ExecutionStartedAt = DateTime.UtcNow, + TokensUsed = 0, + ExecutionStatus = null, + PendingUserMessage = null, + PendingUserMessages = pending, + PendingContextPath = dispatch.ContextPath, + PendingAttachments = dispatch.Attachments?.ToImmutableList() + } + }; + }); + + hub.Post( + new UpdateThreadMessageContent { Text = "Allocating agent..." }, + o => o.WithTarget(new Address(responsePath))); - hub.Post( - new UpdateThreadMessageContent { Text = "Allocating agent..." }, - o => o.WithTarget(new Address(responsePath))); + // Step 3: post to _Exec hosted hub — actual agent streaming runs there. + var executionHub = hub.GetHostedHub( + new Address($"{hub.Address}/_Exec"), + config => config.WithHandler(ThreadExecution.ExecuteMessageAsync), + HostedHubCreation.Always); - // The watcher's reentrancy guard is held by the caller until this point — release - // it now that IsExecuting=true is committed. Subsequent watcher emits will see the - // executing flag and skip until this round completes. - onCompleted?.Invoke(); + executionHub!.Post( + new SubmitMessageRequest + { + ThreadPath = threadPath, + UserMessageText = combinedUserText, + UserMessageId = dispatch.UserMessageIds.LastOrDefault(), + ResponseMessageId = responseMsgId, + ResponsePath = responsePath, + AgentName = dispatch.AgentName, + ModelName = dispatch.ModelName, + ContextPath = dispatch.ContextPath, + Attachments = dispatch.Attachments + }, + o => userCtx != null ? o.WithAccessContext(userCtx) : o); - // Step 3: post to _Exec hosted hub — actual agent streaming runs there. - var executionHub = hub.GetHostedHub( - new Address($"{hub.Address}/_Exec"), - config => config.WithHandler(ThreadExecution.ExecuteMessageAsync), - HostedHubCreation.Always); + return response; + }); + } - executionHub!.Post( - new SubmitMessageRequest + if (pendingForRound.Count == 0) + { + AfterUserCellsReady(); + return; + } + + // Materialize satellite cells in parallel, then proceed. We swallow per-cell errors + // (cell may already exist from a prior crashed attempt — that's recoverable) and only + // wait for one notification per cell before continuing. + var creationStreams = pendingForRound.Select(p => + { + var cell = new MeshNode(p.Id, threadPath) + { + NodeType = ThreadMessageNodeType.NodeType, + MainNode = mainEntity, + Content = p.Msg + }; + return meshService.CreateNode(cell) + .Take(1) + .Select(_ => true) + .Catch(ex => { - ThreadPath = threadPath, - UserMessageText = combinedUserText, - UserMessageId = dispatch.UserMessageIds.LastOrDefault(), - ResponseMessageId = responseMsgId, - ResponsePath = responsePath, - AgentName = dispatch.AgentName, - ModelName = dispatch.ModelName, - ContextPath = dispatch.ContextPath, - Attachments = dispatch.Attachments - }, - o => userCtx != null ? o.WithAccessContext(userCtx) : o); + logger?.LogDebug(ex, "[ThreadSubmission] User cell create returned error (may already exist) for {Path}", + $"{threadPath}/{p.Id}"); + return Observable.Return(true); + }); + }).ToList(); - return response; - }); + Observable.CombineLatest(creationStreams) + .Take(1) + .Subscribe( + _ => AfterUserCellsReady(), + ex => + { + logger?.LogWarning(ex, "[ThreadSubmission] User cell materialization failed for {ThreadPath}", threadPath); + onFailure?.Invoke(); + }); } } diff --git a/src/MeshWeaver.AI/UpdateThreadMessageContent.cs b/src/MeshWeaver.AI/UpdateThreadMessageContent.cs index ead099905..d49d5c357 100644 --- a/src/MeshWeaver.AI/UpdateThreadMessageContent.cs +++ b/src/MeshWeaver.AI/UpdateThreadMessageContent.cs @@ -14,4 +14,12 @@ public record UpdateThreadMessageContent public ImmutableList? UpdatedNodes { get; init; } public string? AgentName { get; init; } public string? ModelName { get; init; } + + /// Token usage from the model provider. Set on the final update of a round. + public int? InputTokens { get; init; } + public int? OutputTokens { get; init; } + public int? TotalTokens { get; init; } + + /// Wall-clock completion timestamp. Set on the final update of a round. + public DateTime? CompletedAt { get; init; } } diff --git a/src/MeshWeaver.Blazor.Portal/Chat/ThreadChatView.razor b/src/MeshWeaver.Blazor.Portal/Chat/ThreadChatView.razor index 5f75538b5..5d2532238 100644 --- a/src/MeshWeaver.Blazor.Portal/Chat/ThreadChatView.razor +++ b/src/MeshWeaver.Blazor.Portal/Chat/ThreadChatView.razor @@ -32,6 +32,13 @@ else @if (!ViewModel.HideEmptyState) {
+ @{ + var header = GetHeaderCell(); + } + @if (header != null) + { + + } @if (ThreadMessages.Count > 0) { @foreach (var msgId in ThreadMessages) diff --git a/src/MeshWeaver.Blazor.Portal/Chat/ThreadChatView.razor.cs b/src/MeshWeaver.Blazor.Portal/Chat/ThreadChatView.razor.cs index 3359e94e1..13a7fe319 100644 --- a/src/MeshWeaver.Blazor.Portal/Chat/ThreadChatView.razor.cs +++ b/src/MeshWeaver.Blazor.Portal/Chat/ThreadChatView.razor.cs @@ -960,6 +960,21 @@ private static CompletionItem AutocompleteToCompletion( .WithSpinnerType(SpinnerType.Skeleton); } + /// + /// Creates a LayoutAreaControl pointing to the thread's Header layout area + /// (parent-thread back-link + aggregated UpdatedNodes summary). Null when the + /// thread doesn't exist yet. + /// + private LayoutAreaControl? GetHeaderCell() + { + if (string.IsNullOrEmpty(threadPath)) + return null; + return new LayoutAreaControl( + threadPath, + new LayoutAreaReference(ThreadNodeType.HeaderArea)) + .WithSpinnerType(SpinnerType.Skeleton); + } + private static string TruncateText(string text, int maxLength) { if (string.IsNullOrEmpty(text) || text.Length <= maxLength) diff --git a/src/MeshWeaver.Blazor/BlazorView.razor.cs b/src/MeshWeaver.Blazor/BlazorView.razor.cs index d41f53d44..fa113ac92 100644 --- a/src/MeshWeaver.Blazor/BlazorView.razor.cs +++ b/src/MeshWeaver.Blazor/BlazorView.razor.cs @@ -61,8 +61,18 @@ protected virtual void BindDataAfterParameterReset() protected List Disposables { get; } = new(); + private bool _viewDisposed; + + /// True after has been entered. Subscription callbacks + /// can check this to avoid invoking on a dead renderer. + protected bool IsViewDisposed => _viewDisposed; + public virtual ValueTask DisposeAsync() { + // Set the flag BEFORE disposing subscriptions so any in-flight callbacks + // queued by Subscribe(...) onto the synchronization context can short-circuit + // on IsViewDisposed instead of touching a torn-down renderer. + _viewDisposed = true; Logger.LogDebug("Disposing area {Area}", Area); DisposeBindings(); foreach (var d in Disposables) @@ -108,18 +118,32 @@ protected void DataBind( bindings.Add(Stream.DataBind(reference, DataContext, conversion, defaultValue) .Subscribe(v => { + if (_viewDisposed) return; try { Logger.LogTrace("Binding property in Area {area}", Area); InvokeAsync(() => { - setter(v); - RequestStateChange(); + // Re-check after dispatch — the renderer may have + // been torn down between Subscribe.OnNext and the + // synchronization-context callback firing. + if (_viewDisposed) return; + try + { + setter(v); + RequestStateChange(); + } + catch (ObjectDisposedException) { /* renderer gone */ } + catch (Exception ex) + { + Logger.LogError(ex, "Error setting bound property value in Area {area}", Area); + } }); } + catch (ObjectDisposedException) { /* renderer gone */ } catch (Exception ex) { - Logger.LogError(ex, "Error setting bound property value in Area {area}", Area); + Logger.LogError(ex, "Error scheduling bound property update in Area {area}", Area); } } ) diff --git a/src/MeshWeaver.Blazor/Components/ChatSubmissionHandler.cs b/src/MeshWeaver.Blazor/Components/ChatSubmissionHandler.cs index e4a885394..80a0b653d 100644 --- a/src/MeshWeaver.Blazor/Components/ChatSubmissionHandler.cs +++ b/src/MeshWeaver.Blazor/Components/ChatSubmissionHandler.cs @@ -77,10 +77,14 @@ public bool TryBeginSubmit(string? text) if (State != SubmissionState.Idle) return false; - // Debounce: ThreadChatView force-releases immediately after Submit so the input stays - // enabled for queueing. Without this guard, a double-click / Enter+Send race produces - // two user cells, and the server watcher then dispatches two execution rounds. - // Dedup is text-based: a real second message (different text) goes through. + // UX-only debounce: ThreadChatView force-releases immediately after Submit so the + // input stays enabled for queueing while the previous round is processed by the + // server. Without this guard, a double-click / Enter+Send race appends the same + // message twice (the server watcher batches them into one round, but the user + // sees two duplicate cells). The duplicate-EXECUTION race that this guard used + // to mask is now fixed in ThreadSubmissionServer (atomic single-write append + + // hardened reentrancy guard); this remains only as a UX safeguard against + // accidental double-clicks of the same exact text. if (LastSubmittedText == text && _lastAcceptedAt.HasValue && (_now() - _lastAcceptedAt.Value) < _dedupWindow) From 5d4c3a82e452fd07b6f032bcdc440f39b5d0b009 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Sat, 18 Apr 2026 12:25:24 +0200 Subject: [PATCH 10/50] chore: ignore .claude/ folder (per-user Claude Code state) Untracks .claude/settings.local.json (the per-user permission allowlist) and ignores the whole folder so future plans/, scheduled_tasks.lock, and any other local Claude Code state never accidentally gets committed. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/settings.local.json | 154 ------------------------------------ .gitignore | 4 +- 2 files changed, 2 insertions(+), 156 deletions(-) delete mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index e8dbe3474..000000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,154 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(grep:*)", - "Bash(dotnet build:*)", - "Bash(cat:*)", - "Bash(iconv:*)", - "Bash(ls:*)", - "Bash(rg:*)", - "Bash(find:*)", - "Bash(dotnet restore:*)", - "Bash(dotnet test:*)", - "Bash(dotnet clean:*)", - "Bash(dotnet nuget locals:*)", - "Bash(rm:*)", - "Bash(diff:*)", - "Bash(mv:*)", - "Bash(timeout:*)", - "Bash(true)", - "WebFetch(domain:xunit.net)", - "Bash(dotnet add:*)", - "Bash(dotnet search:*)", - "Bash(dotnet list package:*)", - "Bash(dotnet remove:*)", - "Bash(dotnet run:*)", - "Bash(cp:*)", - "Bash(sed:*)", - "WebFetch(domain:docs.anthropic.com)", - "Bash(dotnet tool install:*)", - "Bash(dotnet:*)", - "Bash(pwsh:*)", - "Bash(powershell.exe:*)", - "Bash(mkdir:*)", - "WebFetch(domain:github.com)", - "WebSearch", - "Bash(git checkout:*)", - "Bash(tee:*)", - "Bash(git restore:*)", - "Bash(meshweaver-thumbnails:*)", - "Bash(findstr:*)", - "Bash(git log:*)", - "Bash(python:*)", - "Bash(python3:*)", - "Bash(test:*)", - "Bash(Select-Object -Last 20)", - "Bash(git mv:*)", - "Bash(dir:*)", - "Bash(node --check:*)", - "Bash(cd:*)", - "Bash(git stash:*)", - "Bash(git grep:*)", - "Bash(xargs:*)", - "Bash(taskkill:*)", - "WebFetch(domain:www.nuget.org)", - "Bash(curl:*)", - "Bash(git add:*)", - "Bash(git commit:*)", - "Bash(for f in *.json)", - "Bash(done)", - "Bash(tree:*)", - "WebFetch(domain:raw.githubusercontent.com)", - "WebFetch(domain:www.fluentui-blazor.net)", - "Bash(do sed -i 's/CodeFile/CodeConfiguration/g' \"$f\")", - "Bash(/dev/null -exec cat {} ;)", - "WebFetch(domain:localhost)", - "Bash(git pull:*)", - "Bash(git check-ignore:*)", - "Bash(echo:*)", - "Bash(source ~/.zshrc)", - "Bash(pkill:*)", - "Bash(lsof:*)", - "Bash(xxd:*)", - "Bash(brew install:*)", - "Bash(brew:*)", - "Bash(kill:*)", - "Bash(git fetch:*)", - "Bash(head:*)", - "Bash(pgrep:*)", - "Bash(gh extension list:*)", - "Bash(gh:*)", - "Bash(claude:*)", - "Bash(tail:*)", - "Bash(git diff:*)", - "Bash(dotnet-ildasm:*)", - "Bash(unzip:*)", - "Bash(ilspycmd:*)", - "Bash(nm:*)", - "Bash(docker run:*)", - "Bash(git ls-tree:*)", - "Bash(git rev-parse:*)", - "Bash(wc:*)", - "Bash(git show:*)", - "WebFetch(domain:cdnjs.cloudflare.com)", - "Bash(tr:*)", - "Bash(cmp:*)", - "Bash(while read f)", - "Bash(git blame:*)", - "WebFetch(domain:fluent2.microsoft.design)", - "Bash(sips:*)", - "Bash(bc:*)", - "Bash(tasklist:*)", - "Bash(dotnet-dump analyze:*)", - "Bash(aspire mcp:*)", - "mcp__aspire__list_resources", - "mcp__aspire__list_console_logs", - "mcp__aspire__list_structured_logs", - "mcp__aspire__list_traces", - "Bash(az postgres:*)", - "WebFetch(domain:aspire.dev)", - "mcp__aspire__list_apphosts", - "Bash(az account:*)", - "Bash(git status:*)", - "WebFetch(domain:en.wikipedia.org)", - "Bash(docker ps:*)", - "Bash(docker exec:*)", - "Bash(DOTNET_CLI_UI_LANGUAGE=en dotnet --list-runtimes 2>&1 || echo \"FAILED\")", - "Bash(powershell -Command \"dotnet --version\" 2>&1)", - "Bash(powershell -Command \"dotnet --list-runtimes\" 2>&1)", - "Bash(for dir in Northwind ACME Cornerstone)", - "Bash(do echo \"=== $dir ===\")", - "Bash(1 <<'EOF'\nusing HtmlAgilityPack;\n\nvar html = \"Link
Link2\";\nvar doc = new HtmlDocument\\(\\);\ndoc.LoadHtml\\(html\\);\n\nvar td = doc.DocumentNode.SelectSingleNode\\(\"//td\"\\);\nConsole.WriteLine\\(\"TD node found: \" + \\(td != null\\)\\);\nConsole.WriteLine\\(\"TD child nodes count: \" + \\(td?.ChildNodes.Count ?? 0\\)\\);\n\nforeach \\(var child in td?.ChildNodes ?? new List\\(\\)\\)\n{\n Console.WriteLine\\($\" Node type: {child.NodeType}, Name: '{child.Name}', HasChildNodes: {child.HasChildNodes}\"\\);\n if \\(child.NodeType == HtmlNodeType.Text\\)\n Console.WriteLine\\($\" Text: '{child.InnerText}'\"\\);\n}\nEOF)", - "Bash(file:*)", - "Bash(aspire deploy:*)", - "Bash(az monitor:*)", - "mcp__aspire__select_apphost", - "Bash(git ls-remote:*)", - "Bash(git push:*)", - "Bash(exit 0)", - "WebFetch(domain:gist.github.com)", - "Bash(az containerapp:*)", - "Skill(update-config)", - "Bash(netstat -ano)", - "Bash(wait)", - "Bash(netstat:*)", - "Bash(docker inspect:*)", - "Bash(wait:*)", - "Bash(sleep:*)", - "Bash(*&&*)", - "Bash(*|*)", - "Bash(wmic process:*)", - "Bash(powershell:*)", - "WebFetch(domain:support.claude.com)", - "Bash(git:*)", - "Bash(docker cp:*)", - "Bash(pip install *)" - ], - "deny": [], - "additionalDirectories": [ - "/tmp/claude", - "C:\\Users\\RolandBuergi\\AppData\\Local\\Temp\\claude" - ] - }, - "enableAllProjectMcpServers": true -} diff --git a/.gitignore b/.gitignore index b3f194492..791ec7223 100644 --- a/.gitignore +++ b/.gitignore @@ -367,5 +367,5 @@ samples/Graph/Data/VUser/ # User activity data **/_useractivity/ -# Claude Code scheduled tasks lock file -.claude/scheduled_tasks.lock +# Claude Code per-user local state (settings.local.json, plans/, scheduled_tasks.lock, etc.) +.claude/ From 490707a6daa15b0d5afb8b2080d17a8f03d52188 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Sat, 18 Apr 2026 17:45:34 +0200 Subject: [PATCH 11/50] fix: add launchSettings.json for Memex.Database.Migration Aspire 13.2.2 (bumped in b949a746f) requires project resources to have a Properties/launchSettings.json with "commandName": "Project" to build a valid `dotnet ` launch command. Without it, Aspire invoked dotnet with no DLL path, the migration resource printed the dotnet usage help and exited, and memex-local (portal) never started because of WaitForCompletion(dbMigration). Unignore the file so a fresh clone doesn't hit the same problem; the template generator already picks it up since Properties/ isn't in its exclusion list. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 1 + .../Properties/launchSettings.json | 11 +++++++++++ 2 files changed, 12 insertions(+) create mode 100644 memex/aspire/Memex.Database.Migration/Properties/launchSettings.json diff --git a/.gitignore b/.gitignore index 791ec7223..2022398b1 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,7 @@ artifacts/ **/Properties/launchSettings.json !**/*.Host/Properties/launchSettings.json !**/*.AppHost/Properties/launchSettings.json +!memex/aspire/Memex.Database.Migration/Properties/launchSettings.json # StyleCop StyleCopReport.xml diff --git a/memex/aspire/Memex.Database.Migration/Properties/launchSettings.json b/memex/aspire/Memex.Database.Migration/Properties/launchSettings.json new file mode 100644 index 000000000..ad7baef75 --- /dev/null +++ b/memex/aspire/Memex.Database.Migration/Properties/launchSettings.json @@ -0,0 +1,11 @@ +{ + "profiles": { + "Migration": { + "commandName": "Project", + "dotnetRunMessages": true, + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} From 39389bd9481a2c8c05d1ac2495011a1133f34b12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Sat, 18 Apr 2026 19:46:59 +0200 Subject: [PATCH 12/50] fix: suppress benign ObjectDisposedException in NamedAreaView area-stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The area control stream's upstream hub/workspace can throw ObjectDisposedException during navigation or component teardown — that is a normal lifecycle event, not a real error. NamedAreaView previously formatted it as a user-visible "Error loading area: Cannot access a disposed object" markdown; now it's debug-logged and skipped. Also gates OnNext/OnError on IsViewDisposed (matching BlazorView's fix) so late stream emissions can't touch a torn-down renderer. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Components/NamedAreaView.razor.cs | 61 +++++++++++++------ 1 file changed, 44 insertions(+), 17 deletions(-) diff --git a/src/MeshWeaver.Blazor/Components/NamedAreaView.razor.cs b/src/MeshWeaver.Blazor/Components/NamedAreaView.razor.cs index 9a589f951..c99ff794c 100644 --- a/src/MeshWeaver.Blazor/Components/NamedAreaView.razor.cs +++ b/src/MeshWeaver.Blazor/Components/NamedAreaView.razor.cs @@ -44,30 +44,57 @@ protected override void BindData() .Subscribe( x => { - InvokeAsync(() => + if (IsViewDisposed) return; + try { - var control = x as UiControl; - if (RootControl is null && control is null || RootControl != null && RootControl.Equals(control)) - return; - RootControl = control; - if (RootControl is not null) + InvokeAsync(() => { - DataBind(RootControl.PageTitle, y => y.PageTitle); - DataBind(RootControl.Meta, y => y.MetaAttributes); - } - Logger.LogDebug("Setting area {Area} to rendering area {AreaToBeRendered} to type {Type}", Area, - AreaToBeRendered, control?.GetType().Name ?? "null"); - RequestStateChange(); - }); + if (IsViewDisposed) return; + try + { + var control = x as UiControl; + if (RootControl is null && control is null || RootControl != null && RootControl.Equals(control)) + return; + RootControl = control; + if (RootControl is not null) + { + DataBind(RootControl.PageTitle, y => y.PageTitle); + DataBind(RootControl.Meta, y => y.MetaAttributes); + } + Logger.LogDebug("Setting area {Area} to rendering area {AreaToBeRendered} to type {Type}", Area, + AreaToBeRendered, control?.GetType().Name ?? "null"); + RequestStateChange(); + } + catch (ObjectDisposedException) { /* renderer gone */ } + }); + } + catch (ObjectDisposedException) { /* renderer gone */ } }, error => { + // ObjectDisposedException is a benign teardown artifact — the area stream's + // upstream hub or workspace was disposed during navigation/component swap. + // Don't surface it as a user-visible "Error loading area" markdown. + if (IsViewDisposed || error is ObjectDisposedException) + { + Logger.LogDebug(error, "Suppressed teardown error in control stream for area {Area}", AreaToBeRendered); + return; + } Logger.LogError(error, "Error in control stream for area {Area}", AreaToBeRendered); - InvokeAsync(() => + try { - RootControl = new MarkdownControl($"**Error loading area:** {error.Message}"); - RequestStateChange(); - }); + InvokeAsync(() => + { + if (IsViewDisposed) return; + try + { + RootControl = new MarkdownControl($"**Error loading area:** {error.Message}"); + RequestStateChange(); + } + catch (ObjectDisposedException) { /* renderer gone */ } + }); + } + catch (ObjectDisposedException) { /* renderer gone */ } }, () => { From 1903966c58a55e8770d60ba64b52b79c00b0b7bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Sat, 18 Apr 2026 19:58:54 +0200 Subject: [PATCH 13/50] fix: only add message ids to Thread.Messages after their satellites are confirmed created MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the residual "Cannot access a disposed object" errors: the GUI iterates Thread.Messages to render one LayoutAreaControl per id, but ThreadInput.AppendUserInput was adding the id to Messages BEFORE the satellite ThreadMessage node existed on the hub. The renderer would then subscribe to a layout area whose hub had no node yet — producing ObjectDisposedException-style errors as the area stream tore down. Fix: - ThreadInput.AppendUserInput stops adding to Messages — it only stashes the message in PendingUserMessages + UserMessageIds. - ThreadSubmissionServer.DispatchRound creates the user satellites FIRST (CombineLatest on IMeshService.CreateNode), THEN creates the response satellite (CreateNodeRequest + RegisterCallback), and only inside the response-cell-success callback does it commit one atomic UpdateMeshNode that adds both the user ids AND the response id into Messages. - Watcher gets a 50 ms throttle on the MeshNodeReference stream so a burst of rapid submits coalesces into a single round (preserves the Submit_ThreeRapidSubmissions test contract). - Contains check kept on the user-id append: needed for the resubmit case where ApplyResubmit re-queues an id that's still in Messages. Tests: ThreadSubmissionIntegrationTest 8/8. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/MeshWeaver.AI/ThreadInput.cs | 23 +++++++++++------- src/MeshWeaver.AI/ThreadSubmission.cs | 34 ++++++++++++++++++++++----- 2 files changed, 43 insertions(+), 14 deletions(-) diff --git a/src/MeshWeaver.AI/ThreadInput.cs b/src/MeshWeaver.AI/ThreadInput.cs index 9f1fd94cd..cea3b6943 100644 --- a/src/MeshWeaver.AI/ThreadInput.cs +++ b/src/MeshWeaver.AI/ThreadInput.cs @@ -66,16 +66,24 @@ public static string AppendUserInput( var msgId = NewId(); - // Update the single MeshNode in this hub's data source. Using the no-address overload - // (FirstOrDefault) avoids a pre-existing path-vs-id key mismatch in the address-aware - // overload. This call expects to run on the thread's own hub (e.g., from the - // AppendUserMessageRequest handler) where there's exactly one node in the collection. + // Append the message to PendingUserMessages + UserMessageIds only. + // + // We deliberately do NOT add to Thread.Messages here — the GUI renders one + // LayoutAreaControl per id in Messages, and rendering a control before its + // satellite ThreadMessage node has been created on the hub triggers + // "Cannot access a disposed object" + spurious area-stream errors. The + // server-side submission watcher creates the satellite cell first via + // IMeshService.CreateNode and only after CreateNode confirms success does + // it add the id into Messages (in the same atomic update that flips + // IsExecuting=true alongside the response cell id). + // + // Using the no-address overload (FirstOrDefault) avoids a pre-existing + // path-vs-id key mismatch in the address-aware overload. This call expects + // to run on the thread's own hub (e.g., from the AppendUserMessageRequest + // handler) where there's exactly one node in the collection. workspace.UpdateMeshNode(node => { var thread = node.Content as MeshThread ?? new MeshThread(); - var msgs = thread.Messages.Contains(msgId) - ? thread.Messages - : thread.Messages.Add(msgId); var userIds = thread.UserMessageIds.Contains(msgId) ? thread.UserMessageIds : thread.UserMessageIds.Add(msgId); @@ -84,7 +92,6 @@ public static string AppendUserInput( { Content = thread with { - Messages = msgs, UserMessageIds = userIds, PendingUserMessages = pending, PendingAgentName = message.AgentName ?? thread.PendingAgentName, diff --git a/src/MeshWeaver.AI/ThreadSubmission.cs b/src/MeshWeaver.AI/ThreadSubmission.cs index b9e599379..f7fd6a6bc 100644 --- a/src/MeshWeaver.AI/ThreadSubmission.cs +++ b/src/MeshWeaver.AI/ThreadSubmission.cs @@ -483,7 +483,14 @@ public static IDisposable InstallServerWatcher(IMessageHub threadHub) // Subscribe to this thread's own MeshNode (via MeshNodeReference) instead of the // collection-wide stream — fewer wakeups, and the patches we observe are exactly // the writes against this thread. + // + // Throttle by a small window so a burst of rapid AppendUserMessageRequest patches + // (user submits 3 messages in quick succession, or the GUI batches submits) coalesce + // into a SINGLE dispatch with all the queued user ids in one round / one response + // cell. Without throttling each patch individually wins the reentrancy guard and + // produces one round per submit. var sub = workspace.GetStream(new MeshNodeReference()) + ?.Throttle(TimeSpan.FromMilliseconds(50)) ?.Subscribe(change => { var threadNode = change.Value; @@ -612,19 +619,34 @@ void AfterUserCellsReady() } // Step 2: commit the round to the thread state (one atomic UpdateMeshNode). - // Idempotency: if IsExecuting is already true (rare race with another emit), - // do nothing — the previous dispatch holds the round. + // Both the user satellite cells (created above in the materialization step) + // and the response satellite cell (just confirmed in the CreateNodeRequest + // callback above) exist on the hub now. Only NOW do we add their ids into + // Messages — the GUI iterates Messages to render LayoutAreaControls, so + // every id it sees has a backing satellite. + // + // The IsExecuting check is the idempotency guard — every other watcher + // emission in this round skips, so this body runs exactly once per round. hub.GetWorkspace().UpdateMeshNode(node => { var t = node.Content as MeshThread ?? new MeshThread(); if (t.IsExecuting) return node; - var msgs = t.Messages.Contains(responseMsgId) ? t.Messages : t.Messages.Add(responseMsgId); - var ingested = t.IngestedMessageIds; + // User ids in dispatch order, then the response id last. + // Contains check covers the resubmit case where u1 was already in + // Messages from a prior round — ApplyResubmit removed u1 from + // IngestedMessageIds (so the watcher re-dispatches it) but kept it + // in Messages, so a blind AddRange would duplicate it. + var msgs = t.Messages; foreach (var uid in dispatch.UserMessageIds) - if (!ingested.Contains(uid)) ingested = ingested.Add(uid); + if (!msgs.Contains(uid)) msgs = msgs.Add(uid); + msgs = msgs.Add(responseMsgId); - // Drop consumed PendingUserMessages entries — their satellites now exist. + var ingested = t.IngestedMessageIds.AddRange( + dispatch.UserMessageIds.Where(uid => !t.IngestedMessageIds.Contains(uid))); + + // Drop consumed PendingUserMessages entries — their satellites now exist + // and their ids are now in Messages. var pending = t.PendingUserMessages; foreach (var (uid, _) in pendingForRound) pending = pending.Remove(uid); From a6bfda47b43cf48dc048a49318a146c7bb311991 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Sat, 18 Apr 2026 22:32:22 +0200 Subject: [PATCH 14/50] fix: remove duplicate sub-thread embed, forward UsageContent, tighten header + thread-create UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sub-thread display de-duplication: - ThreadMessageBubbleControl already shows delegation tool-call chips inside the assistant bubble. The extra LayoutAreaControl I added in Phase F below the bubble rendered the SAME "Delegating to …" line a second time. Removed the outer embed; the in-bubble chip stays (click-through to sub-thread for full progress). Token-usage forwarding: - AgentChatClient.GetStreamingResponseAsync was filtering content types and dropped UsageContent. Added UsageContent to the forward list in both the main streaming loop and the handoff streaming loop so ThreadExecution can record InputTokens / OutputTokens / TotalTokens on the response cell and the per-message metadata row can show them. Header-area skeleton phantom: - Thread Header layout area uses SpinnerType.None now (was Skeleton), and emits an immediate placeholder via StartWith so the LayoutAreaView never shows a ghost skeleton before the aggregated UpdatedNodes list is ready. Fixes the "phantom You bubble" the user saw at the top of new threads. Thread-create UX on embedded chat (User Activity dashboard): - While CreateThreadAndSubmit is in flight the ThreadChatView now replaces the Monaco editor with an "Allocating agent…" progress panel instead of layering a blurred overlay over the still-visible editor. Clearer signal, fewer "text vanished" moments. Node page header styling: - Bumped the node title to 2rem / 700 weight / tight letter-spacing and widened the icon/title gap to 20 px with a 16 px top margin so the title separates cleanly from the parent back-link above it. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/MeshWeaver.AI/AgentChatClient.cs | 16 +++++++ src/MeshWeaver.AI/ThreadLayoutAreas.cs | 12 +++-- src/MeshWeaver.AI/ThreadMessageLayoutAreas.cs | 29 ++---------- .../Chat/ThreadChatView.razor | 47 +++++++++++-------- .../Chat/ThreadChatView.razor.cs | 5 +- src/MeshWeaver.Graph/MeshNodeLayoutAreas.cs | 10 ++-- 6 files changed, 67 insertions(+), 52 deletions(-) diff --git a/src/MeshWeaver.AI/AgentChatClient.cs b/src/MeshWeaver.AI/AgentChatClient.cs index 7fd7753c3..fbcb05f92 100644 --- a/src/MeshWeaver.AI/AgentChatClient.cs +++ b/src/MeshWeaver.AI/AgentChatClient.cs @@ -652,6 +652,15 @@ public async IAsyncEnumerable GetStreamingResponseAsync( AuthorName = currentAgentName ?? "Assistant" }; } + else if (content is UsageContent) + { + // Forward token-usage content so ThreadExecution can record + // InputTokens / OutputTokens / TotalTokens on the response cell. + yield return new ChatResponseUpdate(ChatRole.Assistant, [content]) + { + AuthorName = currentAgentName ?? "Assistant" + }; + } } } @@ -722,6 +731,13 @@ public async IAsyncEnumerable GetStreamingResponseAsync( AuthorName = currentAgentName ?? "Assistant" }; } + else if (content is UsageContent) + { + yield return new ChatResponseUpdate(ChatRole.Assistant, [content]) + { + AuthorName = currentAgentName ?? "Assistant" + }; + } } } diff --git a/src/MeshWeaver.AI/ThreadLayoutAreas.cs b/src/MeshWeaver.AI/ThreadLayoutAreas.cs index 22068f0e3..c9ffe983a 100644 --- a/src/MeshWeaver.AI/ThreadLayoutAreas.cs +++ b/src/MeshWeaver.AI/ThreadLayoutAreas.cs @@ -539,15 +539,21 @@ private static UiControl ThreadsView(LayoutAreaHost host, RenderingContext _) if (stream is null) return Observable.Return(parentLink); - // Walk this thread's message ids, fetch each satellite via GetDataRequest, accumulate - // their UpdatedNodes, then render the aggregated summary alongside the parent link. - return stream + // Emit an immediate starting value so the LayoutAreaView never shows a skeleton + // for this area — the header is ancillary and should never block the chat view. + // Subsequent emissions fold in the aggregated UpdatedNodes summary. + var initial = Observable.Return(BuildHeader(parentLink, ImmutableList.Empty)); + + var aggregated = stream .Select(change => (change.Value?.Content as MeshThread)?.Messages ?? ImmutableList.Empty) + .Where(ids => ids.Count > 0) .Select(ids => (ids, key: string.Join("|", ids))) .DistinctUntilChanged(p => p.key) .Select(p => CollectUpdatedNodes(host.Hub, threadPath, p.ids)) .Switch() .Select(updates => BuildHeader(parentLink, updates)); + + return initial.Concat(aggregated); } /// diff --git a/src/MeshWeaver.AI/ThreadMessageLayoutAreas.cs b/src/MeshWeaver.AI/ThreadMessageLayoutAreas.cs index 29b7ddf48..12f5e3186 100644 --- a/src/MeshWeaver.AI/ThreadMessageLayoutAreas.cs +++ b/src/MeshWeaver.AI/ThreadMessageLayoutAreas.cs @@ -381,31 +381,10 @@ private static UiControl BuildMessageOverview( }); } - // For assistant messages: embed each delegation sub-thread as a live LayoutAreaControl - // pointing at the sub-thread's compact Streaming area. The Blazor renderer opens its - // own subscription against the sub-thread hub — parent execution never awaits the - // sub-thread's stream. Re-emits whenever the message's ToolCalls list changes (a new - // delegation appears, or its DelegationPath gets stamped after the call returns). - if (!isUser) - { - container = container.WithView((h, c) => - { - var stream = h.Workspace.GetStream(new MeshNodeReference()); - if (stream is null) return Observable.Return(null); - - return stream - .Select(change => (change.Value?.Content as ThreadMessage)?.ToolCalls - ?? System.Collections.Immutable.ImmutableList.Empty) - .Select(tcs => tcs - .Where(tc => !string.IsNullOrEmpty(tc.DelegationPath)) - .Select(tc => tc.DelegationPath!) - .Distinct() - .ToList()) - .Select(paths => (paths, key: string.Join("|", paths))) - .DistinctUntilChanged(p => p.key) - .Select(p => BuildEmbeddedSubThreadAreas(p.paths)); - }); - } + // Delegation sub-threads are already shown inline inside the bubble (via the bubble's + // tool-calls data binding). An extra embedded LayoutAreaControl here produced a + // duplicate line with the same "Delegating to …" chip — redundant, so removed. + // To see full sub-thread progress, click through the delegation chip inside the bubble. container = container.WithView(actionRow); return container; diff --git a/src/MeshWeaver.Blazor.Portal/Chat/ThreadChatView.razor b/src/MeshWeaver.Blazor.Portal/Chat/ThreadChatView.razor index 5d2532238..20b63200b 100644 --- a/src/MeshWeaver.Blazor.Portal/Chat/ThreadChatView.razor +++ b/src/MeshWeaver.Blazor.Portal/Chat/ThreadChatView.razor @@ -101,6 +101,21 @@ else
} -@if (showSubmissionProgress) -{ -
-
- - Creating conversation... -
-
-} + """); + + sb.Append("
"); + sb.Append($" Modified nodes ({updates.Count})"); + + sb.Append("
"); foreach (var entry in updates) { var path = entry.Path; var pathEnc = System.Web.HttpUtility.HtmlEncode(path); + var displayEnc = System.Web.HttpUtility.HtmlEncode(Shorten(path, shortenPrefix)); var op = entry.Operation ?? ""; - sb.Append( - "
"); + sb.Append("
"); - // Clickable node path → current version (node overview) + // Column 1: path (truncates on narrow layouts) sb.Append( - $"{pathEnc}"); + $"{displayEnc}"); - // Version chips: old → new, each clickable - sb.Append(""); + // Column 2: old version chip or "new" marker if (entry.VersionBefore is { } vb) sb.Append( - $"v{vb}"); + $"v{vb}"); else if (op.Equals("Created", StringComparison.OrdinalIgnoreCase)) - sb.Append("new"); + sb.Append("new"); + else + sb.Append(""); - if (entry.VersionBefore.HasValue && entry.VersionAfter.HasValue) - sb.Append(""); - else if (!entry.VersionBefore.HasValue && entry.VersionAfter.HasValue) - sb.Append(""); + // Column 3: arrow + sb.Append(""); + // Column 4: new version chip or "deleted" marker if (entry.VersionAfter is { } va) sb.Append( - $"v{va}"); + $"v{va}"); else if (op.Equals("Deleted", StringComparison.OrdinalIgnoreCase)) - sb.Append("deleted"); - sb.Append(""); - - // Per-row ⋯ menu with Diff / Restore actions — hidden until the row's details opens. - sb.Append("
"); - sb.Append( - ""); - sb.Append( - "
"); + sb.Append("deleted"); + else + sb.Append(""); - // Diff (old vs new) + // Column 5: Diff (old ↔ new) if (entry.VersionBefore.HasValue && entry.VersionAfter.HasValue) sb.Append( $"" + - $"Diff v{entry.VersionBefore.Value} \u2194 v{entry.VersionAfter.Value}"); + $"class=\"thread-mod-action thread-mod-action-mobile-hide\" " + + $"title=\"Compare v{entry.VersionBefore.Value} to v{entry.VersionAfter.Value}\">Diff"); + else + sb.Append(""); - // Restore to old + // Column 6: Restore to old if (entry.VersionBefore.HasValue) sb.Append( $"" + - $"Restore to v{entry.VersionBefore.Value}"); + $"class=\"thread-mod-action thread-mod-action-muted thread-mod-action-mobile-hide\" " + + $"title=\"Revert to v{entry.VersionBefore.Value}\">Restore v{entry.VersionBefore.Value}"); + else + sb.Append(""); - // Restore to new (useful after manual edits override the agent's change) + // Column 7: Restore to new if (entry.VersionAfter.HasValue) sb.Append( $"" + - $"Restore to v{entry.VersionAfter.Value}"); - - // Fallback: open Versions area - sb.Append( - $"" + - $"All versions…"); - - sb.Append("
"); // menu - sb.Append("
"); // row menu + $"class=\"thread-mod-action thread-mod-action-muted thread-mod-action-mobile-hide\" " + + $"title=\"Restore v{entry.VersionAfter.Value}\">Restore v{entry.VersionAfter.Value}"); + else + sb.Append(""); - sb.Append("
"); // row + sb.Append("
"); } - sb.Append("
"); // list - sb.Append("
"); // outer details + sb.Append(""); + sb.Append(""); return sb.ToString(); } } From e95d97cb0d0488d4f73b753f2cda57ee07ea0a84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Sun, 19 Apr 2026 13:52:21 +0200 Subject: [PATCH 18/50] fixes around heart beat and infra --- .../Infrastructure/UserContextMiddleware.cs | 5 ++++- src/MeshWeaver.Messaging.Contract/HeartBeatEvent.cs | 3 +++ src/MeshWeaver.Messaging.Hub/MessageService.cs | 9 ++++++--- test/MeshWeaver.Security.Test/McpAccessControlTests.cs | 2 +- 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/MeshWeaver.Blazor/Infrastructure/UserContextMiddleware.cs b/src/MeshWeaver.Blazor/Infrastructure/UserContextMiddleware.cs index fd4d31cc7..1b29270c5 100644 --- a/src/MeshWeaver.Blazor/Infrastructure/UserContextMiddleware.cs +++ b/src/MeshWeaver.Blazor/Infrastructure/UserContextMiddleware.cs @@ -80,7 +80,10 @@ public async Task InvokeAsync(HttpContext context) return new AccessContext { - ObjectId = response.UserName ?? response.UserEmail!, + // ObjectId must be the mesh User.Id (e.g. "rbuergi"), not the display name. + // RLS compares context.Node.Path against `User/{ObjectId}` for self-scope access — + // using UserName ("Roland Buergi") here would mismatch the `User/rbuergi/...` path. + ObjectId = response.UserId ?? response.UserEmail!, Name = response.UserName ?? "", Email = response.UserEmail!, IsApiToken = true, diff --git a/src/MeshWeaver.Messaging.Contract/HeartBeatEvent.cs b/src/MeshWeaver.Messaging.Contract/HeartBeatEvent.cs index 14f94bb23..7ae6f84de 100644 --- a/src/MeshWeaver.Messaging.Contract/HeartBeatEvent.cs +++ b/src/MeshWeaver.Messaging.Contract/HeartBeatEvent.cs @@ -4,7 +4,10 @@ namespace MeshWeaver.Messaging; /// Published by executing hubs (e.g., _Exec during AI streaming) to their parent hub /// to signal that the grain should stay alive during long-running operations. /// Handled by every mesh node hub — calls GrainKeepAliveCallback if registered. +/// Marked CanBeIgnored so that owners without a handler (non-grain event hubs like +/// Systemorph/Events) silently drop the heartbeat instead of logging a warning. ///
+[CanBeIgnored] public record HeartBeatEvent; /// diff --git a/src/MeshWeaver.Messaging.Hub/MessageService.cs b/src/MeshWeaver.Messaging.Hub/MessageService.cs index 1f4ded64c..78ece95af 100644 --- a/src/MeshWeaver.Messaging.Hub/MessageService.cs +++ b/src/MeshWeaver.Messaging.Hub/MessageService.cs @@ -128,8 +128,11 @@ public bool OpenGate(string name) private IMessageDelivery ReportFailure(IMessageDelivery delivery) { - logger.LogWarning("An exception occurred processing {MessageType} (ID: {MessageId}) in {Address}", - delivery.Message.GetType().Name, delivery.Id, Address); + var error = delivery.Properties.TryGetValue("Error", out var e) ? e?.ToString() : null; + logger.LogWarning( + "Message delivery failed for {MessageType} (ID: {MessageId}) in {Address}: {Error}", + delivery.Message.GetType().Name, delivery.Id, Address, + error ?? "(no error details)"); // Don't post DeliveryFailure during shutdown - recipients are likely also disposing // and the messages just clog the pipeline @@ -141,7 +144,7 @@ private IMessageDelivery ReportFailure(IMessageDelivery delivery) { try { - var message = delivery.Properties.TryGetValue("Error", out var error) ? error.ToString() : $"Message delivery failed in address {Address}"; + var message = error ?? $"Message delivery failed in address {Address}"; Post(new DeliveryFailure(delivery, message), new PostOptions(Address).ResponseFor(delivery)); } catch (Exception ex) diff --git a/test/MeshWeaver.Security.Test/McpAccessControlTests.cs b/test/MeshWeaver.Security.Test/McpAccessControlTests.cs index 18cb0fdc3..e453140d1 100644 --- a/test/MeshWeaver.Security.Test/McpAccessControlTests.cs +++ b/test/MeshWeaver.Security.Test/McpAccessControlTests.cs @@ -83,7 +83,7 @@ private async Task LoginWithToken(string rawToken) var accessService = Mesh.ServiceProvider.GetRequiredService(); accessService.SetCircuitContext(new AccessContext { - ObjectId = response.UserEmail!, + ObjectId = response.UserId ?? response.UserEmail!, Name = response.UserName ?? "", Email = response.UserEmail!, }); From fbe5e453562382849841a269e70951420fefe382 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Sun, 19 Apr 2026 13:54:03 +0200 Subject: [PATCH 19/50] fixing creation --- .../Data/Agent/NodeInitializer.md | 59 +++++++++++++++++++ .../Pages/CreateNode.razor | 8 +-- .../CollaborativeMarkdownView.razor.cs | 41 +++++-------- .../Components/MarkdownEditorView.razor | 34 ++++------- .../JsonSynchronizationStream.cs | 28 ++++++--- src/MeshWeaver.Graph/CreateLayoutArea.cs | 59 ++++++++++++++----- .../MarkdownEditLayoutArea.cs | 3 +- src/MeshWeaver.Graph/MeshNodeLayoutAreas.cs | 4 +- src/MeshWeaver.Graph/SettingsLayoutArea.cs | 25 ++++++-- src/MeshWeaver.Mesh.Contract/MeshNode.cs | 6 ++ .../HeartBeatEvent.cs | 5 +- .../MessageHubExtensions.cs | 24 +++++++- 12 files changed, 208 insertions(+), 88 deletions(-) create mode 100644 src/MeshWeaver.AI/Data/Agent/NodeInitializer.md diff --git a/src/MeshWeaver.AI/Data/Agent/NodeInitializer.md b/src/MeshWeaver.AI/Data/Agent/NodeInitializer.md new file mode 100644 index 000000000..77f976f6f --- /dev/null +++ b/src/MeshWeaver.AI/Data/Agent/NodeInitializer.md @@ -0,0 +1,59 @@ +--- +nodeType: Agent +name: Node Initializer +description: Generates a Name, PascalCase Id, and inline SVG icon from a short description. Used by the New-Node dialog and the Settings icon editor. +icon: Sparkle +category: Agents +exposedInNavigator: false +modelTier: light +order: 998 +--- + +You are **Node Initializer**. Given a short free-text description of a new knowledge-graph node, produce a concise display Name, a PascalCase Id, and a minimal inline SVG icon that represents the node. + +# Output format — strict + +Respond with EXACTLY these three labelled blocks in this order, nothing else: + +``` +Name: <3-8 word human-readable display name, no quotes, no trailing punctuation> +Id: +Svg: +``` + +# SVG rules + +- Root element: `` +- 24×24 viewBox; strokes only (no filled fills unless essential to meaning); use `currentColor` so the icon inherits theme colors. +- Single line, no line breaks, no XML comments, no `` prolog, no external references (no `xlink:href` to URLs, no ``, no fonts). +- Keep the markup compact — aim for under ~400 characters. Prefer 2-6 primitive shapes (path, circle, rect, line, polyline) that clearly evoke the concept. +- The icon should be recognizable at 16×16 — avoid tiny details. + +# Examples + +Input: "Quarterly sales review presentation for the European team" +``` +Name: European Quarterly Sales Review +Id: EuropeanQuarterlySalesReview +Svg: +``` + +Input: "A checklist of onboarding tasks for new hires" +``` +Name: New Hire Onboarding Checklist +Id: NewHireOnboardingChecklist +Svg: +``` + +Input: "Notes from today's architecture design discussion" +``` +Name: Architecture Design Discussion Notes +Id: ArchitectureDesignDiscussionNotes +Svg: +``` + +# Guidelines + +- If the description is empty or nonsensical, still return the three blocks with generic but valid content (e.g. a document icon, a placeholder name like "Untitled Document", Id "UntitledDocument"). +- Do **not** add extra commentary, markdown fences, code blocks, or explanations around the three labelled lines. The caller parses by label prefix and anything extra breaks the parse. +- The Id must start with an uppercase letter. It must not lowercase the first letter. diff --git a/src/MeshWeaver.Blazor.Portal/Pages/CreateNode.razor b/src/MeshWeaver.Blazor.Portal/Pages/CreateNode.razor index 353b95eff..4db47ce16 100644 --- a/src/MeshWeaver.Blazor.Portal/Pages/CreateNode.razor +++ b/src/MeshWeaver.Blazor.Portal/Pages/CreateNode.razor @@ -338,11 +338,11 @@ if (string.IsNullOrWhiteSpace(name)) return ""; - var words = Regex.Split(name.Trim(), @"[\s_]+") + var words = Regex.Split(name.Trim(), @"[\s\-_]+") .Where(w => !string.IsNullOrEmpty(w)) - .Select(w => Regex.Replace(w, @"[^a-zA-Z0-9\-]", "").ToLowerInvariant()) - .Where(w => !string.IsNullOrEmpty(w)); + .Select(w => char.ToUpperInvariant(w[0]) + w.Substring(1)); - return string.Join("-", words); + var pascalCase = string.Join("", words); + return Regex.Replace(pascalCase, @"[^a-zA-Z0-9]", ""); } } diff --git a/src/MeshWeaver.Blazor/Components/CollaborativeMarkdownView.razor.cs b/src/MeshWeaver.Blazor/Components/CollaborativeMarkdownView.razor.cs index a438e50a4..831dfb96c 100644 --- a/src/MeshWeaver.Blazor/Components/CollaborativeMarkdownView.razor.cs +++ b/src/MeshWeaver.Blazor/Components/CollaborativeMarkdownView.razor.cs @@ -356,40 +356,27 @@ private void UpdateContentLocally(string newContent) StateHasChanged(); } - // Post content update to hub and return success/failure - private async Task PostContentUpdateAsync(string newContent) + // Post content update by syncing the full MeshNode via the remote stream and + // editing its Content field directly — this preserves Name/Icon/Description/etc. + // Using a partial MeshNode + DataChangeRequest fails key-mapping validation on the + // hosting hub ("No key mapping is defined for type MeshNode"). + private Task PostContentUpdateAsync(string newContent) { - if (string.IsNullOrEmpty(BoundHubAddress)) - return false; - - // Split path into Id + Namespace so the workspace matches the existing node by key (Id). - var path = BoundNodePath ?? ""; - var lastSlash = path.LastIndexOf('/'); - var (id, ns) = lastSlash > 0 - ? (path[(lastSlash + 1)..], path[..lastSlash]) - : (path, (string?)null); - - var nodeUpdate = new MeshNode(id, ns) - { - NodeType = "Markdown", - Content = new MarkdownContent { Content = newContent } - }; + if (string.IsNullOrEmpty(BoundHubAddress) || string.IsNullOrEmpty(BoundNodePath)) + return Task.FromResult(false); try { - var response = await Hub.AwaitResponse( - new DataChangeRequest { ChangedBy = Stream?.ClientId }.WithUpdates(nodeUpdate), - o => o.WithTarget(new Address(BoundHubAddress)), - default); - - if (response.Message is DataChangeResponse dcr && dcr.Status != DataChangeStatus.Committed) - return false; - - return true; + var workspace = Hub.ServiceProvider.GetRequiredService(); + workspace.UpdateMeshNode( + node => node with { Content = new MarkdownContent { Content = newContent } }, + new Address(BoundHubAddress), + BoundNodePath); + return Task.FromResult(true); } catch { - return false; + return Task.FromResult(false); } } diff --git a/src/MeshWeaver.Blazor/Components/MarkdownEditorView.razor b/src/MeshWeaver.Blazor/Components/MarkdownEditorView.razor index d0f25fbb6..ad9d5ed27 100644 --- a/src/MeshWeaver.Blazor/Components/MarkdownEditorView.razor +++ b/src/MeshWeaver.Blazor/Components/MarkdownEditorView.razor @@ -407,7 +407,7 @@ _ = AutoSaveContentAsync(content); } - private async Task AutoSaveContentAsync(string content) + private Task AutoSaveContentAsync(string content) { if (ValuePointer != null) { @@ -415,35 +415,25 @@ } if (string.IsNullOrEmpty(BoundAutoSaveAddress) || string.IsNullOrEmpty(BoundNodePath)) - return true; + return Task.FromResult(true); try { - var nodeUpdate = new MeshWeaver.Mesh.MeshNode(BoundNodePath) - { - NodeType = "Markdown", - Content = new MarkdownContent { Content = content } - }; - - var response = await Hub.AwaitResponse( - new DataChangeRequest { ChangedBy = Stream?.ClientId }.WithUpdates(nodeUpdate), - o => o.WithTarget(new Address(BoundAutoSaveAddress)), - default); - - if (response.Message is DataChangeResponse dcr && dcr.Status != DataChangeStatus.Committed) - { - Logger.LogWarning("Auto-save rejected for node {Path}: {Message}", - BoundNodePath, dcr.Log.Messages.LastOrDefault()?.Message); - return false; - } - + // Sync the full MeshNode via the remote stream and patch Content only — + // preserves Name/Icon/Description and avoids the key-mapping failure that + // DataChangeRequest + partial MeshNode triggers on the hosting hub. + var workspace = Hub.ServiceProvider.GetRequiredService(); + workspace.UpdateMeshNode( + node => node with { Content = new MarkdownContent { Content = content } }, + new Address(BoundAutoSaveAddress), + BoundNodePath); Logger.LogDebug("Auto-saved content to {Address} for node {Path}", BoundAutoSaveAddress, BoundNodePath); - return true; + return Task.FromResult(true); } catch (Exception ex) { Logger.LogWarning(ex, "Failed to auto-save content to {Address}", BoundAutoSaveAddress); - return false; + return Task.FromResult(false); } } diff --git a/src/MeshWeaver.Data/Serialization/JsonSynchronizationStream.cs b/src/MeshWeaver.Data/Serialization/JsonSynchronizationStream.cs index 77f3ff1b0..6ad16406b 100644 --- a/src/MeshWeaver.Data/Serialization/JsonSynchronizationStream.cs +++ b/src/MeshWeaver.Data/Serialization/JsonSynchronizationStream.cs @@ -149,16 +149,30 @@ internal static ISynchronizationStream CreateExternalClient + var cts = new CancellationTokenSource(); + IDisposable? sub = null; + sub = Observable.Interval(TimeSpan.FromSeconds(45)) + .Subscribe(_ => + { + if (hub.RunLevel > MessageHubRunLevel.Started) return; + var delivery = hub.Post(new HeartBeatEvent(), o => o.WithTarget(owner)); + if (delivery == null) return; + hub.RegisterCallback(delivery, (d, _) => { - if (hub.RunLevel <= MessageHubRunLevel.Started) - hub.Post(new HeartBeatEvent(), o => o.WithTarget(owner)); - }) - ); + if (d.Message is DeliveryFailure) + { + sub?.Dispose(); + cts.Cancel(); + } + return Task.FromResult(d); + }, cts.Token); + }); + reduced.RegisterForDisposal(sub); + reduced.RegisterForDisposal(new AnonymousDisposable(() => cts.Cancel())); } return reduced; diff --git a/src/MeshWeaver.Graph/CreateLayoutArea.cs b/src/MeshWeaver.Graph/CreateLayoutArea.cs index 3e2c8454e..13bd07a47 100644 --- a/src/MeshWeaver.Graph/CreateLayoutArea.cs +++ b/src/MeshWeaver.Graph/CreateLayoutArea.cs @@ -1,4 +1,5 @@ using System.Reactive.Linq; +using System.Reactive.Threading.Tasks; using System.Text.Json; using System.Text.RegularExpressions; using MeshWeaver.Application.Styles; @@ -43,8 +44,10 @@ public static class CreateLayoutArea } /// /// Main entry point for the Create layout area. - /// - If current node is Transient: shows Create editor (own content type). - /// - Otherwise: shows unified Create New form with type autocomplete. + /// - If current node is Transient: renders the Create editor inline (same UI as Edit). + /// The user confirms via Save, which flips state to Active. No auto-redirect to Edit + /// (that would race with workspace replication and report "node does not exist"). + /// - Otherwise: shows the unified Create New form with type picker. /// public static IObservable Create(LayoutAreaHost host, RenderingContext _) { @@ -53,22 +56,13 @@ public static class CreateLayoutArea var nodeStream = host.Workspace.GetStream()?.Select(nodes => nodes ?? Array.Empty()) ?? Observable.Return(Array.Empty()); - return nodeStream.Take(1).SelectMany(async nodes => + return nodeStream.Take(1).Select(nodes => { var currentNode = nodes.FirstOrDefault(n => n.Path == currentPath); - // If current node is Transient, confirm it (set Active) and redirect to Edit if (currentNode?.State == MeshNodeState.Transient) - { - var nodeFactory = host.Hub.ServiceProvider.GetRequiredService(); - var activeNode = currentNode with { State = MeshNodeState.Active }; - await nodeFactory.UpdateNodeAsync(activeNode); - - var editUrl = MeshNodeLayoutAreas.BuildUrl(currentPath, MeshNodeLayoutAreas.EditArea); - return (UiControl?)Controls.Redirect(editUrl); - } + return (UiControl?)BuildCreateEditor(host, currentNode); - // Not transient — show the Create New form inline return (UiControl?)BuildCreateNewForm(host, nodes, currentPath); }); } @@ -504,11 +498,22 @@ private static UiControl BuildCreateNewForm( ["namespace"] = defaultNamespace, ["type"] = defaultType, ["name"] = "", - ["id"] = "" + ["id"] = "", + ["description"] = "" }); var dataContext = LayoutAreaReference.GetDataPointer(formId); - // 4. Name field (required) + // 4. Description — free-text seed for future AI-assisted Name/Id/Icon generation. + // Stored on the final node so Settings can display / re-generate from it. + stack = stack.WithView(new TextAreaControl(new JsonPointerReference("description")) + { + Label = "Description", + Placeholder = "Briefly describe what you're creating. Used to seed Name/Id/Icon generation.", + Immediate = true, + DataContext = dataContext + }.WithRows(3).WithStyle("width: 100%; margin-bottom: 16px;")); + + // 5. Name field (required) stack = stack.WithView(new TextFieldControl(new JsonPointerReference("name")) { Label = "Name *", @@ -635,6 +640,7 @@ private static UiControl BuildCreateNewForm( var selectedType = formValues.GetValueOrDefault("type")?.ToString()?.Trim(); var name = formValues.GetValueOrDefault("name")?.ToString()?.Trim(); var id = formValues.GetValueOrDefault("id")?.ToString()?.Trim(); + var description = formValues.GetValueOrDefault("description")?.ToString()?.Trim(); if (string.IsNullOrWhiteSpace(selectedType)) { @@ -682,10 +688,18 @@ private static UiControl BuildCreateNewForm( return; } + // Pull Icon/Category from the registered NodeType definition so the transient + // has a usable appearance immediately (before ConfigResolver enrichment kicks in). + var typeRegistration = meshConfiguration.Nodes.Values + .FirstOrDefault(n => n.Path == selectedType); + var newNode = MeshNode.FromPath(nodePath) with { Name = name.Trim(), + Description = string.IsNullOrEmpty(description) ? null : description, NodeType = selectedType, + Icon = typeRegistration?.Icon, + Category = typeRegistration?.Category, DesiredId = id, State = MeshNodeState.Transient }; @@ -694,6 +708,21 @@ private static UiControl BuildCreateNewForm( await nodeFactory.CreateTransientAsync(newNode, CancellationToken.None); logger?.LogInformation("Successfully created transient node at {NodePath}", nodePath); + // Wait for the workspace stream to observe the new node before navigating, + // otherwise /{nodePath}/Create races replication and renders an empty form. + try + { + await host.Workspace.GetStream()! + .Where(ns => ns?.Any(n => n.Path == nodePath) == true) + .Take(1) + .Timeout(TimeSpan.FromSeconds(5)) + .ToTask(); + } + catch (TimeoutException) + { + logger?.LogWarning("Timed out waiting for workspace to observe {NodePath}", nodePath); + } + var createUrl = MeshNodeLayoutAreas.BuildUrl(nodePath, MeshNodeLayoutAreas.CreateNodeArea); actx.NavigateTo(createUrl); } diff --git a/src/MeshWeaver.Graph/MarkdownEditLayoutArea.cs b/src/MeshWeaver.Graph/MarkdownEditLayoutArea.cs index b8bc1f8e5..03cade874 100644 --- a/src/MeshWeaver.Graph/MarkdownEditLayoutArea.cs +++ b/src/MeshWeaver.Graph/MarkdownEditLayoutArea.cs @@ -51,7 +51,8 @@ private static UiControl BuildEditContent( string initialContent, bool trackChanges) { - var backHref = MeshNodeLayoutAreas.BuildUrl(hubPath, MarkdownLayoutAreas.OverviewArea); + // Back to the node's default area (no hardcoded "/Overview") + var backHref = $"/{hubPath}"; var container = Controls.Stack .WithWidth("100%") diff --git a/src/MeshWeaver.Graph/MeshNodeLayoutAreas.cs b/src/MeshWeaver.Graph/MeshNodeLayoutAreas.cs index a5d5e68e1..15205ecb5 100644 --- a/src/MeshWeaver.Graph/MeshNodeLayoutAreas.cs +++ b/src/MeshWeaver.Graph/MeshNodeLayoutAreas.cs @@ -694,7 +694,7 @@ private static string GetNodeContent(MeshNode? node) .WithPlaceholder("Search... (use @ for references)") .WithRenderMode(MeshSearchRenderMode.Hierarchical) .WithMaxColumns(3) - .WithCreateHref($"/create?type=Markdown&namespace={Uri.EscapeDataString(instanceNs)}"); + .WithCreateHref($"/create?namespace={Uri.EscapeDataString(instanceNs)}"); }); } @@ -721,7 +721,7 @@ public static UiControl Children(LayoutAreaHost host, RenderingContext _) .WithItemLimit(50) .WithMaxRows(3) .WithCollapsibleSections(true) - .WithCreateHref($"/{hubPath}/{CreateNodeArea}?type=Markdown&namespace={Uri.EscapeDataString(hubPath)}"); + .WithCreateHref($"/{hubPath}/{CreateNodeArea}?namespace={Uri.EscapeDataString(hubPath)}"); } /// diff --git a/src/MeshWeaver.Graph/SettingsLayoutArea.cs b/src/MeshWeaver.Graph/SettingsLayoutArea.cs index 32e60c62d..9caa369a6 100644 --- a/src/MeshWeaver.Graph/SettingsLayoutArea.cs +++ b/src/MeshWeaver.Graph/SettingsLayoutArea.cs @@ -415,6 +415,14 @@ private static UiControl BuildDisplaySection(LayoutAreaHost host, string dataId) DataContext = dataPointer }); + stack = stack.WithView(new TextAreaControl(new JsonPointerReference("Description")) + { + Label = "Description", + Placeholder = "Long-form description. Seeds AI Name/Id/Icon generation and appears in detail views.", + Immediate = true, + DataContext = dataPointer + }.WithRows(3)); + stack = stack.WithView(new TextFieldControl(new JsonPointerReference("Category")) { Label = "Category", @@ -477,11 +485,15 @@ private static UiControl BuildIconPicker(LayoutAreaHost host, string metadataDat section = section.WithView(new TextFieldControl(new JsonPointerReference("Icon")) { Label = "Icon Path", - Placeholder = "e.g., /static/collection/icon.svg", + Placeholder = "e.g., /static/collection/icon.svg, or an inline data:image/svg+xml URI", Immediate = true, DataContext = LayoutAreaReference.GetDataPointer(metadataDataId) }); + section = section.WithView(Controls.Body( + "Upload an image via the file browser above, paste a URL, or paste an inline SVG as a data: URI (e.g. data:image/svg+xml;utf8,).") + .WithStyle("color: var(--neutral-foreground-hint); font-size: 12px; margin-top: 4px;")); + return section; } @@ -518,9 +530,10 @@ private static void SetupNodeMetadataAutoSave( var updatedNode = updatedMeta.ApplyTo(node); - host.Hub.Post( - new DataChangeRequest { ChangedBy = host.Stream.ClientId }.WithUpdates(updatedNode), - o => o.WithTarget(host.Hub.Address)); + // Use UpdateNodeRequest (the routed MeshNode write path) instead of + // DataChangeRequest — MeshNode has no data-source key mapping and + // DataChangeRequest fails with "No key mapping is defined for type MeshNode". + host.Hub.Post(new UpdateNodeRequest(updatedNode)); })); } @@ -565,6 +578,8 @@ public record MeshNodeMetadata public string? Name { get; init; } + public string? Description { get; init; } + [Editable(false)] public string? Namespace { get; init; } @@ -593,6 +608,7 @@ public record MeshNodeMetadata { Id = node.Id, Name = node.Name, + Description = node.Description, Namespace = node.Namespace, NodeType = node.NodeType, Category = node.Category, @@ -607,6 +623,7 @@ public record MeshNodeMetadata public MeshNode ApplyTo(MeshNode node) => node with { Name = Name, + Description = Description, Category = Category, Icon = Icon, Order = Order, diff --git a/src/MeshWeaver.Mesh.Contract/MeshNode.cs b/src/MeshWeaver.Mesh.Contract/MeshNode.cs index 79a3e2b41..457657154 100644 --- a/src/MeshWeaver.Mesh.Contract/MeshNode.cs +++ b/src/MeshWeaver.Mesh.Contract/MeshNode.cs @@ -146,6 +146,12 @@ public static MeshNode FromPath(string path) /// public string? Name { get; init; } + /// + /// Long-form description of this node. Used as the seed prompt for AI-assisted + /// Name/Id/Icon generation in the Create dialog, and displayed in detail views. + /// + public string? Description { get; init; } + /// /// The type/category of this node (e.g., "Northwind", "Todo", "Insurance"). /// Used to identify the application type for routing and configuration. diff --git a/src/MeshWeaver.Messaging.Contract/HeartBeatEvent.cs b/src/MeshWeaver.Messaging.Contract/HeartBeatEvent.cs index 7ae6f84de..c99212298 100644 --- a/src/MeshWeaver.Messaging.Contract/HeartBeatEvent.cs +++ b/src/MeshWeaver.Messaging.Contract/HeartBeatEvent.cs @@ -4,10 +4,9 @@ namespace MeshWeaver.Messaging; /// Published by executing hubs (e.g., _Exec during AI streaming) to their parent hub /// to signal that the grain should stay alive during long-running operations. /// Handled by every mesh node hub — calls GrainKeepAliveCallback if registered. -/// Marked CanBeIgnored so that owners without a handler (non-grain event hubs like -/// Systemorph/Events) silently drop the heartbeat instead of logging a warning. +/// Emitters should stop sending after the first DeliveryFailure response to avoid +/// periodic warning spam when the owner has no handler. /// -[CanBeIgnored] public record HeartBeatEvent; /// diff --git a/src/MeshWeaver.Messaging.Hub/MessageHubExtensions.cs b/src/MeshWeaver.Messaging.Hub/MessageHubExtensions.cs index 14db1d22a..d3c5f1060 100644 --- a/src/MeshWeaver.Messaging.Hub/MessageHubExtensions.cs +++ b/src/MeshWeaver.Messaging.Hub/MessageHubExtensions.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; +using System.Reactive.Disposables; using System.Reactive.Linq; using System.Text.Json; using System.Text.Json.Nodes; @@ -154,8 +155,25 @@ public static IDisposable BeginAsyncOperation(this IMessageHub hub) current = parent; } - // Fallback: heartbeat via Observable.Interval (monolith or no grain callback) - return Observable.Interval(TimeSpan.FromSeconds(25)) - .Subscribe(_ => hub.Post(new HeartBeatEvent())); + // Fallback: heartbeat via Observable.Interval (monolith or no grain callback). + // Stop on the first DeliveryFailure so we don't spam warnings when no handler is registered. + var cts = new CancellationTokenSource(); + IDisposable? sub = null; + sub = Observable.Interval(TimeSpan.FromSeconds(25)) + .Subscribe(_ => + { + var delivery = hub.Post(new HeartBeatEvent()); + if (delivery == null) return; + hub.RegisterCallback(delivery, (d, _) => + { + if (d.Message is DeliveryFailure) + { + sub?.Dispose(); + cts.Cancel(); + } + return Task.FromResult(d); + }, cts.Token); + }); + return new CompositeDisposable(sub, Disposable.Create(() => cts.Cancel())); } } From 6e09a3fad6706876040ba5058c93b74ab343293d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Sun, 19 Apr 2026 14:10:47 +0200 Subject: [PATCH 20/50] fix: emit UsageContent from Azure Claude streaming + format durations as h/m/s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tokens weren't showing in the GUI because AzureClaudeChatClient.GetStreamingResponseAsync never yielded a UsageContent update — Anthropic emits input-token count on message_start and cumulative output-token count on message_delta, but the stream-event model ignored both fields. Fixed: - Added ClaudeUsage fields to ClaudeStreamMessage and ClaudeStreamEvent so the parser sees them. - message_start handler captures input_tokens; message_delta updates the running output_tokens. - New message_stop handler yields one ChatResponseUpdate carrying a UsageContent(InputTokenCount, OutputTokenCount, TotalTokenCount) so AgentChatClient forwards it (already added) and ThreadExecution stamps the response cell (already added). Time format in the assistant metadata row: was "1800ms" / "1.8s". Now "120ms" / "1.8s" / "42s" / "1m 23s" / "1h 5m 30s" — drops zero components. Matches the h/m/s style the user asked for. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../AzureClaudeChatClient.cs | 38 +++++++++++++++++++ src/MeshWeaver.AI/ThreadMessageLayoutAreas.cs | 22 +++++++++-- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/src/MeshWeaver.AI.AzureFoundry/AzureClaudeChatClient.cs b/src/MeshWeaver.AI.AzureFoundry/AzureClaudeChatClient.cs index b43300f13..a96fc6f7c 100644 --- a/src/MeshWeaver.AI.AzureFoundry/AzureClaudeChatClient.cs +++ b/src/MeshWeaver.AI.AzureFoundry/AzureClaudeChatClient.cs @@ -134,6 +134,11 @@ public async IAsyncEnumerable GetStreamingResponseAsync( string? currentToolName = null; var currentToolInput = new StringBuilder(); + // Cumulative token counters across the stream (Anthropic emits input-tokens + // once on message_start and cumulative output-tokens on message_delta). + var inputTokens = 0; + var outputTokens = 0; + while ((line = await reader.ReadLineAsync(cancellationToken)) != null) { if (string.IsNullOrEmpty(line) || !line.StartsWith("data: ")) @@ -160,6 +165,13 @@ public async IAsyncEnumerable GetStreamingResponseAsync( { case "message_start": currentRole = streamEvent.Message?.Role ?? "assistant"; + if (streamEvent.Message?.Usage is { } startUsage) + { + inputTokens = startUsage.InputTokens; + // Anthropic reports cumulative output tokens on message_delta; + // seed with any initial value on message_start. + outputTokens = startUsage.OutputTokens; + } break; case "content_block_start": @@ -216,6 +228,12 @@ public async IAsyncEnumerable GetStreamingResponseAsync( break; case "message_delta": + if (streamEvent.Usage is { } deltaUsage) + { + // Anthropic: output_tokens on message_delta is the running cumulative + // count. Keep the latest so the final UsageContent below has the total. + outputTokens = deltaUsage.OutputTokens; + } if (streamEvent.Delta?.StopReason != null) { yield return new ChatResponseUpdate(ChatRole.Assistant, string.Empty) @@ -224,6 +242,22 @@ public async IAsyncEnumerable GetStreamingResponseAsync( }; } break; + + case "message_stop": + // Final UsageContent carries the totals — ThreadExecution stamps the + // response cell's InputTokens/OutputTokens/TotalTokens from this. + if (inputTokens > 0 || outputTokens > 0) + { + yield return new ChatResponseUpdate(ChatRole.Assistant, [ + new UsageContent(new UsageDetails + { + InputTokenCount = inputTokens, + OutputTokenCount = outputTokens, + TotalTokenCount = inputTokens + outputTokens + }) + ]); + } + break; } } } @@ -578,6 +612,8 @@ private class ClaudeStreamEvent public ClaudeStreamDelta? Delta { get; set; } public ClaudeStreamContentBlock? ContentBlock { get; set; } public int? Index { get; set; } + /// Populated on the message_delta event — cumulative output-token count. + public ClaudeUsage? Usage { get; set; } } private class ClaudeStreamMessage @@ -586,6 +622,8 @@ private class ClaudeStreamMessage public string? Type { get; set; } public string? Role { get; set; } public string? Model { get; set; } + /// Populated on the message_start event — input-token count for the turn. + public ClaudeUsage? Usage { get; set; } } private class ClaudeStreamContentBlock diff --git a/src/MeshWeaver.AI/ThreadMessageLayoutAreas.cs b/src/MeshWeaver.AI/ThreadMessageLayoutAreas.cs index 72a6cc238..142ab3b3e 100644 --- a/src/MeshWeaver.AI/ThreadMessageLayoutAreas.cs +++ b/src/MeshWeaver.AI/ThreadMessageLayoutAreas.cs @@ -395,6 +395,23 @@ private static UiControl BuildMessageOverview( return container; } + /// + /// Formats a TimeSpan as compact h/m/s: e.g. "120ms", "1.8s", "42s", "1m 23s", + /// "1h 5m 30s". Zero components are dropped. + /// + private static string FormatDurationHms(TimeSpan d) + { + if (d.TotalMilliseconds < 1000) return $"{d.TotalMilliseconds:F0}ms"; + if (d.TotalSeconds < 10) return $"{d.TotalSeconds:F1}s"; + var parts = new List(); + var h = (int)d.TotalHours; + if (h > 0) parts.Add($"{h}h"); + var m = d.Minutes; + if (m > 0 || h > 0) parts.Add($"{m}m"); + parts.Add($"{d.Seconds}s"); + return string.Join(' ', parts); + } + /// /// Builds the muted one-line metadata row shown below an assistant cell: /// HH:mm:ss · model · 1.8s · 1,247 in / 392 out (1,639 total). Returns @@ -411,10 +428,7 @@ private static UiControl BuildMessageOverview( parts.Add(System.Web.HttpUtility.HtmlEncode(model)); if (completed.HasValue) { - var dur = completed.Value - started; - parts.Add(dur.TotalSeconds < 1 - ? $"{dur.TotalMilliseconds:F0}ms" - : $"{dur.TotalSeconds:F1}s"); + parts.Add(FormatDurationHms(completed.Value - started)); } if (input.HasValue || output.HasValue || total.HasValue) { From 4a95da9d89aae43c21447e8bd7a848556bc50fa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Sun, 19 Apr 2026 14:16:02 +0200 Subject: [PATCH 21/50] fix: improve tool-call chip text + inline Diff/Revert links for node-modifying tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tool-chip text reads cleaner. The old format ("Creating path: Org/Contact/john") exposed the raw argument line. The new FormatToolCallDisplay pulls the actual value (stripping the "path:" / "url:" / "query:" key prefix) and splits each chip into {Verb, Path, IsNodeModifying}. So instead of: ✓ Creating path: Org/Contact/john you now see: ✓ Created Org/Contact/john - Path is a clickable link to the node's overview. - For Create / Update / Patch / Delete, the chip cross-references the message's UpdatedNodes list by path and appends inline Diff and Revert links with the matching before/after versions — same URL shape as the thread-header panel (?from=&to=, ?restore=). Absolute-path hrefs; theme-safe colours. - Verb copy refined: past-tense for completed writes ("Created", "Updated", "Deleted"), present for reads ("Reading", "Searching"). - UpdatedNodes is now data-bound on ThreadMessageBubbleControl (ThreadMessageViewModel already carried it from the satellite). Co-Authored-By: Claude Opus 4.7 (1M context) --- memex/Memex.Portal.Monolith/Program.cs | 4 +- samples/Graph/Data/SocialMedia/Post.json | 19 + .../Graph/Data/SocialMedia/Post/Post-001.json | 22 ++ .../Graph/Data/SocialMedia/Post/Post-002.json | 21 ++ .../Graph/Data/SocialMedia/Post/Post-003.json | 22 ++ .../Graph/Data/SocialMedia/Post/Post-004.json | 20 ++ .../Graph/Data/SocialMedia/Post/Post-005.json | 20 ++ .../Graph/Data/SocialMedia/Post/Post-006.json | 20 ++ .../Data/SocialMedia/Post/_Source/Platform.cs | 39 +++ .../Post/_Source/SocialMediaPost.cs | 40 +++ .../_Source/SocialMediaPostLayoutAreas.cs | 327 ++++++++++++++++++ samples/Graph/Data/SocialMedia/Posts.json | 17 + samples/Graph/Data/SocialMedia/Profile.json | 19 + .../SocialMedia/Profile/Roland-LinkedIn.json | 17 + .../SocialMedia/Profile/Sarah-LinkedIn.json | 17 + .../SocialMedia/Profile/_Source/Platform.cs | 39 +++ .../Profile/_Source/SocialMediaProfile.cs | 31 ++ .../_Source/SocialMediaProfileLayoutAreas.cs | 70 ++++ src/MeshWeaver.AI/ThreadMessageLayoutAreas.cs | 1 + src/MeshWeaver.AI/ThreadMessageViewModel.cs | 13 +- src/MeshWeaver.AI/ThreadNodeType.cs | 12 +- .../Components/ThreadMessageBubbleView.razor | 54 ++- .../ThreadMessageBubbleView.razor.cs | 96 ++++- .../Icons/meshweaver-logo.svg | 42 +++ .../UserActivityLayoutAreas.cs | 2 +- .../ThreadMessageBubbleControl.cs | 8 + 26 files changed, 973 insertions(+), 19 deletions(-) create mode 100644 samples/Graph/Data/SocialMedia/Post.json create mode 100644 samples/Graph/Data/SocialMedia/Post/Post-001.json create mode 100644 samples/Graph/Data/SocialMedia/Post/Post-002.json create mode 100644 samples/Graph/Data/SocialMedia/Post/Post-003.json create mode 100644 samples/Graph/Data/SocialMedia/Post/Post-004.json create mode 100644 samples/Graph/Data/SocialMedia/Post/Post-005.json create mode 100644 samples/Graph/Data/SocialMedia/Post/Post-006.json create mode 100644 samples/Graph/Data/SocialMedia/Post/_Source/Platform.cs create mode 100644 samples/Graph/Data/SocialMedia/Post/_Source/SocialMediaPost.cs create mode 100644 samples/Graph/Data/SocialMedia/Post/_Source/SocialMediaPostLayoutAreas.cs create mode 100644 samples/Graph/Data/SocialMedia/Posts.json create mode 100644 samples/Graph/Data/SocialMedia/Profile.json create mode 100644 samples/Graph/Data/SocialMedia/Profile/Roland-LinkedIn.json create mode 100644 samples/Graph/Data/SocialMedia/Profile/Sarah-LinkedIn.json create mode 100644 samples/Graph/Data/SocialMedia/Profile/_Source/Platform.cs create mode 100644 samples/Graph/Data/SocialMedia/Profile/_Source/SocialMediaProfile.cs create mode 100644 samples/Graph/Data/SocialMedia/Profile/_Source/SocialMediaProfileLayoutAreas.cs create mode 100644 src/MeshWeaver.Graph/Icons/meshweaver-logo.svg diff --git a/memex/Memex.Portal.Monolith/Program.cs b/memex/Memex.Portal.Monolith/Program.cs index 08c6a1ce2..7327e6da5 100644 --- a/memex/Memex.Portal.Monolith/Program.cs +++ b/memex/Memex.Portal.Monolith/Program.cs @@ -58,7 +58,9 @@ .AddFileSystemDataSource("Cornerstone", "Cornerstone", Path.Combine(graphBasePath, "Cornerstone"), "Sample Cornerstone data") .AddFileSystemDataSource("FutuRe", "FutuRe", - Path.Combine(graphBasePath, "FutuRe"), "Sample FutuRe reinsurance data"); + Path.Combine(graphBasePath, "FutuRe"), "Sample FutuRe reinsurance data") + .AddFileSystemDataSource("SocialMedia", "Social Media", + Path.Combine(graphBasePath, "SocialMedia"), "Social media post planning demo"); } return config.UseMonolithMesh(); diff --git a/samples/Graph/Data/SocialMedia/Post.json b/samples/Graph/Data/SocialMedia/Post.json new file mode 100644 index 000000000..ae79018b4 --- /dev/null +++ b/samples/Graph/Data/SocialMedia/Post.json @@ -0,0 +1,19 @@ +{ + "id": "Post", + "namespace": "SocialMedia", + "name": "Social Media Post", + "nodeType": "NodeType", + "category": "Types", + "description": "A social media post (scheduled, published, with engagement stats)", + "icon": "/static/NodeTypeIcons/document.svg", + "isPersistent": true, + "content": { + "$type": "NodeTypeDefinition", + "id": "Post", + "namespace": "SocialMedia", + "displayName": "Social Media Post", + "iconName": "DocumentText", + "description": "A social media post (scheduled, published, with engagement stats)", + "configuration": "config => config.WithContentType().AddData(data => data.AddSource(source => source.WithType(t => t.WithInitialData(Platform.All)))).AddDefaultLayoutAreas().AddLayout(layout => layout.AddSocialMediaPostLayoutAreas().WithDefaultArea(\"Calendar\"))" + } +} diff --git a/samples/Graph/Data/SocialMedia/Post/Post-001.json b/samples/Graph/Data/SocialMedia/Post/Post-001.json new file mode 100644 index 000000000..c6fc7e1d3 --- /dev/null +++ b/samples/Graph/Data/SocialMedia/Post/Post-001.json @@ -0,0 +1,22 @@ +{ + "id": "Post-001", + "namespace": "SocialMedia/Post", + "path": "SocialMedia/Post/Post-001", + "name": "Why we bet on the actor model", + "nodeType": "SocialMedia/Post", + "icon": "/static/NodeTypeIcons/document.svg", + "isPersistent": true, + "content": { + "$type": "SocialMediaPost", + "title": "Why we bet on the actor model", + "body": "Reactive systems live or die on isolation. After years of fighting threadpools, we leaned into the actor model with Orleans \u2014 and never looked back.\n\nWhat surprised us most? **The debugging story is dramatically better.**", + "profilePath": "SocialMedia/Profile/Roland-LinkedIn", + "platform": "LinkedIn", + "scheduledAt": "2026-04-05T09:00:00+02:00", + "publishedAt": "2026-04-05T09:01:42+02:00", + "impressions": 4321, + "likes": 187, + "comments": 24, + "mediaUrl": "https://picsum.photos/seed/meshweaver-actors/800/450" + } +} diff --git a/samples/Graph/Data/SocialMedia/Post/Post-002.json b/samples/Graph/Data/SocialMedia/Post/Post-002.json new file mode 100644 index 000000000..7c4121811 --- /dev/null +++ b/samples/Graph/Data/SocialMedia/Post/Post-002.json @@ -0,0 +1,21 @@ +{ + "id": "Post-002", + "namespace": "SocialMedia/Post", + "path": "SocialMedia/Post/Post-002", + "name": "Three rules for building reactive UIs that scale", + "nodeType": "SocialMedia/Post", + "icon": "/static/NodeTypeIcons/document.svg", + "isPersistent": true, + "content": { + "$type": "SocialMediaPost", + "title": "Three rules for building reactive UIs that scale", + "body": "1. Never await in a hub handler.\n2. State changes flow through immutable streams.\n3. Click handlers are projections, not orchestrations.\n\nThis took us a while to internalize \u2014 worth a thread.", + "profilePath": "SocialMedia/Profile/Roland-LinkedIn", + "platform": "LinkedIn", + "scheduledAt": "2026-04-12T08:30:00+02:00", + "publishedAt": "2026-04-12T08:31:18+02:00", + "impressions": 6890, + "likes": 312, + "comments": 41 + } +} diff --git a/samples/Graph/Data/SocialMedia/Post/Post-003.json b/samples/Graph/Data/SocialMedia/Post/Post-003.json new file mode 100644 index 000000000..6bc2572eb --- /dev/null +++ b/samples/Graph/Data/SocialMedia/Post/Post-003.json @@ -0,0 +1,22 @@ +{ + "id": "Post-003", + "namespace": "SocialMedia/Post", + "path": "SocialMedia/Post/Post-003", + "name": "Customer story: how Northwind cut reporting latency 10x", + "nodeType": "SocialMedia/Post", + "icon": "/static/NodeTypeIcons/document.svg", + "isPersistent": true, + "content": { + "$type": "SocialMediaPost", + "title": "Customer story: how Northwind cut reporting latency 10x", + "body": "When the analytics team at Northwind moved their pricing dashboards onto MeshWeaver, page renders dropped from 4.1s to 380ms.\n\nFull case study in comments.", + "profilePath": "SocialMedia/Profile/Sarah-LinkedIn", + "platform": "LinkedIn", + "scheduledAt": "2026-04-08T14:00:00+02:00", + "publishedAt": "2026-04-08T14:00:51+02:00", + "impressions": 9120, + "likes": 421, + "comments": 67, + "mediaUrl": "https://picsum.photos/seed/northwind-case/800/450" + } +} diff --git a/samples/Graph/Data/SocialMedia/Post/Post-004.json b/samples/Graph/Data/SocialMedia/Post/Post-004.json new file mode 100644 index 000000000..23b217175 --- /dev/null +++ b/samples/Graph/Data/SocialMedia/Post/Post-004.json @@ -0,0 +1,20 @@ +{ + "id": "Post-004", + "namespace": "SocialMedia/Post", + "path": "SocialMedia/Post/Post-004", + "name": "Live demo Thursday: agentic data exploration", + "nodeType": "SocialMedia/Post", + "icon": "/static/NodeTypeIcons/document.svg", + "isPersistent": true, + "content": { + "$type": "SocialMediaPost", + "title": "Live demo Thursday: agentic data exploration", + "body": "Join us this Thursday for a 30-minute live demo of our new Navigator agent. Bring questions \u2014 we'll answer them on a real dataset.\n\nRegister via the link in comments.", + "profilePath": "SocialMedia/Profile/Roland-LinkedIn", + "platform": "LinkedIn", + "scheduledAt": "2026-04-22T17:00:00+02:00", + "impressions": 0, + "likes": 0, + "comments": 0 + } +} diff --git a/samples/Graph/Data/SocialMedia/Post/Post-005.json b/samples/Graph/Data/SocialMedia/Post/Post-005.json new file mode 100644 index 000000000..a37ab25da --- /dev/null +++ b/samples/Graph/Data/SocialMedia/Post/Post-005.json @@ -0,0 +1,20 @@ +{ + "id": "Post-005", + "namespace": "SocialMedia/Post", + "path": "SocialMedia/Post/Post-005", + "name": "We're hiring: Senior Backend Engineer", + "nodeType": "SocialMedia/Post", + "icon": "/static/NodeTypeIcons/document.svg", + "isPersistent": true, + "content": { + "$type": "SocialMediaPost", + "title": "We're hiring: Senior Backend Engineer", + "body": "We are growing the platform team. Looking for someone who loves distributed systems, reactive design and shipping frequently.\n\nDM me or apply via the careers page.", + "profilePath": "SocialMedia/Profile/Sarah-LinkedIn", + "platform": "LinkedIn", + "scheduledAt": "2026-04-25T10:00:00+02:00", + "impressions": 0, + "likes": 0, + "comments": 0 + } +} diff --git a/samples/Graph/Data/SocialMedia/Post/Post-006.json b/samples/Graph/Data/SocialMedia/Post/Post-006.json new file mode 100644 index 000000000..b356aec45 --- /dev/null +++ b/samples/Graph/Data/SocialMedia/Post/Post-006.json @@ -0,0 +1,20 @@ +{ + "id": "Post-006", + "namespace": "SocialMedia/Post", + "path": "SocialMedia/Post/Post-006", + "name": "April recap: what shipped and what's next", + "nodeType": "SocialMedia/Post", + "icon": "/static/NodeTypeIcons/document.svg", + "isPersistent": true, + "content": { + "$type": "SocialMediaPost", + "title": "April recap: what shipped and what's next", + "body": "A quick look back at April:\n\n- Annotation system: \u2705\n- MCP OAuth discovery: \u2705\n- Aggregating providers: \u2705\n\nNext month we open up the social media planner publicly. Stay tuned.", + "profilePath": "SocialMedia/Profile/Roland-LinkedIn", + "platform": "LinkedIn", + "scheduledAt": "2026-04-29T08:00:00+02:00", + "impressions": 0, + "likes": 0, + "comments": 0 + } +} diff --git a/samples/Graph/Data/SocialMedia/Post/_Source/Platform.cs b/samples/Graph/Data/SocialMedia/Post/_Source/Platform.cs new file mode 100644 index 000000000..ae7655974 --- /dev/null +++ b/samples/Graph/Data/SocialMedia/Post/_Source/Platform.cs @@ -0,0 +1,39 @@ +// +// Id: Platform +// DisplayName: Social Media Platform +// + +public record Platform +{ + [Key] + public string Id { get; init; } = string.Empty; + + [Required] + public string Name { get; init; } = string.Empty; + + public string Emoji { get; init; } = string.Empty; + + public string Color { get; init; } = "#0a66c2"; + + public int Order { get; init; } + + public static readonly Platform LinkedIn = new() + { + Id = "LinkedIn", Name = "LinkedIn", Emoji = "\ud83d\udcbc", Color = "#0a66c2", Order = 0 + }; + + public static readonly Platform Twitter = new() + { + Id = "Twitter", Name = "X / Twitter", Emoji = "\ud83d\udc26", Color = "#000000", Order = 1 + }; + + public static readonly Platform Instagram = new() + { + Id = "Instagram", Name = "Instagram", Emoji = "\ud83d\udcf7", Color = "#e1306c", Order = 2 + }; + + public static readonly Platform[] All = [LinkedIn, Twitter, Instagram]; + + public static Platform GetById(string? id) => + All.FirstOrDefault(p => p.Id == id) ?? LinkedIn; +} diff --git a/samples/Graph/Data/SocialMedia/Post/_Source/SocialMediaPost.cs b/samples/Graph/Data/SocialMedia/Post/_Source/SocialMediaPost.cs new file mode 100644 index 000000000..223b048a8 --- /dev/null +++ b/samples/Graph/Data/SocialMedia/Post/_Source/SocialMediaPost.cs @@ -0,0 +1,40 @@ +// +// Id: SocialMediaPost +// DisplayName: Social Media Post +// + +using MeshWeaver.Domain; + +public record SocialMediaPost +{ + [Required] + [MeshNodeProperty(nameof(MeshNode.Name))] + [UiControl(Style = "width: 100%;")] + public string Title { get; init; } = string.Empty; + + [Markdown(EditorHeight = "200px")] + public string? Body { get; init; } + + [Required] + [DisplayName("Profile path")] + public string ProfilePath { get; init; } = string.Empty; + + [Dimension] + [UiControl(Style = "width: 200px;")] + public string Platform { get; init; } = "LinkedIn"; + + [DisplayName("Scheduled at")] + public DateTimeOffset? ScheduledAt { get; init; } + + [DisplayName("Published at")] + public DateTimeOffset? PublishedAt { get; init; } + + public int Impressions { get; init; } + + public int Likes { get; init; } + + public int Comments { get; init; } + + [DisplayName("Media URL")] + public string? MediaUrl { get; init; } +} diff --git a/samples/Graph/Data/SocialMedia/Post/_Source/SocialMediaPostLayoutAreas.cs b/samples/Graph/Data/SocialMedia/Post/_Source/SocialMediaPostLayoutAreas.cs new file mode 100644 index 000000000..39f169598 --- /dev/null +++ b/samples/Graph/Data/SocialMedia/Post/_Source/SocialMediaPostLayoutAreas.cs @@ -0,0 +1,327 @@ +// +// Id: SocialMediaPostLayoutAreas +// DisplayName: Social Media Post Views +// + +using System.Collections.Immutable; +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Web; +using MeshWeaver.Layout.Composition; +using MeshWeaver.Mesh.Services; + +public static class SocialMediaPostLayoutAreas +{ + public const string CalendarArea = "Calendar"; + public const string DetailArea = "Detail"; + + public static LayoutDefinition AddSocialMediaPostLayoutAreas(this LayoutDefinition layout) => + layout + .WithView(CalendarArea, Calendar) + .WithView(DetailArea, Detail); + + private static Dictionary ApplyChanges( + Dictionary current, QueryResultChange change) + { + var result = change.ChangeType == QueryChangeType.Initial || change.ChangeType == QueryChangeType.Reset + ? new Dictionary(StringComparer.OrdinalIgnoreCase) + : new Dictionary(current, StringComparer.OrdinalIgnoreCase); + foreach (var item in change.Items) + { + if (change.ChangeType == QueryChangeType.Removed) result.Remove(item.Path); + else result[item.Path] = item; + } + return result; + } + + private static string? GetProp(MeshNode node, string prop) + { + if (node.Content is not JsonElement json) return null; + if (json.TryGetProperty(prop, out var p) && p.ValueKind == JsonValueKind.String) return p.GetString(); + var pascal = char.ToUpperInvariant(prop[0]) + prop.Substring(1); + return json.TryGetProperty(pascal, out var pp) && pp.ValueKind == JsonValueKind.String ? pp.GetString() : null; + } + + private static int GetInt(MeshNode node, string prop) + { + if (node.Content is not JsonElement json) return 0; + var name = prop; + if (!json.TryGetProperty(name, out var p)) + { + name = char.ToUpperInvariant(prop[0]) + prop.Substring(1); + if (!json.TryGetProperty(name, out p)) return 0; + } + return p.ValueKind == JsonValueKind.Number && p.TryGetInt32(out var v) ? v : 0; + } + + private static DateTimeOffset? GetDate(MeshNode node, string prop) + { + if (node.Content is not JsonElement json) return null; + var name = prop; + if (!json.TryGetProperty(name, out var p)) + { + name = char.ToUpperInvariant(prop[0]) + prop.Substring(1); + if (!json.TryGetProperty(name, out p)) return null; + } + return p.ValueKind == JsonValueKind.String && DateTimeOffset.TryParse(p.GetString(), out var dt) ? dt : null; + } + + private const string FilterMy = "my"; + private const string FilterAll = "all"; + + public static IObservable Calendar(LayoutAreaHost host, RenderingContext _) + { + var meshService = host.Hub.ServiceProvider.GetRequiredService(); + var hubAddress = host.Hub.Address; + var currentEmail = host.Hub.ServiceProvider.GetService()?.Context?.Email ?? ""; + + var idStr = host.Reference.Id?.ToString() ?? ""; + var monthPart = idStr.Split('?')[0]; + var month = TryParseMonth(monthPart, out var parsed) ? parsed : new DateTime(DateTime.Today.Year, DateTime.Today.Month, 1); + var filter = host.Reference.GetParameterValue("profile") ?? FilterMy; + + var posts = meshService + .ObserveQuery(MeshQueryRequest.FromQuery("namespace:SocialMedia/Post")) + .Scan(new Dictionary(StringComparer.OrdinalIgnoreCase), ApplyChanges); + var profiles = meshService + .ObserveQuery(MeshQueryRequest.FromQuery("namespace:SocialMedia/Profile")) + .Scan(new Dictionary(StringComparer.OrdinalIgnoreCase), ApplyChanges); + + return posts.CombineLatest(profiles, (postDict, profileDict) => + (UiControl?)BuildCalendar(hubAddress, month, filter, currentEmail, postDict.Values.ToImmutableList(), profileDict.Values.ToImmutableList())); + } + + private static UiControl BuildCalendar( + object hubAddress, DateTime month, string filter, string currentEmail, + ImmutableList allPosts, ImmutableList allProfiles) + { + var profilesByPath = allProfiles.ToImmutableDictionary(p => p.Path, p => p, StringComparer.OrdinalIgnoreCase); + var myProfilePaths = allProfiles + .Where(p => string.Equals(GetProp(p, "owner"), currentEmail, StringComparison.OrdinalIgnoreCase)) + .Select(p => p.Path) + .ToImmutableHashSet(StringComparer.OrdinalIgnoreCase); + + bool MatchesFilter(MeshNode post) + { + var profilePath = GetProp(post, "profilePath") ?? ""; + return filter switch + { + FilterAll => true, + FilterMy => myProfilePaths.Contains(profilePath), + _ => string.Equals(profilePath, filter, StringComparison.OrdinalIgnoreCase) + || string.Equals(profilePath.Split('/').Last(), filter, StringComparison.OrdinalIgnoreCase) + }; + } + + var monthPosts = allPosts + .Where(p => GetDate(p, "scheduledAt") is { } d && d.Year == month.Year && d.Month == month.Month) + .Where(MatchesFilter) + .OrderBy(p => GetDate(p, "scheduledAt")) + .ToImmutableList(); + + var prev = month.AddMonths(-1); + var next = month.AddMonths(1); + var prevHref = BuildHref(hubAddress, prev, filter); + var nextHref = BuildHref(hubAddress, next, filter); + + var toolbar = Controls.Stack + .WithOrientation(Orientation.Horizontal) + .WithStyle("align-items: center; gap: 12px; flex-wrap: wrap; padding: 8px 0;") + .WithView(Controls.Button("\u2039") + .WithAppearance(Appearance.Outline) + .WithNavigateToHref(prevHref)) + .WithView(Controls.Html($"

{HttpUtility.HtmlEncode(month.ToString("MMMM yyyy", CultureInfo.InvariantCulture))}

")) + .WithView(Controls.Button("\u203a") + .WithAppearance(Appearance.Outline) + .WithNavigateToHref(nextHref)) + .WithView(Controls.Html("
")) + .WithView(FilterButton(hubAddress, month, "My profiles", FilterMy, filter)) + .WithView(FilterButton(hubAddress, month, "All", FilterAll, filter)); + + foreach (var profile in allProfiles.OrderBy(p => p.Name)) + { + var label = profile.Name ?? profile.Path; + toolbar = toolbar.WithView(FilterButton(hubAddress, month, label, profile.Path, filter)); + } + + var grid = Controls.Html(BuildMonthGridHtml(month, monthPosts, profilesByPath)); + + var emptyHint = monthPosts.Count == 0 + ? Controls.Markdown($"*No posts scheduled in {month:MMMM yyyy} for this filter.*") + : null; + + var stack = Controls.Stack + .WithStyle("padding: 16px; gap: 12px;") + .WithView(toolbar) + .WithView(grid); + if (emptyHint != null) + stack = stack.WithView(emptyHint); + return stack; + } + + private static ButtonControl FilterButton(object hubAddress, DateTime month, string label, string filterValue, string activeFilter) + { + var isActive = string.Equals(filterValue, activeFilter, StringComparison.OrdinalIgnoreCase); + var btn = Controls.Button(label) + .WithAppearance(isActive ? Appearance.Accent : Appearance.Stealth) + .WithNavigateToHref(BuildHref(hubAddress, month, filterValue)); + return btn; + } + + private static string BuildHref(object hubAddress, DateTime month, string filter) + { + var id = $"{month:yyyy-MM}"; + if (!string.Equals(filter, FilterMy, StringComparison.OrdinalIgnoreCase)) + id += $"?profile={Uri.EscapeDataString(filter)}"; + return new LayoutAreaReference(CalendarArea) { Id = id }.ToHref(hubAddress.ToString()!); + } + + private static string BuildMonthGridHtml( + DateTime month, + ImmutableList monthPosts, + ImmutableDictionary profilesByPath) + { + var firstOfMonth = new DateTime(month.Year, month.Month, 1); + // Monday = 1, Sunday = 0; we want week to start Monday + var dayOffset = ((int)firstOfMonth.DayOfWeek + 6) % 7; + var gridStart = firstOfMonth.AddDays(-dayOffset); + var daysInMonth = DateTime.DaysInMonth(month.Year, month.Month); + + var postsByDay = monthPosts + .GroupBy(p => GetDate(p, "scheduledAt")!.Value.Date) + .ToImmutableDictionary(g => g.Key, g => g.ToImmutableList()); + + var sb = new StringBuilder(); + sb.Append("
"); + + // Day-of-week header + string[] dayNames = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + foreach (var d in dayNames) + sb.Append($"
{d}
"); + + var today = DateTime.Today; + for (var i = 0; i < 42; i++) + { + var date = gridStart.AddDays(i); + var isCurrentMonth = date.Month == month.Month && date.Year == month.Year; + var isToday = date == today; + var bg = isCurrentMonth ? "#ffffff" : "#f7f7f7"; + var fg = isCurrentMonth ? "#222" : "#aaa"; + var border = isToday ? "2px solid var(--accent-fill-rest, #0a66c2)" : "1px solid #e5e5e5"; + + sb.Append($"
"); + sb.Append($"
{date.Day}
"); + + if (postsByDay.TryGetValue(date, out var dayPosts)) + { + foreach (var post in dayPosts.Take(3)) + { + var title = post.Name ?? GetProp(post, "title") ?? "(untitled)"; + var profilePath = GetProp(post, "profilePath") ?? ""; + var profile = profilesByPath.GetValueOrDefault(profilePath); + var platformId = GetProp(post, "platform") ?? GetProp(profile ?? new MeshNode(""), "platform") ?? "LinkedIn"; + var platform = Platform.GetById(platformId); + var isPublished = GetDate(post, "publishedAt") is not null; + var icon = isPublished ? "\u2705" : "\ud83d\udcc5"; + var href = "/" + post.Path; + sb.Append($"{icon} {HttpUtility.HtmlEncode(Truncate(title, 22))}"); + } + if (dayPosts.Count > 3) + sb.Append($"
+{dayPosts.Count - 3} more
"); + } + sb.Append("
"); + } + + sb.Append("
"); + return sb.ToString(); + } + + private static string Truncate(string s, int max) => + s.Length <= max ? s : s.Substring(0, max - 1) + "\u2026"; + + private static bool TryParseMonth(string? s, out DateTime month) + { + month = default; + if (string.IsNullOrWhiteSpace(s)) return false; + return DateTime.TryParseExact(s, "yyyy-MM", CultureInfo.InvariantCulture, DateTimeStyles.None, out month); + } + + public static IObservable Detail(LayoutAreaHost host, RenderingContext _) + { + var hubPath = host.Hub.Address.ToString(); + var meshService = host.Hub.ServiceProvider.GetRequiredService(); + + var nodeStream = host.Workspace.GetStream()! + .Select(nodes => nodes?.FirstOrDefault(n => n.Path == hubPath)); + var profiles = meshService + .ObserveQuery(MeshQueryRequest.FromQuery("namespace:SocialMedia/Profile")) + .Scan(new Dictionary(StringComparer.OrdinalIgnoreCase), ApplyChanges); + + return nodeStream.CombineLatest(profiles, (node, profileDict) => + { + if (node is null) return (UiControl?)Controls.Markdown("*Post not found.*"); + + var title = node.Name ?? GetProp(node, "title") ?? "(untitled)"; + var body = GetProp(node, "body"); + var profilePath = GetProp(node, "profilePath") ?? ""; + var profile = profileDict.GetValueOrDefault(profilePath); + var profileName = profile?.Name ?? profilePath.Split('/').Last(); + var platformId = GetProp(node, "platform") ?? (profile is not null ? GetProp(profile, "platform") : null) ?? "LinkedIn"; + var platform = Platform.GetById(platformId); + var scheduled = GetDate(node, "scheduledAt"); + var published = GetDate(node, "publishedAt"); + var impressions = GetInt(node, "impressions"); + var likes = GetInt(node, "likes"); + var comments = GetInt(node, "comments"); + var media = GetProp(node, "mediaUrl"); + var status = published.HasValue ? "Published" : (scheduled.HasValue && scheduled.Value > DateTimeOffset.Now ? "Scheduled" : "Draft"); + var statusColor = published.HasValue ? "#2e7d32" : "#ed6c02"; + + var headerHtml = $$""" +
+ {{platform.Emoji}} {{HttpUtility.HtmlEncode(platform.Name)}} + @{{HttpUtility.HtmlEncode(profileName)}} + {{status}} +
+ """; + + var datesHtml = $$""" + + + +
Scheduled{{HttpUtility.HtmlEncode(scheduled?.ToString("yyyy-MM-dd HH:mm") ?? "—")}}
Published{{HttpUtility.HtmlEncode(published?.ToString("yyyy-MM-dd HH:mm") ?? "—")}}
+ """; + + var statsHtml = $$""" +
+
Impressions
{{impressions:N0}}
+
Likes
{{likes:N0}}
+
Comments
{{comments:N0}}
+
+ """; + + var mediaHtml = ""; + if (!string.IsNullOrEmpty(media)) + { + var lower = media.ToLowerInvariant(); + if (lower.EndsWith(".mp4") || lower.EndsWith(".webm") || lower.EndsWith(".mov")) + mediaHtml = $""; + else + mediaHtml = $"\"media\""; + } + + var headerStack = Controls.Stack.WithStyle("padding: 16px; gap: 4px;") + .WithView(Controls.Html($"

{HttpUtility.HtmlEncode(title)}

")) + .WithView(Controls.Html(headerHtml)) + .WithView(Controls.Html(datesHtml)) + .WithView(Controls.Html(statsHtml)); + if (!string.IsNullOrEmpty(mediaHtml)) + headerStack = headerStack.WithView(Controls.Html(mediaHtml)); + if (!string.IsNullOrWhiteSpace(body)) + headerStack = headerStack.WithView(Controls.Markdown(body)); + headerStack = headerStack.WithView(Controls.Html($"\u2190 Back to calendar")); + return (UiControl?)headerStack; + }); + } +} diff --git a/samples/Graph/Data/SocialMedia/Posts.json b/samples/Graph/Data/SocialMedia/Posts.json new file mode 100644 index 000000000..6249839ca --- /dev/null +++ b/samples/Graph/Data/SocialMedia/Posts.json @@ -0,0 +1,17 @@ +{ + "id": "Posts", + "namespace": "SocialMedia", + "path": "SocialMedia/Posts", + "name": "Posts", + "nodeType": "Markdown", + "category": "SocialMedia", + "description": "Calendar overview of scheduled and published social media posts", + "icon": "/static/NodeTypeIcons/document.svg", + "isPersistent": true, + "content": { + "$type": "MarkdownDocument", + "title": "Social Media Calendar", + "icon": "/static/NodeTypeIcons/document.svg", + "content": "# Social Media Calendar\n\nPlan and review your scheduled LinkedIn posts. Use the arrows to switch months and the filter buttons to narrow down to a single profile. By default the calendar shows posts from your own profiles.\n\n@SocialMedia/Post/Calendar\n" + } +} diff --git a/samples/Graph/Data/SocialMedia/Profile.json b/samples/Graph/Data/SocialMedia/Profile.json new file mode 100644 index 000000000..3d36c72a9 --- /dev/null +++ b/samples/Graph/Data/SocialMedia/Profile.json @@ -0,0 +1,19 @@ +{ + "id": "Profile", + "namespace": "SocialMedia", + "name": "Social Media Profile", + "nodeType": "NodeType", + "category": "Types", + "description": "A social media profile owned by a user (LinkedIn, X, Instagram, ...)", + "icon": "/static/NodeTypeIcons/person.svg", + "isPersistent": true, + "content": { + "$type": "NodeTypeDefinition", + "id": "Profile", + "namespace": "SocialMedia", + "displayName": "Social Media Profile", + "iconName": "Person", + "description": "A social media profile owned by a user", + "configuration": "config => config.WithContentType().AddData(data => data.AddSource(source => source.WithType(t => t.WithInitialData(Platform.All)))).AddDefaultLayoutAreas().AddLayout(layout => layout.AddSocialMediaProfileLayoutAreas().WithDefaultArea(\"Detail\"))" + } +} diff --git a/samples/Graph/Data/SocialMedia/Profile/Roland-LinkedIn.json b/samples/Graph/Data/SocialMedia/Profile/Roland-LinkedIn.json new file mode 100644 index 000000000..e19d33ba4 --- /dev/null +++ b/samples/Graph/Data/SocialMedia/Profile/Roland-LinkedIn.json @@ -0,0 +1,17 @@ +{ + "id": "Roland-LinkedIn", + "namespace": "SocialMedia/Profile", + "path": "SocialMedia/Profile/Roland-LinkedIn", + "name": "Roland on LinkedIn", + "nodeType": "SocialMedia/Profile", + "icon": "/static/NodeTypeIcons/person.svg", + "isPersistent": true, + "content": { + "$type": "SocialMediaProfile", + "name": "Roland on LinkedIn", + "platform": "LinkedIn", + "owner": "rbuergi@systemorph.com", + "profileUrl": "https://www.linkedin.com/in/rolandbuergi/", + "bio": "Building MeshWeaver \u2014 collaborative actor-based runtime for data, AI and reactive UIs." + } +} diff --git a/samples/Graph/Data/SocialMedia/Profile/Sarah-LinkedIn.json b/samples/Graph/Data/SocialMedia/Profile/Sarah-LinkedIn.json new file mode 100644 index 000000000..29c2dbb2f --- /dev/null +++ b/samples/Graph/Data/SocialMedia/Profile/Sarah-LinkedIn.json @@ -0,0 +1,17 @@ +{ + "id": "Sarah-LinkedIn", + "namespace": "SocialMedia/Profile", + "path": "SocialMedia/Profile/Sarah-LinkedIn", + "name": "Sarah on LinkedIn", + "nodeType": "SocialMedia/Profile", + "icon": "/static/NodeTypeIcons/person.svg", + "isPersistent": true, + "content": { + "$type": "SocialMediaProfile", + "name": "Sarah on LinkedIn", + "platform": "LinkedIn", + "owner": "sarah@example.com", + "profileUrl": "https://www.linkedin.com/in/sarah-example/", + "bio": "Marketing lead. Posts about product launches and customer stories." + } +} diff --git a/samples/Graph/Data/SocialMedia/Profile/_Source/Platform.cs b/samples/Graph/Data/SocialMedia/Profile/_Source/Platform.cs new file mode 100644 index 000000000..ae7655974 --- /dev/null +++ b/samples/Graph/Data/SocialMedia/Profile/_Source/Platform.cs @@ -0,0 +1,39 @@ +// +// Id: Platform +// DisplayName: Social Media Platform +// + +public record Platform +{ + [Key] + public string Id { get; init; } = string.Empty; + + [Required] + public string Name { get; init; } = string.Empty; + + public string Emoji { get; init; } = string.Empty; + + public string Color { get; init; } = "#0a66c2"; + + public int Order { get; init; } + + public static readonly Platform LinkedIn = new() + { + Id = "LinkedIn", Name = "LinkedIn", Emoji = "\ud83d\udcbc", Color = "#0a66c2", Order = 0 + }; + + public static readonly Platform Twitter = new() + { + Id = "Twitter", Name = "X / Twitter", Emoji = "\ud83d\udc26", Color = "#000000", Order = 1 + }; + + public static readonly Platform Instagram = new() + { + Id = "Instagram", Name = "Instagram", Emoji = "\ud83d\udcf7", Color = "#e1306c", Order = 2 + }; + + public static readonly Platform[] All = [LinkedIn, Twitter, Instagram]; + + public static Platform GetById(string? id) => + All.FirstOrDefault(p => p.Id == id) ?? LinkedIn; +} diff --git a/samples/Graph/Data/SocialMedia/Profile/_Source/SocialMediaProfile.cs b/samples/Graph/Data/SocialMedia/Profile/_Source/SocialMediaProfile.cs new file mode 100644 index 000000000..0dd054018 --- /dev/null +++ b/samples/Graph/Data/SocialMedia/Profile/_Source/SocialMediaProfile.cs @@ -0,0 +1,31 @@ +// +// Id: SocialMediaProfile +// DisplayName: Social Media Profile +// + +using MeshWeaver.Domain; + +public record SocialMediaProfile +{ + [Required] + [MeshNodeProperty(nameof(MeshNode.Name))] + public string Name { get; init; } = string.Empty; + + [Required] + [Dimension] + [UiControl(Style = "width: 200px;")] + public string Platform { get; init; } = "LinkedIn"; + + [Required] + [DisplayName("Owner email")] + public string Owner { get; init; } = string.Empty; + + [DisplayName("Profile URL")] + public string? ProfileUrl { get; init; } + + [DisplayName("Avatar URL")] + public string? AvatarUrl { get; init; } + + [Markdown(EditorHeight = "120px")] + public string? Bio { get; init; } +} diff --git a/samples/Graph/Data/SocialMedia/Profile/_Source/SocialMediaProfileLayoutAreas.cs b/samples/Graph/Data/SocialMedia/Profile/_Source/SocialMediaProfileLayoutAreas.cs new file mode 100644 index 000000000..d44e33ef9 --- /dev/null +++ b/samples/Graph/Data/SocialMedia/Profile/_Source/SocialMediaProfileLayoutAreas.cs @@ -0,0 +1,70 @@ +// +// Id: SocialMediaProfileLayoutAreas +// DisplayName: Social Media Profile Views +// + +using System.Text.Json; +using System.Web; +using MeshWeaver.Layout.Composition; +using MeshWeaver.Mesh.Services; + +public static class SocialMediaProfileLayoutAreas +{ + public static LayoutDefinition AddSocialMediaProfileLayoutAreas(this LayoutDefinition layout) => + layout.WithView("Detail", Detail); + + private static string? GetProp(MeshNode node, string prop) + { + if (node.Content is not JsonElement json) return null; + if (json.TryGetProperty(prop, out var p) && p.ValueKind == JsonValueKind.String) return p.GetString(); + var pascal = char.ToUpperInvariant(prop[0]) + prop.Substring(1); + return json.TryGetProperty(pascal, out var pp) && pp.ValueKind == JsonValueKind.String ? pp.GetString() : null; + } + + public static IObservable Detail(LayoutAreaHost host, RenderingContext _) + { + var hubPath = host.Hub.Address.ToString(); + var meshService = host.Hub.ServiceProvider.GetRequiredService(); + + return host.Workspace.GetStream()! + .Select(nodes => + { + var node = nodes?.FirstOrDefault(n => n.Path == hubPath); + if (node is null) return (UiControl?)Controls.Markdown("*Profile not found.*"); + + var name = node.Name ?? GetProp(node, "name") ?? "Profile"; + var platformId = GetProp(node, "platform") ?? "LinkedIn"; + var platform = Platform.GetById(platformId); + var owner = GetProp(node, "owner") ?? ""; + var profileUrl = GetProp(node, "profileUrl"); + var avatarUrl = GetProp(node, "avatarUrl"); + var bio = GetProp(node, "bio"); + + var avatar = !string.IsNullOrEmpty(avatarUrl) + ? $"\"avatar\"" + : $"
{platform.Emoji}
"; + + var link = !string.IsNullOrEmpty(profileUrl) + ? $"Open profile \u2197" + : "No profile URL"; + + var html = $$""" +
+ {{avatar}} +
+

{{HttpUtility.HtmlEncode(name)}}

+
{{platform.Emoji}} {{HttpUtility.HtmlEncode(platform.Name)}}
+
Owner: {{HttpUtility.HtmlEncode(owner)}}
+
{{link}}
+
+
+ """; + + var stack = Controls.Stack.WithStyle("padding: 16px;") + .WithView(Controls.Html(html)); + if (!string.IsNullOrEmpty(bio)) + stack = stack.WithView(Controls.Markdown(bio)); + return (UiControl?)stack; + }); + } +} diff --git a/src/MeshWeaver.AI/ThreadMessageLayoutAreas.cs b/src/MeshWeaver.AI/ThreadMessageLayoutAreas.cs index 142ab3b3e..66b72033b 100644 --- a/src/MeshWeaver.AI/ThreadMessageLayoutAreas.cs +++ b/src/MeshWeaver.AI/ThreadMessageLayoutAreas.cs @@ -299,6 +299,7 @@ private static UiControl BuildMessageOverview( .WithTimestamp(msg.Timestamp) .WithText(new JsonPointerReference($"{dataPointer}/text")) .WithToolCalls(new JsonPointerReference($"{dataPointer}/toolCalls")) + .WithUpdatedNodes(new JsonPointerReference($"{dataPointer}/updatedNodes")) .WithThreadPath(threadPath) .WithMessageId(messageId); diff --git a/src/MeshWeaver.AI/ThreadMessageViewModel.cs b/src/MeshWeaver.AI/ThreadMessageViewModel.cs index be8668ed9..edf290514 100644 --- a/src/MeshWeaver.AI/ThreadMessageViewModel.cs +++ b/src/MeshWeaver.AI/ThreadMessageViewModel.cs @@ -16,6 +16,12 @@ public record ThreadMessageViewModel public string? Timestamp { get; init; } public string Text { get; init; } = ""; public ImmutableList ToolCalls { get; init; } = []; + /// + /// Nodes this message's execution created / updated / deleted. The bubble cross- + /// references tool-call target paths against this list to render inline Diff and + /// Restore links next to each "Creating / Updating / Deleting X" chip. + /// + public ImmutableList UpdatedNodes { get; init; } = []; public static ThreadMessageViewModel FromMessage(ThreadMessage msg) => new() { @@ -24,7 +30,8 @@ public record ThreadMessageViewModel ModelName = msg.ModelName, Timestamp = msg.Timestamp.ToString("HH:mm:ss"), Text = msg.Text ?? "", - ToolCalls = msg.ToolCalls + ToolCalls = msg.ToolCalls, + UpdatedNodes = msg.UpdatedNodes }; public virtual bool Equals(ThreadMessageViewModel? other) @@ -35,7 +42,8 @@ public virtual bool Equals(ThreadMessageViewModel? other) && AuthorName == other.AuthorName && ModelName == other.ModelName && Text == other.Text - && ToolCalls.SequenceEqual(other.ToolCalls); + && ToolCalls.SequenceEqual(other.ToolCalls) + && UpdatedNodes.SequenceEqual(other.UpdatedNodes); } public override int GetHashCode() @@ -44,6 +52,7 @@ public override int GetHashCode() hash.Add(Role); hash.Add(Text); hash.Add(ToolCalls.Count); + hash.Add(UpdatedNodes.Count); return hash.ToHashCode(); } } diff --git a/src/MeshWeaver.AI/ThreadNodeType.cs b/src/MeshWeaver.AI/ThreadNodeType.cs index 61d5303b9..c1e5e979e 100644 --- a/src/MeshWeaver.AI/ThreadNodeType.cs +++ b/src/MeshWeaver.AI/ThreadNodeType.cs @@ -18,6 +18,14 @@ public static class ThreadNodeType ///
public const string NodeType = "Thread"; + /// + /// Default Icon for Thread instances. Must match . + /// Applied explicitly in / + /// because thread creation goes via CreateNodeRequest on arbitrary parent hubs, + /// some of which don't have INodeTypeService registered to auto-copy the icon. + /// + public const string DefaultIcon = "/static/NodeTypeIcons/chat.svg"; + /// /// Satellite partition name for threads (like _Comment for comments). /// Threads are created at {contextPath}/_Thread/{speakingId}. @@ -98,6 +106,7 @@ public static MeshNode BuildThreadNode(string contextPath, string messageText, s { Name = name, NodeType = NodeType, + Icon = DefaultIcon, MainNode = contextPath, Content = new Thread { CreatedBy = createdBy } }; @@ -131,6 +140,7 @@ public static (MeshNode Thread, string UserMsgId, string ResponseMsgId) BuildThr { Name = name, NodeType = NodeType, + Icon = DefaultIcon, MainNode = contextPath, Content = new Thread { @@ -182,7 +192,7 @@ public static MeshNode CreateMeshNode( Func? hubConfiguration = null) => new(NodeType) { Name = "Thread", - Icon = "/static/NodeTypeIcons/chat.svg", + Icon = DefaultIcon, IsSatelliteType = true, ExcludeFromContext = ImmutableHashSet.Create("search"), AssemblyLocation = typeof(ThreadNodeType).Assembly.Location, diff --git a/src/MeshWeaver.Blazor/Components/ThreadMessageBubbleView.razor b/src/MeshWeaver.Blazor/Components/ThreadMessageBubbleView.razor index 756e59ca6..cd3f4c2e0 100644 --- a/src/MeshWeaver.Blazor/Components/ThreadMessageBubbleView.razor +++ b/src/MeshWeaver.Blazor/Components/ThreadMessageBubbleView.razor @@ -61,6 +61,8 @@ } else { + var display = FormatToolCallDisplay(call); + var change = display.IsNodeModifying ? FindChange(display.Path) : null; @* Completed delegation or regular tool call: expandable details *@
@@ -71,7 +73,27 @@ } else { - @summary + @display.Verb + @if (!string.IsNullOrEmpty(display.Path)) + { +   + @display.Path + } + @if (change is not null) + { + @if (change.VersionBefore.HasValue && change.VersionAfter.HasValue) + { + Diff + } + @if (change.VersionBefore.HasValue) + { + Revert v@(change.VersionBefore) + } + } } @if (!string.IsNullOrEmpty(call.Arguments)) @@ -237,6 +259,36 @@ .thread-msg-tool-entry-name:hover { color: var(--neutral-foreground-rest); } + .thread-msg-tool-path { + color: var(--accent-fill-rest); + text-decoration: none; + font-family: var(--monospace-font, monospace); + font-size: 0.72rem; + padding: 1px 4px; + border-radius: 3px; + } + .thread-msg-tool-path:hover { + background: var(--neutral-layer-3); + text-decoration: underline; + } + .thread-msg-tool-action { + display: inline-flex; + align-items: center; + margin-left: 6px; + padding: 1px 6px; + font-size: 0.7rem; + border-radius: 3px; + color: var(--accent-fill-rest); + text-decoration: none; + border: 1px solid transparent; + } + .thread-msg-tool-action:hover { + background: var(--neutral-layer-3); + border-color: var(--neutral-stroke-rest); + } + .thread-msg-tool-action-muted { + color: var(--neutral-foreground-hint); + } .thread-msg-tool-pending { color: var(--accent-fill-rest); animation: thread-dots-blink 1.4s infinite both; diff --git a/src/MeshWeaver.Blazor/Components/ThreadMessageBubbleView.razor.cs b/src/MeshWeaver.Blazor/Components/ThreadMessageBubbleView.razor.cs index d8beff703..f9df1a4ba 100644 --- a/src/MeshWeaver.Blazor/Components/ThreadMessageBubbleView.razor.cs +++ b/src/MeshWeaver.Blazor/Components/ThreadMessageBubbleView.razor.cs @@ -13,10 +13,18 @@ public partial class ThreadMessageBubbleView : BlazorView? toolCalls; + private IReadOnlyList? updatedNodes; private bool isEditing; private bool HasToolCalls => toolCalls is { Count: > 0 }; + /// + /// Shape returned by : tells the view how + /// to render the chip — verb text, target path (when the tool modifies a node), + /// and whether the path should be decorated with Diff + Restore links. + /// + public readonly record struct ToolCallDisplay(string Verb, string? Path, bool IsNodeModifying); + private MarkdownControl MarkdownVm => new MarkdownControl(messageText ?? "") .WithStyle("background: transparent;"); @@ -46,32 +54,94 @@ protected override void BindData() if (result != null && prev != null && result.SequenceEqual(prev)) return prev; return result; }); + DataBind(ViewModel.UpdatedNodes, x => x.updatedNodes, (val, prev) => + { + IReadOnlyList? result = val switch + { + null => null, + IReadOnlyList list => list, + JsonElement je => je.Deserialize>(Hub.JsonSerializerOptions), + _ => null + }; + if (result == null && prev == null) return prev; + if (result != null && prev != null && result.SequenceEqual(prev)) return prev; + return result; + }); + } + + /// + /// Matches a tool-call target path against the message's aggregated + /// UpdatedNodes so the chip can render Diff / Restore links with the + /// correct before/after versions. + /// + private NodeChangeEntry? FindChange(string? path) + { + if (string.IsNullOrEmpty(path) || updatedNodes is null) + return null; + return updatedNodes.FirstOrDefault(n => + string.Equals(n.Path, path, StringComparison.Ordinal)); } private static string FormatToolCallSummary(ToolCallEntry call) + { + var d = FormatToolCallDisplay(call); + return d.Path is null ? d.Verb : $"{d.Verb} {d.Path}"; + } + + /// + /// Splits the tool call into a verb + target-path + flag. Node-modifying verbs + /// (Create / Update / Patch / Delete) flow through with IsNodeModifying=true + /// so the view can render inline Diff + Restore links next to the path. + /// + private static ToolCallDisplay FormatToolCallDisplay(ToolCallEntry call) { if (!string.IsNullOrEmpty(call.DelegationPath)) { - // Delegation: extract agent name from DisplayName (e.g., "Delegating to Coder..." → "Coder") var name = call.DisplayName ?? call.Name; if (name.Contains("Delegating to ")) name = name.Replace("Delegating to ", "").TrimEnd('.', ' '); - return name; + return new ToolCallDisplay(name, null, false); + } + + // Args come serialized as "path: Org/X\ncontent: ...". Strip the "path: " prefix. + var rawArgs = call.Arguments ?? ""; + string? path = null; + foreach (var line in rawArgs.Split('\n')) + { + var trimmed = line.Trim(); + if (trimmed.StartsWith("path:", StringComparison.OrdinalIgnoreCase)) + { + path = trimmed["path:".Length..].Trim(); + break; + } + if (trimmed.StartsWith("url:", StringComparison.OrdinalIgnoreCase)) + { + path = trimmed["url:".Length..].Trim(); + break; + } + if (trimmed.StartsWith("query:", StringComparison.OrdinalIgnoreCase)) + { + path = trimmed["query:".Length..].Trim(); + break; + } } + // Fallback to the first arg line if we couldn't identify the key. + if (string.IsNullOrEmpty(path)) + path = rawArgs.Split('\n').FirstOrDefault()?.Trim(); - // Regular tool calls: friendly verb - var target = call.Arguments?.Split('\n').FirstOrDefault()?.Trim() ?? ""; return call.Name switch { - "Get" or "get_node" => $"Getting {target}", - "Search" or "search_nodes" => $"Searching {target}", - "Create" or "create_node" => $"Creating {target}", - "Update" or "update_node" => $"Updating {target}", - "Patch" or "patch_node" => $"Patching {target}", - "Delete" or "delete_node" => $"Deleting {target}", - "NavigateTo" or "navigate_to" => $"Navigating to {target}", - "store_plan" => "Storing plan", - _ => call.DisplayName ?? call.Name + "Get" or "get_node" => new ToolCallDisplay("Reading", path, false), + "Search" or "search_nodes" => new ToolCallDisplay("Searching", path, false), + "Create" or "create_node" => new ToolCallDisplay("Created", path, true), + "Update" or "update_node" => new ToolCallDisplay("Updated", path, true), + "Patch" or "patch_node" => new ToolCallDisplay("Patched", path, true), + "Delete" or "delete_node" => new ToolCallDisplay("Deleted", path, true), + "NavigateTo" or "navigate_to" => new ToolCallDisplay("Navigating to", path, false), + "SearchWeb" => new ToolCallDisplay("Searching web for", path, false), + "FetchWebPage" => new ToolCallDisplay("Fetching", path, false), + "store_plan" => new ToolCallDisplay("Stored plan", null, false), + _ => new ToolCallDisplay(call.DisplayName ?? call.Name, path, false) }; } } diff --git a/src/MeshWeaver.Graph/Icons/meshweaver-logo.svg b/src/MeshWeaver.Graph/Icons/meshweaver-logo.svg new file mode 100644 index 000000000..96946bae4 --- /dev/null +++ b/src/MeshWeaver.Graph/Icons/meshweaver-logo.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/MeshWeaver.Graph/UserActivityLayoutAreas.cs b/src/MeshWeaver.Graph/UserActivityLayoutAreas.cs index a4eec8e4d..60c7af578 100644 --- a/src/MeshWeaver.Graph/UserActivityLayoutAreas.cs +++ b/src/MeshWeaver.Graph/UserActivityLayoutAreas.cs @@ -237,7 +237,7 @@ private static UiControl BuildDocumentationCard() "
" + - "\"\"" + + "\"\"" + "
" + // Content diff --git a/src/MeshWeaver.Layout/ThreadMessageBubbleControl.cs b/src/MeshWeaver.Layout/ThreadMessageBubbleControl.cs index 173145f9f..9d3ddba96 100644 --- a/src/MeshWeaver.Layout/ThreadMessageBubbleControl.cs +++ b/src/MeshWeaver.Layout/ThreadMessageBubbleControl.cs @@ -52,6 +52,13 @@ public record ThreadMessageBubbleControl() : UiControl public object? ToolCalls { get; init; } + /// + /// Data-bound list of nodes created/updated/deleted by this message's execution. + /// The bubble cross-references tool-call target paths against this list to show + /// inline Diff + Restore links on Create/Update/Delete/Patch tool chips. + /// + public object? UpdatedNodes { get; init; } + /// Model name used for this response (e.g., "claude-sonnet-4-6"). public string? ModelName { get; init; } @@ -68,4 +75,5 @@ public record ThreadMessageBubbleControl() : UiControl this with { MessageId = id }; public ThreadMessageBubbleControl WithThreadPath(string? path) => this with { ThreadPath = path }; public ThreadMessageBubbleControl WithToolCalls(object? toolCalls) => this with { ToolCalls = toolCalls }; + public ThreadMessageBubbleControl WithUpdatedNodes(object? updatedNodes) => this with { UpdatedNodes = updatedNodes }; } From 88b8ae2c56c6d8c154f6dfc0f424cd405792b160 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Sun, 19 Apr 2026 15:12:06 +0200 Subject: [PATCH 22/50] fix: remove await from DeleteLayoutArea and reply before storage delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete click handler and render path are now fully reactive (no await on the hub thread): Subscribe on the form-data stream, Subscribe on IMeshService.DeleteNode, CombineLatest of ObservePermissions + FromAsync descendant count. A blocked hub no longer deadlocks the UI. DeleteSelfFromStorage posts the success response BEFORE issuing the persistence delete. Under Orleans (and during monolith disposal) the storage write can tear this hub down; replying first guarantees the caller's RegisterCallback resolves. Validators have already passed at this point, so a late storage failure is logged — the Ok reply cannot be walked back. Tests exercise the exact production pattern: hub.Post(DeleteNodeRequest) + hub.RegisterCallback, TaskCompletionSource driven by the callback, WaitAsync(10s) as deadlock guard. Added recursive variant that would hang if the self-hub disappeared before posting its reply. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/MeshWeaver.Graph/DeleteLayoutArea.cs | 125 +++++++++--------- .../MeshExtensions.cs | 18 +-- .../DeleteLayoutAreaIntegrationTest.cs | 83 ++++++++++++ 3 files changed, 159 insertions(+), 67 deletions(-) diff --git a/src/MeshWeaver.Graph/DeleteLayoutArea.cs b/src/MeshWeaver.Graph/DeleteLayoutArea.cs index bc4962ccd..d3eb90248 100644 --- a/src/MeshWeaver.Graph/DeleteLayoutArea.cs +++ b/src/MeshWeaver.Graph/DeleteLayoutArea.cs @@ -31,6 +31,9 @@ public static class DeleteLayoutArea } /// /// Entry point for the Delete layout area. + /// Fully reactive composition — no await on the rendering path. + /// Permission and descendant-count streams are combined via CombineLatest; + /// a blocked hub cannot produce an emission so the render stays empty instead of deadlocking. /// [Browsable(false)] public static IObservable Delete(LayoutAreaHost host, RenderingContext _) @@ -39,45 +42,42 @@ public static class DeleteLayoutArea var backHref = MeshNodeLayoutAreas.BuildUrl(nodePath, MeshNodeLayoutAreas.OverviewArea); var meshQuery = host.Hub.ServiceProvider.GetService(); - // Count descendants and check permissions asynchronously - return Observable.FromAsync(async () => - { - // Permission gate: check Delete permission - var canDelete = await PermissionHelper.CanDeleteAsync(host.Hub, nodePath); - if (!canDelete) - return -1; // Sentinel value for access denied + var permissionsObs = PermissionHelper.ObservePermissions(host.Hub, nodePath).Take(1); - var descendantCount = 0; - if (meshQuery != null) - { - await foreach (var _ in meshQuery.QueryAsync( - MeshQueryRequest.FromQuery($"path:{nodePath} scope:descendants"))) - descendantCount++; - } - return descendantCount; - }).Select(descendantCount => - { - // Access denied - if (descendantCount < 0) - { - return (UiControl?)Controls.Stack.WithWidth("100%").WithStyle("padding: 24px;") - .WithView(Controls.Stack - .WithOrientation(Orientation.Horizontal) - .WithHorizontalGap(16) - .WithStyle("align-items: center; margin-bottom: 24px;") - .WithView(Controls.Button("Back") - .WithAppearance(Appearance.Lightweight) - .WithIconStart(FluentIcons.ArrowLeft()) - .WithNavigateToHref(backHref)) - .WithView(Controls.H2("Access Denied").WithStyle("margin: 0; color: var(--error);"))) - .WithView(Controls.Html( - "

You do not have permission to delete this node.

")); - } - - return BuildDeletePage(host, nodePath, backHref, descendantCount); - }); + var descendantsObs = meshQuery != null + ? Observable.FromAsync(token => CountDescendantsAsync(meshQuery, nodePath, token)) + : Observable.Return(0); + + return permissionsObs.CombineLatest(descendantsObs, + (perms, count) => (canDelete: perms.HasFlag(Permission.Delete), count)) + .Select(tuple => tuple.canDelete + ? BuildDeletePage(host, nodePath, backHref, tuple.count) + : BuildAccessDenied(backHref)); } + private static async Task CountDescendantsAsync(IMeshService meshQuery, string nodePath, CancellationToken ct) + { + var count = 0; + await foreach (var _ in meshQuery.QueryAsync( + MeshQueryRequest.FromQuery($"path:{nodePath} scope:descendants"), ct)) + count++; + return count; + } + + private static UiControl BuildAccessDenied(string backHref) => + Controls.Stack.WithWidth("100%").WithStyle("padding: 24px;") + .WithView(Controls.Stack + .WithOrientation(Orientation.Horizontal) + .WithHorizontalGap(16) + .WithStyle("align-items: center; margin-bottom: 24px;") + .WithView(Controls.Button("Back") + .WithAppearance(Appearance.Lightweight) + .WithIconStart(FluentIcons.ArrowLeft()) + .WithNavigateToHref(backHref)) + .WithView(Controls.H2("Access Denied").WithStyle("margin: 0; color: var(--error);"))) + .WithView(Controls.Html( + "

You do not have permission to delete this node.

")); + private static UiControl BuildDeletePage(LayoutAreaHost host, string nodePath, string backHref, int descendantCount) { // Set up data binding for confirmation field @@ -138,31 +138,38 @@ private static UiControl BuildDeletePage(LayoutAreaHost host, string nodePath, s .WithAppearance(Appearance.Accent) .WithStyle("background: var(--error, #d32f2f); color: white;") .WithIconStart(FluentIcons.Delete()) - .WithClickAction(async ctx => + .WithClickAction(ctx => { - var formValues = await ctx.Host.Stream - .GetDataStream>(dataId).FirstAsync(); - - var confirmation = formValues.GetValueOrDefault("confirmation")?.ToString()?.Trim(); - if (confirmation != "DELETE") - { - ShowDialog(ctx, "Confirmation Required", - "Please type **DELETE** in the confirmation field to proceed."); - return; - } - - // Reactive delete — subscribe with onNext/onError to surface failures. - host.Hub.ServiceProvider.GetRequiredService() - .DeleteNode(nodePath).Subscribe( - _ => - { - // Empty the area in-place — no redirect. The user can navigate via menu/back. - ctx.Host.UpdateArea(MeshNodeLayoutAreas.DeleteArea, null); - }, - ex => + // Fully reactive: no await anywhere on the hub thread. + // 1) Read the form data synchronously via Take(1).Subscribe + // 2) Validate + // 3) Call IMeshService.DeleteNode (Post + RegisterCallback under the hood) + // and propagate onNext/onError via Subscribe. + ctx.Host.Stream + .GetDataStream>(dataId) + .Take(1) + .Subscribe(formValues => + { + var confirmation = formValues.GetValueOrDefault("confirmation")?.ToString()?.Trim(); + if (confirmation != "DELETE") { - ShowDialog(ctx, "Delete Failed", $"Could not delete node: {ex.Message}"); - }); + ShowDialog(ctx, "Confirmation Required", + "Please type **DELETE** in the confirmation field to proceed."); + return; + } + + host.Hub.ServiceProvider.GetRequiredService() + .DeleteNode(nodePath) + .Subscribe( + _ => + { + // Empty the area in-place — no redirect. The user can navigate via menu/back. + ctx.Host.UpdateArea(MeshNodeLayoutAreas.DeleteArea, null); + }, + ex => ShowDialog(ctx, "Delete Failed", $"Could not delete node: {ex.Message}")); + }); + + return Task.CompletedTask; }))); return stack; diff --git a/src/MeshWeaver.Mesh.Contract/MeshExtensions.cs b/src/MeshWeaver.Mesh.Contract/MeshExtensions.cs index 2137054a7..8856fd56a 100644 --- a/src/MeshWeaver.Mesh.Contract/MeshExtensions.cs +++ b/src/MeshWeaver.Mesh.Contract/MeshExtensions.cs @@ -657,25 +657,27 @@ private static void DeleteSelfFromStorage( IMeshStorage persistence, ILogger logger) { + // Post the response FIRST, while the hub is still alive. Under Orleans (and + // during monolith disposal) the storage-level delete can tear this hub down + // before we'd otherwise get a chance to reply — the caller would then wait + // forever on its RegisterCallback. Validators have already passed, so this + // is the commit point; if the storage write itself fails we can only log. + hub.Post(DeleteNodeResponse.Ok(), o => o.ResponseFor(request)); + Observable.FromAsync(token => persistence.DeleteNodeAsync(path, recursive: false, token)) .Subscribe( _ => { hub.ServiceProvider.GetService() ?.Publish(MeshChangeEvent.Deleted(path)); - hub.Post(DeleteNodeResponse.Ok(), o => o.ResponseFor(request)); logger.LogInformation( "Node deleted at {Path} by {DeletedBy}", path, capturedRequest.DeletedBy ?? "system"); }, ex => - { - logger.LogError(ex, "Error deleting node at {Path}", path); - hub.Post( - DeleteNodeResponse.Fail($"Unexpected error: {ex.Message}", - NodeDeletionRejectionReason.Unknown), - o => o.ResponseFor(request)); - }); + logger.LogError(ex, + "Storage delete failed for {Path} after Ok response was already sent — response cannot be walked back", + path)); } /// diff --git a/test/MeshWeaver.Hosting.Monolith.Test/DeleteLayoutAreaIntegrationTest.cs b/test/MeshWeaver.Hosting.Monolith.Test/DeleteLayoutAreaIntegrationTest.cs index 31821f849..e4af538b1 100644 --- a/test/MeshWeaver.Hosting.Monolith.Test/DeleteLayoutAreaIntegrationTest.cs +++ b/test/MeshWeaver.Hosting.Monolith.Test/DeleteLayoutAreaIntegrationTest.cs @@ -1,4 +1,6 @@ +using System; using System.Linq; +using System.Reactive.Linq; using System.Threading.Tasks; using FluentAssertions; using FluentAssertions.Extensions; @@ -93,4 +95,85 @@ await NodeFactory.CreateNodeAsync( .ToListAsync(TestContext.Current.CancellationToken); children.Should().BeEmpty("all children should be deleted"); } + + /// + /// Replicates the exact production pattern the Delete click handler must use: + /// hub.Post(new DeleteNodeRequest(...)) + hub.RegisterCallback(...). + /// No await on the delete path — the callback drives a + /// which the test awaits at the xunit boundary only. A blocked hub cannot produce a callback, + /// so the 10 s WaitAsync guard fails the test instead of hanging forever. + /// + [Fact(Timeout = 20000)] + public async Task DeleteNode_PostRegisterCallback_DoesNotDeadlock() + { + var nodePath = $"{TestPartition}/del-reactive"; + await NodeFactory.CreateNodeAsync( + new MeshNode("del-reactive", TestPartition) { Name = "Reactive Delete", NodeType = "Markdown" }); + + var client = GetClient(); + var nodeAddress = new Address(nodePath); + await client.AwaitResponse( + new PingRequest(), + o => o.WithTarget(nodeAddress), + TestContext.Current.CancellationToken); + + var nodeHub = Mesh.GetHostedHub(nodeAddress, HostedHubCreation.Never)!; + + // Production pattern: Post + RegisterCallback. No await on the hub-bound path. + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var delivery = nodeHub.Post(new DeleteNodeRequest(nodePath) { Recursive = true })!; + _ = nodeHub.RegisterCallback(delivery, (d, _) => + { + tcs.TrySetResult(((IMessageDelivery)d).Message); + return Task.FromResult(d); + }); + + var response = await tcs.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); + response.Success.Should().BeTrue($"delete should succeed (error: {response.Error})"); + + var lookup = await MeshQuery.QueryAsync($"path:{nodePath}") + .FirstOrDefaultAsync(TestContext.Current.CancellationToken); + lookup.Should().BeNull("node should be gone after Post+RegisterCallback delete"); + } + + /// + /// Recursive delete via Post + RegisterCallback. Verifies DeleteSelfFromStorage + /// posts the success response BEFORE issuing the storage write, so a hub that + /// gets torn down by the storage delete still delivers its reply to the caller. + /// + [Fact(Timeout = 20000)] + public async Task DeleteNode_PostRegisterCallback_Recursive_DoesNotDeadlock() + { + await NodeFactory.CreateNodeAsync( + new MeshNode("del-rec-parent", TestPartition) { Name = "Parent", NodeType = "Group" }); + await NodeFactory.CreateNodeAsync( + new MeshNode("c1", $"{TestPartition}/del-rec-parent") { Name = "C1", NodeType = "Markdown" }); + await NodeFactory.CreateNodeAsync( + new MeshNode("c2", $"{TestPartition}/del-rec-parent") { Name = "C2", NodeType = "Markdown" }); + + var parentPath = $"{TestPartition}/del-rec-parent"; + var client = GetClient(); + await client.AwaitResponse(new PingRequest(), o => o.WithTarget(new Address(parentPath)), + TestContext.Current.CancellationToken); + + var parentHub = Mesh.GetHostedHub(new Address(parentPath), HostedHubCreation.Never)!; + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var delivery = parentHub.Post(new DeleteNodeRequest(parentPath) { Recursive = true })!; + _ = parentHub.RegisterCallback(delivery, (d, _) => + { + tcs.TrySetResult(((IMessageDelivery)d).Message); + return Task.FromResult(d); + }); + + var response = await tcs.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); + response.Success.Should().BeTrue($"recursive delete should succeed (error: {response.Error})"); + + var parent = await MeshQuery.QueryAsync($"path:{parentPath}") + .FirstOrDefaultAsync(TestContext.Current.CancellationToken); + parent.Should().BeNull(); + var children = await MeshQuery.QueryAsync($"namespace:{parentPath}") + .ToListAsync(TestContext.Current.CancellationToken); + children.Should().BeEmpty(); + } } From 2cf3a5f7ca4a6dfce3558192def8c297e797a70a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Sun, 19 Apr 2026 15:30:43 +0200 Subject: [PATCH 23/50] changing coder --- src/MeshWeaver.AI/Data/Agent/Coder.md | 29 +++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/src/MeshWeaver.AI/Data/Agent/Coder.md b/src/MeshWeaver.AI/Data/Agent/Coder.md index cba425421..24e52ec31 100644 --- a/src/MeshWeaver.AI/Data/Agent/Coder.md +++ b/src/MeshWeaver.AI/Data/Agent/Coder.md @@ -16,6 +16,24 @@ delegations: You are **Coder**, the node type engineering agent. You create and modify custom NodeTypes including their source code (`_Source/`), data models, layout areas, reference data, CSV loaders, and JSON definitions. +# Decision Rule: NodeType vs Markdown + +When the user describes a **data model, object type, custom entity, or interactive view** — e.g. "social media posts with a calendar", "a task tracker", "risk model with charts", "build X as code" — you build a **NodeType**: a `NodeType` JSON + `_Source/` C# files + at least one instance JSON. + +You build a **Markdown** node ONLY when the user explicitly asks for a document, note, article, or narrative page (e.g. "write a doc about X", "draft a changelog", "add an FAQ page"). + +**Never** use a Markdown node as a shortcut for something that should be typed data. If in doubt, build a NodeType — a user who wanted Markdown will say so. + +## Canonical Example + +The walkthrough at [SocialMedia model node type](@@Doc/DataMesh/SocialMedia) is the reference implementation. It has exactly the shape you should produce: + +- `Post.json`, `Profile.json` — NodeType definitions with a `configuration` lambda +- `Post/_Source/*.cs`, `Profile/_Source/*.cs` — content record, reference data (`Platform`), layout areas +- `Post/Post-001.json`, `Profile/Roland-LinkedIn.json` — instances alongside (IDs are meaningful — never `SamplePost`/`SampleProfile`) + +When asked to build "X as code" or "X as a model", open that example, mirror its shape, then adapt to the user's domain. + # How Node Types Work A NodeType is a MeshNode with `nodeType: "NodeType"` whose `content` contains a `NodeTypeDefinition` with a `configuration` field. The configuration is a C# lambda expression compiled at startup. @@ -244,7 +262,7 @@ For domain-specific logic (financial models, reinsurance cession, risk analysis, 2. **Business Rules** — pure C# calculation engines with no framework dependencies 3. **Layout Areas** — reactive charts with `Chart.Create(DataSet.Bar(...))`, filter toolbars via `host.Toolbar(model, id)`, and `host.GetDataStream(id).Select(...)` for reactive updates -See the full walkthrough with a reinsurance cession example: [Business Rules & Calculations](@@Doc/Architecture/BusinessRules) +See [SocialMedia](@@Doc/DataMesh/SocialMedia) for a plain-CRUD reference example, and [Business Rules & Calculations](@@Doc/Architecture/BusinessRules) for a chart/calculation-heavy reinsurance-cession example. For a production implementation, see: - [CededCashflows.cs](https://github.com/Systemorph/MeshWeaver.Reinsurance/blob/main/src/MeshWeaver.Reinsurance/Cession/CededCashflows.cs) — cession calculation engine @@ -311,7 +329,8 @@ When asked to create an interactive document, create a Markdown node with the ex **NEVER just describe what you would create. ALWAYS call Create, Update, or Patch to write the actual content.** If you didn't call a write tool, nothing was produced. The user expects to see a real node with real content after your work — not a description of what could be created. -- Asked to create a Markdown document? → Call `Create` with the full markdown content. +- Asked for a data model, type, or view? → Create a **NodeType**: JSON + `_Source/` `.cs` files + at least one sample instance. **NEVER substitute a Markdown node** for typed data — see the Decision Rule at the top. +- Asked for a document, article, or narrative page? → Create a Markdown node with the full content. - Asked to create a NodeType? → Call `Create` for each source file and the JSON definition. - Asked to modify a node? → Call `Get` first, then `Update` with the modified content. @@ -323,6 +342,8 @@ Use the standard Mesh tools (Get, Search, Create, Update, Delete) to manage node Use ContentCollection tools to upload CSV/data files. When creating `_Source/` files, create them as MeshNodes with: -- `nodeType: "Code"` +- `nodeType: "Code"` (NOT `"Markdown"` — source code files are always Code nodes) - `namespace: "{typePath}/_Source"` -- `content` containing the C# source code +- `content` shaped as `{ "$type": "CodeConfiguration", "code": "…", "language": "csharp" }` containing the C# source + +See [SocialMedia/Post/_Source](@@Doc/DataMesh/SocialMedia) for the concrete file naming and content shape to mirror. From 343aea1458dfa0a07de0cfc58ec36f311a1eae1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Sun, 19 Apr 2026 15:32:39 +0200 Subject: [PATCH 24/50] returning error when laout not found. --- .../Composition/LayoutDefinition.cs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/MeshWeaver.Layout/Composition/LayoutDefinition.cs b/src/MeshWeaver.Layout/Composition/LayoutDefinition.cs index 2813065ab..c12b4c3d7 100644 --- a/src/MeshWeaver.Layout/Composition/LayoutDefinition.cs +++ b/src/MeshWeaver.Layout/Composition/LayoutDefinition.cs @@ -81,6 +81,22 @@ public async ValueTask RenderAsync( "Named renderers: [{Named}], predicate renderers: {Count}", context.Area, host.Hub.Address, string.Join(", ", NamedRenderers.Keys), AsyncRenderers.Count); + + // Surface a visible "area not found" placeholder so the client doesn't spin forever + // waiting for an Update that no renderer is going to produce. + var availableAreas = NamedRenderers.Keys.OrderBy(k => k).ToArray(); + var availableLine = availableAreas.Length == 0 + ? "_no named areas registered on this hub_" + : "Available named areas: " + string.Join(", ", availableAreas.Select(a => $"`{a}`")); + var notFound = new MarkdownControl( + $"**Area not found**\n\nNo renderer is registered for area `{context.Area}` on hub `{host.Hub.Address}`.\n\n{availableLine}"); + result = result with + { + Store = result.Store.Update(LayoutAreaReference.Areas, + coll => coll.SetItem(context.Area, notFound)), + Updates = result.Updates.Append( + new EntityUpdate(LayoutAreaReference.Areas, context.Area, notFound)) + }; } return result; From 18a5603ceec776f5594542a9a54aa9b880362005 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Sun, 19 Apr 2026 15:51:04 +0200 Subject: [PATCH 25/50] feat: configurable Sources on NodeTypeDefinition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `Sources` property to `NodeTypeDefinition` holding query-syntax lines that point at the Code nodes to compile with the lambda. The compilation service expands `$self` to the owning NodeType's path, rebases relative `namespace:X` values (no `/`) onto that path, and ANDs each query with `nodeType:Code` so non-Code children cannot leak in. `@path` / `@@path` shorthand resolves to both a `path:` exact match and a `namespace:... scope:subtree` folder match. Matches across lines are de-duplicated. Default is `["namespace:_Source scope:subtree"]` — behaviour preserved for existing NodeTypes. Covers the NodeType cross-sharing case the ACME Project/Todo sample works around today by duplicating Status/Category/Priority. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../MeshNodeCompilationService.cs | 148 +++++++--- .../Configuration/NodeTypeDefinition.cs | 28 ++ .../MeshNodeCompilationServiceTest.cs | 254 ++++++++++++++++++ 3 files changed, 394 insertions(+), 36 deletions(-) diff --git a/src/MeshWeaver.Graph/Configuration/MeshNodeCompilationService.cs b/src/MeshWeaver.Graph/Configuration/MeshNodeCompilationService.cs index 01b7cc3ce..863d76044 100644 --- a/src/MeshWeaver.Graph/Configuration/MeshNodeCompilationService.cs +++ b/src/MeshWeaver.Graph/Configuration/MeshNodeCompilationService.cs @@ -28,6 +28,71 @@ internal class MeshNodeCompilationService( private readonly CompilationCacheOptions _cacheOptions = cacheOptions.Value ?? new CompilationCacheOptions(); private JsonSerializerOptions JsonOptions => hub.JsonSerializerOptions; private readonly DynamicMeshNodeAttributeGenerator _attributeGenerator = new(); + + /// + /// Resolve one Sources entry into one-or-more concrete queries, ready to hand + /// to . Rules: + /// + /// $self expands to . + /// A leading @@ or @ marks a shorthand that yields both a + /// path:X exact match and a namespace:X scope:subtree folder + /// match (de-duplicated downstream by the caller). + /// A namespace:X value that is a single relative segment (no + /// /, no absolute root) is rebased onto , + /// so the default namespace:_Source reads as "my own _Source folder". + /// Every emitted query is ANDed with nodeType:Code so non-code + /// children can never leak into the compilation. + /// + /// + private static IEnumerable ExpandSourceQuery(string rawQuery, string selfPath) + { + var expanded = rawQuery.Replace("$self", selfPath).Trim(); + + var isAt = expanded.StartsWith("@@") || expanded.StartsWith("@"); + if (isAt) + { + var stripped = expanded.TrimStart('@').TrimStart(); + if (stripped.Length == 0) yield break; + if (stripped.Contains(':')) + { + // "@namespace:X scope:subtree" — already qualified, pass through. + yield return WithCodeTypeFilter(stripped); + yield break; + } + yield return WithCodeTypeFilter($"path:{stripped}"); + yield return WithCodeTypeFilter($"namespace:{stripped} scope:subtree"); + yield break; + } + + // Rebase relative "namespace:X" values onto selfPath. A value without '/' is + // assumed to be a subfolder of the NodeType (the default "_Source" case). + var rebased = RebaseRelativeNamespace(expanded, selfPath); + yield return WithCodeTypeFilter(rebased); + } + + private static string RebaseRelativeNamespace(string query, string selfPath) + { + const string nsKey = "namespace:"; + var idx = query.IndexOf(nsKey, StringComparison.OrdinalIgnoreCase); + if (idx < 0) return query; + + var valueStart = idx + nsKey.Length; + var valueEnd = valueStart; + while (valueEnd < query.Length && !char.IsWhiteSpace(query[valueEnd])) + valueEnd++; + var value = query.Substring(valueStart, valueEnd - valueStart); + + // Relative iff it contains no path separator (e.g. "_Source"). + if (value.Length == 0 || value.Contains('/')) return query; + + var rebased = $"{selfPath}/{value}"; + return query.Substring(0, valueStart) + rebased + query.Substring(valueEnd); + } + + private static string WithCodeTypeFilter(string query) => + query.Contains("nodeType:", StringComparison.OrdinalIgnoreCase) + ? query + : $"{query} nodeType:{CodeNodeType.NodeType}"; private readonly List _references = GetDefaultReferences(); private static List GetDefaultReferences() @@ -220,23 +285,57 @@ private async Task ResolveCodeIncludesAsync( return dllPath; } - // Get CodeConfiguration from child MeshNodes under the _Source path - // For NodeType nodes (where Content is NodeTypeDefinition), use the node's own path - // For instance nodes, use the NodeType's path (e.g., "Person/_Source" for Alice with NodeType="Person") - // Collect ALL CodeConfiguration files and combine them + // Resolve the owning NodeTypeDefinition once — used both for source discovery + // (Sources / _Source convention) and for Configuration / ContentCollections. + // - If this node IS a NodeType, "self" is its own path and we read its Content. + // - If this node is an instance, "self" is the NodeType's path and we fetch it. var meshQuery = hub.ServiceProvider.GetService(); + NodeTypeDefinition? ntDef = null; + string selfPath; + if (node.Content is NodeTypeDefinition selfDef) + { + ntDef = selfDef; + selfPath = node.Path; + } + else + { + selfPath = node.NodeType; + if (meshQuery != null) + { + await foreach (var nodeTypeNode in meshQuery.QueryAsync($"path:{node.NodeType}", ct: ct).WithCancellation(ct)) + { + if (nodeTypeNode?.Content is NodeTypeDefinition fetched) + ntDef = fetched; + break; + } + } + } + + // Collect Code nodes from each configured source query. + // Default: "_Source" subtree directly under the NodeType (implicitly self-relative). + var sourceQueries = ntDef?.Sources is { Count: > 0 } configured + ? configured + : (IReadOnlyList)["namespace:_Source scope:subtree"]; + var codeFiles = new List(); - var codeParentPath = node.Content is NodeTypeDefinition - ? $"{node.Path}/_Source" // NodeType node - use its own _Source path - : $"{node.NodeType}/_Source"; // Instance node - use NodeType's _Source path if (meshQuery != null) { - var codeQuery = $"namespace:{codeParentPath} scope:subtree"; - await foreach (var codeNode in meshQuery.QueryAsync(codeQuery, ct: ct).WithCancellation(ct)) + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var rawQuery in sourceQueries) { - if (codeNode.Content is CodeConfiguration cf && !string.IsNullOrWhiteSpace(cf.Code)) + if (string.IsNullOrWhiteSpace(rawQuery)) continue; + + foreach (var finalQuery in ExpandSourceQuery(rawQuery, selfPath)) { - codeFiles.Add(cf); + await foreach (var codeNode in meshQuery.QueryAsync(finalQuery, ct: ct).WithCancellation(ct)) + { + if (codeNode.Content is CodeConfiguration cf + && !string.IsNullOrWhiteSpace(cf.Code) + && seen.Add(codeNode.Path ?? cf.Code!)) + { + codeFiles.Add(cf); + } + } } } } @@ -260,31 +359,8 @@ private async Task ResolveCodeIncludesAsync( _ => new CodeConfiguration { Code = string.Join("\n\n", codeFiles.Select(cf => cf.Code)) } }; - // Get Configuration and ContentCollections from the NodeTypeDefinition content - // Configuration is the source code that gets compiled into HubConfiguration - // For NodeType nodes (where node.Content is NodeTypeDefinition), use the node's own content - // For instance nodes, look up the NodeType node to get its Configuration - string? configuration = null; - List? contentCollections = null; - if (node.Content is NodeTypeDefinition selfDef) - { - // Node is itself a NodeType definition - use its own Configuration - configuration = selfDef.Configuration; - contentCollections = selfDef.ContentCollections; - } - else if (meshQuery != null) - { - // Instance node - look up the NodeType to get its Configuration - await foreach (var nodeTypeNode in meshQuery.QueryAsync($"path:{node.NodeType}", ct: ct).WithCancellation(ct)) - { - if (nodeTypeNode?.Content is NodeTypeDefinition ntd) - { - configuration = ntd.Configuration; - contentCollections = ntd.ContentCollections; - } - break; - } - } + var configuration = ntDef?.Configuration; + var contentCollections = ntDef?.ContentCollections; try { diff --git a/src/MeshWeaver.Graph/Configuration/NodeTypeDefinition.cs b/src/MeshWeaver.Graph/Configuration/NodeTypeDefinition.cs index a6c417258..7288d3f2a 100644 --- a/src/MeshWeaver.Graph/Configuration/NodeTypeDefinition.cs +++ b/src/MeshWeaver.Graph/Configuration/NodeTypeDefinition.cs @@ -110,4 +110,32 @@ public record NodeTypeDefinition /// When set, the Create form only allows selection from these namespaces. /// public List? RestrictedToNamespaces { get; init; } + + /// + /// Locations of the Code nodes to compile with this NodeType's + /// lambda. Each entry is either: + /// + /// A mesh query — e.g. "namespace:_Source scope:subtree", + /// "namespace:SocialMedia/Post/_Source scope:subtree". A + /// namespace:X with a single segment (no /, like + /// _Source) is automatically rebased onto the owning NodeType's + /// path. The macro $self can be used anywhere in the query and + /// expands to that path. + /// A single-node shorthand — "@path/to/code" or + /// "@@path/to/code". Resolves to both an exact-path match and a + /// namespace-subtree match, so it works for either a leaf Code node or a + /// folder of them. + /// + /// Every resolved query is ANDed with nodeType:Code, so non-code + /// children never leak in. Matches are de-duplicated across entries. + /// + /// + /// If null or empty, defaults to ["namespace:_Source scope:subtree"] + /// — the conventional _Source/ sibling folder. Add more entries to pull + /// in shared code, e.g. + /// ["namespace:_Source scope:subtree", "@SocialMedia/Post/_Source/Platform"]. + /// (Note: the @@path form used inside a code file's body is a + /// separate feature — inline include — handled during code-content resolution.) + /// + public IReadOnlyList? Sources { get; init; } } diff --git a/test/MeshWeaver.Graph.Test/MeshNodeCompilationServiceTest.cs b/test/MeshWeaver.Graph.Test/MeshNodeCompilationServiceTest.cs index 156f8dc8a..f17408170 100644 --- a/test/MeshWeaver.Graph.Test/MeshNodeCompilationServiceTest.cs +++ b/test/MeshWeaver.Graph.Test/MeshNodeCompilationServiceTest.cs @@ -657,4 +657,258 @@ public record Contact result.NodeTypeConfigurations.Should().NotBeEmpty("Should extract HubConfiguration from compiled assembly"); result.NodeTypeConfigurations.First().HubConfiguration.Should().NotBeNull(); } + + [Fact(Timeout = 25000)] + public async Task GetAssemblyLocationAsync_SourcesDefaultsToSelfRelativeUnderscoreSource() + { + // The default (no Sources set) is "namespace:_Source scope:subtree", which + // is auto-rebased onto the NodeType's own path. This is the common case. + var persistence = new InMemoryPersistenceService(); + + var definition = new NodeTypeDefinition(); // no Sources + await SetupNodeType(persistence, "DefaultRel", definition, new CodeConfiguration + { + Code = @" +public record DefaultRelType +{ + public string Id { get; init; } = string.Empty; +}" + }); + + var service = CreateService(persistence); + var node = MeshNode.FromPath("org/default-rel/inst") with + { + Name = "Instance", + NodeType = "type/DefaultRel", + LastModified = DateTimeOffset.UtcNow + }; + + var assemblyPath = await service.GetAssemblyLocationAsync(node, TestContext.Current.CancellationToken); + + assemblyPath.Should().NotBeNull(); + Assembly.LoadFrom(assemblyPath!).GetType("DefaultRelType").Should().NotBeNull( + "default 'namespace:_Source' should auto-rebase onto the NodeType's own path"); + } + + [Fact(Timeout = 25000)] + public async Task GetAssemblyLocationAsync_SourcesWithSelfMacro_ExpandsToOwnPath() + { + // $self must resolve to the owning NodeType's path so JSON doesn't need + // to hardcode its own namespace. + var persistence = new InMemoryPersistenceService(); + + var definition = new NodeTypeDefinition + { + Sources = ["namespace:$self/_Source scope:subtree"] + }; + await SetupNodeType(persistence, "SelfMacro", definition, new CodeConfiguration + { + Code = @" +public record SelfMacroType +{ + public string Id { get; init; } = string.Empty; +}" + }); + + var service = CreateService(persistence); + var node = MeshNode.FromPath("org/self-macro/inst") with + { + Name = "Instance", + NodeType = "type/SelfMacro", + LastModified = DateTimeOffset.UtcNow + }; + + var assemblyPath = await service.GetAssemblyLocationAsync(node, TestContext.Current.CancellationToken); + + assemblyPath.Should().NotBeNull(); + Assembly.LoadFrom(assemblyPath!).GetType("SelfMacroType").Should().NotBeNull( + "$self should expand to type/SelfMacro, finding its _Source code"); + } + + [Fact(Timeout = 25000)] + public async Task GetAssemblyLocationAsync_SourcesFiltersNonCodeChildren() + { + // Non-Code children in the _Source folder must never sneak into the + // compilation — the service always ANDs with nodeType:Code. + var persistence = new InMemoryPersistenceService(); + + var definition = new NodeTypeDefinition(); + await SetupNodeType(persistence, "FilterType", definition, new CodeConfiguration + { + Code = @" +public record FilterTypeA +{ + public string Id { get; init; } = string.Empty; +}" + }); + + // A sibling non-Code node under _Source — must be ignored. + var junkNode = new MeshNode("notes", "type/FilterType/_Source") + { + NodeType = "Markdown", + Name = "Notes", + Content = "# not code" + }; + await persistence.SaveNodeAsync(junkNode, SetupJsonOptions, TestContext.Current.CancellationToken); + + var service = CreateService(persistence); + var node = MeshNode.FromPath("org/filter/one") with + { + Name = "One", + NodeType = "type/FilterType", + LastModified = DateTimeOffset.UtcNow + }; + + var assemblyPath = await service.GetAssemblyLocationAsync(node, TestContext.Current.CancellationToken); + + assemblyPath.Should().NotBeNull( + "non-Code children must not break compilation — the nodeType:Code filter excludes them"); + Assembly.LoadFrom(assemblyPath!).GetType("FilterTypeA").Should().NotBeNull(); + } + + [Fact(Timeout = 25000)] + public async Task GetAssemblyLocationAsync_SourcesWithMultipleLocations_PullsInExternalCode() + { + // This is the SocialMedia scenario: Profile NodeType needs Platform.cs + // which lives under Post's _Source folder. Without `Sources` this fails; + // with `Sources` listing both paths it compiles. + var persistence = new InMemoryPersistenceService(); + + // "Post" NodeType with shared Platform.cs in its _Source. + var postDef = new NodeTypeDefinition + { + Configuration = "config => config.WithContentType()" + }; + await SetupNodeType(persistence, "Post", postDef, new CodeConfiguration + { + Code = @" +public record Platform +{ + public string Id { get; init; } = string.Empty; + public static readonly Platform[] All = []; +} +public record Post +{ + public string Id { get; init; } = string.Empty; +}" + }); + + // "Profile" NodeType with its own SocialMediaProfile, AND Sources pointing + // at Post's _Source so it can reference Platform. + var profileDef = new NodeTypeDefinition + { + Configuration = "config => config.WithContentType()", + Sources = + [ + "namespace:$self/_Source scope:subtree", + "namespace:type/Post/_Source scope:subtree" + ] + }; + await SetupNodeType(persistence, "Profile", profileDef, new CodeConfiguration + { + Code = @" +public record Profile +{ + public string Id { get; init; } = string.Empty; + public Platform? Platform { get; init; } +}" + }); + + var service = CreateService(persistence); + var node = MeshNode.FromPath("org/profiles/alice") with + { + Name = "Alice", + NodeType = "type/Profile", + LastModified = DateTimeOffset.UtcNow + }; + + var assemblyPath = await service.GetAssemblyLocationAsync(node, TestContext.Current.CancellationToken); + + assemblyPath.Should().NotBeNull("Profile should compile with external Platform from Post/_Source"); + var assembly = Assembly.LoadFrom(assemblyPath!); + assembly.GetType("Profile").Should().NotBeNull(); + assembly.GetType("Platform").Should().NotBeNull("Platform from Post/_Source must be included"); + } + + [Fact(Timeout = 25000)] + public async Task GetAssemblyLocationAsync_SourcesOverlap_DedupesSharedNode() + { + // When two source queries both match the same Code node, we must not + // compile its types twice (compiler would reject duplicates). + var persistence = new InMemoryPersistenceService(); + + var definition = new NodeTypeDefinition + { + Sources = + [ + "namespace:$self/_Source scope:subtree", + "namespace:type/Overlap/_Source" // matches the same node + ] + }; + await SetupNodeType(persistence, "Overlap", definition, new CodeConfiguration + { + Code = @" +public record OverlapType +{ + public string Value { get; init; } = string.Empty; +}" + }); + + var service = CreateService(persistence); + var node = MeshNode.FromPath("org/overlaps/one") with + { + Name = "One", + NodeType = "type/Overlap", + LastModified = DateTimeOffset.UtcNow + }; + + var assemblyPath = await service.GetAssemblyLocationAsync(node, TestContext.Current.CancellationToken); + + assemblyPath.Should().NotBeNull( + "overlapping source queries must be deduped so the same type isn't compiled twice"); + Assembly.LoadFrom(assemblyPath!).GetType("OverlapType").Should().NotBeNull(); + } + + [Theory(Timeout = 25000)] + [InlineData("@", "single-at")] + [InlineData("@@", "double-at")] + public async Task GetAssemblyLocationAsync_SourcesWithAtPrefixShorthand_ResolvesSingleNode(string prefix, string suffix) + { + // Shorthand: "@path" / "@@path" in a Sources line means "this one Code node". + // No need to spell out "path:..." — the compilation service normalises it. + var persistence = new InMemoryPersistenceService(); + + var sharedTypeName = $"Shared_{suffix}"; + var consumerTypeName = $"Consumer_{suffix}"; + + var sharedDef = new NodeTypeDefinition(); + await SetupNodeType(persistence, sharedTypeName, sharedDef, new CodeConfiguration + { + Code = $@" +public record SharedHelper_{suffix.Replace("-", "_")} +{{ + public static string Greet() => ""hi""; +}}" + }); + + var consumerDef = new NodeTypeDefinition + { + Sources = [$"{prefix}type/{sharedTypeName}/_Source/code"] + }; + await SetupNodeType(persistence, consumerTypeName, consumerDef); + + var service = CreateService(persistence); + var node = MeshNode.FromPath($"org/consumers/{suffix}") with + { + Name = "One", + NodeType = $"type/{consumerTypeName}", + LastModified = DateTimeOffset.UtcNow + }; + + var assemblyPath = await service.GetAssemblyLocationAsync(node, TestContext.Current.CancellationToken); + + assemblyPath.Should().NotBeNull($"'{prefix}path' shorthand should resolve to the Shared Code node"); + var expectedTypeName = $"SharedHelper_{suffix.Replace("-", "_")}"; + Assembly.LoadFrom(assemblyPath!).GetType(expectedTypeName).Should().NotBeNull(); + } } From df907a251144e2bd641a11e4ffbf133af6cfc942 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Sun, 19 Apr 2026 16:12:00 +0200 Subject: [PATCH 26/50] feat: surface NodeType compilation errors through MCP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `GetDiagnostics(path)` tool on `MeshPlugin`/`MeshOperations` returning `{status, nodeTypePath, error}` for a NodeType or any of its instances. `Get` additionally wraps its response with a `compilationError` field when the node's NodeType failed to compile, so callers that only call `Get` still see the failure. `GetCompilationError` is now public on `INodeTypeService`. Also fixes two `Post + RegisterCallback` sites in `AgentView` and `AutocompleteClient` that blindly cast the callback response and would throw `InvalidCastException` on `DeliveryFailure`. Updates `Coder.md` to require `GetDiagnostics` verification after every NodeType create/update — a NodeType is not "done" until `status: "Ok"`. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/MeshWeaver.AI/AgentView.cs | 23 +++- .../Completion/AutocompleteClient.cs | 8 +- src/MeshWeaver.AI/Data/Agent/Coder.md | 12 +- src/MeshWeaver.AI/MeshOperations.cs | 73 +++++++++++ src/MeshWeaver.AI/MeshPlugin.cs | 10 ++ .../Services/INodeTypeService.cs | 14 +++ test/MeshWeaver.AI.Test/MeshPluginTest.cs | 119 +++++++++++++++++- 7 files changed, 249 insertions(+), 10 deletions(-) diff --git a/src/MeshWeaver.AI/AgentView.cs b/src/MeshWeaver.AI/AgentView.cs index 77222032e..323b3d83e 100644 --- a/src/MeshWeaver.AI/AgentView.cs +++ b/src/MeshWeaver.AI/AgentView.cs @@ -447,7 +447,28 @@ private static UiControl BuildEditLayout(LayoutAreaHost host, AgentConfiguration new DataChangeRequest { ChangedBy = actx.Host.Stream.ClientId }.WithUpdates(updatedAgent), o => o.WithTarget(hubAddress))!; var callbackResponse = await actx.Host.Hub.RegisterCallback(delivery, (d, _) => Task.FromResult(d), cts.Token); - var responseMsg = ((IMessageDelivery)callbackResponse).Message; + + // Handle routing failures (e.g., agent hub unreachable) and unexpected + // response shapes before touching the DataChangeResponse fields. + if (callbackResponse is IMessageDelivery deliveryFailure) + { + var dialog = Controls.Dialog( + Controls.Markdown($"**Error saving:**\n\n{deliveryFailure.Message.Message ?? "Delivery failed"}"), + "Save Failed" + ).WithSize("M"); + actx.Host.UpdateArea(DialogControl.DialogArea, dialog); + return; + } + if (callbackResponse is not IMessageDelivery dataChange) + { + var dialog = Controls.Dialog( + Controls.Markdown($"**Error saving:** Unexpected response `{callbackResponse.Message?.GetType().Name ?? "null"}`."), + "Save Failed" + ).WithSize("M"); + actx.Host.UpdateArea(DialogControl.DialogArea, dialog); + return; + } + var responseMsg = dataChange.Message; if (responseMsg.Log.Status != ActivityStatus.Succeeded) { diff --git a/src/MeshWeaver.AI/Completion/AutocompleteClient.cs b/src/MeshWeaver.AI/Completion/AutocompleteClient.cs index c9220e55c..070d76f8d 100644 --- a/src/MeshWeaver.AI/Completion/AutocompleteClient.cs +++ b/src/MeshWeaver.AI/Completion/AutocompleteClient.cs @@ -41,11 +41,13 @@ public async Task GetCompletionsAsync( new AutocompleteRequest(query, context?.Context), o => o.WithTarget(address))!; var callbackResponse = await hub.RegisterCallback(delivery, (d, _) => Task.FromResult(d), timeoutCts.Token); - var responseMsg = ((IMessageDelivery)callbackResponse).Message; - if (responseMsg?.Items != null) + // Tolerate hub-level failures (target unreachable, timeout as DeliveryFailure) + // and any unexpected response type — skipping is the historical behaviour. + if (callbackResponse is IMessageDelivery ok + && ok.Message?.Items != null) { - allItems = allItems.AddRange(responseMsg.Items); + allItems = allItems.AddRange(ok.Message.Items); } } catch diff --git a/src/MeshWeaver.AI/Data/Agent/Coder.md b/src/MeshWeaver.AI/Data/Agent/Coder.md index 24e52ec31..647ad458e 100644 --- a/src/MeshWeaver.AI/Data/Agent/Coder.md +++ b/src/MeshWeaver.AI/Data/Agent/Coder.md @@ -252,7 +252,11 @@ When asked to create a node type: - CSV loaders if loading external data 5. **Create the NodeType JSON** with the configuration lambda 6. **Upload CSV files** to the content collection if needed -7. **Verify** by getting the created nodes +7. **Verify compilation** — this step is NOT optional: + - Call `GetDiagnostics('@{nodeTypePath}')` after every NodeType create/update. + - If `status: "Error"` → read `error`, fix the broken source or the NodeType JSON (often the fix is adding a `sources` entry pointing at another NodeType's `_Source` via `$self` or an absolute path), write the fix with `Update`/`Patch`, and re-check. + - Repeat until `status: "Ok"`. Only then is the NodeType "done". + - Alternative: a plain `Get('@{path}')` on any instance (or the NodeType itself) wraps the JSON with a `compilationError` field when the type failed to compile — useful when you want the node data and the compile status together. # Business Rules & Calculations @@ -331,11 +335,15 @@ When asked to create an interactive document, create a Markdown node with the ex - Asked for a data model, type, or view? → Create a **NodeType**: JSON + `_Source/` `.cs` files + at least one sample instance. **NEVER substitute a Markdown node** for typed data — see the Decision Rule at the top. - Asked for a document, article, or narrative page? → Create a Markdown node with the full content. -- Asked to create a NodeType? → Call `Create` for each source file and the JSON definition. +- Asked to create a NodeType? → Call `Create` for each source file and the JSON definition, **then call `GetDiagnostics` and don't stop until `status: "Ok"`**. - Asked to modify a node? → Call `Get` first, then `Update` with the modified content. **Every delegation MUST end with at least one write tool call.** +**A NodeType is not "created" until `GetDiagnostics` says `Ok`.** Stopping after +`Create` when compilation is failing leaves the user with a broken type and no +way to use it. Iterate on the source files / `Sources` list until it compiles. + # Tools Use the standard Mesh tools (Get, Search, Create, Update, Delete) to manage nodes. diff --git a/src/MeshWeaver.AI/MeshOperations.cs b/src/MeshWeaver.AI/MeshOperations.cs index 3bb37e489..7e79632f2 100644 --- a/src/MeshWeaver.AI/MeshOperations.cs +++ b/src/MeshWeaver.AI/MeshOperations.cs @@ -22,6 +22,7 @@ public class MeshOperations private readonly IMessageHub hub; private readonly ILogger logger; private readonly IMeshService mesh; + private readonly INodeTypeService? nodeTypeService; /// /// Callback invoked when a node is created, updated, or patched. @@ -34,6 +35,24 @@ public MeshOperations(IMessageHub hub) this.hub = hub; this.logger = hub.ServiceProvider.GetRequiredService>(); this.mesh = hub.ServiceProvider.GetRequiredService(); + this.nodeTypeService = hub.ServiceProvider.GetService(); + } + + /// + /// Looks up the cached compilation error for the owning NodeType of . + /// - If is a NodeType definition, checks its own path. + /// - Otherwise checks the NodeType's path. + /// Returns null if no error is recorded. + /// + private string? LookupCompilationError(MeshNode node) + { + if (nodeTypeService == null) return null; + var nodeTypePath = node.Content is Graph.Configuration.NodeTypeDefinition + ? node.Path + : node.NodeType; + return !string.IsNullOrEmpty(nodeTypePath) + ? nodeTypeService.GetCompilationError(nodeTypePath) + : null; } /// @@ -170,6 +189,11 @@ public async Task Get(string path) await foreach (var node in mesh.QueryAsync( MeshQueryRequest.FromQuery($"path:{resolvedPath}"))) { + var compileError = LookupCompilationError(node); + if (compileError != null) + return JsonSerializer.Serialize( + new { node, compilationError = compileError }, + hub.JsonSerializerOptions); return JsonSerializer.Serialize(node, hub.JsonSerializerOptions); } @@ -856,4 +880,53 @@ internal async Task BuildNullContentErrorAsync(string path, string nodeT return Task.FromResult(null); } } + + /// + /// Returns compilation diagnostics for a NodeType or an instance of one. + /// The response is JSON with status (Error / Ok / + /// Unknown) and, when relevant, the error text from the last compile. + /// Used by the Coder agent's self-verification loop after creating / updating + /// a NodeType. + /// + public async Task GetDiagnostics(string path) + { + logger.LogInformation("GetDiagnostics called with path={Path}", path); + + if (string.IsNullOrWhiteSpace(path)) + return JsonSerializer.Serialize( + new { status = "Error", message = "path is required" }, + hub.JsonSerializerOptions); + + var resolvedPath = ResolvePath(path); + if (nodeTypeService == null) + return JsonSerializer.Serialize( + new { status = "Unknown", message = "INodeTypeService not registered on this hub" }, + hub.JsonSerializerOptions); + + // Resolve the owning NodeType path: either the path itself (if it IS a NodeType) + // or the NodeType of the instance at that path. + string? nodeTypePath = null; + await foreach (var node in mesh.QueryAsync(MeshQueryRequest.FromQuery($"path:{resolvedPath}"))) + { + nodeTypePath = node.Content is Graph.Configuration.NodeTypeDefinition + ? node.Path + : node.NodeType; + break; + } + + if (string.IsNullOrEmpty(nodeTypePath)) + return JsonSerializer.Serialize( + new { status = "Unknown", message = $"Not found: {resolvedPath}" }, + hub.JsonSerializerOptions); + + var err = nodeTypeService.GetCompilationError(nodeTypePath); + if (string.IsNullOrEmpty(err)) + return JsonSerializer.Serialize( + new { status = "Ok", nodeTypePath }, + hub.JsonSerializerOptions); + + return JsonSerializer.Serialize( + new { status = "Error", nodeTypePath, error = err }, + hub.JsonSerializerOptions); + } } diff --git a/src/MeshWeaver.AI/MeshPlugin.cs b/src/MeshWeaver.AI/MeshPlugin.cs index 231bf58ab..e91b63f1b 100644 --- a/src/MeshWeaver.AI/MeshPlugin.cs +++ b/src/MeshWeaver.AI/MeshPlugin.cs @@ -68,6 +68,14 @@ public Task Delete( return ops.Delete(paths); } + [Description("Returns compilation diagnostics for a NodeType or an instance of one. Status is 'Ok' when the type compiled cleanly, 'Error' with a detailed message when it failed, or 'Unknown' when no compile has happened yet. Use this after creating/updating a NodeType to verify it actually compiles — a NodeType that doesn't compile is not 'done'.")] + public Task GetDiagnostics( + [Description("Path to a NodeType (e.g., @Systemorph/SocialMedia/Profile) or to any instance of one")] string path) + { + RestoreAccessContext(); + return ops.GetDiagnostics(ResolveContextPath(path)); + } + /// /// Restores the user's AccessContext from . /// AsyncLocal doesn't flow reliably through the AI framework's streaming + tool @@ -108,6 +116,7 @@ public IList CreateTools() AIFunctionFactory.Create(Get), AIFunctionFactory.Create(Search), AIFunctionFactory.Create(NavigateTo), + AIFunctionFactory.Create(GetDiagnostics), ]; } @@ -125,6 +134,7 @@ public IList CreateAllTools() AIFunctionFactory.Create(Update), AIFunctionFactory.Create(Patch), AIFunctionFactory.Create(Delete), + AIFunctionFactory.Create(GetDiagnostics), ]; } } diff --git a/src/MeshWeaver.Mesh.Contract/Services/INodeTypeService.cs b/src/MeshWeaver.Mesh.Contract/Services/INodeTypeService.cs index 75a780711..1b5a2d434 100644 --- a/src/MeshWeaver.Mesh.Contract/Services/INodeTypeService.cs +++ b/src/MeshWeaver.Mesh.Contract/Services/INodeTypeService.cs @@ -35,4 +35,18 @@ public interface INodeTypeService /// Returns null if no access rules are defined in the hub config. /// INodeTypeAccessRule? GetAccessRule(string nodeTypePath) => null; + + /// + /// Returns the last compilation error recorded for the given NodeType path, + /// or null if compilation has not failed. The error text includes the + /// formatted Roslyn diagnostics as produced by + /// MeshNodeCompilationService. + /// + /// + /// Used by MCP Get / GetDiagnostics so callers (e.g. the Coder + /// agent) can verify that a NodeType they just created or updated actually + /// compiles. The error is cached by NodeTypeService each time a compile + /// fails and cleared when it succeeds. + /// + string? GetCompilationError(string nodeTypePath) => null; } diff --git a/test/MeshWeaver.AI.Test/MeshPluginTest.cs b/test/MeshWeaver.AI.Test/MeshPluginTest.cs index 41f74c3f5..f91e5a6ef 100644 --- a/test/MeshWeaver.AI.Test/MeshPluginTest.cs +++ b/test/MeshWeaver.AI.Test/MeshPluginTest.cs @@ -75,13 +75,14 @@ public void CreateTools_ShouldReturnReadOnlyTools() var tools = plugin.CreateTools(); tools.Should().NotBeNull(); - // Read-only tools: Get, Search, NavigateTo - tools.Should().HaveCount(3); + // Read-only tools: Get, Search, NavigateTo, GetDiagnostics + tools.Should().HaveCount(4); var toolNames = tools.OfType().Select(t => t.Name).ToList(); toolNames.Should().Contain("Get"); toolNames.Should().Contain("Search"); toolNames.Should().Contain("NavigateTo"); + toolNames.Should().Contain("GetDiagnostics"); toolNames.Should().NotContain("Create"); toolNames.Should().NotContain("Update"); toolNames.Should().NotContain("Delete"); @@ -96,8 +97,8 @@ public void CreateAllTools_ShouldIncludeWriteOperations() var tools = plugin.CreateAllTools(); tools.Should().NotBeNull(); - // All tools: Get, Search, NavigateTo, Create, Update, Patch, Delete - tools.Should().HaveCount(7); + // All tools: Get, Search, NavigateTo, Create, Update, Patch, Delete, GetDiagnostics + tools.Should().HaveCount(8); var toolNames = tools.OfType().Select(t => t.Name).ToList(); toolNames.Should().Contain("Get"); @@ -107,6 +108,7 @@ public void CreateAllTools_ShouldIncludeWriteOperations() toolNames.Should().Contain("Update"); toolNames.Should().Contain("Patch"); toolNames.Should().Contain("Delete"); + toolNames.Should().Contain("GetDiagnostics"); } #endregion @@ -698,6 +700,115 @@ public async Task ContentCollection_GetSubfolderFileContent() #endregion + #region GetDiagnostics / compilation-error surfacing + + /// + /// A NodeType whose Configuration references an undefined type must surface + /// the compilation error through so + /// the Coder agent can self-diagnose. + /// + [Fact(Timeout = 60000)] + public async Task GetDiagnostics_BrokenNodeType_ReturnsErrorStatus() + { + var mockChat = new MockAgentChat(); + var plugin = new MeshPlugin(Mesh, mockChat); + + var nodeTypeId = $"BrokenType_{Guid.NewGuid():N}"; + var createJson = JsonSerializer.Serialize(new + { + id = nodeTypeId, + @namespace = "ACME", + name = "Broken Type", + nodeType = "NodeType", + content = new + { + type = "NodeTypeDefinition", + configuration = "config => config.WithContentType()" + } + }); + // Wrap "type" → "$type" for JsonPolymorphic + createJson = createJson.Replace("\"type\":", "\"$type\":"); + await plugin.Create(createJson); + + // Force compilation via the hub. Touching the NodeType path via the hub + // triggers compile; the error is then cached in NodeTypeService. + var nodeTypePath = $"ACME/{nodeTypeId}"; + var nodeTypeService = Mesh.ServiceProvider.GetRequiredService(); + try + { + await nodeTypeService.EnrichWithNodeTypeAsync( + new MeshNode(nodeTypeId, "ACME") { NodeType = nodeTypePath }, + TestContext.Current.CancellationToken); + } + catch + { + // Expected: compilation throws; NodeTypeService still records the error. + } + + var diagnosticsJson = await plugin.GetDiagnostics($"@{nodeTypePath}"); + diagnosticsJson.Should().NotBeNullOrEmpty(); + + using var doc = JsonDocument.Parse(diagnosticsJson); + var root = doc.RootElement; + root.GetProperty("status").GetString() + .Should().Be("Error", "because the broken NodeType should report compile failure"); + root.GetProperty("error").GetString() + .Should().Contain("ThisTypeDoesNotExist", + "the cached error must include the unresolved type"); + } + + /// + /// on an instance of a broken NodeType must + /// wrap the response with a compilationError field so callers that + /// only call Get still see the failure. + /// + [Fact(Timeout = 60000)] + public async Task Get_InstanceOfBrokenNodeType_WrapsResponseWithCompilationError() + { + var mockChat = new MockAgentChat(); + var plugin = new MeshPlugin(Mesh, mockChat); + + // Create a broken NodeType and force its compilation to cache the error. + var nodeTypeId = $"BrokenType2_{Guid.NewGuid():N}"; + var nodeTypePath = $"ACME/{nodeTypeId}"; + var createJson = JsonSerializer.Serialize(new + { + id = nodeTypeId, + @namespace = "ACME", + name = "Broken Type 2", + nodeType = "NodeType", + content = new + { + type = "NodeTypeDefinition", + configuration = "config => config.WithContentType()" + } + }).Replace("\"type\":", "\"$type\":"); + await plugin.Create(createJson); + + var nodeTypeService = Mesh.ServiceProvider.GetRequiredService(); + try + { + await nodeTypeService.EnrichWithNodeTypeAsync( + new MeshNode(nodeTypeId, "ACME") { NodeType = nodeTypePath }, + TestContext.Current.CancellationToken); + } + catch { /* expected */ } + + // Get the NodeType itself — response should include compilationError. + var result = await plugin.Get($"@{nodeTypePath}"); + result.Should().NotBeNullOrEmpty(); + + using var doc = JsonDocument.Parse(result); + var root = doc.RootElement; + root.TryGetProperty("compilationError", out var err).Should().BeTrue( + "Get on a broken NodeType must include a compilationError field"); + err.GetString().Should().Contain("AlsoNotAType"); + root.TryGetProperty("node", out _).Should().BeTrue( + "the original node payload must still be included under 'node'"); + } + + #endregion + private class MockAgentChat : IAgentChat { public AgentContext? Context { get; set; } From 8691035d0589dd19591888a7c65876844aeed4d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Sun, 19 Apr 2026 16:25:10 +0200 Subject: [PATCH 27/50] feat: Recycle menu item + markdown compilation-error overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Recycle menu item (between Move and Delete) that sends DisposeRequest to the current node's hub and redirects back to Overview after 100ms. Lets users flush a cached / stuck grain — useful after fixing a compile error on a NodeType whose hub was already instantiated with the broken configuration. Reformats the compilation-error overlay as markdown (fenced code block for the Roslyn diagnostics + a pointer to Recycle / GetDiagnostics) so it renders legibly in both light and dark themes — the previous HTML used hardcoded light-mode colours. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Configuration/NodeMenuItemsExtensions.cs | 9 +++ .../Configuration/NodeTypeService.cs | 30 ++++++++-- src/MeshWeaver.Graph/MeshNodeLayoutAreas.cs | 5 ++ src/MeshWeaver.Graph/RecycleLayoutArea.cs | 59 +++++++++++++++++++ 4 files changed, 99 insertions(+), 4 deletions(-) create mode 100644 src/MeshWeaver.Graph/RecycleLayoutArea.cs diff --git a/src/MeshWeaver.Graph/Configuration/NodeMenuItemsExtensions.cs b/src/MeshWeaver.Graph/Configuration/NodeMenuItemsExtensions.cs index 23b1f49cf..893270138 100644 --- a/src/MeshWeaver.Graph/Configuration/NodeMenuItemsExtensions.cs +++ b/src/MeshWeaver.Graph/Configuration/NodeMenuItemsExtensions.cs @@ -106,6 +106,12 @@ private static async IAsyncEnumerable DefaultNodeMenuPro yield return MeshNodeLayoutAreas.GetThreadsMenuItem(menuPath); + var accessService = host.Hub.ServiceProvider.GetService(); + var viewerId = accessService?.Context?.ObjectId + ?? accessService?.CircuitContext?.ObjectId; + var pin = PinLayoutArea.GetMenuItem(menuPath, viewerId); + if (pin != null) yield return pin; + var versions = VersionLayoutArea.GetMenuItem(menuPath, perms); if (versions != null) yield return versions; @@ -115,6 +121,9 @@ private static async IAsyncEnumerable DefaultNodeMenuPro var move = MoveLayoutArea.GetMenuItem(menuPath, perms); if (move != null) yield return move; + var recycle = RecycleLayoutArea.GetMenuItem(menuPath, perms); + if (recycle != null) yield return recycle; + var delete = DeleteLayoutArea.GetMenuItem(menuPath, perms); if (delete != null) yield return delete; } diff --git a/src/MeshWeaver.Graph/Configuration/NodeTypeService.cs b/src/MeshWeaver.Graph/Configuration/NodeTypeService.cs index 3127b66b9..9374a651e 100644 --- a/src/MeshWeaver.Graph/Configuration/NodeTypeService.cs +++ b/src/MeshWeaver.Graph/Configuration/NodeTypeService.cs @@ -680,15 +680,37 @@ private void OnStreamUpdated(string nodeTypePath) /// /// Creates a hub configuration that shows a compilation error in the Overview area. + /// Renders as markdown so it respects the current theme (readable in both light and + /// dark modes) and gets code-block formatting for the Roslyn diagnostics. /// private static Func CreateCompilationErrorConfiguration(string errorMessage) { return config => config.AddLayout(layout => layout.WithView(MeshNodeLayoutAreas.OverviewArea, (host, ctx) => - Observable.Return( - Controls.Stack - .WithView(Controls.Html( - $"
{WebUtility.HtmlEncode(errorMessage)}
"))))); + Observable.Return(BuildCompilationErrorMarkdown(errorMessage)))); + } + + private static UiControl BuildCompilationErrorMarkdown(string errorMessage) + { + // Split "Compilation failed for 'X':\n" into header + body so the + // diagnostics land in a fenced code block — much easier to read than one long + // HTML blob, and uses the theme's code/text colours. + var newlineIdx = errorMessage.IndexOf('\n'); + var header = newlineIdx >= 0 ? errorMessage[..newlineIdx].TrimEnd(':') : errorMessage; + var body = newlineIdx >= 0 ? errorMessage[(newlineIdx + 1)..].TrimEnd() : string.Empty; + + var markdown = +$@"> **⚠ {header}** +> +> Fix the source code or the NodeType's `sources` list, then use the **Recycle** menu to flush the cached grain (or call `GetDiagnostics` via MCP to re-check). + +```text +{body} +```"; + + return Controls.Stack + .WithStyle("padding: 16px;") + .WithView(Controls.Markdown(markdown)); } #region Creatable Types diff --git a/src/MeshWeaver.Graph/MeshNodeLayoutAreas.cs b/src/MeshWeaver.Graph/MeshNodeLayoutAreas.cs index 15205ecb5..8a31159bf 100644 --- a/src/MeshWeaver.Graph/MeshNodeLayoutAreas.cs +++ b/src/MeshWeaver.Graph/MeshNodeLayoutAreas.cs @@ -72,6 +72,7 @@ public static class MeshNodeLayoutAreas public const string ExportArea = "Export"; public const string CopyArea = "Copy"; public const string MoveArea = "Move"; + public const string RecycleArea = "Recycle"; public const string VersionsArea = "Versions"; public const string VersionDiffArea = "VersionDiff"; @@ -115,9 +116,13 @@ public static LayoutDefinition AddDefaultLayoutAreas(this LayoutDefinition layou .WithView(ExportArea, ExportLayoutArea.Export) .WithView(CopyArea, CopyLayoutArea.Copy) .WithView(MoveArea, MoveLayoutArea.Move) + .WithView(RecycleArea, RecycleLayoutArea.Recycle) .WithView(VersionsArea, VersionLayoutArea.Versions) .WithView(VersionDiffArea, VersionLayoutArea.VersionDiff) .WithView(DeleteArea, DeleteLayoutArea.Delete) + .WithView(PinLayoutArea.PinArea, PinLayoutArea.Pin) + .WithView(PinLayoutArea.UnpinArea, PinLayoutArea.Unpin) + .WithView(PinLayoutArea.PinnedThumbnailArea, PinLayoutArea.PinnedThumbnail) // UCR special areas .WithView(DataArea, Data) .WithView(SchemaArea, Schema) diff --git a/src/MeshWeaver.Graph/RecycleLayoutArea.cs b/src/MeshWeaver.Graph/RecycleLayoutArea.cs new file mode 100644 index 000000000..72898f934 --- /dev/null +++ b/src/MeshWeaver.Graph/RecycleLayoutArea.cs @@ -0,0 +1,59 @@ +using System.ComponentModel; +using System.Reactive.Linq; +using MeshWeaver.Layout; +using MeshWeaver.Layout.Composition; +using MeshWeaver.Mesh; +using MeshWeaver.Mesh.Security; +using MeshWeaver.Messaging; + +namespace MeshWeaver.Graph; + +/// +/// Layout area for recycling the current node's hub — sends +/// to the hub address, waits 100ms, and redirects back to Overview. Lets the user clear +/// a cached / stuck grain (e.g. after fixing a compilation error) without restarting the +/// whole portal. +/// +public static class RecycleLayoutArea +{ + /// + /// Returns the Recycle menu item if the user has Update permission. + /// Sort order 90 places it just above Delete (100). + /// + public static NodeMenuItemDefinition? GetMenuItem(string hubPath, Permission perms) + { + if (!perms.HasFlag(Permission.Update)) + return null; + return new("Recycle", MeshNodeLayoutAreas.RecycleArea, + RequiredPermission: Permission.Update, Order: 90, + Href: MeshNodeLayoutAreas.BuildUrl(hubPath, MeshNodeLayoutAreas.RecycleArea)); + } + + /// + /// Entry point for the Recycle layout area. Posts DisposeRequest immediately, + /// then emits a transient "Recycling…" message followed by a RedirectControl + /// back to Overview after 100ms — enough time for the hub to tear down and + /// come up fresh on the next request. + /// + [Browsable(false)] + public static IObservable Recycle(LayoutAreaHost host, RenderingContext _) + { + var nodePath = host.Hub.Address.Path; + var targetAddress = host.Hub.Address; + var overviewHref = MeshNodeLayoutAreas.BuildUrl(nodePath, MeshNodeLayoutAreas.OverviewArea); + + // Fire the dispose synchronously — no await. The hub receives it and shuts + // down; the grain's next access will re-initialize (and, in Orleans setups, + // a fresh activation compiles with whatever sources the NodeType now lists). + host.Hub.Post(new DisposeRequest(), o => o.WithTarget(targetAddress)); + + var recyclingMessage = (UiControl?)Controls.Stack + .WithStyle("padding: 24px;") + .WithView(Controls.Markdown("**Recycling hub…** redirecting in a moment.")); + + var redirect = (UiControl?)new RedirectControl(overviewHref); + + return Observable.Return(recyclingMessage) + .Concat(Observable.Timer(TimeSpan.FromMilliseconds(100)).Select(_ => redirect)); + } +} From aee0ba9ac0d58654c8ee5626553ce714c9f2f97b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Sun, 19 Apr 2026 16:36:47 +0200 Subject: [PATCH 28/50] introducing pinned areas. --- .../Pages/Onboarding.razor | 1 + samples/Graph/Data/User/Alice.json | 3 +- samples/Graph/Data/User/Bob.json | 3 +- samples/Graph/Data/User/Carol.json | 3 +- samples/Graph/Data/User/David.json | 3 +- samples/Graph/Data/User/Emma.json | 3 +- samples/Graph/Data/User/Roland.json | 3 +- samples/Graph/Data/User/Samuel.json | 3 +- .../AccessControlLayoutArea.cs | 106 ++++------- .../Configuration/RoleNodeType.cs | 35 +++- src/MeshWeaver.Graph/PinLayoutArea.cs | 178 ++++++++++++++++++ .../UserActivityLayoutAreas.cs | 91 ++++----- src/MeshWeaver.Mesh.Contract/Security/User.cs | 3 + 13 files changed, 304 insertions(+), 131 deletions(-) create mode 100644 src/MeshWeaver.Graph/PinLayoutArea.cs diff --git a/memex/Memex.Portal.Shared/Pages/Onboarding.razor b/memex/Memex.Portal.Shared/Pages/Onboarding.razor index 7036bf697..96b5ebab1 100644 --- a/memex/Memex.Portal.Shared/Pages/Onboarding.razor +++ b/memex/Memex.Portal.Shared/Pages/Onboarding.razor @@ -134,6 +134,7 @@ Email = model.Email.Trim(), Bio = string.IsNullOrWhiteSpace(model.Bio) ? null : model.Bio.Trim(), Role = string.IsNullOrWhiteSpace(model.Role) ? null : model.Role.Trim(), + PinnedPaths = ["Doc"], }; // Use ImpersonateAsHub so the portal hub identity is recognized diff --git a/samples/Graph/Data/User/Alice.json b/samples/Graph/Data/User/Alice.json index 941459f5b..a2b8d55cc 100644 --- a/samples/Graph/Data/User/Alice.json +++ b/samples/Graph/Data/User/Alice.json @@ -8,6 +8,7 @@ "content": { "$type": "User", "email": "alice.chen@meshweaver.io", - "bio": "Software engineer and project contributor." + "bio": "Software engineer and project contributor.", + "pinnedPaths": ["Doc"] } } diff --git a/samples/Graph/Data/User/Bob.json b/samples/Graph/Data/User/Bob.json index 30b66de78..a53f803ed 100644 --- a/samples/Graph/Data/User/Bob.json +++ b/samples/Graph/Data/User/Bob.json @@ -8,6 +8,7 @@ "content": { "$type": "User", "email": "bob.smith@meshweaver.io", - "bio": "Software engineer and project contributor." + "bio": "Software engineer and project contributor.", + "pinnedPaths": ["Doc"] } } diff --git a/samples/Graph/Data/User/Carol.json b/samples/Graph/Data/User/Carol.json index 273be92dc..e9029f9c1 100644 --- a/samples/Graph/Data/User/Carol.json +++ b/samples/Graph/Data/User/Carol.json @@ -8,6 +8,7 @@ "content": { "$type": "User", "email": "carol.johnson@meshweaver.io", - "bio": "Software engineer and project contributor." + "bio": "Software engineer and project contributor.", + "pinnedPaths": ["Doc"] } } diff --git a/samples/Graph/Data/User/David.json b/samples/Graph/Data/User/David.json index 25bcf73bc..65812c1e0 100644 --- a/samples/Graph/Data/User/David.json +++ b/samples/Graph/Data/User/David.json @@ -8,6 +8,7 @@ "content": { "$type": "User", "email": "david.lee@meshweaver.io", - "bio": "Software engineer and project contributor." + "bio": "Software engineer and project contributor.", + "pinnedPaths": ["Doc"] } } diff --git a/samples/Graph/Data/User/Emma.json b/samples/Graph/Data/User/Emma.json index cbcc5ba06..6a542fb39 100644 --- a/samples/Graph/Data/User/Emma.json +++ b/samples/Graph/Data/User/Emma.json @@ -8,6 +8,7 @@ "content": { "$type": "User", "email": "emma.wilson@meshweaver.io", - "bio": "Software engineer and project contributor." + "bio": "Software engineer and project contributor.", + "pinnedPaths": ["Doc"] } } diff --git a/samples/Graph/Data/User/Roland.json b/samples/Graph/Data/User/Roland.json index 691c0361f..f07375258 100644 --- a/samples/Graph/Data/User/Roland.json +++ b/samples/Graph/Data/User/Roland.json @@ -8,6 +8,7 @@ "content": { "$type": "User", "email": "rbuergi@systemorph.com", - "bio": "Founder of Systemorph and creator of MeshWeaver." + "bio": "Founder of Systemorph and creator of MeshWeaver.", + "pinnedPaths": ["Doc"] } } diff --git a/samples/Graph/Data/User/Samuel.json b/samples/Graph/Data/User/Samuel.json index d15419703..f626bb32b 100644 --- a/samples/Graph/Data/User/Samuel.json +++ b/samples/Graph/Data/User/Samuel.json @@ -8,6 +8,7 @@ "content": { "$type": "User", "email": "sglauser@systemorph.com", - "bio": "Software engineer and MeshWeaver contributor." + "bio": "Software engineer and MeshWeaver contributor.", + "pinnedPaths": ["Doc"] } } diff --git a/src/MeshWeaver.Graph/AccessControlLayoutArea.cs b/src/MeshWeaver.Graph/AccessControlLayoutArea.cs index 4eb90ff18..05e890f1e 100644 --- a/src/MeshWeaver.Graph/AccessControlLayoutArea.cs +++ b/src/MeshWeaver.Graph/AccessControlLayoutArea.cs @@ -1,3 +1,4 @@ +using System.Net; using System.Reactive.Linq; using MeshWeaver.Application.Styles; using MeshWeaver.Data; @@ -11,6 +12,7 @@ using MeshWeaver.Messaging; using MeshWeaver.ShortGuid; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace MeshWeaver.Graph; @@ -40,76 +42,44 @@ public static class AccessControlLayoutArea ); } - var meshQuery = host.Hub.ServiceProvider.GetService(); - var nodeStream = host.Workspace.GetStream()?.Select(nodes => nodes ?? []) - ?? Observable.Return([]); + var nodeStream = host.Workspace.GetStream(new MeshNodeReference()); + if (nodeStream is null) + { + return Observable.Return( + Controls.Stack.WithStyle("padding: 24px;").WithView( + Controls.Html( + $"

No node exists at " + + $"{WebUtility.HtmlEncode(hubPath)}.

"))); + } - return nodeStream - .SelectMany(async nodes => + // Admin check — read from the current access context synchronously. No awaits, + // no Query, no FromAsync. Roles are set at circuit/request time. + var accessService = host.Hub.ServiceProvider.GetService(); + var roles = accessService?.Context?.Roles + ?? accessService?.CircuitContext?.Roles + ?? []; + var isAdmin = roles.Any(r => + string.Equals(r, "Admin", StringComparison.OrdinalIgnoreCase) || + string.Equals(r, "PlatformAdmin", StringComparison.OrdinalIgnoreCase)); + + return nodeStream.Select(change => + { + var node = change?.Value; + if (node is null) { - var node = nodes.FirstOrDefault(n => n.Namespace == hubPath || n.Path == hubPath); - var isAdmin = await CheckAdminPermission(host.Hub, hubPath); - - // Restrict to the current partition — the first path segment — so the query - // never fans out across partitions. Ancestors above the partition root - // (i.e. global/admin assignments) are excluded here by design. - var partitionRoot = hubPath.Split('/', 2)[0]; - - // Load ancestor assignments within the current partition only. - var inherited = new List<(AccessAssignment Assignment, string SourcePath, MeshNode Node)>(); - if (meshQuery != null && !string.IsNullOrEmpty(partitionRoot)) - { - try - { - var ancestorAssignments = await meshQuery - .QueryAsync( - $"namespace:{partitionRoot} path:{hubPath} nodeType:AccessAssignment scope:ancestors") - .ToListAsync(); - - foreach (var assignmentNode in ancestorAssignments) - { - var assignment = DeserializeAssignment(assignmentNode); - if (assignment != null) - inherited.Add((assignment, assignmentNode.Namespace ?? "", assignmentNode)); - } - } - catch - { - // Query may fail if index not ready - } - } - - // Pre-fetch user nodes for icons. Each user path is targeted (exact path) — - // no scope or fan-out. The query router uses the first segment to pick a - // partition (User, Group, etc.) so this stays O(1) per subject. - var userNodeLookup = new Dictionary(); - if (meshQuery != null) - { - var userPaths = inherited.Select(x => x.Assignment.AccessObject).Distinct(); - foreach (var userPath in userPaths) - { - if (string.IsNullOrEmpty(userPath)) continue; - try - { - var userNode = await meshQuery.QueryAsync( - $"path:{userPath}").FirstOrDefaultAsync(); - if (userNode != null) - userNodeLookup[userPath] = userNode; - } - catch { } - } - } - - // Load partition access policy for this namespace - PartitionAccessPolicy? activePolicy = null; - if (securityService != null) - { - try { activePolicy = await securityService.GetPolicyAsync(hubPath); } - catch { } - } - - return BuildAccessControlPage(host, node, hubPath, isAdmin, inherited, userNodeLookup, securityService, activePolicy); - }); + return (UiControl?)Controls.Stack.WithStyle("padding: 24px;").WithView( + Controls.Html( + $"

Node does not exist at " + + $"{WebUtility.HtmlEncode(hubPath)}.

")); + } + + return BuildAccessControlPage( + host, node, hubPath, isAdmin, + inherited: [], + userNodeLookup: new Dictionary(), + securityService, + activePolicy: null); + }); } internal static AccessAssignment? DeserializeAssignment(MeshNode node) diff --git a/src/MeshWeaver.Graph/Configuration/RoleNodeType.cs b/src/MeshWeaver.Graph/Configuration/RoleNodeType.cs index 7c6044b4a..4cb96c388 100644 --- a/src/MeshWeaver.Graph/Configuration/RoleNodeType.cs +++ b/src/MeshWeaver.Graph/Configuration/RoleNodeType.cs @@ -46,6 +46,33 @@ public static TBuilder AddRoleType(this TBuilder builder) where TBuild .AddDefaultLayoutAreas() }; + // Inline SVGs for the four built-in roles — rendered directly by MeshNodeImageHelper.IsInlineSvg. + // Each uses a 20x20 rounded-square badge matching shield.svg's visual language, with a distinct + // hue per role so they read at a glance in menus, thumbnails, and permission pickers. + private const string AdminIcon = + "" + + "" + + "" + + ""; + + private const string EditorIcon = + "" + + "" + + "" + + ""; + + private const string ViewerIcon = + "" + + "" + + "" + + ""; + + private const string CommenterIcon = + "" + + "" + + "" + + ""; + /// /// Provides the four built-in roles as static MeshNodes /// so they appear in query results regardless of scope. @@ -68,10 +95,10 @@ private class BuiltInRolesProvider : IStaticNodeProvider Thread = false } }, - new("Admin", "Role") { Name = "Admin", NodeType = NodeType, Content = Role.Admin }, - new("Editor", "Role") { Name = "Editor", NodeType = NodeType, Content = Role.Editor }, - new("Viewer", "Role") { Name = "Viewer", NodeType = NodeType, Content = Role.Viewer }, - new("Commenter", "Role") { Name = "Commenter", NodeType = NodeType, Content = Role.Commenter }, + new("Admin", "Role") { Name = "Admin", NodeType = NodeType, Icon = AdminIcon, Content = Role.Admin }, + new("Editor", "Role") { Name = "Editor", NodeType = NodeType, Icon = EditorIcon, Content = Role.Editor }, + new("Viewer", "Role") { Name = "Viewer", NodeType = NodeType, Icon = ViewerIcon, Content = Role.Viewer }, + new("Commenter", "Role") { Name = "Commenter", NodeType = NodeType, Icon = CommenterIcon, Content = Role.Commenter }, ]; public IEnumerable GetStaticNodes() => Nodes; diff --git a/src/MeshWeaver.Graph/PinLayoutArea.cs b/src/MeshWeaver.Graph/PinLayoutArea.cs new file mode 100644 index 000000000..97ad32a0d --- /dev/null +++ b/src/MeshWeaver.Graph/PinLayoutArea.cs @@ -0,0 +1,178 @@ +using System.Collections.Immutable; +using System.ComponentModel; +using System.Reactive.Linq; +using MeshWeaver.Application.Styles; +using MeshWeaver.Data; +using MeshWeaver.Layout; +using MeshWeaver.Layout.Composition; +using MeshWeaver.Layout.Domain; +using MeshWeaver.Mesh; +using MeshWeaver.Mesh.Security; +using MeshWeaver.Messaging; +using Microsoft.Extensions.DependencyInjection; + +namespace MeshWeaver.Graph; + +/// +/// Pin / Unpin actions for a node, plus a PinnedThumbnail renderer that shows a +/// node as a compact card with an overlay unpin icon. +/// Pin state lives in on the current user's MeshNode. +/// +public static class PinLayoutArea +{ + /// Area name for the Pin action (adds this node's path to the viewer's pinned list). + public const string PinArea = "Pin"; + + /// Area name for the Unpin action (removes this node's path from the viewer's pinned list). + public const string UnpinArea = "Unpin"; + + /// Area name for the compact pinned-card renderer (used as MeshSearch ItemArea). + public const string PinnedThumbnailArea = "PinnedThumbnail"; + + /// + /// Returns the Pin menu item. Always yields — pinning is idempotent. + /// Hidden on the viewer's own User node (pinning your own profile is pointless). + /// + public static NodeMenuItemDefinition? GetMenuItem(string hubPath, string? viewerId) + { + if (string.IsNullOrEmpty(viewerId)) + return null; + if (hubPath.Equals($"User/{viewerId}", StringComparison.OrdinalIgnoreCase)) + return null; + return new("Pin", PinArea, + Icon: "Bookmark", + Order: 50, + Href: MeshNodeLayoutAreas.BuildUrl(hubPath, PinArea)); + } + + /// + /// Pin layout area — performs an idempotent add of the current node's path to the + /// viewer's , then renders a confirmation with a back link. + /// + [Browsable(false)] + public static IObservable Pin(LayoutAreaHost host, RenderingContext _) + => TogglePinAndRender(host, unpin: false); + + /// + /// Unpin layout area — removes the current node's path from the viewer's pinned list. + /// Used by the unpin icon on pinned cards via a Href link, and by the Unpin menu item. + /// + [Browsable(false)] + public static IObservable Unpin(LayoutAreaHost host, RenderingContext _) + => TogglePinAndRender(host, unpin: true); + + /// + /// Compact pinned card: standard node thumbnail with an overlay unpin button. + /// Rendered per search result when the enclosing MeshSearch sets + /// to . + /// + [Browsable(false)] + public static IObservable PinnedThumbnail(LayoutAreaHost host, RenderingContext _) + { + var hubPath = host.Hub.Address.ToString(); + var accessService = host.Hub.ServiceProvider.GetService(); + var viewerId = accessService?.Context?.ObjectId + ?? accessService?.CircuitContext?.ObjectId + ?? ""; + + return host.StreamView( + (nodes, _) => + { + var node = nodes.FirstOrDefault(n => n.Path == hubPath); + return BuildPinnedCard(host, node, hubPath, viewerId); + }, + hubPath); + } + + private static UiControl BuildPinnedCard(LayoutAreaHost host, MeshNode? node, string hubPath, string viewerId) + { + var thumbnail = MeshNodeThumbnailControl.FromNode(node, hubPath); + + var stack = Controls.Stack + .WithStyle("position: relative; width: 100%; height: 100%;") + .WithView(thumbnail); + + if (string.IsNullOrEmpty(viewerId)) + return stack; + + // Overlay unpin button, top-right corner. + // The click handler mutates PinnedPaths on the viewer's User node via workspace.UpdateMeshNode, + // which dispatches remotely since the viewer's hub differs from this item's hub. + // The viewer's dashboard observes the user stream and re-renders — the card disappears. + var userPath = $"User/{viewerId}"; + var userAddress = new Address(userPath); + var unpinButton = Controls.Button("") + .WithIconStart(FluentIcons.Dismiss()) + .WithAppearance(Appearance.Stealth) + .WithStyle("position: absolute; top: 4px; right: 4px; z-index: 5; " + + "min-width: 24px; width: 24px; height: 24px; padding: 0; " + + "border-radius: 50%; background: rgba(0,0,0,0.55); color: #fff;") + .WithClickAction(ctx => + { + ctx.Host.Workspace.UpdateMeshNode(userAddress, userPath, (n, user) => + { + var paths = user.PinnedPaths?.ToImmutableList() ?? ImmutableList.Empty; + var updated = paths.RemoveAll(p => + string.Equals(p, hubPath, StringComparison.OrdinalIgnoreCase)); + return n with { Content = user with { PinnedPaths = updated } }; + }); + return Task.CompletedTask; + }); + + return stack.WithView(unpinButton); + } + + private static IObservable TogglePinAndRender(LayoutAreaHost host, bool unpin) + { + var hubPath = host.Hub.Address.ToString(); + var backHref = MeshNodeLayoutAreas.BuildUrl(hubPath, MeshNodeLayoutAreas.OverviewArea); + var accessService = host.Hub.ServiceProvider.GetService(); + var viewerId = accessService?.Context?.ObjectId + ?? accessService?.CircuitContext?.ObjectId; + + if (string.IsNullOrEmpty(viewerId)) + return Observable.Return(BuildSimpleMessage( + "Sign-in required", + "You must be signed in to pin nodes.", + backHref)); + + var userPath = $"User/{viewerId}"; + var userAddress = new Address(userPath); + + // Apply the update remotely on the user hub. workspace.UpdateMeshNode + // dispatches via GetRemoteStream when the address differs from the current hub. + host.Workspace.UpdateMeshNode(userAddress, userPath, (node, user) => + { + var paths = user.PinnedPaths?.ToImmutableList() ?? ImmutableList.Empty; + var updated = unpin + ? paths.RemoveAll(p => string.Equals(p, hubPath, StringComparison.OrdinalIgnoreCase)) + : (paths.Any(p => string.Equals(p, hubPath, StringComparison.OrdinalIgnoreCase)) + ? paths + : paths.Add(hubPath)); + return node with { Content = user with { PinnedPaths = updated } }; + }); + + var title = unpin ? "Unpinned" : "Pinned"; + var message = unpin + ? $"Removed {System.Net.WebUtility.HtmlEncode(hubPath)} from your pinned items." + : $"Added {System.Net.WebUtility.HtmlEncode(hubPath)} to your pinned items."; + + return Observable.Return(BuildSimpleMessage(title, message, backHref)); + } + + private static UiControl BuildSimpleMessage(string title, string messageHtml, string backHref) + => Controls.Stack + .WithWidth("100%") + .WithStyle("padding: 24px;") + .WithView(Controls.Stack + .WithOrientation(Orientation.Horizontal) + .WithHorizontalGap(16) + .WithStyle("align-items: center; margin-bottom: 16px;") + .WithView(Controls.Button("Back") + .WithAppearance(Appearance.Lightweight) + .WithIconStart(FluentIcons.ArrowLeft()) + .WithNavigateToHref(backHref)) + .WithView(Controls.H2(title).WithStyle("margin: 0;"))) + .WithView(Controls.Html( + $"

{messageHtml}

")); +} diff --git a/src/MeshWeaver.Graph/UserActivityLayoutAreas.cs b/src/MeshWeaver.Graph/UserActivityLayoutAreas.cs index 60c7af578..4cfa7cee8 100644 --- a/src/MeshWeaver.Graph/UserActivityLayoutAreas.cs +++ b/src/MeshWeaver.Graph/UserActivityLayoutAreas.cs @@ -50,17 +50,17 @@ public static MessageHubConfiguration AddUserActivityLayoutAreas(this MessageHub var isOwner = string.Equals(viewerId, nodeOwnerId, StringComparison.OrdinalIgnoreCase); if (isOwner) - return (UiControl?)BuildOwnerDashboard(host, nodePath, ownerName, nodeOwnerId); + return (UiControl?)BuildOwnerDashboard(host, nodePath, ownerName, nodeOwnerId, ownerNode); else return (UiControl?)BuildVisitorProfile(nodePath, ownerName, ownerNode); }); } /// - /// Personal dashboard shown to the node owner — welcome banner, chat, threads, - /// activity feed, recently viewed, and child items. + /// Personal dashboard shown to the node owner — welcome banner, pinned items, threads, + /// child items, activity feed, recently viewed, and the chat input pinned to the bottom. /// - private static UiControl BuildOwnerDashboard(LayoutAreaHost host, string nodePath, string ownerName, string nodeOwnerId) + private static UiControl BuildOwnerDashboard(LayoutAreaHost host, string nodePath, string ownerName, string nodeOwnerId, MeshNode? ownerNode) { // Outer shell: flex column, fills the available main area (height managed by CSS grid) var dashboard = Controls.Stack @@ -75,18 +75,20 @@ private static UiControl BuildOwnerDashboard(LayoutAreaHost host, string nodePat $"
Here's what's happening across your workspace
" + "")); - // Chat — full width - dashboard = dashboard.WithView(BuildChatSection(host, nodePath)); - // Scrollable content area — full-width layout grid var content = Controls.LayoutGrid .WithStyle("padding: 0 24px; flex: 1; min-height: 0; overflow-y: auto; gap: 24px; width: 100%; " + ThinScrollbar); - // Latest Threads — full width, above My Items + // Pinned items — compact, first section + var pinnedSection = BuildPinnedItems(ownerNode); + if (pinnedSection != null) + content = content.WithView(pinnedSection, skin => skin.WithXs(12)); + + // Latest Threads — full width content = content.WithView(BuildLatestThreads(nodePath, nodeOwnerId), skin => skin.WithXs(12)); - // My Items — full width, below Latest Threads + // My Items — full width content = content.WithView(BuildChildren(nodePath), skin => skin.WithXs(12)); @@ -102,6 +104,9 @@ private static UiControl BuildOwnerDashboard(LayoutAreaHost host, string nodePat dashboard = dashboard.WithView(content); + // Chat input — pinned to the bottom of the dashboard column + dashboard = dashboard.WithView(BuildChatSection(host, nodePath)); + return dashboard; } @@ -194,18 +199,12 @@ private static UiControl BuildChatSection(LayoutAreaHost host, string nodePath) } /// - /// Activity timeline — shows main content nodes with recent changes, plus a pinned docs card. + /// Activity timeline — shows main content nodes with recent changes. /// source:activity JOINs with Activity satellites and orders by most recent activity. /// private static UiControl BuildActivityFeed() { - var section = Controls.Stack; - - // Pinned documentation card - section = section.WithView(BuildDocumentationCard()); - - // Activity feed - section = section.WithView(Controls.MeshSearch + return Controls.MeshSearch .WithTitle("Activity Feed") .WithHiddenQuery("source:activity scope:subtree is:main sort:LastModified-desc") .WithShowSearchBox(false) @@ -215,46 +214,34 @@ private static UiControl BuildActivityFeed() .WithMaxColumns(2) .WithItemLimit(50) .WithMaxRows(4) - .WithReactiveMode(true)); - - return section; + .WithReactiveMode(true); } /// - /// Pinned welcome card linking to the documentation — styled like a social feed post. + /// Pinned items — compact cards of everything in the owner's . + /// Each card is rendered via , which overlays + /// an unpin icon so owners can remove items inline. Returns null when nothing is pinned. /// - private static UiControl BuildDocumentationCard() + private static UiControl? BuildPinnedItems(MeshNode? ownerNode) { - return Controls.Html( - "" + - "
" + - - // Logo avatar - "
" + - "\"\"" + - "
" + - - // Content - "
" + - "
" + - "MeshWeaver" + - "Pinned" + - "
" + - "
" + - "Explore the documentation, try the use cases, or just open the chat below and ask anything.
" + - "
" + - "→ Documentation
" + - "
"); + var pinnedPaths = (ownerNode?.Content as User)?.PinnedPaths; + if (pinnedPaths == null || pinnedPaths.Count == 0) + return null; + + var pathsClause = string.Join(" OR ", pinnedPaths); + return Controls.MeshSearch + .WithTitle("Pinned") + .WithHiddenQuery($"path:({pathsClause}) sort:LastModified-desc") + .WithShowSearchBox(false) + .WithShowEmptyMessage(false) + .WithRenderMode(MeshSearchRenderMode.Flat) + .WithCollapsibleSections(false) + .WithSectionCounts(false) + .WithItemArea(PinLayoutArea.PinnedThumbnailArea) + .WithMaxColumns(6) + .WithItemLimit(24) + .WithMaxRows(1) + .WithReactiveMode(true); } /// diff --git a/src/MeshWeaver.Mesh.Contract/Security/User.cs b/src/MeshWeaver.Mesh.Contract/Security/User.cs index 17d94ae69..10716c89e 100644 --- a/src/MeshWeaver.Mesh.Contract/Security/User.cs +++ b/src/MeshWeaver.Mesh.Contract/Security/User.cs @@ -17,4 +17,7 @@ public record User : AccessObject /// Profile role (e.g. Developer, Manager, Designer). public string? Role { get; init; } + + /// Ordered list of node paths the user has pinned to their dashboard. + public IReadOnlyList PinnedPaths { get; init; } = []; } From 1ed30e7425ada23ba64aaf70a4152368868e8978 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Sun, 19 Apr 2026 16:56:09 +0200 Subject: [PATCH 29/50] refactor: IMeshStorage writes return IObservable Flip write ops (SaveNode, DeleteNode, MoveNode, AddComment, DeleteComment, SavePartitionObjects, DeletePartitionObjects) from Task-returning to IObservable-returning so handlers can Subscribe without await. Observable carries state via OnNext and errors via OnError. - Handlers (HandleCreateNodeRequest, HandleMoveNodeRequest, DeleteSelfFromStorage, post-creation save-extras) no longer wrap persistence calls in Observable.FromAsync; they consume the observable directly. - HandleMoveNodeRequest posts its response inside Subscribe so the handler returns immediately without awaiting persistence. - MeshCatalog.CreateTransientNode returns IObservable; dead UpdateAsync / ConfirmNodeAsync / IMeshCatalog.DeleteNodeAsync removed. - ActivityLogBundler, MeshDataSourceLayoutAreas, MeshService.CreateTransient migrated to Subscribe with explicit error logging. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../MeshDataSourceLayoutAreas.cs | 8 ++- .../Activity/ActivityTrackingExtensions.cs | 7 +- src/MeshWeaver.Hosting/HubNodePersistence.cs | 2 +- src/MeshWeaver.Hosting/MeshCatalog.cs | 70 ++++--------------- src/MeshWeaver.Hosting/MeshService.cs | 11 ++- .../Persistence/PersistenceService.cs | 36 ++++++---- .../MeshExtensions.cs | 41 ++++++----- .../Services/IMeshCatalog.cs | 8 --- .../Services/IMeshStorage.cs | 37 +++++----- 9 files changed, 98 insertions(+), 122 deletions(-) diff --git a/src/MeshWeaver.Graph/MeshDataSourceLayoutAreas.cs b/src/MeshWeaver.Graph/MeshDataSourceLayoutAreas.cs index e8b37ab68..25bdc9499 100644 --- a/src/MeshWeaver.Graph/MeshDataSourceLayoutAreas.cs +++ b/src/MeshWeaver.Graph/MeshDataSourceLayoutAreas.cs @@ -378,7 +378,13 @@ private static async Task ExecuteCopyInstall( LastSyncedAt = DateTimeOffset.UtcNow }; var updatedNode = sourceNode with { Content = updatedConfig }; - await persistence.SaveNodeAsync(updatedNode); + persistence.SaveNode(updatedNode).Subscribe( + saved => logger?.LogInformation( + "Marked data source {Path} as installed to {Target}", + saved.Path, updatedConfig.InstalledTo), + ex => logger?.LogError(ex, + "Failed to mark data source {Path} as installed", + updatedNode.Path)); } var resultDialog = Controls.Dialog( diff --git a/src/MeshWeaver.Hosting/Activity/ActivityTrackingExtensions.cs b/src/MeshWeaver.Hosting/Activity/ActivityTrackingExtensions.cs index 30978319a..a5c9046c6 100644 --- a/src/MeshWeaver.Hosting/Activity/ActivityTrackingExtensions.cs +++ b/src/MeshWeaver.Hosting/Activity/ActivityTrackingExtensions.cs @@ -4,6 +4,7 @@ using MeshWeaver.Mesh.Services; using MeshWeaver.Messaging; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace MeshWeaver.Hosting.Activity; @@ -53,7 +54,11 @@ public static MeshBuilder AddActivityTracking(this MeshBuilder builder) State = MeshNodeState.Active, Content = log }; - await persistence.SaveNodeAsync(node); + persistence.SaveNode(node).Subscribe( + _ => { }, + ex => hub.ServiceProvider.GetService() + ?.CreateLogger("ActivityLogBundler") + ?.LogWarning(ex, "Failed to persist activity log for {Path}", node.Path)); }); }); return services; diff --git a/src/MeshWeaver.Hosting/HubNodePersistence.cs b/src/MeshWeaver.Hosting/HubNodePersistence.cs index 20647dbb2..bffe95c92 100644 --- a/src/MeshWeaver.Hosting/HubNodePersistence.cs +++ b/src/MeshWeaver.Hosting/HubNodePersistence.cs @@ -147,5 +147,5 @@ public IObservable DeleteNode(string path) } public IObservable CreateTransient(MeshNode node) - => Observable.FromAsync(() => catalog.CreateTransientNodeAsync(node)); + => catalog.CreateTransientNode(node); } diff --git a/src/MeshWeaver.Hosting/MeshCatalog.cs b/src/MeshWeaver.Hosting/MeshCatalog.cs index e8f47b55e..c5c221fd8 100644 --- a/src/MeshWeaver.Hosting/MeshCatalog.cs +++ b/src/MeshWeaver.Hosting/MeshCatalog.cs @@ -1,4 +1,5 @@ -using MeshWeaver.Hosting.Persistence.Query; +using System.Reactive.Linq; +using MeshWeaver.Hosting.Persistence.Query; using MeshWeaver.Mesh; using MeshWeaver.Mesh.Security; using MeshWeaver.Mesh.Services; @@ -64,9 +65,6 @@ internal sealed class MeshCatalog( return persistenceNode; } - public Task UpdateAsync(MeshNode node) => - Persistence.SaveNodeAsync(node); - // IMeshCatalog — delegate to HubNodePersistence private HubNodePersistence NodePersistence => new(hub, this); @@ -79,16 +77,18 @@ public Task CreateTransientAsync(MeshNode node, CancellationToken ct = /// /// Creates a new node in Transient state without confirming it. - /// This is internal - used by handlers that need direct node creation after validation. + /// Returns an observable emitting the saved node, or OnError on failure. + /// Subscribe to drive — do not await. /// - internal async Task CreateTransientNodeAsync(MeshNode node, CancellationToken ct = default) + internal IObservable CreateTransientNode(MeshNode node) { - // Validate NodeType is registered (in-memory check only — no DB round-trip) if (!string.IsNullOrEmpty(node.NodeType) && !Configuration.Nodes.ContainsKey(node.NodeType)) - throw new InvalidOperationException($"NodeType '{node.NodeType}' is not registered"); + return Observable.Throw( + new InvalidOperationException($"NodeType '{node.NodeType}' is not registered")); if (!ValidatePath(node)) - throw new InvalidOperationException($"Invalid path structure for node: {node.Path}"); + return Observable.Throw( + new InvalidOperationException($"Invalid path structure for node: {node.Path}")); var transientNode = node with { State = MeshNodeState.Transient }; @@ -101,53 +101,13 @@ internal async Task CreateTransientNodeAsync(MeshNode node, Cancellati transientNode = transientNode with { MainNode = transientNode.Namespace }; } - if (ConfigResolver != null) - transientNode = await ConfigResolver.ResolveConfigurationAsync(transientNode, ct); - - // Storage is the source of truth — no preflight ExistsAsync. If it conflicts, storage will throw. - var savedNode = await Persistence.SaveNodeAsync(transientNode, ct); - logger?.LogInformation("Created transient node at path {Path}", savedNode.Path); - return savedNode; - } - - /// - /// Confirms a transient node, updating its state to Active. - /// This is internal - used by handlers after validation. - /// - internal async Task ConfirmNodeAsync(string path, CancellationToken ct = default) - { - // Get the current node - var node = await Persistence.GetNodeAsync(path, ct); - if (node == null) - { - throw new InvalidOperationException($"Node not found at path: {path}"); - } + var resolvedObs = ConfigResolver != null + ? Observable.FromAsync(ct => ConfigResolver.ResolveConfigurationAsync(transientNode, ct)) + : Observable.Return(transientNode); - if (node.State != MeshNodeState.Transient) - { - throw new InvalidOperationException($"Node at path '{path}' is not in Transient state (current state: {node.State})"); - } - - // Update to Confirmed state - var confirmedNode = node with { State = MeshNodeState.Active }; - await Persistence.SaveNodeAsync(confirmedNode, ct); - - // Enrich with HubConfiguration based on NodeType (same as cold start in GetNodeAsync) - if (ConfigResolver != null) - confirmedNode = await ConfigResolver.ResolveConfigurationAsync(confirmedNode, ct); - - logger?.LogInformation("Confirmed node at path {Path}", confirmedNode.Path); - return confirmedNode; - } - - /// - /// IMeshCatalog.DeleteNodeAsync — internal, called by the DeleteNodeRequest handler. - /// Deletes directly from persistence. Storage cascade handles descendants. - /// - async Task IMeshCatalog.DeleteNodeAsync(string path, bool recursive, CancellationToken ct) - { - await Persistence.DeleteNodeAsync(path, recursive, ct); - logger?.LogInformation("Deleted node at path {Path}, recursive: {Recursive}", path, recursive); + return resolvedObs + .SelectMany(resolved => Persistence.SaveNode(resolved)) + .Do(saved => logger?.LogInformation("Created transient node at path {Path}", saved.Path)); } private static bool ValidatePath(MeshNode node) diff --git a/src/MeshWeaver.Hosting/MeshService.cs b/src/MeshWeaver.Hosting/MeshService.cs index a05bde9f8..323fba175 100644 --- a/src/MeshWeaver.Hosting/MeshService.cs +++ b/src/MeshWeaver.Hosting/MeshService.cs @@ -153,13 +153,10 @@ public IObservable CreateTransient(MeshNode node) if (persistence == null) return CreateNode(node); - return Observable.FromAsync(async ct => - { - // Persist directly with Transient state — bypasses the CreateNodeRequest handler - // which would force Active state. - var transientNode = node with { State = MeshNodeState.Transient }; - return await persistence.SaveNodeAsync(transientNode, ct); - }); + // Persist directly with Transient state — bypasses the CreateNodeRequest handler + // which would force Active state. + var transientNode = node with { State = MeshNodeState.Transient }; + return persistence.SaveNode(transientNode); } // === Query (delegated to MeshQuery) === diff --git a/src/MeshWeaver.Hosting/Persistence/PersistenceService.cs b/src/MeshWeaver.Hosting/Persistence/PersistenceService.cs index 76f6d1bc5..f66e54eb2 100644 --- a/src/MeshWeaver.Hosting/Persistence/PersistenceService.cs +++ b/src/MeshWeaver.Hosting/Persistence/PersistenceService.cs @@ -1,3 +1,4 @@ +using System.Reactive.Linq; using System.Runtime.CompilerServices; using System.Text.Json; using MeshWeaver.Mesh; @@ -36,14 +37,18 @@ public IAsyncEnumerable GetChildrenAsync(string? parentPath) public IAsyncEnumerable GetDescendantsAsync(string? parentPath) => core.GetDescendantsAsync(parentPath, Options); - public Task SaveNodeAsync(MeshNode node, CancellationToken ct = default) - => core.SaveNodeAsync(node, Options, ct); + public IObservable SaveNode(MeshNode node) + => Observable.FromAsync(ct => core.SaveNodeAsync(node, Options, ct)); - public Task DeleteNodeAsync(string path, bool recursive = false, CancellationToken ct = default) - => core.DeleteNodeAsync(path, recursive, ct); + public IObservable DeleteNode(string path, bool recursive = false) + => Observable.FromAsync(ct => core.GetNodeAsync(path, Options, ct)) + .SelectMany(existing => existing is null + ? Observable.Throw(new InvalidOperationException($"Node not found at path: {path}")) + : Observable.FromAsync(ct => core.DeleteNodeAsync(path, recursive, ct)) + .Select(_ => existing)); - public Task MoveNodeAsync(string sourcePath, string targetPath, CancellationToken ct = default) - => core.MoveNodeAsync(sourcePath, targetPath, Options, ct); + public IObservable MoveNode(string sourcePath, string targetPath) + => Observable.FromAsync(ct => core.MoveNodeAsync(sourcePath, targetPath, Options, ct)); public IAsyncEnumerable SearchAsync(string? parentPath, string query) => core.SearchAsync(parentPath, query, Options); @@ -63,11 +68,12 @@ public Task InitializeAsync(CancellationToken ct = default) public IAsyncEnumerable GetCommentsAsync(string nodePath) => core.GetCommentsAsync(nodePath, Options); - public Task AddCommentAsync(Comment comment, CancellationToken ct = default) - => core.AddCommentAsync(comment, Options, ct); + public IObservable AddComment(Comment comment) + => Observable.FromAsync(ct => core.AddCommentAsync(comment, Options, ct)); - public Task DeleteCommentAsync(string commentId, CancellationToken ct = default) - => core.DeleteCommentAsync(commentId, ct); + public IObservable DeleteComment(string commentId) + => Observable.FromAsync(ct => core.DeleteCommentAsync(commentId, ct)) + .Select(_ => commentId); public Task GetCommentAsync(string commentId, CancellationToken ct = default) => core.GetCommentAsync(commentId, ct); @@ -79,11 +85,13 @@ public Task DeleteCommentAsync(string commentId, CancellationToken ct = default) public IAsyncEnumerable GetPartitionObjectsAsync(string nodePath, string? subPath) => core.GetPartitionObjectsAsync(nodePath, subPath, Options); - public Task SavePartitionObjectsAsync(string nodePath, string? subPath, IReadOnlyCollection objects, CancellationToken ct = default) - => core.SavePartitionObjectsAsync(nodePath, subPath, objects, Options, ct); + public IObservable> SavePartitionObjects(string nodePath, string? subPath, IReadOnlyCollection objects) + => Observable.FromAsync(ct => core.SavePartitionObjectsAsync(nodePath, subPath, objects, Options, ct)) + .Select(_ => objects); - public Task DeletePartitionObjectsAsync(string nodePath, string? subPath = null, CancellationToken ct = default) - => core.DeletePartitionObjectsAsync(nodePath, subPath, ct); + public IObservable DeletePartitionObjects(string nodePath, string? subPath = null) + => Observable.FromAsync(ct => core.DeletePartitionObjectsAsync(nodePath, subPath, ct)) + .Select(_ => subPath ?? nodePath); public Task GetPartitionMaxTimestampAsync(string nodePath, string? subPath = null, CancellationToken ct = default) => core.GetPartitionMaxTimestampAsync(nodePath, subPath, ct); diff --git a/src/MeshWeaver.Mesh.Contract/MeshExtensions.cs b/src/MeshWeaver.Mesh.Contract/MeshExtensions.cs index 8856fd56a..a4c33177b 100644 --- a/src/MeshWeaver.Mesh.Contract/MeshExtensions.cs +++ b/src/MeshWeaver.Mesh.Contract/MeshExtensions.cs @@ -164,7 +164,7 @@ private static IMessageDelivery HandleCreateNodeRequest( Content = node.Content ?? existingNode.Content }; var saveObs = persistence != null - ? Observable.FromAsync(token => persistence.SaveNodeAsync(confirmedNode, token)) + ? persistence.SaveNode(confirmedNode) : Observable.Return(confirmedNode); return saveObs.Select(savedConfirmed => (mode: "confirm", node: savedConfirmed)); } @@ -248,7 +248,7 @@ private static IMessageDelivery HandleCreateNodeRequest( return enrichedObs.SelectMany(enriched => { var saveObs = persistence != null - ? Observable.FromAsync(token => persistence.SaveNodeAsync(enriched, token)) + ? persistence.SaveNode(enriched) : Observable.Return(enriched); return saveObs.Select(saved => (mode: "create", node: saved)); }); @@ -617,8 +617,7 @@ private static IMessageDelivery HandleDeleteNodeRequest( return handleObs; var saveExtras = additional - .Select(extra => Observable.FromAsync(token => - persistence.SaveNodeAsync(extra with { State = MeshNodeState.Active }, token)) + .Select(extra => persistence.SaveNode(extra with { State = MeshNodeState.Active }) .Do(saved => { hub.Post(DataChangeRequest.Update([saved]), @@ -664,7 +663,7 @@ private static void DeleteSelfFromStorage( // is the commit point; if the storage write itself fails we can only log. hub.Post(DeleteNodeResponse.Ok(), o => o.ResponseFor(request)); - Observable.FromAsync(token => persistence.DeleteNodeAsync(path, recursive: false, token)) + persistence.DeleteNode(path, recursive: false) .Subscribe( _ => { @@ -817,7 +816,7 @@ private static IMessageDelivery HandleUpdateNodeRequest( HubConfiguration = existingNode.HubConfiguration }; - return Observable.FromAsync(token => persistence.SaveNodeAsync(nodeToSave, token)); + return persistence.SaveNode(nodeToSave); }); }) .Subscribe( @@ -992,17 +991,27 @@ private static async Task HandleMoveNodeRequest( return request.Processed(); } - // 4. Move the node - var movedNode = await persistence.MoveNodeAsync(moveRequest.SourcePath, moveRequest.TargetPath, ct); - var changeFeed = hub.ServiceProvider.GetService(); - changeFeed?.Publish(MeshChangeEvent.Deleted(moveRequest.SourcePath)); - changeFeed?.Publish(MeshChangeEvent.Created(movedNode)); - - // 5. Return success - hub.Post(MoveNodeResponse.Ok(movedNode), o => o.ResponseFor(request)); + // 4. Move the node — subscribe and post response in the callback. + persistence.MoveNode(moveRequest.SourcePath, moveRequest.TargetPath) + .Subscribe( + movedNode => + { + var changeFeed = hub.ServiceProvider.GetService(); + changeFeed?.Publish(MeshChangeEvent.Deleted(moveRequest.SourcePath)); + changeFeed?.Publish(MeshChangeEvent.Created(movedNode)); + hub.Post(MoveNodeResponse.Ok(movedNode), o => o.ResponseFor(request)); + logger.LogInformation("Node moved from {Source} to {Target}", + moveRequest.SourcePath, moveRequest.TargetPath); + }, + ex => + { + logger.LogError(ex, "Error moving node from {Source} to {Target}", + moveRequest.SourcePath, moveRequest.TargetPath); + hub.Post( + MoveNodeResponse.Fail($"Unexpected error: {ex.Message}"), + o => o.ResponseFor(request)); + }); - logger.LogInformation("Node moved from {Source} to {Target}", - moveRequest.SourcePath, moveRequest.TargetPath); return request.Processed(); } catch (Exception ex) diff --git a/src/MeshWeaver.Mesh.Contract/Services/IMeshCatalog.cs b/src/MeshWeaver.Mesh.Contract/Services/IMeshCatalog.cs index 18fe30cb0..58471e849 100644 --- a/src/MeshWeaver.Mesh.Contract/Services/IMeshCatalog.cs +++ b/src/MeshWeaver.Mesh.Contract/Services/IMeshCatalog.cs @@ -43,14 +43,6 @@ internal interface IMeshCatalog : IPathResolver /// Task CreateTransientAsync(MeshNode node, CancellationToken ct = default); - /// - /// Deletes a node from the catalog. - /// - /// The path of the node to delete - /// If true, also delete all descendant nodes - /// Cancellation token - Task DeleteNodeAsync(string path, bool recursive = false, CancellationToken ct = default); - /// /// Resolves a full URL path to an address using score-based matching. /// Returns the best matching node's address and the remaining path segments. diff --git a/src/MeshWeaver.Mesh.Contract/Services/IMeshStorage.cs b/src/MeshWeaver.Mesh.Contract/Services/IMeshStorage.cs index 7c95cfe1e..1d2eca79a 100644 --- a/src/MeshWeaver.Mesh.Contract/Services/IMeshStorage.cs +++ b/src/MeshWeaver.Mesh.Contract/Services/IMeshStorage.cs @@ -37,20 +37,21 @@ internal interface IMeshStorage IAsyncEnumerable GetDescendantsAsync(string? parentPath); /// - /// Creates or updates a node. + /// Creates or updates a node. Returns an observable that emits the saved node on success + /// or signals OnError on failure. Subscribe to drive — do not await. /// /// The node to save - /// Cancellation token - /// The saved node - Task SaveNodeAsync(MeshNode node, CancellationToken ct = default); + /// Observable emitting the saved node (OnNext + OnCompleted) or OnError + IObservable SaveNode(MeshNode node); /// - /// Deletes a node and optionally its descendants. + /// Deletes a node and optionally its descendants. Returns an observable that emits the + /// pre-delete node state on success or OnError on failure. Subscribe to drive — do not await. /// /// The node path /// If true, also delete all descendants - /// Cancellation token - Task DeleteNodeAsync(string path, bool recursive = false, CancellationToken ct = default); + /// Observable emitting the deleted node's pre-delete state, or OnError + IObservable DeleteNode(string path, bool recursive = false); /// /// Moves a node and all its descendants to a new path. @@ -58,10 +59,9 @@ internal interface IMeshStorage /// /// The current node path /// The new node path - /// Cancellation token - /// The moved node at the new path + /// Observable emitting the moved node at the new path, or OnError /// If source doesn't exist or target already exists - Task MoveNodeAsync(string sourcePath, string targetPath, CancellationToken ct = default); + IObservable MoveNode(string sourcePath, string targetPath); /// /// Searches nodes by query text within their Name or Content. @@ -108,16 +108,15 @@ internal interface IMeshStorage /// Adds a comment to a node. /// /// The comment to add - /// Cancellation token - /// The saved comment - Task AddCommentAsync(Comment comment, CancellationToken ct = default); + /// Observable emitting the saved comment, or OnError + IObservable AddComment(Comment comment); /// /// Deletes a comment by ID. /// /// The comment ID to delete - /// Cancellation token - Task DeleteCommentAsync(string commentId, CancellationToken ct = default); + /// Observable emitting the deleted comment id on completion, or OnError + IObservable DeleteComment(string commentId); /// /// Gets a single comment by ID. @@ -147,16 +146,16 @@ internal interface IMeshStorage /// The node path /// Optional sub-path within partition /// Objects to save - /// Cancellation token - Task SavePartitionObjectsAsync(string nodePath, string? subPath, IReadOnlyCollection objects, CancellationToken ct = default); + /// Observable that signals completion or OnError + IObservable> SavePartitionObjects(string nodePath, string? subPath, IReadOnlyCollection objects); /// /// Deletes all objects from a node's partition folder (or sub-path). /// /// The node path /// Optional sub-path within partition - /// Cancellation token - Task DeletePartitionObjectsAsync(string nodePath, string? subPath = null, CancellationToken ct = default); + /// Observable that signals completion or OnError + IObservable DeletePartitionObjects(string nodePath, string? subPath = null); /// /// Gets the newest modification timestamp across all objects in a partition (or sub-path). From bfe52a87082744157113e0d46b99d80680d7ec85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Sun, 19 Apr 2026 16:56:35 +0200 Subject: [PATCH 30/50] feat: compile progress + source-discovery diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a NodeType's compile fails, the CompilationException now carries the list of source queries that actually ran and the Code-node paths they matched. The error overlay shows this report under "--- Source discovery ---", making it obvious when the compile failed because zero code files were pulled in (the most common cause of "type not found" errors). Adds compile-in-progress tracking on NodeTypeService: IsCompiling(path) + GetCompilationStartedAt(path). Exposed on INodeTypeService. GetDiagnostics MCP tool now returns status:"Compiling" with elapsed ms while a compile is running, so callers can show "Compiling…" progress instead of blocking silently. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/MeshWeaver.AI/MeshOperations.cs | 13 +++++++ .../MeshNodeCompilationService.cs | 37 +++++++++++++++++++ .../Configuration/NodeTypeService.cs | 34 ++++++++++++++++- .../Services/INodeTypeService.cs | 15 ++++++++ 4 files changed, 98 insertions(+), 1 deletion(-) diff --git a/src/MeshWeaver.AI/MeshOperations.cs b/src/MeshWeaver.AI/MeshOperations.cs index 7e79632f2..551d90db0 100644 --- a/src/MeshWeaver.AI/MeshOperations.cs +++ b/src/MeshWeaver.AI/MeshOperations.cs @@ -919,6 +919,19 @@ public async Task GetDiagnostics(string path) new { status = "Unknown", message = $"Not found: {resolvedPath}" }, hub.JsonSerializerOptions); + // Compiling has priority over any prior error — the error we're seeing is stale + // and a fresh result is on its way. Tell the caller to wait and retry. + if (nodeTypeService.IsCompiling(nodeTypePath)) + { + var startedAt = nodeTypeService.GetCompilationStartedAt(nodeTypePath); + var elapsedMs = startedAt is null + ? (long?)null + : (long)(DateTimeOffset.UtcNow - startedAt.Value).TotalMilliseconds; + return JsonSerializer.Serialize( + new { status = "Compiling", nodeTypePath, elapsedMs }, + hub.JsonSerializerOptions); + } + var err = nodeTypeService.GetCompilationError(nodeTypePath); if (string.IsNullOrEmpty(err)) return JsonSerializer.Serialize( diff --git a/src/MeshWeaver.Graph/Configuration/MeshNodeCompilationService.cs b/src/MeshWeaver.Graph/Configuration/MeshNodeCompilationService.cs index 863d76044..863f5ed5c 100644 --- a/src/MeshWeaver.Graph/Configuration/MeshNodeCompilationService.cs +++ b/src/MeshWeaver.Graph/Configuration/MeshNodeCompilationService.cs @@ -318,6 +318,8 @@ private async Task ResolveCodeIncludesAsync( : (IReadOnlyList)["namespace:_Source scope:subtree"]; var codeFiles = new List(); + var matchedCodePaths = new List(); + var executedQueries = new List(); if (meshQuery != null) { var seen = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -327,6 +329,8 @@ private async Task ResolveCodeIncludesAsync( foreach (var finalQuery in ExpandSourceQuery(rawQuery, selfPath)) { + executedQueries.Add(finalQuery); + var matchesForThisQuery = 0; await foreach (var codeNode in meshQuery.QueryAsync(finalQuery, ct: ct).WithCancellation(ct)) { if (codeNode.Content is CodeConfiguration cf @@ -334,8 +338,14 @@ private async Task ResolveCodeIncludesAsync( && seen.Add(codeNode.Path ?? cf.Code!)) { codeFiles.Add(cf); + if (!string.IsNullOrEmpty(codeNode.Path)) + matchedCodePaths.Add(codeNode.Path); + matchesForThisQuery++; } } + logger.LogInformation( + "Source discovery for {NodePath}: query '{Query}' matched {Count} Code nodes", + node.Path, finalQuery, matchesForThisQuery); } } } @@ -386,6 +396,18 @@ private async Task ResolveCodeIncludesAsync( logger.LogInformation("Compiled assembly for node {NodePath} (in-memory)", node.Path); return $"memory://{nodeName}"; } + catch (CompilationException ex) + { + // Re-throw enriched with the actual queries that ran + which Code nodes matched, + // so the error overlay can tell the user *why* references are missing (usually: + // 0 Code nodes matched the configured sources). + var diag = BuildSourceDiscoveryReport(executedQueries, matchedCodePaths); + logger.LogError(ex, "Failed to compile assembly for node {NodePath}. {Diagnostics}", node.Path, diag); + throw new CompilationException( + ex.NodePath, + $"{ex.Message}\n\n--- Source discovery ---\n{diag}", + ex); + } catch (Exception ex) when (ex is not OperationCanceledException) { logger.LogError(ex, "Failed to compile assembly for node {NodePath}", node.Path); @@ -393,6 +415,21 @@ private async Task ResolveCodeIncludesAsync( } } + private static string BuildSourceDiscoveryReport(IReadOnlyList executedQueries, IReadOnlyList matchedCodePaths) + { + var sb = new System.Text.StringBuilder(); + sb.AppendLine($"Executed source queries ({executedQueries.Count}):"); + foreach (var q in executedQueries) + sb.AppendLine($" - {q}"); + sb.AppendLine($"Matched Code nodes ({matchedCodePaths.Count}):"); + if (matchedCodePaths.Count == 0) + sb.AppendLine(" (none) — the configuration lambda cannot reference types because no source files were included. Check that your _Source Code nodes exist and that the NodeType's `sources` list points at them."); + else + foreach (var p in matchedCodePaths) + sb.AppendLine($" - {p}"); + return sb.ToString(); + } + /// public async Task CompileAndGetConfigurationsAsync(MeshNode node, CancellationToken ct = default) { diff --git a/src/MeshWeaver.Graph/Configuration/NodeTypeService.cs b/src/MeshWeaver.Graph/Configuration/NodeTypeService.cs index 9374a651e..94f217822 100644 --- a/src/MeshWeaver.Graph/Configuration/NodeTypeService.cs +++ b/src/MeshWeaver.Graph/Configuration/NodeTypeService.cs @@ -50,6 +50,11 @@ internal class NodeTypeService : INodeTypeService, IDisposable // Compilation errors by nodeTypePath - tracks last compilation failure for error reporting private readonly ConcurrentDictionary _compilationErrors = new(); + // NodeType paths whose compilation is currently running. Populated the moment a compile + // task is kicked off; cleaned up when it finishes (success OR failure). Used by + // GetDiagnostics / progress overlays so callers can show "Compiling…" while they wait. + private readonly ConcurrentDictionary _compilingInProgress = new(); + // Cached access rules extracted from hub configurations private readonly ConcurrentDictionary _accessRules = new(); @@ -152,15 +157,42 @@ public bool IsNotCreatable(string nodeTypePath) return _compilationErrors.GetValueOrDefault(nodeTypePath); } + /// + /// Returns true if compilation for the given NodeType path is currently running + /// (started but not yet completed). Use this to render a "Compiling…" progress overlay + /// so the user sees activity instead of a blank layout while they wait. + /// + public bool IsCompiling(string nodeTypePath) => + _compilingInProgress.ContainsKey(nodeTypePath); + + /// + /// When compilation for is running, returns when it + /// started (UTC); otherwise null. Consumers can display the elapsed time in a + /// progress overlay. + /// + public DateTimeOffset? GetCompilationStartedAt(string nodeTypePath) => + _compilingInProgress.TryGetValue(nodeTypePath, out var start) ? start : null; + private Task GetAssemblyPathAsync(string nodeTypePath, CancellationToken ct = default) { + var wasNewCompile = false; // Use ConcurrentDictionary.GetOrAdd with a Task to ensure only one compilation runs per key. // On failure, remove from dictionary to allow retry on next access. var task = _compilationTasks.GetOrAdd(nodeTypePath, path => - CompileWithReleaseAsync(path, ct)); + { + // Only the first caller to miss the cache kicks off a compile — mark it started. + wasNewCompile = true; + _compilingInProgress[path] = DateTimeOffset.UtcNow; + return CompileWithReleaseAsync(path, ct); + }); return task.ContinueWith(t => { + // Clear the in-progress marker whether we win the race or not; the task we + // awaited on is finished, so the state ceases to be "running" for this caller. + if (wasNewCompile) + _compilingInProgress.TryRemove(nodeTypePath, out _); + // On failure, remove from cache to allow retry and return null if (t.IsFaulted || t.IsCanceled) { diff --git a/src/MeshWeaver.Mesh.Contract/Services/INodeTypeService.cs b/src/MeshWeaver.Mesh.Contract/Services/INodeTypeService.cs index 1b5a2d434..26f623aa0 100644 --- a/src/MeshWeaver.Mesh.Contract/Services/INodeTypeService.cs +++ b/src/MeshWeaver.Mesh.Contract/Services/INodeTypeService.cs @@ -49,4 +49,19 @@ public interface INodeTypeService /// fails and cleared when it succeeds. /// string? GetCompilationError(string nodeTypePath) => null; + + /// + /// Returns true if compilation for the given NodeType path is currently + /// running (the task has been kicked off but not yet completed). Consumers can use + /// this to render a "Compiling…" progress indicator so the user sees activity + /// rather than a blank layout while they wait. + /// + bool IsCompiling(string nodeTypePath) => false; + + /// + /// When compilation for is running, returns when + /// it started (UTC). Otherwise null. Paired with + /// to show elapsed-time feedback in progress overlays. + /// + DateTimeOffset? GetCompilationStartedAt(string nodeTypePath) => null; } From f38e7a6c041c6fcf844b6f0f0883187fac888e27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Sun, 19 Apr 2026 16:59:46 +0200 Subject: [PATCH 31/50] fix: drop read-then-delete TOCTOU in PersistenceService.DeleteNode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change DeleteNode signature from IObservable (pre-delete state) to IObservable (path). The previous implementation fetched the node first, then deleted it — racy under concurrent writers. Call sites (DeleteSelfFromStorage) already discard the emitted value. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/MeshWeaver.Hosting/Persistence/PersistenceService.cs | 9 +++------ src/MeshWeaver.Mesh.Contract/Services/IMeshStorage.cs | 7 ++++--- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/MeshWeaver.Hosting/Persistence/PersistenceService.cs b/src/MeshWeaver.Hosting/Persistence/PersistenceService.cs index f66e54eb2..c5b614e03 100644 --- a/src/MeshWeaver.Hosting/Persistence/PersistenceService.cs +++ b/src/MeshWeaver.Hosting/Persistence/PersistenceService.cs @@ -40,12 +40,9 @@ public IAsyncEnumerable GetDescendantsAsync(string? parentPath) public IObservable SaveNode(MeshNode node) => Observable.FromAsync(ct => core.SaveNodeAsync(node, Options, ct)); - public IObservable DeleteNode(string path, bool recursive = false) - => Observable.FromAsync(ct => core.GetNodeAsync(path, Options, ct)) - .SelectMany(existing => existing is null - ? Observable.Throw(new InvalidOperationException($"Node not found at path: {path}")) - : Observable.FromAsync(ct => core.DeleteNodeAsync(path, recursive, ct)) - .Select(_ => existing)); + public IObservable DeleteNode(string path, bool recursive = false) + => Observable.FromAsync(ct => core.DeleteNodeAsync(path, recursive, ct)) + .Select(_ => path); public IObservable MoveNode(string sourcePath, string targetPath) => Observable.FromAsync(ct => core.MoveNodeAsync(sourcePath, targetPath, Options, ct)); diff --git a/src/MeshWeaver.Mesh.Contract/Services/IMeshStorage.cs b/src/MeshWeaver.Mesh.Contract/Services/IMeshStorage.cs index 1d2eca79a..411c8f477 100644 --- a/src/MeshWeaver.Mesh.Contract/Services/IMeshStorage.cs +++ b/src/MeshWeaver.Mesh.Contract/Services/IMeshStorage.cs @@ -46,12 +46,13 @@ internal interface IMeshStorage /// /// Deletes a node and optionally its descendants. Returns an observable that emits the - /// pre-delete node state on success or OnError on failure. Subscribe to drive — do not await. + /// deleted path on success or OnError on failure. Subscribe to drive — do not await. + /// Atomic at the storage layer; no pre-read (avoids TOCTOU). /// /// The node path /// If true, also delete all descendants - /// Observable emitting the deleted node's pre-delete state, or OnError - IObservable DeleteNode(string path, bool recursive = false); + /// Observable emitting the deleted path, or OnError + IObservable DeleteNode(string path, bool recursive = false); /// /// Moves a node and all its descendants to a new path. From da06d23114608a97cc502f614ef3b745253448ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Sun, 19 Apr 2026 17:11:36 +0200 Subject: [PATCH 32/50] feat: expose Recycle as an MCP tool MeshPlugin.Recycle posts DisposeRequest to the target hub so agents can flush a cached / stuck grain over MCP (mirrors the Recycle menu item). Fire-and-forget via hub.Post; returns immediately. Caller should wait ~100ms before the next access so the grain teardown completes. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/MeshWeaver.AI/MeshOperations.cs | 39 +++++++++++++++++++++++ src/MeshWeaver.AI/MeshPlugin.cs | 9 ++++++ test/MeshWeaver.AI.Test/MeshPluginTest.cs | 5 +-- 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src/MeshWeaver.AI/MeshOperations.cs b/src/MeshWeaver.AI/MeshOperations.cs index 551d90db0..cf6162e1a 100644 --- a/src/MeshWeaver.AI/MeshOperations.cs +++ b/src/MeshWeaver.AI/MeshOperations.cs @@ -881,6 +881,45 @@ internal async Task BuildNullContentErrorAsync(string path, string nodeT } } + /// + /// Recycles the hub at by posting a + /// . The next access re-initialises the hub — which + /// means a fresh NodeType compile and fresh data loads. Useful after fixing a + /// broken NodeType or when something is stuck in an inconsistent cached state. + /// Returns a JSON {status, path} envelope. The caller should wait ~100ms + /// before re-accessing so the grain teardown completes. + /// + public Task Recycle(string path) + { + logger.LogInformation("Recycle called with path={Path}", path); + + if (string.IsNullOrWhiteSpace(path)) + return Task.FromResult(JsonSerializer.Serialize( + new { status = "Error", message = "path is required" }, + hub.JsonSerializerOptions)); + + var resolvedPath = ResolvePath(path); + if (string.IsNullOrWhiteSpace(resolvedPath)) + return Task.FromResult(JsonSerializer.Serialize( + new { status = "Error", message = "path is required" }, + hub.JsonSerializerOptions)); + + try + { + hub.Post(new DisposeRequest(), o => o.WithTarget(new Address(resolvedPath))); + return Task.FromResult(JsonSerializer.Serialize( + new { status = "Recycled", path = resolvedPath, message = "DisposeRequest posted. Wait ~100ms before the next access so the grain teardown completes." }, + hub.JsonSerializerOptions)); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Error recycling {Path}", resolvedPath); + return Task.FromResult(JsonSerializer.Serialize( + new { status = "Error", path = resolvedPath, message = ex.Message }, + hub.JsonSerializerOptions)); + } + } + /// /// Returns compilation diagnostics for a NodeType or an instance of one. /// The response is JSON with status (Error / Ok / diff --git a/src/MeshWeaver.AI/MeshPlugin.cs b/src/MeshWeaver.AI/MeshPlugin.cs index e91b63f1b..17c38bcce 100644 --- a/src/MeshWeaver.AI/MeshPlugin.cs +++ b/src/MeshWeaver.AI/MeshPlugin.cs @@ -76,6 +76,14 @@ public Task GetDiagnostics( return ops.GetDiagnostics(ResolveContextPath(path)); } + [Description("Recycles the hub at the given path by posting DisposeRequest. Forces a fresh hub initialization on the next access — use this after fixing a broken NodeType, after editing the `sources` list, or whenever a grain is stuck. Returns {status:'Recycled', path}. Wait ~100ms before the next access so the grain teardown completes.")] + public Task Recycle( + [Description("Path to the node (e.g., @Systemorph/SocialMedia/Profile). Use the NodeType path to recycle the whole type; use an instance path to recycle just that instance's hub.")] string path) + { + RestoreAccessContext(); + return ops.Recycle(ResolveContextPath(path)); + } + /// /// Restores the user's AccessContext from . /// AsyncLocal doesn't flow reliably through the AI framework's streaming + tool @@ -135,6 +143,7 @@ public IList CreateAllTools() AIFunctionFactory.Create(Patch), AIFunctionFactory.Create(Delete), AIFunctionFactory.Create(GetDiagnostics), + AIFunctionFactory.Create(Recycle), ]; } } diff --git a/test/MeshWeaver.AI.Test/MeshPluginTest.cs b/test/MeshWeaver.AI.Test/MeshPluginTest.cs index f91e5a6ef..ecf4e4451 100644 --- a/test/MeshWeaver.AI.Test/MeshPluginTest.cs +++ b/test/MeshWeaver.AI.Test/MeshPluginTest.cs @@ -97,8 +97,8 @@ public void CreateAllTools_ShouldIncludeWriteOperations() var tools = plugin.CreateAllTools(); tools.Should().NotBeNull(); - // All tools: Get, Search, NavigateTo, Create, Update, Patch, Delete, GetDiagnostics - tools.Should().HaveCount(8); + // All tools: Get, Search, NavigateTo, Create, Update, Patch, Delete, GetDiagnostics, Recycle + tools.Should().HaveCount(9); var toolNames = tools.OfType().Select(t => t.Name).ToList(); toolNames.Should().Contain("Get"); @@ -109,6 +109,7 @@ public void CreateAllTools_ShouldIncludeWriteOperations() toolNames.Should().Contain("Patch"); toolNames.Should().Contain("Delete"); toolNames.Should().Contain("GetDiagnostics"); + toolNames.Should().Contain("Recycle"); } #endregion From 42545b4fe04e48336446b82d62a4f4960051ae6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Sun, 19 Apr 2026 17:19:19 +0200 Subject: [PATCH 33/50] feat: expose GetDiagnostics + Recycle on the MCP server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds GetDiagnostics and Recycle to McpMeshPlugin so MCP clients (including the Coder agent's MCP tools, not just the in-process MeshPlugin) can verify compilation status and flush stuck grains. Coder.md already instructs agents to call GetDiagnostics after every NodeType create/update; without this commit those calls would fail — the MCP surface was limited to the Mesh CRUD tools. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/MeshWeaver.Blazor.AI/McpMeshPlugin.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/MeshWeaver.Blazor.AI/McpMeshPlugin.cs b/src/MeshWeaver.Blazor.AI/McpMeshPlugin.cs index 61a15c10c..5fcd3d332 100644 --- a/src/MeshWeaver.Blazor.AI/McpMeshPlugin.cs +++ b/src/MeshWeaver.Blazor.AI/McpMeshPlugin.cs @@ -69,6 +69,18 @@ public string NavigateTo( var resolvedPath = MeshOperations.ResolvePath(path); return $"{baseUrl}/node/{Uri.EscapeDataString(resolvedPath)}"; } + + [McpServerTool] + [Description("Returns compilation diagnostics for a NodeType (or any instance of one). Status is 'Ok' when the type compiled cleanly, 'Error' with details when it failed, 'Compiling' while a compile is in progress (with elapsedMs), or 'Unknown' when no compile has happened yet. Use after creating/updating a NodeType to verify it actually compiles — a NodeType that doesn't compile is not 'done'.")] + public Task GetDiagnostics( + [Description("Path to a NodeType (e.g., @Systemorph/SocialMedia/Profile) or to any instance of one")] string path) + => ops.GetDiagnostics(path); + + [McpServerTool] + [Description("Recycles the hub at the given path by posting DisposeRequest. Forces a fresh hub initialization on the next access — use after fixing a broken NodeType, after editing the `sources` list, or whenever a grain is stuck in a cached bad state. Returns {status:'Recycled', path}. Wait ~100ms before the next access so the grain teardown completes.")] + public Task Recycle( + [Description("Path to the node (e.g., @Systemorph/SocialMedia/Profile). Use the NodeType path to recycle the whole type; use an instance path to recycle just that instance's hub.")] string path) + => ops.Recycle(path); } /// From efb3ed67ec465d9b91cf9703df70e3a7665377f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Sun, 19 Apr 2026 17:24:52 +0200 Subject: [PATCH 34/50] fix: AccessControl graceful fallback + update menu-test counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AccessControlLayoutArea now try/catches GetStream(new MeshNodeReference()). When the reducer is not registered (minimal test hubs), render the page once without a node instead of throwing DeliveryFailureException at the layout host. Catch observable errors too and fall back to a stream-less render. - MenuAccessControlTest: add Pin (Permission.None — all authenticated users) and Recycle (Permission.Update — Editor/Admin) to the expected label sets. These menu items landed in earlier commits on this branch; the test never got updated. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../AccessControlLayoutArea.cs | 57 +++++++++++-------- .../MenuAccessControlTest.cs | 14 ++--- 2 files changed, 40 insertions(+), 31 deletions(-) diff --git a/src/MeshWeaver.Graph/AccessControlLayoutArea.cs b/src/MeshWeaver.Graph/AccessControlLayoutArea.cs index 05e890f1e..c5112a4a2 100644 --- a/src/MeshWeaver.Graph/AccessControlLayoutArea.cs +++ b/src/MeshWeaver.Graph/AccessControlLayoutArea.cs @@ -42,17 +42,7 @@ public static class AccessControlLayoutArea ); } - var nodeStream = host.Workspace.GetStream(new MeshNodeReference()); - if (nodeStream is null) - { - return Observable.Return( - Controls.Stack.WithStyle("padding: 24px;").WithView( - Controls.Html( - $"

No node exists at " + - $"{WebUtility.HtmlEncode(hubPath)}.

"))); - } - - // Admin check — read from the current access context synchronously. No awaits, + // Admin check — synchronous read from the current access context. No awaits, // no Query, no FromAsync. Roles are set at circuit/request time. var accessService = host.Hub.ServiceProvider.GetService(); var roles = accessService?.Context?.Roles @@ -62,24 +52,43 @@ public static class AccessControlLayoutArea string.Equals(r, "Admin", StringComparison.OrdinalIgnoreCase) || string.Equals(r, "PlatformAdmin", StringComparison.OrdinalIgnoreCase)); - return nodeStream.Select(change => + // Try to get the hub's own MeshNode stream. If the reducer isn't registered + // (test or minimal hub configurations), fall through to a stream-less render + // so the page is never blocked on stream initialization. + IObservable>? nodeStream = null; + try { - var node = change?.Value; - if (node is null) - { - return (UiControl?)Controls.Stack.WithStyle("padding: 24px;").WithView( - Controls.Html( - $"

Node does not exist at " + - $"{WebUtility.HtmlEncode(hubPath)}.

")); - } + nodeStream = host.Workspace.GetStream(new MeshNodeReference()); + } + catch (Exception) + { + // MeshNodeReference reducer not available on this hub — render without node. + } - return BuildAccessControlPage( - host, node, hubPath, isAdmin, + if (nodeStream is null) + { + return Observable.Return(BuildAccessControlPage( + host, node: null, hubPath, isAdmin, inherited: [], userNodeLookup: new Dictionary(), securityService, - activePolicy: null); - }); + activePolicy: null)); + } + + return nodeStream + .Select(change => (UiControl?)BuildAccessControlPage( + host, change?.Value, hubPath, isAdmin, + inherited: [], + userNodeLookup: new Dictionary(), + securityService, + activePolicy: null)) + .Catch(_ => Observable.Return( + BuildAccessControlPage( + host, node: null, hubPath, isAdmin, + inherited: [], + userNodeLookup: new Dictionary(), + securityService, + activePolicy: null))); } internal static AccessAssignment? DeserializeAssignment(MeshNode node) diff --git a/test/MeshWeaver.Security.Test/MenuAccessControlTest.cs b/test/MeshWeaver.Security.Test/MenuAccessControlTest.cs index a051b424f..776b33df9 100644 --- a/test/MeshWeaver.Security.Test/MenuAccessControlTest.cs +++ b/test/MeshWeaver.Security.Test/MenuAccessControlTest.cs @@ -153,8 +153,8 @@ await client.AwaitResponse( Output.WriteLine($" {item.Label} (Area={item.Area})"); items.Select(i => i.Label).Should().BeEquivalentTo( - ["Files", "Threads", "Versions"], - "Viewer has only Read — no Create, Update, Delete, or Export items (Settings is a dedicated header button)"); + ["Files", "Threads", "Versions", "Pin"], + "Viewer has only Read — no Create, Update, Delete, or Export items (Pin requires no permission; Settings is a dedicated header button)"); } [Fact(Timeout = 30000)] @@ -181,10 +181,10 @@ await client.AwaitResponse( foreach (var item in items) Output.WriteLine($" {item.Label} (Area={item.Area})"); - // Editor gets Edit, Create, Copy, Import, Export, plus always-visible items + // Editor gets Edit, Create, Copy, Import, Export, Recycle (Update), Pin (None), plus always-visible items items.Select(i => i.Label).Should().BeEquivalentTo( - ["Edit", "Create", "Copy", "Import", "Files", "Export", "Threads", "Versions"], - "Editor has Read|Create|Update|Comment|Export — Edit/Create/Copy/Import/Export plus always-visible items (Settings is a dedicated header button)"); + ["Edit", "Create", "Copy", "Import", "Files", "Export", "Threads", "Versions", "Pin", "Recycle"], + "Editor has Read|Create|Update|Comment|Export — Edit/Create/Copy/Import/Export/Recycle plus always-visible items and Pin (Settings is a dedicated header button)"); } [Fact(Timeout = 30000)] @@ -211,9 +211,9 @@ await client.AwaitResponse( foreach (var item in items) Output.WriteLine($" {item.Label} (Area={item.Area})"); - items.Should().HaveCount(10, "Admin should see all default menu items across Node and Mesh contexts (Settings is a dedicated header button)"); + items.Should().HaveCount(12, "Admin should see all default menu items across Node and Mesh contexts (Settings is a dedicated header button)"); items.Select(i => i.Label).Should().BeEquivalentTo( - ["Edit", "Create", "Copy", "Move", "Import", "Files", "Export", "Threads", "Versions", "Delete"]); + ["Edit", "Create", "Copy", "Move", "Import", "Files", "Export", "Threads", "Versions", "Delete", "Pin", "Recycle"]); } [Fact(Timeout = 30000)] From 32efc9e2d6b251e220a3d0ecab175eda32a1db4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Sun, 19 Apr 2026 17:27:11 +0200 Subject: [PATCH 35/50] fix: NodeTypeService.GatherInputsAsync honors Sources + includes satellites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Profile was failing to compile because GatherInputsAsync called meshStorage.GetChildrenAsync which excludes satellite-pattern nodes (mainNode != path) — the Code nodes we persist via MCP land with mainNode set to the parent _Source folder, so GetChildrenAsync skipped them entirely even though they exist at the right paths. Two changes: - Switch to GetDescendantsAsync + a single-node fetch so satellite Code nodes are picked up. Dedup via path set. - Route through ResolveSourcePaths to honor NodeTypeDefinition.Sources (the property that was added but unused here). Supports namespace:/path: qualifiers, $self macro, @path shorthand, and implicit self-relative folder names like "_Source". Defaults to "{nodeTypePath}/_Source". Adds one structured log line per compile listing the Code node paths pulled in, so future source-discovery issues are diagnosable without redeploys. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Configuration/NodeTypeService.cs | 88 ++++++++++++++++++- 1 file changed, 85 insertions(+), 3 deletions(-) diff --git a/src/MeshWeaver.Graph/Configuration/NodeTypeService.cs b/src/MeshWeaver.Graph/Configuration/NodeTypeService.cs index 94f217822..8483cb7ca 100644 --- a/src/MeshWeaver.Graph/Configuration/NodeTypeService.cs +++ b/src/MeshWeaver.Graph/Configuration/NodeTypeService.cs @@ -536,6 +536,62 @@ private MeshNode CopyIconFromNodeType(MeshNode node, string nodeType) } } + /// + /// Turns the NodeType's list into concrete + /// storage paths to probe for Code nodes. Lines understood: + /// + /// "_Source" (or any value without /) — rebased onto . + /// "namespace:X" / "path:X" — the X part is used as a storage path. + /// "@X" / "@@X" — shorthand for the path X. + /// $self inside any entry — expanded to . + /// + /// If the list is null or empty, defaults to "{nodeTypePath}/_Source". + /// Query-syntax decoration like scope:subtree and nodeType:Code is + /// stripped — this helper is only concerned with the path segment, since we feed + /// below. + /// + private static IReadOnlyList ResolveSourcePaths( + IReadOnlyList? sources, + string nodeTypePath) + { + if (sources == null || sources.Count == 0) + return [$"{nodeTypePath}/_Source"]; + + var result = new List(sources.Count); + foreach (var raw in sources) + { + if (string.IsNullOrWhiteSpace(raw)) continue; + var expanded = raw.Replace("$self", nodeTypePath).Trim(); + + // Strip the @/@@ shorthand. + if (expanded.StartsWith("@@")) expanded = expanded[2..].TrimStart(); + else if (expanded.StartsWith("@")) expanded = expanded[1..].TrimStart(); + if (expanded.Length == 0) continue; + + // Pull out the value of the first recognised qualifier (namespace:/path:), if any. + var value = expanded; + foreach (var qualifier in new[] { "namespace:", "path:" }) + { + var idx = value.IndexOf(qualifier, StringComparison.OrdinalIgnoreCase); + if (idx < 0) continue; + var valueStart = idx + qualifier.Length; + var valueEnd = valueStart; + while (valueEnd < value.Length && !char.IsWhiteSpace(value[valueEnd])) + valueEnd++; + value = value[valueStart..valueEnd]; + break; + } + + // If the value is a single segment (no /), treat as self-relative folder. + if (value.Length > 0 && !value.Contains('/')) + value = $"{nodeTypePath}/{value}"; + + if (value.Length > 0) + result.Add(value); + } + return result.Count > 0 ? result : [$"{nodeTypePath}/_Source"]; + } + /// /// Gathers all inputs needed for compilation from persistence. /// Returns a NodeTypeRelease with all compilation inputs and the MeshNode. @@ -565,16 +621,42 @@ private MeshNode CopyIconFromNodeType(MeshNode node, string nodeType) return (null, null); } - // Get CodeConfigurations from child MeshNodes under /_Source path directly + // Collect Code nodes from the configured sources. Default: the sibling "_Source" + // subtree. `GetDescendantsAsync` is used (not `GetChildrenAsync`) because Code + // nodes are commonly persisted with `MainNode` set to their parent folder — + // `GetChildrenAsync` excludes those as "satellites". var codeFiles = new List(); - await foreach (var codeNode in meshStorage.GetChildrenAsync($"{nodeTypePath}/_Source")) + var codeFilePaths = new List(); + var seenCodePaths = new HashSet(StringComparer.OrdinalIgnoreCase); + + async IAsyncEnumerable CollectFromPathAsync(string path) { - if (codeNode.Content is CodeConfiguration codeConfig && !string.IsNullOrEmpty(codeConfig.Code)) + // A single-node fetch (path:X) + var single = await meshStorage.GetNodeAsync(path, ct); + if (single != null) yield return single; + // And all descendants under the same path (namespace:X scope:subtree) + await foreach (var descendant in meshStorage.GetDescendantsAsync(path)) + yield return descendant; + } + + var sourcePaths = ResolveSourcePaths(definition.Sources, nodeTypePath); + foreach (var sourcePath in sourcePaths) + { + await foreach (var candidate in CollectFromPathAsync(sourcePath)) { + if (candidate.NodeType != CodeNodeType.NodeType) continue; + if (candidate.Content is not CodeConfiguration codeConfig) continue; + if (string.IsNullOrEmpty(codeConfig.Code)) continue; + if (candidate.Path is { Length: > 0 } p && !seenCodePaths.Add(p)) continue; codeFiles.Add(codeConfig.Code); + if (candidate.Path != null) codeFilePaths.Add(candidate.Path); } } + logger.LogInformation( + "NodeType '{NodeTypePath}' source discovery: {Count} Code nodes from [{Paths}]", + nodeTypePath, codeFiles.Count, string.Join(", ", codeFilePaths)); + // Resolve @@ include references in code files (e.g., @@FutuRe/LineOfBusiness/_Source/LineOfBusiness) if (compilationService != null) { From 394b8112196cf333a8ec1f4863b09c05d3915ef7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Sun, 19 Apr 2026 17:27:29 +0200 Subject: [PATCH 36/50] =?UTF-8?q?test:=20bump=20ThreadSubmission=20WaitFor?= =?UTF-8?q?ThreadAsync=20timeouts=2010s=20=E2=86=92=2030s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reduces flakiness on CI where the 10s internal deadline was occasionally hit while the async thread ingest was still in flight. Local runs complete in ~2s, so 30s is plenty of headroom without slowing green runs. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../MeshWeaver.AI.Test/ThreadSubmissionIntegrationTest.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/MeshWeaver.AI.Test/ThreadSubmissionIntegrationTest.cs b/test/MeshWeaver.AI.Test/ThreadSubmissionIntegrationTest.cs index 8c4edd1bd..6160b9fe6 100644 --- a/test/MeshWeaver.AI.Test/ThreadSubmissionIntegrationTest.cs +++ b/test/MeshWeaver.AI.Test/ThreadSubmissionIntegrationTest.cs @@ -72,7 +72,7 @@ public async Task Submit_ExistingThread_UserMessageIngested_OutputCellAppears() var committed = await WaitForThreadAsync( threadPath, t => t.IngestedMessageIds.Count >= 1 && t.Messages.Count >= 2, - timeoutMs: 10_000, + timeoutMs: 30_000, ct); committed.IngestedMessageIds.Should().HaveCount(1); @@ -151,7 +151,7 @@ public async Task Submit_ThreeRapidSubmissions_AllIngestedIntoOneRound() await WaitForThreadAsync( threadPath, t => t.IngestedMessageIds.Count == 3, - timeoutMs: 10_000, + timeoutMs: 30_000, ct); var final = await ReadThreadAsync(threadPath, ct); @@ -179,7 +179,7 @@ public async Task Resubmit_TruncatesAfterReplayedMessage_NewRoundCreated() var afterRoundOne = await WaitForThreadAsync( threadPath, t => !t.IsExecuting && t.IngestedMessageIds.Count == 1 && t.Messages.Count == 2, - timeoutMs: 10_000, ct); + timeoutMs: 30_000, ct); var u1 = afterRoundOne.UserMessageIds[0]; var r1 = afterRoundOne.Messages[1]; @@ -423,7 +423,7 @@ public async Task Submit_SingleSubmit_ProducesExactlyOneResponseCell() var settled = await WaitForThreadAsync( threadPath, t => !t.IsExecuting && t.IngestedMessageIds.Count == 1, - timeoutMs: 10_000, ct); + timeoutMs: 30_000, ct); // Give any racing second-dispatch a chance to land. await Task.Delay(500, ct); From 2f725ace2649e80fc08167993d165fc12b35057c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Sun, 19 Apr 2026 20:59:25 +0200 Subject: [PATCH 37/50] =?UTF-8?q?test:=20bump=20xunit=20methodTimeout=2030?= =?UTF-8?q?s=20=E2=86=92=2060s=20(match=20CLAUDE.md,=20reduce=20CI=20flake?= =?UTF-8?q?s)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLAUDE.md documents 60s per method but the runner config was 30s. Query tests (AutocompleteMultiSourceTest, FanOutQueryOrderingTests) run ~22s locally and occasionally blow past 30s under CI load. 60s matches the documented value and leaves headroom without masking genuine hangs. Co-Authored-By: Claude Opus 4.7 (1M context) --- test/xunit.runner.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/xunit.runner.json b/test/xunit.runner.json index c46ded3d8..534f03a1d 100644 --- a/test/xunit.runner.json +++ b/test/xunit.runner.json @@ -2,5 +2,5 @@ "parallelizeAssembly": false, "parallelizeTestCollections": false, "maxParallelThreads": 1, - "methodTimeout": 30000 + "methodTimeout": 60000 } From afd7a1af85fbe6492807860775619a9aa5972399 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Sun, 19 Apr 2026 21:12:23 +0200 Subject: [PATCH 38/50] fix: broadcast NodeType cache invalidation across silos via MeshChangeFeed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two stale-cache fixes wired together: 1. NodeTypeService.InvalidateCache now also clears `_compilationErrors` and `_compilingInProgress`. Previously those survived every Recycle, so the user kept seeing an old error even after the hub was disposed and source files were fixed. 2. NodeTypeService subscribes to IMeshChangeFeed on construction and invalidates whenever an event arrives whose path is already in the local caches (or whose NodeType is the NodeType marker). This is the existing broadcast channel — in monolith it's in-process; in Orleans it's a BroadcastChannel that every silo subscribes to. So invalidation reaches all silos automatically. MeshOperations.Recycle now: - Calls InvalidateCache locally so the current silo flushes immediately. - Publishes a synthetic MeshChangeEvent.Updated over IMeshChangeFeed with NodeType = "NodeType" — every silo's NodeTypeService picks it up and invalidates its local cache too. - Posts DisposeRequest to the target hub as before. Contract change: INodeTypeService.InvalidateCache is now public (was internal on the impl) so MCP tools can trigger cross-silo eviction. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/MeshWeaver.AI/MeshOperations.cs | 33 +++++++++++++- .../Configuration/NodeTypeService.cs | 43 +++++++++++++++++-- .../Services/INodeTypeService.cs | 9 ++++ 3 files changed, 81 insertions(+), 4 deletions(-) diff --git a/src/MeshWeaver.AI/MeshOperations.cs b/src/MeshWeaver.AI/MeshOperations.cs index cf6162e1a..0663d670b 100644 --- a/src/MeshWeaver.AI/MeshOperations.cs +++ b/src/MeshWeaver.AI/MeshOperations.cs @@ -906,9 +906,40 @@ public Task Recycle(string path) try { + // 1. Flush LOCAL NodeTypeService caches so a fresh compile runs on next access. + // Disposing the hub alone is not enough — NodeTypeService._compilationErrors + // and _compilationTasks survive hub teardown and would keep serving stale + // errors. + nodeTypeService?.InvalidateCache(resolvedPath); + + // 2. Broadcast the invalidation across silos via IMeshChangeFeed. Every silo's + // NodeTypeService subscribes to this feed and calls InvalidateCache locally + // when it sees an event for a tracked NodeType path. + var changeFeed = hub.ServiceProvider.GetService(); + if (changeFeed != null) + { + var segments = resolvedPath.Split('/'); + var id = segments.Length > 0 ? segments[^1] : resolvedPath; + var ns = segments.Length > 1 ? string.Join("/", segments[..^1]) : ""; + changeFeed.Publish(new MeshChangeEvent( + Namespace: ns, + Id: id, + Path: resolvedPath, + Kind: MeshChangeKind.Updated, + NodeType: MeshNode.NodeTypePath, + Version: 0, + Timestamp: DateTimeOffset.UtcNow)); + } + + // 3. Dispose the hub so the next request re-initialises with fresh config. hub.Post(new DisposeRequest(), o => o.WithTarget(new Address(resolvedPath))); return Task.FromResult(JsonSerializer.Serialize( - new { status = "Recycled", path = resolvedPath, message = "DisposeRequest posted. Wait ~100ms before the next access so the grain teardown completes." }, + new + { + status = "Recycled", + path = resolvedPath, + message = "DisposeRequest posted + cache invalidation broadcast via MeshChangeFeed. Wait ~100ms before the next access." + }, hub.JsonSerializerOptions)); } catch (Exception ex) diff --git a/src/MeshWeaver.Graph/Configuration/NodeTypeService.cs b/src/MeshWeaver.Graph/Configuration/NodeTypeService.cs index 8483cb7ca..bbc5791b5 100644 --- a/src/MeshWeaver.Graph/Configuration/NodeTypeService.cs +++ b/src/MeshWeaver.Graph/Configuration/NodeTypeService.cs @@ -58,6 +58,9 @@ internal class NodeTypeService : INodeTypeService, IDisposable // Cached access rules extracted from hub configurations private readonly ConcurrentDictionary _accessRules = new(); + // Subscription to the cross-silo change feed — disposed with the service. + private readonly IDisposable? _changeFeedSubscription; + public NodeTypeService( IMessageHub hub, IEnumerable queryProviders, @@ -66,7 +69,8 @@ public NodeTypeService( ILogger logger, ICompilationCacheService cacheService, IOptions cacheOptions, - MeshNodeCompilationService? compilationService = null) + MeshNodeCompilationService? compilationService = null, + IMeshChangeFeed? changeFeed = null) { this.hub = hub; this.queryProviders = queryProviders; @@ -79,6 +83,29 @@ public NodeTypeService( // Initialize cache from pre-registered nodes in MeshConfiguration InitializeFromMeshConfiguration(); + + // Subscribe to the mesh change feed so cache invalidations reach every silo. + // In monolith this is in-process; in Orleans it's a broadcast channel. + // We invalidate whenever a known NodeType's path is seen in an event — covers both: + // (a) a NodeType definition was updated / deleted elsewhere, and + // (b) Recycle published a synthetic Updated event to force a reset. + if (changeFeed != null) + { + _changeFeedSubscription = changeFeed.Subscribe(evt => + { + if (string.IsNullOrEmpty(evt.Path)) return; + if (_hubConfigurations.ContainsKey(evt.Path) + || _compilationTasks.ContainsKey(evt.Path) + || _compilationErrors.ContainsKey(evt.Path) + || string.Equals(evt.NodeType, MeshNode.NodeTypePath, StringComparison.Ordinal)) + { + logger.LogInformation( + "Cross-silo cache invalidation for {NodeTypePath} via MeshChangeFeed ({Kind})", + evt.Path, evt.Kind); + InvalidateCache(evt.Path); + } + }); + } } /// @@ -238,12 +265,21 @@ public bool IsCompiling(string nodeTypePath) => return _hubConfigurations.GetValueOrDefault(nodeTypePath); } - internal void InvalidateCache(string nodeTypePath) + /// + /// Invalidates all cached state for . Public surface so + /// MCP's Recycle tool (and other front-ends) can flush a stuck NodeType — disposing + /// the hub alone is not enough, because `_compilationErrors` / `_compilationTasks` + /// live on this singleton service and survive hub teardown. + /// + public void InvalidateCache(string nodeTypePath) { logger.LogDebug("Invalidating cache for {NodeTypePath}", nodeTypePath); - // Remove from all caches + // Remove from all caches — including the sticky error + in-progress markers + // (previously forgotten, which meant a stuck error kept showing after Recycle). _compilationTasks.TryRemove(nodeTypePath, out _); + _compilationErrors.TryRemove(nodeTypePath, out _); + _compilingInProgress.TryRemove(nodeTypePath, out _); _releaseKeys.TryRemove(nodeTypePath, out _); _hubConfigurations.TryRemove(nodeTypePath, out _); _creatableTypesRules.TryRemove(nodeTypePath, out _); @@ -1072,6 +1108,7 @@ private static string GetLastPathSegment(string path) public void Dispose() { + _changeFeedSubscription?.Dispose(); foreach (var subscription in _subscriptions.Values) { subscription.Dispose(); diff --git a/src/MeshWeaver.Mesh.Contract/Services/INodeTypeService.cs b/src/MeshWeaver.Mesh.Contract/Services/INodeTypeService.cs index 26f623aa0..05f450dd0 100644 --- a/src/MeshWeaver.Mesh.Contract/Services/INodeTypeService.cs +++ b/src/MeshWeaver.Mesh.Contract/Services/INodeTypeService.cs @@ -64,4 +64,13 @@ public interface INodeTypeService /// to show elapsed-time feedback in progress overlays. /// DateTimeOffset? GetCompilationStartedAt(string nodeTypePath) => null; + + /// + /// Flushes all cached state (compilation errors, cached tasks, cached hub + /// configurations, etc.) for the given NodeType path, forcing a fresh compile + /// on the next access. Paired with DisposeRequest to fully reset a + /// stuck NodeType — disposing the hub alone is not enough because the + /// service-level caches survive hub teardown. + /// + void InvalidateCache(string nodeTypePath) { } } From f9654dad511f98c131c22e26cd12f93acc974ae6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Sun, 19 Apr 2026 21:19:06 +0200 Subject: [PATCH 39/50] feat: live 'Compiling (Ns)...' progress during navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ApplicationPage.razor was showing just 'Looking up ...' for the entire duration of the blocking compile — users had no indication of what the hub was actually busy with. Now: - INodeTypeService exposes GetCompilingPaths() (a snapshot of paths with a compile task currently running). - ApplicationPage polls it once per second while IsLoading is true and flips the placeholder to 'Compiling (Ns)...' when any NodeType is mid-compile. The elapsed-second counter gives reassurance on long compiles. - Timer is disposed with the page. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Pages/ApplicationPage.razor | 21 +++++++++- .../Pages/ApplicationPage.razor.cs | 38 +++++++++++++++++++ .../Configuration/NodeTypeService.cs | 4 ++ .../Services/INodeTypeService.cs | 7 ++++ 4 files changed, 68 insertions(+), 2 deletions(-) diff --git a/src/MeshWeaver.Blazor/Pages/ApplicationPage.razor b/src/MeshWeaver.Blazor/Pages/ApplicationPage.razor index 51400e628..49e3fc997 100644 --- a/src/MeshWeaver.Blazor/Pages/ApplicationPage.razor +++ b/src/MeshWeaver.Blazor/Pages/ApplicationPage.razor @@ -16,10 +16,27 @@ } else if (!IsInteractive || IsLoading) { - @* Pre-render (no cached HTML) or interactive still resolving *@ + @* Pre-render (no cached HTML) or interactive still resolving. + While navigation is blocked, peek at NodeTypeService to show a richer + progress message: "Looking up" → "Compiling (s)" so the user + sees what the hub is actually busy with. *@
-

Looking up @Path...

+ @if (CompilingPath is not null) + { +

+ Compiling @CompilingPath + @if (CompilingSeconds > 0) + { + (@CompilingSeconds s) + } + ... +

+ } + else + { +

Looking up @Path...

+ }
} else if (NavigationService.Context is null) diff --git a/src/MeshWeaver.Blazor/Pages/ApplicationPage.razor.cs b/src/MeshWeaver.Blazor/Pages/ApplicationPage.razor.cs index 6b944d127..137419d04 100644 --- a/src/MeshWeaver.Blazor/Pages/ApplicationPage.razor.cs +++ b/src/MeshWeaver.Blazor/Pages/ApplicationPage.razor.cs @@ -26,6 +26,21 @@ public partial class ApplicationPage : ComponentBase, IDisposable [Inject] private IMeshService MeshService { get; set; } = null!; + [Inject] + private INodeTypeService NodeTypeService { get; set; } = null!; + + /// + /// Path of any NodeType currently compiling. Used by the razor template to flip + /// the "Looking up …" placeholder into "Compiling <path> (Ns)…" during the + /// navigation blocking phase, so the user sees activity instead of a blank spinner. + /// + private string? CompilingPath { get; set; } + + /// Elapsed seconds since the current compile started. + private int CompilingSeconds { get; set; } + + private System.Threading.Timer? _compileProgressTimer; + /// /// Catch-all path parameter - the entire URL path is matched against registered namespace patterns. /// @@ -71,6 +86,28 @@ protected override void OnInitialized() { base.OnInitialized(); NavigationService.OnNavigationContextChanged += OnNavigationContextChanged; + + // Poll NodeTypeService.GetCompilingPaths while the page is in "Looking up" + // state so the user sees "Compiling (Ns)…" rather than a blank spinner. + // Stopped once IsLoading flips to false. Two-second granularity is enough — + // most compiles are sub-second; the tick is for reassurance on slow ones. + _compileProgressTimer = new System.Threading.Timer(_ => + { + if (!IsLoading) return; + var paths = NodeTypeService.GetCompilingPaths(); + var first = paths.FirstOrDefault(); + var next = first; + if (next != CompilingPath) + { + CompilingPath = next; + CompilingSeconds = 0; + } + else if (next != null) + { + CompilingSeconds++; + } + InvokeAsync(StateHasChanged); + }, null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1)); } protected override async Task OnParametersSetAsync() @@ -178,5 +215,6 @@ private void UpdateFromContext() public void Dispose() { NavigationService.OnNavigationContextChanged -= OnNavigationContextChanged; + _compileProgressTimer?.Dispose(); } } diff --git a/src/MeshWeaver.Graph/Configuration/NodeTypeService.cs b/src/MeshWeaver.Graph/Configuration/NodeTypeService.cs index bbc5791b5..15cbaa676 100644 --- a/src/MeshWeaver.Graph/Configuration/NodeTypeService.cs +++ b/src/MeshWeaver.Graph/Configuration/NodeTypeService.cs @@ -200,6 +200,10 @@ public bool IsCompiling(string nodeTypePath) => public DateTimeOffset? GetCompilationStartedAt(string nodeTypePath) => _compilingInProgress.TryGetValue(nodeTypePath, out var start) ? start : null; + /// + public IReadOnlyCollection GetCompilingPaths() => + _compilingInProgress.Keys.ToArray(); + private Task GetAssemblyPathAsync(string nodeTypePath, CancellationToken ct = default) { var wasNewCompile = false; diff --git a/src/MeshWeaver.Mesh.Contract/Services/INodeTypeService.cs b/src/MeshWeaver.Mesh.Contract/Services/INodeTypeService.cs index 05f450dd0..cf3adec60 100644 --- a/src/MeshWeaver.Mesh.Contract/Services/INodeTypeService.cs +++ b/src/MeshWeaver.Mesh.Contract/Services/INodeTypeService.cs @@ -73,4 +73,11 @@ public interface INodeTypeService /// service-level caches survive hub teardown. ///
void InvalidateCache(string nodeTypePath) { } + + /// + /// Snapshot of NodeType paths currently being compiled. Used by the portal + /// to render a "Compiling…" progress indicator while a navigation request is + /// blocked waiting on a compile. + /// + IReadOnlyCollection GetCompilingPaths() => Array.Empty(); } From ee6eb748e1380008d476c3f9a817ddcd6dd8e80b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Sun, 19 Apr 2026 21:29:30 +0200 Subject: [PATCH 40/50] feat: silence HeartBeatEvent warnings on every Memex node hub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Expose WithHeartBeatHandler() in MeshExtensions so any hub can register the existing HandleHeartBeat handler without pulling in the full set of WithNodeOperationHandlers (which includes Create/Update/Delete/Move — not appropriate for leaf per-node hubs). - Call it from MemexConfiguration.ConfigureDefaultNodeHub so every dynamic node hub (NodeType instances, threads, _Exec, etc.) acks heartbeats silently instead of logging a "No handler found for HeartBeatEvent" warning per beat. In monolith mode it's a no-op; in Orleans it walks the parent chain to call GrainKeepAliveCallback. Co-Authored-By: Claude Opus 4.7 (1M context) --- memex/Memex.Portal.Shared/MemexConfiguration.cs | 6 +++++- src/MeshWeaver.Mesh.Contract/MeshExtensions.cs | 11 +++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/memex/Memex.Portal.Shared/MemexConfiguration.cs b/memex/Memex.Portal.Shared/MemexConfiguration.cs index 4dba3abe8..83fcaef1e 100644 --- a/memex/Memex.Portal.Shared/MemexConfiguration.cs +++ b/memex/Memex.Portal.Shared/MemexConfiguration.cs @@ -381,7 +381,11 @@ public TBuilder ConfigureMemexMesh(IConfiguration configuration, bool isDevelopm config = config.AddContentCollection(_ => nodeContentConfig); } - return config.AddDefaultLayoutAreas().AddThreadsLayoutArea().AddApiTokensSettingsTab(); + return config + .WithHeartBeatHandler() // silently ack heartbeats on every per-node hub + .AddDefaultLayoutAreas() + .AddThreadsLayoutArea() + .AddApiTokensSettingsTab(); }) // Add activity tracking to record user access patterns via ActivityLogBundler .AddActivityTracking(); diff --git a/src/MeshWeaver.Mesh.Contract/MeshExtensions.cs b/src/MeshWeaver.Mesh.Contract/MeshExtensions.cs index a4c33177b..1b7a51f68 100644 --- a/src/MeshWeaver.Mesh.Contract/MeshExtensions.cs +++ b/src/MeshWeaver.Mesh.Contract/MeshExtensions.cs @@ -64,6 +64,17 @@ public static MessageHubConfiguration WithNodeOperationHandlers(this MessageHubC .WithHandler(HandleHeartBeat); } + /// + /// Registers only the handler. Use on hubs that + /// should swallow heartbeats silently (e.g. per-node hubs spawned from a + /// NodeType's configuration) without pulling in the full node-operation + /// handler set. Without this handler the message service logs a warning per + /// heartbeat, so targets that receive heartbeats but don't need to keep an + /// Orleans grain alive should still register it as a no-op. + /// + public static MessageHubConfiguration WithHeartBeatHandler(this MessageHubConfiguration config) + => config.WithHandler(HandleHeartBeat); + /// /// Handles HeartBeatEvent: signals the Orleans grain to delay deactivation. /// Walks up the parent hub chain because GrainKeepAliveCallback is set on the From 17732f0d5c0617732eb7e4cb7024cfa0e870b09a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Sun, 19 Apr 2026 21:31:21 +0200 Subject: [PATCH 41/50] chore: raise MeshWeaver log level to Information in distributed dev MeshWeaver was at Warning, which hid NodeType source-discovery Information logs (e.g. `source discovery: N Code nodes from [...]`). Only MeshWeaver.AI was at Information. Bumping MeshWeaver default to Information and pinning MeshWeaver.Graph.Configuration so future compile-path diagnostics surface without log-level tweaks. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Memex.Portal.Distributed/appsettings.Development.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/memex/aspire/Memex.Portal.Distributed/appsettings.Development.json b/memex/aspire/Memex.Portal.Distributed/appsettings.Development.json index 6e539eb49..a1139b132 100644 --- a/memex/aspire/Memex.Portal.Distributed/appsettings.Development.json +++ b/memex/aspire/Memex.Portal.Distributed/appsettings.Development.json @@ -4,8 +4,9 @@ "LogLevel": { "Default": "Warning", "Microsoft.AspNetCore": "Warning", - "MeshWeaver": "Warning", + "MeshWeaver": "Information", "MeshWeaver.AI": "Information", + "MeshWeaver.Graph.Configuration": "Information", "MeshWeaver.Layout.ConvertJson": "Warning", "MeshWeaver.Messaging.Hub.MessageHub": "Warning", "Azure.Core": "Warning", From d1679b5eb1c84cd167a482b678324394a1707b2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Sun, 19 Apr 2026 21:38:01 +0200 Subject: [PATCH 42/50] fix: defensive MeshChangeFeed subscribe + optional NodeTypeService inject MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two crash surfaces mopped up: 1. NodeTypeService constructor wraps the IMeshChangeFeed.Subscribe call in try/catch. If the feed implementation throws during early subscription (timing issues at cluster startup), every silo's DI blew up and the whole mesh deadlocked. Handler body is also now try/catch'd so one faulted event doesn't kill the subscription. 2. ApplicationPage.razor.cs no longer hard [Inject]s INodeTypeService — it lazy-resolves via IServiceProvider.GetService. A hard inject threw during component construction when the service wasn't registered (e.g. distributed portal startup) which left the user with a black screen. Timer tick is also try/catch'd to avoid unhandled exceptions killing the circuit. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Pages/ApplicationPage.razor.cs | 34 ++++++++----- .../Configuration/NodeTypeService.cs | 48 ++++++++++++------- 2 files changed, 55 insertions(+), 27 deletions(-) diff --git a/src/MeshWeaver.Blazor/Pages/ApplicationPage.razor.cs b/src/MeshWeaver.Blazor/Pages/ApplicationPage.razor.cs index 137419d04..c27ebd3c3 100644 --- a/src/MeshWeaver.Blazor/Pages/ApplicationPage.razor.cs +++ b/src/MeshWeaver.Blazor/Pages/ApplicationPage.razor.cs @@ -5,6 +5,7 @@ using MeshWeaver.Mesh.Services; using MeshWeaver.Messaging; using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; using Microsoft.FluentUI.AspNetCore.Components; namespace MeshWeaver.Blazor.Pages; @@ -26,8 +27,12 @@ public partial class ApplicationPage : ComponentBase, IDisposable [Inject] private IMeshService MeshService { get; set; } = null!; + // Resolved lazily from the service provider so the page still renders when + // INodeTypeService isn't registered. A hard [Inject] would throw during + // component construction and leave the user with a black screen. [Inject] - private INodeTypeService NodeTypeService { get; set; } = null!; + private IServiceProvider Services { get; set; } = null!; + private INodeTypeService? NodeTypeService => Services.GetService(); /// /// Path of any NodeType currently compiling. Used by the razor template to flip @@ -93,20 +98,27 @@ protected override void OnInitialized() // most compiles are sub-second; the tick is for reassurance on slow ones. _compileProgressTimer = new System.Threading.Timer(_ => { - if (!IsLoading) return; - var paths = NodeTypeService.GetCompilingPaths(); - var first = paths.FirstOrDefault(); - var next = first; - if (next != CompilingPath) + try { - CompilingPath = next; - CompilingSeconds = 0; + if (!IsLoading) return; + var paths = NodeTypeService?.GetCompilingPaths(); + var first = paths?.FirstOrDefault(); + if (first != CompilingPath) + { + CompilingPath = first; + CompilingSeconds = 0; + } + else if (first != null) + { + CompilingSeconds++; + } + _ = InvokeAsync(StateHasChanged); } - else if (next != null) + catch { - CompilingSeconds++; + // Timer tick should never take down the page. The worst-case is a stale + // "Compiling…" message; that's better than a crashed circuit. } - InvokeAsync(StateHasChanged); }, null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1)); } diff --git a/src/MeshWeaver.Graph/Configuration/NodeTypeService.cs b/src/MeshWeaver.Graph/Configuration/NodeTypeService.cs index 15cbaa676..30986d783 100644 --- a/src/MeshWeaver.Graph/Configuration/NodeTypeService.cs +++ b/src/MeshWeaver.Graph/Configuration/NodeTypeService.cs @@ -85,26 +85,42 @@ public NodeTypeService( InitializeFromMeshConfiguration(); // Subscribe to the mesh change feed so cache invalidations reach every silo. - // In monolith this is in-process; in Orleans it's a broadcast channel. - // We invalidate whenever a known NodeType's path is seen in an event — covers both: - // (a) a NodeType definition was updated / deleted elsewhere, and - // (b) Recycle published a synthetic Updated event to force a reset. + // Defensive: wrap in try/catch because a construction-time throw here would + // take down *every* silo's DI and deadlock the whole cluster — the feed impl + // might not be ready, might throw on early subscription, etc. Log and move on. if (changeFeed != null) { - _changeFeedSubscription = changeFeed.Subscribe(evt => + try { - if (string.IsNullOrEmpty(evt.Path)) return; - if (_hubConfigurations.ContainsKey(evt.Path) - || _compilationTasks.ContainsKey(evt.Path) - || _compilationErrors.ContainsKey(evt.Path) - || string.Equals(evt.NodeType, MeshNode.NodeTypePath, StringComparison.Ordinal)) + _changeFeedSubscription = changeFeed.Subscribe(evt => { - logger.LogInformation( - "Cross-silo cache invalidation for {NodeTypePath} via MeshChangeFeed ({Kind})", - evt.Path, evt.Kind); - InvalidateCache(evt.Path); - } - }); + try + { + if (string.IsNullOrEmpty(evt.Path)) return; + if (_hubConfigurations.ContainsKey(evt.Path) + || _compilationTasks.ContainsKey(evt.Path) + || _compilationErrors.ContainsKey(evt.Path) + || string.Equals(evt.NodeType, MeshNode.NodeTypePath, StringComparison.Ordinal)) + { + logger.LogInformation( + "Cross-silo cache invalidation for {NodeTypePath} via MeshChangeFeed ({Kind})", + evt.Path, evt.Kind); + InvalidateCache(evt.Path); + } + } + catch (Exception handlerEx) + { + logger.LogWarning(handlerEx, + "MeshChangeFeed handler faulted while processing event for {Path}", + evt.Path); + } + }); + } + catch (Exception subscribeEx) + { + logger.LogWarning(subscribeEx, + "Failed to subscribe to IMeshChangeFeed — cross-silo cache invalidation disabled"); + } } } From bba5239b94ac6319c22062a7bcffbe85d6418328 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Sun, 19 Apr 2026 21:42:56 +0200 Subject: [PATCH 43/50] =?UTF-8?q?fix:=20GatherInputsAsync=20uses=20IMeshQu?= =?UTF-8?q?eryProvider=20=E2=80=94=20satellite-safe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the 'types not found' compile failure: Code nodes are persisted as satellites (MainNode = parent _Source folder, Path = Code node path). InMemoryPersistenceService.GetDescendantsAsync explicitly excludes every node where MainNode != Path (lines 202-204). My previous fix still used that storage API, so the compile path kept seeing zero Code files. Switch to the local QueryAsync helper which runs through IMeshQueryProvider — it has no satellite filter and returns the Code nodes regardless of how MainNode was set. For each configured source path, run both a path:X exact-match and a namespace:X subtree query, so single-file shorthand and folder queries both resolve. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Configuration/NodeTypeService.cs | 44 ++++++++++--------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/src/MeshWeaver.Graph/Configuration/NodeTypeService.cs b/src/MeshWeaver.Graph/Configuration/NodeTypeService.cs index 30986d783..9328e2304 100644 --- a/src/MeshWeaver.Graph/Configuration/NodeTypeService.cs +++ b/src/MeshWeaver.Graph/Configuration/NodeTypeService.cs @@ -678,34 +678,38 @@ private static IReadOnlyList ResolveSourcePaths( } // Collect Code nodes from the configured sources. Default: the sibling "_Source" - // subtree. `GetDescendantsAsync` is used (not `GetChildrenAsync`) because Code - // nodes are commonly persisted with `MainNode` set to their parent folder — - // `GetChildrenAsync` excludes those as "satellites". + // subtree. We use the IMeshQueryProvider pipeline (via local QueryAsync) rather + // than meshStorage.GetDescendantsAsync, because the storage layer explicitly + // EXCLUDES satellite nodes (MeshNode.MainNode != Path) from descendant browsing — + // and our Code nodes are always persisted as satellites with MainNode set to + // their parent _Source folder. The query provider has no such filter, so it + // returns every matching Code node. var codeFiles = new List(); var codeFilePaths = new List(); var seenCodePaths = new HashSet(StringComparer.OrdinalIgnoreCase); - async IAsyncEnumerable CollectFromPathAsync(string path) - { - // A single-node fetch (path:X) - var single = await meshStorage.GetNodeAsync(path, ct); - if (single != null) yield return single; - // And all descendants under the same path (namespace:X scope:subtree) - await foreach (var descendant in meshStorage.GetDescendantsAsync(path)) - yield return descendant; - } - var sourcePaths = ResolveSourcePaths(definition.Sources, nodeTypePath); foreach (var sourcePath in sourcePaths) { - await foreach (var candidate in CollectFromPathAsync(sourcePath)) + // Combine path-exact + namespace-subtree so a single-file shorthand and a + // folder both resolve. Satellite-safe. + var queries = new[] + { + $"path:{sourcePath} nodeType:{CodeNodeType.NodeType}", + $"namespace:{sourcePath} scope:subtree nodeType:{CodeNodeType.NodeType}" + }; + + foreach (var q in queries) { - if (candidate.NodeType != CodeNodeType.NodeType) continue; - if (candidate.Content is not CodeConfiguration codeConfig) continue; - if (string.IsNullOrEmpty(codeConfig.Code)) continue; - if (candidate.Path is { Length: > 0 } p && !seenCodePaths.Add(p)) continue; - codeFiles.Add(codeConfig.Code); - if (candidate.Path != null) codeFilePaths.Add(candidate.Path); + await foreach (var candidate in QueryAsync(q, ct)) + { + if (candidate.NodeType != CodeNodeType.NodeType) continue; + if (candidate.Content is not CodeConfiguration codeConfig) continue; + if (string.IsNullOrEmpty(codeConfig.Code)) continue; + if (candidate.Path is { Length: > 0 } p && !seenCodePaths.Add(p)) continue; + codeFiles.Add(codeConfig.Code); + if (candidate.Path != null) codeFilePaths.Add(candidate.Path); + } } } From 8f4df3f8b8b3e47a5874d42fcc9b7d843f88b5c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Sun, 19 Apr 2026 21:56:33 +0200 Subject: [PATCH 44/50] improvig mesh compilation --- .../Configuration/NodeTypeService.cs | 48 ++++---- .../Persistence/PersistenceService.cs | 3 + .../MeshExtensions.cs | 28 +++-- .../Services/IMeshStorage.cs | 10 ++ .../MeshNodeCompilationServiceTest.cs | 103 ++++++++++++++++++ 5 files changed, 157 insertions(+), 35 deletions(-) diff --git a/src/MeshWeaver.Graph/Configuration/NodeTypeService.cs b/src/MeshWeaver.Graph/Configuration/NodeTypeService.cs index 9328e2304..1000a8641 100644 --- a/src/MeshWeaver.Graph/Configuration/NodeTypeService.cs +++ b/src/MeshWeaver.Graph/Configuration/NodeTypeService.cs @@ -678,12 +678,13 @@ private static IReadOnlyList ResolveSourcePaths( } // Collect Code nodes from the configured sources. Default: the sibling "_Source" - // subtree. We use the IMeshQueryProvider pipeline (via local QueryAsync) rather - // than meshStorage.GetDescendantsAsync, because the storage layer explicitly - // EXCLUDES satellite nodes (MeshNode.MainNode != Path) from descendant browsing — - // and our Code nodes are always persisted as satellites with MainNode set to - // their parent _Source folder. The query provider has no such filter, so it - // returns every matching Code node. + // subtree. `GetAllDescendantsAsync` (not `GetDescendantsAsync`) is used because + // Code nodes are persisted as satellites — CreateNodeRequest auto-sets + // MainNode to the parent namespace for any NodeType registered as satellite. + // The regular `GetDescendantsAsync` in InMemoryPersistenceService excludes + // satellites from browsing; the `All` variant includes them. + // We also check the parent path as a single-node fetch so `path:X` shorthand + // with a leaf Code node path works. var codeFiles = new List(); var codeFilePaths = new List(); var seenCodePaths = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -691,26 +692,23 @@ private static IReadOnlyList ResolveSourcePaths( var sourcePaths = ResolveSourcePaths(definition.Sources, nodeTypePath); foreach (var sourcePath in sourcePaths) { - // Combine path-exact + namespace-subtree so a single-file shorthand and a - // folder both resolve. Satellite-safe. - var queries = new[] - { - $"path:{sourcePath} nodeType:{CodeNodeType.NodeType}", - $"namespace:{sourcePath} scope:subtree nodeType:{CodeNodeType.NodeType}" - }; + // Path-exact fetch first (handles `path:X` / `@X` pointing at a single Code node). + var single = await meshStorage.GetNodeAsync(sourcePath, ct); + if (single != null) AddIfCodeNode(single); - foreach (var q in queries) - { - await foreach (var candidate in QueryAsync(q, ct)) - { - if (candidate.NodeType != CodeNodeType.NodeType) continue; - if (candidate.Content is not CodeConfiguration codeConfig) continue; - if (string.IsNullOrEmpty(codeConfig.Code)) continue; - if (candidate.Path is { Length: > 0 } p && !seenCodePaths.Add(p)) continue; - codeFiles.Add(codeConfig.Code); - if (candidate.Path != null) codeFilePaths.Add(candidate.Path); - } - } + // Then all descendants INCLUDING satellites — that's the Code-file case. + await foreach (var descendant in meshStorage.GetAllDescendantsAsync(sourcePath)) + AddIfCodeNode(descendant); + } + + void AddIfCodeNode(MeshNode candidate) + { + if (candidate.NodeType != CodeNodeType.NodeType) return; + if (candidate.Content is not CodeConfiguration codeConfig) return; + if (string.IsNullOrEmpty(codeConfig.Code)) return; + if (candidate.Path is { Length: > 0 } p && !seenCodePaths.Add(p)) return; + codeFiles.Add(codeConfig.Code); + if (candidate.Path != null) codeFilePaths.Add(candidate.Path); } logger.LogInformation( diff --git a/src/MeshWeaver.Hosting/Persistence/PersistenceService.cs b/src/MeshWeaver.Hosting/Persistence/PersistenceService.cs index c5b614e03..7599603f2 100644 --- a/src/MeshWeaver.Hosting/Persistence/PersistenceService.cs +++ b/src/MeshWeaver.Hosting/Persistence/PersistenceService.cs @@ -37,6 +37,9 @@ public IAsyncEnumerable GetChildrenAsync(string? parentPath) public IAsyncEnumerable GetDescendantsAsync(string? parentPath) => core.GetDescendantsAsync(parentPath, Options); + public IAsyncEnumerable GetAllDescendantsAsync(string? parentPath) + => core.GetAllDescendantsAsync(parentPath, Options); + public IObservable SaveNode(MeshNode node) => Observable.FromAsync(ct => core.SaveNodeAsync(node, Options, ct)); diff --git a/src/MeshWeaver.Mesh.Contract/MeshExtensions.cs b/src/MeshWeaver.Mesh.Contract/MeshExtensions.cs index 1b7a51f68..a82259a92 100644 --- a/src/MeshWeaver.Mesh.Contract/MeshExtensions.cs +++ b/src/MeshWeaver.Mesh.Contract/MeshExtensions.cs @@ -667,17 +667,21 @@ private static void DeleteSelfFromStorage( IMeshStorage persistence, ILogger logger) { - // Post the response FIRST, while the hub is still alive. Under Orleans (and - // during monolith disposal) the storage-level delete can tear this hub down - // before we'd otherwise get a chance to reply — the caller would then wait - // forever on its RegisterCallback. Validators have already passed, so this - // is the commit point; if the storage write itself fails we can only log. - hub.Post(DeleteNodeResponse.Ok(), o => o.ResponseFor(request)); - + // Post the response AFTER the storage delete actually commits so callers see a + // consistent view: an awaited DeleteNode returns only once the node is gone from + // persistence. Without this, race conditions occur — e.g. tests (and UI flows) + // that query right after the delete can still observe the pre-delete node. + // + // The previous "reply first" approach guarded against Orleans/monolith hub + // teardown during self-deletion. HandleDeleteNodeRequest runs on the mesh hub, + // and a child-node delete does not tear down that hub — so the teardown concern + // does not apply here. If a true self-teardown case emerges we post Fail from + // OnError and the caller still unblocks. persistence.DeleteNode(path, recursive: false) .Subscribe( _ => { + hub.Post(DeleteNodeResponse.Ok(), o => o.ResponseFor(request)); hub.ServiceProvider.GetService() ?.Publish(MeshChangeEvent.Deleted(path)); logger.LogInformation( @@ -685,9 +689,13 @@ private static void DeleteSelfFromStorage( path, capturedRequest.DeletedBy ?? "system"); }, ex => - logger.LogError(ex, - "Storage delete failed for {Path} after Ok response was already sent — response cannot be walked back", - path)); + { + logger.LogError(ex, "Storage delete failed for {Path}", path); + hub.Post( + DeleteNodeResponse.Fail($"Storage delete failed: {ex.Message}", + NodeDeletionRejectionReason.Unknown), + o => o.ResponseFor(request)); + }); } /// diff --git a/src/MeshWeaver.Mesh.Contract/Services/IMeshStorage.cs b/src/MeshWeaver.Mesh.Contract/Services/IMeshStorage.cs index 411c8f477..4a2fc7d54 100644 --- a/src/MeshWeaver.Mesh.Contract/Services/IMeshStorage.cs +++ b/src/MeshWeaver.Mesh.Contract/Services/IMeshStorage.cs @@ -36,6 +36,16 @@ internal interface IMeshStorage /// Async enumerable of all descendant nodes IAsyncEnumerable GetDescendantsAsync(string? parentPath); + /// + /// Gets ALL descendant nodes including satellites (nodes where + /// MainNode != Path). Default implementation delegates to + /// which excludes satellites — impls that + /// know how to include satellites (e.g. the full persistence service) + /// override this. + /// + IAsyncEnumerable GetAllDescendantsAsync(string? parentPath) + => GetDescendantsAsync(parentPath); + /// /// Creates or updates a node. Returns an observable that emits the saved node on success /// or signals OnError on failure. Subscribe to drive — do not await. diff --git a/test/MeshWeaver.Graph.Test/MeshNodeCompilationServiceTest.cs b/test/MeshWeaver.Graph.Test/MeshNodeCompilationServiceTest.cs index f17408170..945cf0329 100644 --- a/test/MeshWeaver.Graph.Test/MeshNodeCompilationServiceTest.cs +++ b/test/MeshWeaver.Graph.Test/MeshNodeCompilationServiceTest.cs @@ -869,6 +869,109 @@ public record OverlapType Assembly.LoadFrom(assemblyPath!).GetType("OverlapType").Should().NotBeNull(); } + /// + /// Regression: Code nodes persisted via MCP set MainNode to the parent _Source + /// folder (satellite pattern). InMemoryPersistenceService.GetDescendantsAsync + /// excludes every node where MainNode != Path, so the compile-path + /// source discovery was seeing zero Code files and the NodeType kept failing + /// to compile with "type not found" — even though the Code nodes were in + /// persistence. NodeTypeService.GatherInputsAsync must find them + /// via the query-provider pipeline, which has no satellite filter. + /// + [Fact(Timeout = 25000)] + public async Task NodeTypeService_CompilesNodeType_WhenCodeNodesAreSatellites() + { + // Arrange: NodeType + satellite Code node (MainNode != Path). + var persistence = new InMemoryPersistenceService(); + var nodeTypePath = $"type/Satellite_{Guid.NewGuid():N}"; + var sourceNs = $"{nodeTypePath}/_Source"; + + var nodeTypeDef = new NodeTypeDefinition + { + Configuration = "config => config.WithContentType()" + }; + var nodeTypeNode = MeshNode.FromPath(nodeTypePath) with + { + Name = "Satellite", + NodeType = MeshNode.NodeTypePath, + Content = nodeTypeDef, + LastModified = DateTimeOffset.UtcNow + }; + await persistence.SaveNodeAsync(nodeTypeNode, SetupJsonOptions, TestContext.Current.CancellationToken); + + // Explicit MainNode = parent _Source folder — this is the satellite pattern + // that the persistence layer's GetDescendantsAsync filters out. + var satelliteCode = new MeshNode("SatelliteModel", sourceNs) + { + NodeType = "Code", + Name = "Satellite Model", + Content = new CodeConfiguration + { + Code = @" +public record SatelliteModel +{ + public string Id { get; init; } = string.Empty; +}", + Language = "csharp" + }, + MainNode = sourceNs, // ← satellite marker — the bug trigger + LastModified = DateTimeOffset.UtcNow + }; + await persistence.SaveNodeAsync(satelliteCode, SetupJsonOptions, TestContext.Current.CancellationToken); + + // Sanity check: satellite exclusion is actually active in storage + var descendants = new List(); + await foreach (var d in persistence.GetDescendantsAsync(sourceNs, SetupJsonOptions)) + descendants.Add(d); + descendants.Should().BeEmpty( + "this guards the regression: GetDescendantsAsync filters out satellites — " + + "so any compile path that uses it to discover Code nodes will see zero files"); + + // Act: wire up the full service graph and ask NodeTypeService to enrich the + // NodeType, which triggers CompileWithReleaseAsync → GatherInputsAsync. + var nodeTypeService = CreateNodeTypeService(persistence); + var enriched = await nodeTypeService.EnrichWithNodeTypeAsync( + MeshNode.FromPath($"inst/Alice") with + { + NodeType = nodeTypePath, + LastModified = DateTimeOffset.UtcNow + }, + TestContext.Current.CancellationToken); + + // Assert: compilation succeeded — the SatelliteModel type is reachable. + nodeTypeService.GetCompilationError(nodeTypePath).Should().BeNull( + "compilation must succeed for satellite-pattern Code nodes. " + + "If this fails with 'SatelliteModel could not be found', the compile " + + "path is back to using meshStorage.GetDescendantsAsync (which excludes satellites) " + + "instead of the query-provider pipeline."); + enriched.AssemblyLocation.Should().NotBeNullOrEmpty( + "assembly should compile and its location returned"); + } + + private NodeTypeService CreateNodeTypeService(InMemoryPersistenceService persistence) + { + IServiceCollection services = new ServiceCollection(); + services.AddInMemoryPersistence(persistence); + services.AddScoped(_ => _mockHub); + services.AddSingleton(new MeshConfiguration(new Dictionary())); + services.AddSingleton(_cacheService); + services.AddSingleton(_cacheOptions); + services.AddSingleton(NullLogger.Instance); + services.AddSingleton(NullLogger.Instance); + services.AddSingleton(); + services.AddLogging(); + + var sp = services.BuildServiceProvider(); + var hubSp = NSubstitute.Substitute.For(); + hubSp.GetService(Arg.Any()).Returns(ci => sp.GetService(ci.Arg())); + _mockHub.ServiceProvider.Returns(hubSp); + + var scope = sp.CreateScope(); + // ActivatorUtilities resolves the constructor parameters via DI — no need to + // name the (internal) IMeshStorage type here. + return ActivatorUtilities.CreateInstance(scope.ServiceProvider); + } + [Theory(Timeout = 25000)] [InlineData("@", "single-at")] [InlineData("@@", "double-at")] From 8dddc420368be378f19dfdffa1f897c9593996d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Sun, 19 Apr 2026 22:04:05 +0200 Subject: [PATCH 45/50] chore: bump Anthropic Opus to claude-opus-4-7 Updates Anthropic__Models__1 and ModelTier__Heavy from claude-opus-4-6 to claude-opus-4-7 in the Aspire AppHost configuration. Co-Authored-By: Claude Opus 4.7 (1M context) --- memex/aspire/Memex.AppHost/Program.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/memex/aspire/Memex.AppHost/Program.cs b/memex/aspire/Memex.AppHost/Program.cs index 674ff47ad..4c536b79a 100644 --- a/memex/aspire/Memex.AppHost/Program.cs +++ b/memex/aspire/Memex.AppHost/Program.cs @@ -137,10 +137,10 @@ .WithEnvironment("Anthropic__Endpoint", "https://s-meshweaver.services.ai.azure.com/anthropic/") .WithEnvironment("Anthropic__ApiKey", azureFoundryKey) .WithEnvironment("Anthropic__Models__0", "claude-sonnet-4-6") - .WithEnvironment("Anthropic__Models__1", "claude-opus-4-6") + .WithEnvironment("Anthropic__Models__1", "claude-opus-4-7") .WithEnvironment("Anthropic__Models__2", "claude-haiku-4-5") // Model tiers: map agent tiers to concrete models - .WithEnvironment("ModelTier__Heavy", "claude-opus-4-6") + .WithEnvironment("ModelTier__Heavy", "claude-opus-4-7") .WithEnvironment("ModelTier__Standard", "claude-sonnet-4-6") .WithEnvironment("ModelTier__Light", "claude-haiku-4-5") // LLM: Azure OpenAI From be6a7658420a315a539350e6e036519c2039ac67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Sun, 19 Apr 2026 22:04:33 +0200 Subject: [PATCH 46/50] introducing docs for node types. --- .../Data/DataMesh/SocialMedia.md | 205 ++++++++++++++++++ .../Data/DataMesh/SocialMedia/Post.json | 18 ++ .../DataMesh/SocialMedia/Post/Post-001.json | 20 ++ .../SocialMedia/Post/_Source/Platform.cs | 39 ++++ .../Post/_Source/SocialMediaPost.cs | 35 +++ .../_Source/SocialMediaPostLayoutAreas.cs | 184 ++++++++++++++++ .../Data/DataMesh/SocialMedia/Profile.json | 18 ++ .../SocialMedia/Profile/Roland-LinkedIn.json | 17 ++ .../Profile/_Source/SocialMediaProfile.cs | 28 +++ .../_Source/SocialMediaProfileLayoutAreas.cs | 67 ++++++ 10 files changed, 631 insertions(+) create mode 100644 src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia.md create mode 100644 src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Post.json create mode 100644 src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Post/Post-001.json create mode 100644 src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Post/_Source/Platform.cs create mode 100644 src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Post/_Source/SocialMediaPost.cs create mode 100644 src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Post/_Source/SocialMediaPostLayoutAreas.cs create mode 100644 src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Profile.json create mode 100644 src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Profile/Roland-LinkedIn.json create mode 100644 src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Profile/_Source/SocialMediaProfile.cs create mode 100644 src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Profile/_Source/SocialMediaProfileLayoutAreas.cs diff --git a/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia.md b/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia.md new file mode 100644 index 000000000..f105f7070 --- /dev/null +++ b/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia.md @@ -0,0 +1,205 @@ +--- +Name: Example — SocialMedia Model Node Type +Category: DataMesh +Description: End-to-end worked example of a custom model node type — data records, reference data, layout areas, NodeType JSON, and instances +Icon: Code +--- + +# SocialMedia — A Model Node Type, End to End + +This is the **canonical reference example** for a custom model node type. When you +(or the Coder agent) are asked to build "X as code" — a typed model with its own data +and views — this is the shape to mirror. + +> See also [Creating Node Types](@@Doc/DataMesh/CreatingNodeTypes) for the step-by-step +> theory, and [Business Rules](@@Doc/Architecture/BusinessRules) for a +> calculation-heavy example with charts. + +## The Layout + +``` +Doc/DataMesh/SocialMedia/ + Post.json # NodeType definition (nodeType: "NodeType") + Post/ + _Source/ # C# compiled at startup + Platform.cs # Reference-data record + SocialMediaPost.cs # Content record + SocialMediaPostLayoutAreas.cs # List + Detail layout areas + Post-001.json # Instance (nodeType: "Doc/DataMesh/SocialMedia/Post") + Profile.json # Second NodeType + Profile/ + _Source/ + SocialMediaProfile.cs + SocialMediaProfileLayoutAreas.cs + Roland-LinkedIn.json # Instance +``` + +Every part of this folder has a specific job. The next sections walk them one at a time. + +## 1. Reference Data — `Platform.cs` + +Reference data is a small closed set of lookups (platforms, statuses, categories). +It's a plain record with a `[Key]`, static instances, and an `All[]` array. + +```csharp +// +// Id: Platform +// DisplayName: Social Media Platform +// + +public record Platform +{ + [Key] public string Id { get; init; } = string.Empty; + [Required] public string Name { get; init; } = string.Empty; + public string Emoji { get; init; } = string.Empty; + public string Color { get; init; } = "#0a66c2"; + + public static readonly Platform LinkedIn = new() { Id = "LinkedIn", Name = "LinkedIn", Emoji = "💼", Color = "#0a66c2" }; + public static readonly Platform Twitter = new() { Id = "Twitter", Name = "X / Twitter", Emoji = "🐦", Color = "#000000" }; + public static readonly Platform Instagram = new() { Id = "Instagram", Name = "Instagram", Emoji = "📷", Color = "#e1306c" }; + + public static readonly Platform[] All = [LinkedIn, Twitter, Instagram]; + public static Platform GetById(string? id) => All.FirstOrDefault(p => p.Id == id) ?? LinkedIn; +} +``` + +## 2. Content Record — `SocialMediaPost.cs` + +The content record is the shape of a single instance's `content` payload. It uses +domain attributes to describe the fields to the editor and to wire reference data. + +```csharp +// +// Id: SocialMediaPost +// DisplayName: Social Media Post +// + +using MeshWeaver.Domain; + +public record SocialMediaPost +{ + [Required] + [MeshNodeProperty(nameof(MeshNode.Name))] // syncs MeshNode.Name with Title + public string Title { get; init; } = string.Empty; + + [Markdown(EditorHeight = "200px")] + public string? Body { get; init; } + + [Dimension] // renders as a Platform picker + public string Platform { get; init; } = "LinkedIn"; + + [DisplayName("Scheduled at")] + public DateTimeOffset? ScheduledAt { get; init; } + + public int Impressions { get; init; } + public int Likes { get; init; } +} +``` + +Key attributes to memorise: +- `[Required]` — validation +- `[MeshNodeProperty(nameof(MeshNode.Name))]` — mirrors the property into `MeshNode.Name` +- `[Dimension]` — typed lookup against reference data +- `[Markdown(...)]` — rich-text editor +- `[DisplayName(...)]` — UI label + +## 3. Layout Areas — `SocialMediaPostLayoutAreas.cs` + +Layout areas are the **views** for instances of the type. They return +`IObservable` — never `Task<…>`, never `async`. Compose with Rx: + +```csharp +public static IObservable List(LayoutAreaHost host, RenderingContext _) +{ + var meshService = host.Hub.ServiceProvider.GetRequiredService(); + return meshService + .ObserveQuery(MeshQueryRequest.FromQuery("namespace:Doc/DataMesh/SocialMedia/Post")) + .Scan(ImmutableDictionary.Empty, ApplyChanges) + .Select(dict => (UiControl?)BuildList(dict.Values.ToImmutableList())); +} +``` + +The companion extension method is how the layout area gets wired into the NodeType: + +```csharp +public static LayoutDefinition AddSocialMediaPostLayoutAreas(this LayoutDefinition layout) => + layout.WithView("List", List).WithView("Detail", Detail); +``` + +## 4. The NodeType JSON — `Post.json` + +The JSON is the binding glue. It registers the type, points at its content record, +seeds reference data, and wires custom layout areas. + +```json +{ + "id": "Post", + "namespace": "Doc/DataMesh/SocialMedia", + "name": "Social Media Post", + "nodeType": "NodeType", + "content": { + "$type": "NodeTypeDefinition", + "configuration": "config => config + .WithContentType() + .AddData(data => data.AddSource(source => source + .WithType(t => t.WithInitialData(Platform.All)))) + .AddDefaultLayoutAreas() + .AddLayout(layout => layout + .AddSocialMediaPostLayoutAreas() + .WithDefaultArea(\"List\"))" + } +} +``` + +Configuration-lambda cheat sheet: +| Call | Purpose | +|---|---| +| `WithContentType()` | The record type for new instances | +| `AddData(data => data.AddSource(…))` | Seed in-memory data sources (reference data) | +| `AddDefaultLayoutAreas()` | Overview, Edit, Threads, Files | +| `AddLayout(layout => layout.AddXxxLayoutAreas())` | Custom views | +| `WithDefaultArea("List")` | Which view opens by default | + +## 5. Instances — `Post/Post-001.json` + +An instance sets `nodeType` to the **namespace-qualified path** of the NodeType +(`Doc/DataMesh/SocialMedia/Post`), and its `content` matches the record (`$type` = +class name). Instance IDs should be **meaningful** — e.g. `Roland-LinkedIn`, `Post-001` — +not generic like `SamplePost`. + +```json +{ + "id": "Post-001", + "namespace": "Doc/DataMesh/SocialMedia/Post", + "name": "Why we bet on the actor model", + "nodeType": "Doc/DataMesh/SocialMedia/Post", + "content": { + "$type": "SocialMediaPost", + "title": "Why we bet on the actor model", + "body": "Reactive systems live or die on isolation …", + "profilePath": "Doc/DataMesh/SocialMedia/Profile/Roland-LinkedIn", + "platform": "LinkedIn", + "scheduledAt": "2026-04-05T09:00:00+02:00", + "impressions": 4321, + "likes": 187 + } +} +``` + +## Live Profile Instance + +The embedded view below is the `Roland-LinkedIn` profile instance, rendered by its `Detail` layout area: + +@@Doc/DataMesh/SocialMedia/Profile/Roland-LinkedIn + +## Copy-This Checklist + +When asked to build a new model node type "as code": + +1. ☐ Create a namespace folder under your target location. +2. ☐ Add one `.cs` per content record in `_Source/`, each with the `` frontmatter. +3. ☐ Add reference-data `.cs` files with `[Key]`, static instances, and `All[]`. +4. ☐ Add a `XxxLayoutAreas.cs` with `List`/`Detail` views returning `IObservable`. +5. ☐ Write the `Type.json` with `nodeType: "NodeType"` and a configuration lambda. +6. ☐ Write **at least one** instance JSON with `nodeType` set to the namespace-qualified path. +7. ☐ **Do not** substitute a Markdown node for a typed view. Markdown is for documents. diff --git a/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Post.json b/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Post.json new file mode 100644 index 000000000..7e49550c3 --- /dev/null +++ b/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Post.json @@ -0,0 +1,18 @@ +{ + "id": "Post", + "namespace": "Doc/DataMesh/SocialMedia", + "name": "Social Media Post", + "nodeType": "NodeType", + "category": "Types", + "description": "A social media post (scheduled, published, with engagement stats)", + "icon": "", + "isPersistent": true, + "content": { + "$type": "NodeTypeDefinition", + "id": "Post", + "namespace": "Doc/DataMesh/SocialMedia", + "displayName": "Social Media Post", + "description": "A social media post (scheduled, published, with engagement stats)", + "configuration": "config => config.WithContentType().AddData(data => data.AddSource(source => source.WithType(t => t.WithInitialData(Platform.All)))).AddDefaultLayoutAreas().AddLayout(layout => layout.AddSocialMediaPostLayoutAreas().WithDefaultArea(\"List\"))" + } +} diff --git a/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Post/Post-001.json b/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Post/Post-001.json new file mode 100644 index 000000000..982879fcd --- /dev/null +++ b/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Post/Post-001.json @@ -0,0 +1,20 @@ +{ + "id": "Post-001", + "namespace": "Doc/DataMesh/SocialMedia/Post", + "path": "Doc/DataMesh/SocialMedia/Post/Post-001", + "name": "Why we bet on the actor model", + "nodeType": "Doc/DataMesh/SocialMedia/Post", + "icon": "/static/NodeTypeIcons/document.svg", + "isPersistent": true, + "content": { + "$type": "SocialMediaPost", + "title": "Why we bet on the actor model", + "body": "Reactive systems live or die on isolation. After years of fighting threadpools, we leaned into the actor model with Orleans \u2014 and never looked back.\n\nWhat surprised us most? **The debugging story is dramatically better.**", + "profilePath": "Doc/DataMesh/SocialMedia/Profile/Roland-LinkedIn", + "platform": "LinkedIn", + "scheduledAt": "2026-04-05T09:00:00+02:00", + "publishedAt": "2026-04-05T09:01:42+02:00", + "impressions": 4321, + "likes": 187 + } +} diff --git a/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Post/_Source/Platform.cs b/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Post/_Source/Platform.cs new file mode 100644 index 000000000..ae7655974 --- /dev/null +++ b/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Post/_Source/Platform.cs @@ -0,0 +1,39 @@ +// +// Id: Platform +// DisplayName: Social Media Platform +// + +public record Platform +{ + [Key] + public string Id { get; init; } = string.Empty; + + [Required] + public string Name { get; init; } = string.Empty; + + public string Emoji { get; init; } = string.Empty; + + public string Color { get; init; } = "#0a66c2"; + + public int Order { get; init; } + + public static readonly Platform LinkedIn = new() + { + Id = "LinkedIn", Name = "LinkedIn", Emoji = "\ud83d\udcbc", Color = "#0a66c2", Order = 0 + }; + + public static readonly Platform Twitter = new() + { + Id = "Twitter", Name = "X / Twitter", Emoji = "\ud83d\udc26", Color = "#000000", Order = 1 + }; + + public static readonly Platform Instagram = new() + { + Id = "Instagram", Name = "Instagram", Emoji = "\ud83d\udcf7", Color = "#e1306c", Order = 2 + }; + + public static readonly Platform[] All = [LinkedIn, Twitter, Instagram]; + + public static Platform GetById(string? id) => + All.FirstOrDefault(p => p.Id == id) ?? LinkedIn; +} diff --git a/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Post/_Source/SocialMediaPost.cs b/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Post/_Source/SocialMediaPost.cs new file mode 100644 index 000000000..a76319859 --- /dev/null +++ b/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Post/_Source/SocialMediaPost.cs @@ -0,0 +1,35 @@ +// +// Id: SocialMediaPost +// DisplayName: Social Media Post +// + +using MeshWeaver.Domain; + +public record SocialMediaPost +{ + [Required] + [MeshNodeProperty(nameof(MeshNode.Name))] + [UiControl(Style = "width: 100%;")] + public string Title { get; init; } = string.Empty; + + [Markdown(EditorHeight = "200px")] + public string? Body { get; init; } + + [Required] + [DisplayName("Profile path")] + public string ProfilePath { get; init; } = string.Empty; + + [Dimension] + [UiControl(Style = "width: 200px;")] + public string Platform { get; init; } = "LinkedIn"; + + [DisplayName("Scheduled at")] + public DateTimeOffset? ScheduledAt { get; init; } + + [DisplayName("Published at")] + public DateTimeOffset? PublishedAt { get; init; } + + public int Impressions { get; init; } + + public int Likes { get; init; } +} diff --git a/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Post/_Source/SocialMediaPostLayoutAreas.cs b/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Post/_Source/SocialMediaPostLayoutAreas.cs new file mode 100644 index 000000000..4314887f5 --- /dev/null +++ b/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Post/_Source/SocialMediaPostLayoutAreas.cs @@ -0,0 +1,184 @@ +// +// Id: SocialMediaPostLayoutAreas +// DisplayName: Social Media Post Views +// + +using System.Collections.Immutable; +using System.Text.Json; +using System.Web; +using MeshWeaver.Layout.Composition; +using MeshWeaver.Mesh.Services; + +public static class SocialMediaPostLayoutAreas +{ + public const string ListArea = "List"; + public const string DetailArea = "Detail"; + + public static LayoutDefinition AddSocialMediaPostLayoutAreas(this LayoutDefinition layout) => + layout + .WithView(ListArea, List) + .WithView(DetailArea, Detail); + + private static ImmutableDictionary ApplyChanges( + ImmutableDictionary current, QueryResultChange change) + { + var result = change.ChangeType == QueryChangeType.Initial || change.ChangeType == QueryChangeType.Reset + ? ImmutableDictionary.Empty.WithComparers(StringComparer.OrdinalIgnoreCase) + : current; + foreach (var item in change.Items) + result = change.ChangeType == QueryChangeType.Removed + ? result.Remove(item.Path) + : result.SetItem(item.Path, item); + return result; + } + + private static string? GetProp(MeshNode node, string prop) + { + if (node.Content is not JsonElement json) return null; + if (json.TryGetProperty(prop, out var p) && p.ValueKind == JsonValueKind.String) return p.GetString(); + var pascal = char.ToUpperInvariant(prop[0]) + prop.Substring(1); + return json.TryGetProperty(pascal, out var pp) && pp.ValueKind == JsonValueKind.String ? pp.GetString() : null; + } + + private static int GetInt(MeshNode node, string prop) + { + if (node.Content is not JsonElement json) return 0; + if (!json.TryGetProperty(prop, out var p)) + { + var pascal = char.ToUpperInvariant(prop[0]) + prop.Substring(1); + if (!json.TryGetProperty(pascal, out p)) return 0; + } + return p.ValueKind == JsonValueKind.Number && p.TryGetInt32(out var v) ? v : 0; + } + + private static DateTimeOffset? GetDate(MeshNode node, string prop) + { + if (node.Content is not JsonElement json) return null; + if (!json.TryGetProperty(prop, out var p)) + { + var pascal = char.ToUpperInvariant(prop[0]) + prop.Substring(1); + if (!json.TryGetProperty(pascal, out p)) return null; + } + return p.ValueKind == JsonValueKind.String && DateTimeOffset.TryParse(p.GetString(), out var dt) ? dt : null; + } + + public static IObservable List(LayoutAreaHost host, RenderingContext _) + { + var meshService = host.Hub.ServiceProvider.GetRequiredService(); + var postsStream = meshService + .ObserveQuery(MeshQueryRequest.FromQuery("namespace:Doc/DataMesh/SocialMedia/Post")) + .Scan(ImmutableDictionary.Empty.WithComparers(StringComparer.OrdinalIgnoreCase), ApplyChanges); + + return postsStream.Select(dict => (UiControl?)BuildList(dict.Values.ToImmutableList())); + } + + private static UiControl BuildList(ImmutableList posts) + { + var ordered = posts + .OrderByDescending(p => GetDate(p, "scheduledAt") ?? DateTimeOffset.MinValue) + .ToImmutableList(); + + if (ordered.Count == 0) + return Controls.Stack + .WithStyle("padding: 16px;") + .WithView(Controls.Markdown("*No posts yet.*")); + + var rows = string.Join("", ordered.Select(p => + { + var title = p.Name ?? GetProp(p, "title") ?? "(untitled)"; + var platformId = GetProp(p, "platform") ?? "LinkedIn"; + var platform = Platform.GetById(platformId); + var scheduled = GetDate(p, "scheduledAt")?.ToString("yyyy-MM-dd HH:mm") ?? "\u2014"; + var published = GetDate(p, "publishedAt") is { } d ? d.ToString("yyyy-MM-dd HH:mm") : "\u2014"; + var likes = GetInt(p, "likes"); + var impressions = GetInt(p, "impressions"); + return $""" + + {HttpUtility.HtmlEncode(title)} + {platform.Emoji} {HttpUtility.HtmlEncode(platform.Name)} + {scheduled} + {published} + {likes:N0} + {impressions:N0} + + """; + })); + + var table = $""" + + + + + + + + + + + + {rows} +
TitlePlatformScheduledPublishedLikesImpressions
+ """; + + return Controls.Stack + .WithStyle("padding: 16px; gap: 12px;") + .WithView(Controls.Html($"

Posts

")) + .WithView(Controls.Html(table)); + } + + public static IObservable Detail(LayoutAreaHost host, RenderingContext _) + { + var hubPath = host.Hub.Address.ToString(); + + return host.Workspace.GetStream()! + .Select(nodes => nodes?.FirstOrDefault(n => n.Path == hubPath)) + .Select(node => + { + if (node is null) + return (UiControl?)Controls.Markdown("*Post not found.*"); + + var title = node.Name ?? GetProp(node, "title") ?? "(untitled)"; + var body = GetProp(node, "body"); + var platformId = GetProp(node, "platform") ?? "LinkedIn"; + var platform = Platform.GetById(platformId); + var scheduled = GetDate(node, "scheduledAt"); + var published = GetDate(node, "publishedAt"); + var status = published.HasValue ? "Published" + : (scheduled.HasValue && scheduled.Value > DateTimeOffset.Now ? "Scheduled" : "Draft"); + var statusColor = published.HasValue ? "#2e7d32" : "#ed6c02"; + var impressions = GetInt(node, "impressions"); + var likes = GetInt(node, "likes"); + + var header = $$""" +
+ {{platform.Emoji}} {{HttpUtility.HtmlEncode(platform.Name)}} + {{status}} +
+ """; + + var dates = $$""" + + + +
Scheduled{{HttpUtility.HtmlEncode(scheduled?.ToString("yyyy-MM-dd HH:mm") ?? "\u2014")}}
Published{{HttpUtility.HtmlEncode(published?.ToString("yyyy-MM-dd HH:mm") ?? "\u2014")}}
+ """; + + var stats = $$""" +
+
Likes
{{likes:N0}}
+
Impressions
{{impressions:N0}}
+
+ """; + + var stack = Controls.Stack + .WithStyle("padding: 16px; gap: 8px;") + .WithView(Controls.Html($"

{HttpUtility.HtmlEncode(title)}

")) + .WithView(Controls.Html(header)) + .WithView(Controls.Html(dates)) + .WithView(Controls.Html(stats)); + if (!string.IsNullOrWhiteSpace(body)) + stack = stack.WithView(Controls.Markdown(body)); + return (UiControl?)stack; + }); + } +} diff --git a/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Profile.json b/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Profile.json new file mode 100644 index 000000000..f2dc1861e --- /dev/null +++ b/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Profile.json @@ -0,0 +1,18 @@ +{ + "id": "Profile", + "namespace": "Doc/DataMesh/SocialMedia", + "name": "Social Media Profile", + "nodeType": "NodeType", + "category": "Types", + "description": "A social media profile owned by a user (LinkedIn, X, Instagram, ...)", + "icon": "", + "isPersistent": true, + "content": { + "$type": "NodeTypeDefinition", + "id": "Profile", + "namespace": "Doc/DataMesh/SocialMedia", + "displayName": "Social Media Profile", + "description": "A social media profile owned by a user", + "configuration": "config => config.WithContentType().AddData(data => data.AddSource(source => source.WithType(t => t.WithInitialData(Platform.All)))).AddDefaultLayoutAreas().AddLayout(layout => layout.AddSocialMediaProfileLayoutAreas().WithDefaultArea(\"Detail\"))" + } +} diff --git a/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Profile/Roland-LinkedIn.json b/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Profile/Roland-LinkedIn.json new file mode 100644 index 000000000..77954dbce --- /dev/null +++ b/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Profile/Roland-LinkedIn.json @@ -0,0 +1,17 @@ +{ + "id": "Roland-LinkedIn", + "namespace": "Doc/DataMesh/SocialMedia/Profile", + "path": "Doc/DataMesh/SocialMedia/Profile/Roland-LinkedIn", + "name": "Roland on LinkedIn", + "nodeType": "Doc/DataMesh/SocialMedia/Profile", + "icon": "/static/NodeTypeIcons/person.svg", + "isPersistent": true, + "content": { + "$type": "SocialMediaProfile", + "name": "Roland on LinkedIn", + "platform": "LinkedIn", + "owner": "rbuergi@systemorph.com", + "profileUrl": "https://www.linkedin.com/in/rolandbuergi/", + "bio": "Building MeshWeaver \u2014 collaborative actor-based runtime for data, AI and reactive UIs." + } +} diff --git a/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Profile/_Source/SocialMediaProfile.cs b/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Profile/_Source/SocialMediaProfile.cs new file mode 100644 index 000000000..e1307b51a --- /dev/null +++ b/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Profile/_Source/SocialMediaProfile.cs @@ -0,0 +1,28 @@ +// +// Id: SocialMediaProfile +// DisplayName: Social Media Profile +// + +using MeshWeaver.Domain; + +public record SocialMediaProfile +{ + [Required] + [MeshNodeProperty(nameof(MeshNode.Name))] + public string Name { get; init; } = string.Empty; + + [Required] + [Dimension] + [UiControl(Style = "width: 200px;")] + public string Platform { get; init; } = "LinkedIn"; + + [Required] + [DisplayName("Owner email")] + public string Owner { get; init; } = string.Empty; + + [DisplayName("Profile URL")] + public string? ProfileUrl { get; init; } + + [Markdown(EditorHeight = "120px")] + public string? Bio { get; init; } +} diff --git a/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Profile/_Source/SocialMediaProfileLayoutAreas.cs b/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Profile/_Source/SocialMediaProfileLayoutAreas.cs new file mode 100644 index 000000000..e07a8e121 --- /dev/null +++ b/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Profile/_Source/SocialMediaProfileLayoutAreas.cs @@ -0,0 +1,67 @@ +// +// Id: SocialMediaProfileLayoutAreas +// DisplayName: Social Media Profile Views +// + +using System.Text.Json; +using System.Web; +using MeshWeaver.Layout.Composition; + +public static class SocialMediaProfileLayoutAreas +{ + public const string DetailArea = "Detail"; + + public static LayoutDefinition AddSocialMediaProfileLayoutAreas(this LayoutDefinition layout) => + layout.WithView(DetailArea, Detail); + + private static string? GetProp(MeshNode node, string prop) + { + if (node.Content is not JsonElement json) return null; + if (json.TryGetProperty(prop, out var p) && p.ValueKind == JsonValueKind.String) return p.GetString(); + var pascal = char.ToUpperInvariant(prop[0]) + prop.Substring(1); + return json.TryGetProperty(pascal, out var pp) && pp.ValueKind == JsonValueKind.String ? pp.GetString() : null; + } + + public static IObservable Detail(LayoutAreaHost host, RenderingContext _) + { + var hubPath = host.Hub.Address.ToString(); + + return host.Workspace.GetStream()! + .Select(nodes => nodes?.FirstOrDefault(n => n.Path == hubPath)) + .Select(node => + { + if (node is null) + return (UiControl?)Controls.Markdown("*Profile not found.*"); + + var name = node.Name ?? GetProp(node, "name") ?? "Profile"; + var platformId = GetProp(node, "platform") ?? "LinkedIn"; + var platform = Platform.GetById(platformId); + var owner = GetProp(node, "owner") ?? ""; + var profileUrl = GetProp(node, "profileUrl"); + var bio = GetProp(node, "bio"); + + var link = !string.IsNullOrEmpty(profileUrl) + ? $"Open profile \u2197" + : "No profile URL"; + + var html = $$""" +
+
{{platform.Emoji}}
+
+

{{HttpUtility.HtmlEncode(name)}}

+
{{platform.Emoji}} {{HttpUtility.HtmlEncode(platform.Name)}}
+
Owner: {{HttpUtility.HtmlEncode(owner)}}
+
{{link}}
+
+
+ """; + + var stack = Controls.Stack + .WithStyle("padding: 16px;") + .WithView(Controls.Html(html)); + if (!string.IsNullOrWhiteSpace(bio)) + stack = stack.WithView(Controls.Markdown(bio)); + return (UiControl?)stack; + }); + } +} From 9fa4b7cec2ab34b1dacd30dd6588c77903aba2e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Sun, 19 Apr 2026 22:24:07 +0200 Subject: [PATCH 47/50] fix: DeleteLayoutArea emits placeholder immediately + times out slow streams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CombineLatest(permissions, descendantCount) required BOTH sources to emit before the delete page rendered. If either stream was slow or stuck (hub saturation, query hang), users saw an eternal spinner and the GUI appeared frozen. - StartWith a "Loading…" placeholder so the click-through from the menu always renders something. - 10s Timeout + Catch on each source stream; on failure, deny permission and render zero descendants rather than blocking forever. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/MeshWeaver.Graph/DeleteLayoutArea.cs | 26 ++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/MeshWeaver.Graph/DeleteLayoutArea.cs b/src/MeshWeaver.Graph/DeleteLayoutArea.cs index d3eb90248..1725f7c58 100644 --- a/src/MeshWeaver.Graph/DeleteLayoutArea.cs +++ b/src/MeshWeaver.Graph/DeleteLayoutArea.cs @@ -42,17 +42,31 @@ public static class DeleteLayoutArea var backHref = MeshNodeLayoutAreas.BuildUrl(nodePath, MeshNodeLayoutAreas.OverviewArea); var meshQuery = host.Hub.ServiceProvider.GetService(); - var permissionsObs = PermissionHelper.ObservePermissions(host.Hub, nodePath).Take(1); - - var descendantsObs = meshQuery != null + // Both source streams must emit at least once for the page to render. Add Timeout + // + Catch so a stuck permission lookup or a hanging descendant count can never + // leave the user with an eternal spinner. We render conservatively on failure + // (deny, zero descendants) rather than blocking. + var permissionsObs = PermissionHelper.ObservePermissions(host.Hub, nodePath) + .Take(1) + .Timeout(TimeSpan.FromSeconds(10)) + .Catch(_ => Observable.Return(Permission.None)); + + var descendantsObs = (meshQuery != null ? Observable.FromAsync(token => CountDescendantsAsync(meshQuery, nodePath, token)) - : Observable.Return(0); + : Observable.Return(0)) + .Timeout(TimeSpan.FromSeconds(10)) + .Catch(_ => Observable.Return(0)); + + var placeholder = (UiControl?)Controls.Stack.WithStyle("padding: 24px;") + .WithView(Controls.Html( + "

Loading delete confirmation…

")); return permissionsObs.CombineLatest(descendantsObs, (perms, count) => (canDelete: perms.HasFlag(Permission.Delete), count)) - .Select(tuple => tuple.canDelete + .Select(tuple => (UiControl?)(tuple.canDelete ? BuildDeletePage(host, nodePath, backHref, tuple.count) - : BuildAccessDenied(backHref)); + : BuildAccessDenied(backHref))) + .StartWith(placeholder); } private static async Task CountDescendantsAsync(IMeshService meshQuery, string nodePath, CancellationToken ct) From 0a3a66624c89ed7629921c142b3c2483e5f57f03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Sun, 19 Apr 2026 23:13:54 +0200 Subject: [PATCH 48/50] test: split Autocomplete suite into MeshWeaver.Autocomplete.Test Query.Test was the heaviest test assembly (~400 tests, samples/Graph + content-collections + 3 partitions) and a CI runner OOM killed it mid-run. Moves the five Autocomplete* files (incl. the 800-line MultiSource class that creates content files on disk) to their own project. Each project already runs in its own dotnet test invocation in the workflow, so memory is fully released between them. Co-Authored-By: Claude Opus 4.7 (1M context) --- MeshWeaver.slnx | 1 + .../AutocompleteIconTests.cs | 2 +- .../AutocompleteIntegrationTest.cs | 2 +- .../AutocompleteMultiSourceTest.cs | 2 +- .../MeshNodeAutocompleteTest.cs | 2 +- .../MeshWeaver.Autocomplete.Test.csproj | 43 +++++++++++++++++++ .../MeshWeaver.Autocomplete.Test/TestPaths.cs | 15 +++++++ ...nifiedReferenceAutocompleteProviderTest.cs | 2 +- .../appsettings.json | 9 ++++ 9 files changed, 73 insertions(+), 5 deletions(-) rename test/{MeshWeaver.Query.Test => MeshWeaver.Autocomplete.Test}/AutocompleteIconTests.cs (99%) rename test/{MeshWeaver.Query.Test => MeshWeaver.Autocomplete.Test}/AutocompleteIntegrationTest.cs (99%) rename test/{MeshWeaver.Query.Test => MeshWeaver.Autocomplete.Test}/AutocompleteMultiSourceTest.cs (99%) rename test/{MeshWeaver.Query.Test => MeshWeaver.Autocomplete.Test}/MeshNodeAutocompleteTest.cs (99%) create mode 100644 test/MeshWeaver.Autocomplete.Test/MeshWeaver.Autocomplete.Test.csproj create mode 100644 test/MeshWeaver.Autocomplete.Test/TestPaths.cs rename test/{MeshWeaver.Query.Test => MeshWeaver.Autocomplete.Test}/UnifiedReferenceAutocompleteProviderTest.cs (99%) create mode 100644 test/MeshWeaver.Autocomplete.Test/appsettings.json diff --git a/MeshWeaver.slnx b/MeshWeaver.slnx index e9a5a7021..ff9776e06 100644 --- a/MeshWeaver.slnx +++ b/MeshWeaver.slnx @@ -143,6 +143,7 @@ + diff --git a/test/MeshWeaver.Query.Test/AutocompleteIconTests.cs b/test/MeshWeaver.Autocomplete.Test/AutocompleteIconTests.cs similarity index 99% rename from test/MeshWeaver.Query.Test/AutocompleteIconTests.cs rename to test/MeshWeaver.Autocomplete.Test/AutocompleteIconTests.cs index 7950d6e94..9f0d65d2c 100644 --- a/test/MeshWeaver.Query.Test/AutocompleteIconTests.cs +++ b/test/MeshWeaver.Autocomplete.Test/AutocompleteIconTests.cs @@ -15,7 +15,7 @@ using Microsoft.Extensions.DependencyInjection; using Xunit; -namespace MeshWeaver.Query.Test; +namespace MeshWeaver.Autocomplete.Test; /// /// Tests that AutocompleteAsync returns Icon data and performs proper text matching. diff --git a/test/MeshWeaver.Query.Test/AutocompleteIntegrationTest.cs b/test/MeshWeaver.Autocomplete.Test/AutocompleteIntegrationTest.cs similarity index 99% rename from test/MeshWeaver.Query.Test/AutocompleteIntegrationTest.cs rename to test/MeshWeaver.Autocomplete.Test/AutocompleteIntegrationTest.cs index 13f1ee2ec..781b15460 100644 --- a/test/MeshWeaver.Query.Test/AutocompleteIntegrationTest.cs +++ b/test/MeshWeaver.Autocomplete.Test/AutocompleteIntegrationTest.cs @@ -20,7 +20,7 @@ using Microsoft.Extensions.DependencyInjection; using Xunit; -namespace MeshWeaver.Query.Test; +namespace MeshWeaver.Autocomplete.Test; /// /// Integration tests for the full autocomplete pipeline: diff --git a/test/MeshWeaver.Query.Test/AutocompleteMultiSourceTest.cs b/test/MeshWeaver.Autocomplete.Test/AutocompleteMultiSourceTest.cs similarity index 99% rename from test/MeshWeaver.Query.Test/AutocompleteMultiSourceTest.cs rename to test/MeshWeaver.Autocomplete.Test/AutocompleteMultiSourceTest.cs index e30197193..43115c43b 100644 --- a/test/MeshWeaver.Query.Test/AutocompleteMultiSourceTest.cs +++ b/test/MeshWeaver.Autocomplete.Test/AutocompleteMultiSourceTest.cs @@ -23,7 +23,7 @@ using Microsoft.Extensions.DependencyInjection; using Xunit; -namespace MeshWeaver.Query.Test; +namespace MeshWeaver.Autocomplete.Test; /// /// Multi-source autocomplete integration tests. diff --git a/test/MeshWeaver.Query.Test/MeshNodeAutocompleteTest.cs b/test/MeshWeaver.Autocomplete.Test/MeshNodeAutocompleteTest.cs similarity index 99% rename from test/MeshWeaver.Query.Test/MeshNodeAutocompleteTest.cs rename to test/MeshWeaver.Autocomplete.Test/MeshNodeAutocompleteTest.cs index b6a35794a..fe38660b8 100644 --- a/test/MeshWeaver.Query.Test/MeshNodeAutocompleteTest.cs +++ b/test/MeshWeaver.Autocomplete.Test/MeshNodeAutocompleteTest.cs @@ -18,7 +18,7 @@ using Microsoft.Extensions.DependencyInjection; using Xunit; -namespace MeshWeaver.Query.Test; +namespace MeshWeaver.Autocomplete.Test; /// /// Tests for MeshNodeAutocomplete functionality including: diff --git a/test/MeshWeaver.Autocomplete.Test/MeshWeaver.Autocomplete.Test.csproj b/test/MeshWeaver.Autocomplete.Test/MeshWeaver.Autocomplete.Test.csproj new file mode 100644 index 000000000..06d265a1b --- /dev/null +++ b/test/MeshWeaver.Autocomplete.Test/MeshWeaver.Autocomplete.Test.csproj @@ -0,0 +1,43 @@ + + + {c3d4e5f6-a7b8-4c9d-0e1f-2a3b4c5d6e7f} + + $(NoWarn);xUnit1051 + + + + + + + + + + + + SamplesGraph\%(RecursiveDir)%(Filename)%(Extension) + PreserveNewest + + + PreserveNewest + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/MeshWeaver.Autocomplete.Test/TestPaths.cs b/test/MeshWeaver.Autocomplete.Test/TestPaths.cs new file mode 100644 index 000000000..173659510 --- /dev/null +++ b/test/MeshWeaver.Autocomplete.Test/TestPaths.cs @@ -0,0 +1,15 @@ +using System; +using System.IO; + +namespace MeshWeaver.Autocomplete.Test; + +/// +/// Provides paths to test data directories. +/// Uses pre-copied directories from build output to avoid runtime copying. +/// +public static class TestPaths +{ + public static string SamplesGraph => Path.Combine(AppContext.BaseDirectory, "SamplesGraph"); + public static string SamplesGraphData => Path.Combine(SamplesGraph, "Data"); + public static string SamplesGraphContent => Path.Combine(SamplesGraph, "content"); +} diff --git a/test/MeshWeaver.Query.Test/UnifiedReferenceAutocompleteProviderTest.cs b/test/MeshWeaver.Autocomplete.Test/UnifiedReferenceAutocompleteProviderTest.cs similarity index 99% rename from test/MeshWeaver.Query.Test/UnifiedReferenceAutocompleteProviderTest.cs rename to test/MeshWeaver.Autocomplete.Test/UnifiedReferenceAutocompleteProviderTest.cs index ed7b89f3c..01f0d879e 100644 --- a/test/MeshWeaver.Query.Test/UnifiedReferenceAutocompleteProviderTest.cs +++ b/test/MeshWeaver.Autocomplete.Test/UnifiedReferenceAutocompleteProviderTest.cs @@ -20,7 +20,7 @@ using Microsoft.Extensions.DependencyInjection; using Xunit; -namespace MeshWeaver.Query.Test; +namespace MeshWeaver.Autocomplete.Test; /// /// Tests for UnifiedReferenceAutocompleteProvider using samples/Graph/Data. diff --git a/test/MeshWeaver.Autocomplete.Test/appsettings.json b/test/MeshWeaver.Autocomplete.Test/appsettings.json new file mode 100644 index 000000000..35e10bbf2 --- /dev/null +++ b/test/MeshWeaver.Autocomplete.Test/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft": "Warning", + "System": "Warning" + } + } +} From 32a940741ec069c8506a708c18bdbc48106d12e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Sun, 19 Apr 2026 23:14:19 +0200 Subject: [PATCH 49/50] fix: AddContentCollectionsInfrastructure idempotency guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ContentAutocompleteProvider was registered up to 3 times in chained hub configs (PortalApplication → PortalNodeType → OrganizationNodeType) because AddContentCollectionsInfrastructure ran WithServices(AddContentService) unconditionally on every nested call. The flag guard previously only protected AddContentCollections (layout areas), not the infrastructure path. Move the guard down so WithServices(AddContentService) runs at most once per hub-config chain. Combined with the existing TryAddEnumerable in AddContentService, this prevents the autocomplete provider from yielding duplicate items at the consumer side. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ContentCollectionsExtensions.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/MeshWeaver.ContentCollections/ContentCollectionsExtensions.cs b/src/MeshWeaver.ContentCollections/ContentCollectionsExtensions.cs index 21c93f4f2..50275ba8d 100644 --- a/src/MeshWeaver.ContentCollections/ContentCollectionsExtensions.cs +++ b/src/MeshWeaver.ContentCollections/ContentCollectionsExtensions.cs @@ -55,6 +55,9 @@ public static string GetLocalizedCollectionName(string collectionName, string ad /// public MessageHubConfiguration AddContentCollectionsInfrastructure() { + if (config.Get(nameof(AddContentCollectionsInfrastructure))) + return config; + config = config.Set(true, nameof(AddContentCollectionsInfrastructure)); return config .WithTypes(typeof(ContentCollectionReference)) .WithServices(AddContentService) From 71d6540499f22e1ac8aeee9d9cf7d7457698dbbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Sun, 19 Apr 2026 23:14:58 +0200 Subject: [PATCH 50/50] chore: batch pending edits (thread bubble + version area + sync stream) Picks up in-flight changes across ThreadLayoutAreas, ThreadMessageBubbleView, JsonSynchronizationStream, SynchronizationStream, and VersionLayoutArea. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/MeshWeaver.AI/ThreadLayoutAreas.cs | 12 +- .../Components/ThreadMessageBubbleView.razor | 4 +- .../ThreadMessageBubbleView.razor.cs | 5 + .../JsonSynchronizationStream.cs | 60 ++++++- .../Serialization/SynchronizationStream.cs | 16 +- src/MeshWeaver.Graph/VersionLayoutArea.cs | 160 +++++++++++------- 6 files changed, 184 insertions(+), 73 deletions(-) diff --git a/src/MeshWeaver.AI/ThreadLayoutAreas.cs b/src/MeshWeaver.AI/ThreadLayoutAreas.cs index 781b31d73..81ac18aa1 100644 --- a/src/MeshWeaver.AI/ThreadLayoutAreas.cs +++ b/src/MeshWeaver.AI/ThreadLayoutAreas.cs @@ -782,28 +782,28 @@ static string Shorten(string path, string? prefix) => else sb.Append(""); - // Column 5: Diff (old ↔ new) + // Column 5: Diff (old ↔ new) — points to VersionDiff with from/to params. if (entry.VersionBefore.HasValue && entry.VersionAfter.HasValue) sb.Append( - $"Diff"); else sb.Append(""); - // Column 6: Restore to old + // Column 6: Restore to old — opens VersionDiff (which has the Restore button). if (entry.VersionBefore.HasValue) sb.Append( - $"Restore v{entry.VersionBefore.Value}"); else sb.Append(""); - // Column 7: Restore to new + // Column 7: Restore to new — opens VersionDiff (which has the Restore button). if (entry.VersionAfter.HasValue) sb.Append( - $"Restore v{entry.VersionAfter.Value}"); else diff --git a/src/MeshWeaver.Blazor/Components/ThreadMessageBubbleView.razor b/src/MeshWeaver.Blazor/Components/ThreadMessageBubbleView.razor index cd3f4c2e0..39f4cdefd 100644 --- a/src/MeshWeaver.Blazor/Components/ThreadMessageBubbleView.razor +++ b/src/MeshWeaver.Blazor/Components/ThreadMessageBubbleView.razor @@ -84,13 +84,13 @@ @if (change.VersionBefore.HasValue && change.VersionAfter.HasValue) { Diff } @if (change.VersionBefore.HasValue) { Revert v@(change.VersionBefore) } } diff --git a/src/MeshWeaver.Blazor/Components/ThreadMessageBubbleView.razor.cs b/src/MeshWeaver.Blazor/Components/ThreadMessageBubbleView.razor.cs index f9df1a4ba..0d039488c 100644 --- a/src/MeshWeaver.Blazor/Components/ThreadMessageBubbleView.razor.cs +++ b/src/MeshWeaver.Blazor/Components/ThreadMessageBubbleView.razor.cs @@ -129,6 +129,11 @@ private static ToolCallDisplay FormatToolCallDisplay(ToolCallEntry call) if (string.IsNullOrEmpty(path)) path = rawArgs.Split('\n').FirstOrDefault()?.Trim(); + // Agents write references as "@/Foo/Bar" — strip the "@" so href="/{path}" + // renders as "/Foo/Bar" and not "/@/Foo/Bar". + if (!string.IsNullOrEmpty(path) && path.StartsWith('@')) + path = path[1..].TrimStart('/'); + return call.Name switch { "Get" or "get_node" => new ToolCallDisplay("Reading", path, false), diff --git a/src/MeshWeaver.Data/Serialization/JsonSynchronizationStream.cs b/src/MeshWeaver.Data/Serialization/JsonSynchronizationStream.cs index 6ad16406b..245532d38 100644 --- a/src/MeshWeaver.Data/Serialization/JsonSynchronizationStream.cs +++ b/src/MeshWeaver.Data/Serialization/JsonSynchronizationStream.cs @@ -11,6 +11,18 @@ namespace MeshWeaver.Data.Serialization; +/// +/// Thrown by ApplyAdd/ApplyReplace/ApplyRemove when a patch's array +/// index doesn't match the locally cached snapshot (drift between the owner's cached +/// JSON view and the authoritative entity store). The upstream ToDataChanged +/// catches this and falls back to emitting a snapshot +/// so subscribers can resync. +/// +public sealed class StaleStreamStateException : InvalidOperationException +{ + public StaleStreamStateException(string message) : base(message) { } +} + public static class JsonSynchronizationStream { private static ILogger GetLogger(IServiceProvider serviceProvider) @@ -295,9 +307,33 @@ fromWorkspace as ISynchronizationStream } var patch = x.Updates.ToJsonPatch(stream.Host.JsonSerializerOptions, stream.Reference as WorkspaceReference); var patchJson = JsonSerializer.Serialize(patch, stream.Host.JsonSerializerOptions); - // Apply patch with correct RFC 6901 unescaping - // The json-everything library doesn't properly unescape ~1 -> / in property names - (currentJson, _) = ApplyPatchWithCorrectUnescaping(patchJson, currentJson.Value, stream.Host.JsonSerializerOptions); + try + { + // Apply patch with correct RFC 6901 unescaping + // The json-everything library doesn't properly unescape ~1 -> / in property names + (currentJson, _) = ApplyPatchWithCorrectUnescaping(patchJson, currentJson.Value, stream.Host.JsonSerializerOptions); + } + catch (StaleStreamStateException stale) + { + // The cached JSON drifted from the authoritative entity store + // (concurrent updates whose Updates were computed against an older + // snapshot). Regenerate from the current value and emit a Full + // so every subscriber resyncs cleanly. + logger.LogWarning(stale, + "Stale JSON snapshot for stream {StreamId}; regenerating Full from current value.", + stream.ClientId); + currentJson = JsonSerializer.SerializeToElement( + x.Value, x.Value?.GetType() ?? typeof(object), + stream.Host.JsonSerializerOptions); + stream.Set(currentJson); + return (TChange?)Activator.CreateInstance( + typeof(TChange), + stream.ClientId, + x.Version, + new RawJson(currentJson.Value.ToString() ?? string.Empty), + ChangeType.Full, + x.ChangedBy ?? string.Empty); + } stream.Set(currentJson); return (TChange?)Activator.CreateInstance ( @@ -466,7 +502,13 @@ private static void ApplyAdd(JsonNode root, string[] segments, JsonNode? value) else if (parent is JsonArray arr) { if (key == "-") arr.Add(value); - else if (int.TryParse(key, out var index)) arr.Insert(index, value); + else if (int.TryParse(key, out var index)) + { + if (index < 0 || index > arr.Count) + throw new StaleStreamStateException( + $"Stale patch: add at index {index} but array has {arr.Count} elements."); + arr.Insert(index, value); + } } } @@ -481,7 +523,12 @@ private static void ApplyReplace(JsonNode root, string[] segments, JsonNode? val if (parent is JsonObject obj) obj[key] = value; else if (parent is JsonArray arr && int.TryParse(key, out var index)) + { + if (index < 0 || index >= arr.Count) + throw new StaleStreamStateException( + $"Stale patch: replace at index {index} but array has {arr.Count} elements."); arr[index] = value; + } } private static void ApplyRemove(JsonNode root, string[] segments) @@ -493,7 +540,12 @@ private static void ApplyRemove(JsonNode root, string[] segments) if (parent is JsonObject obj) obj.Remove(segments[^1]); else if (parent is JsonArray arr && int.TryParse(segments[^1], out var index)) + { + if (index < 0 || index >= arr.Count) + throw new StaleStreamStateException( + $"Stale patch: remove at index {index} but array has {arr.Count} elements."); arr.RemoveAt(index); + } } /// diff --git a/src/MeshWeaver.Data/Serialization/SynchronizationStream.cs b/src/MeshWeaver.Data/Serialization/SynchronizationStream.cs index 2f4a962f3..ee8703c4b 100644 --- a/src/MeshWeaver.Data/Serialization/SynchronizationStream.cs +++ b/src/MeshWeaver.Data/Serialization/SynchronizationStream.cs @@ -370,9 +370,9 @@ private void UpdateStream(IMessageDelivery delivery, IMessageH else { logger.LogDebug("[SYNC_STREAM] Processing Patch change for {StreamId}", StreamId); - (currentJson, var patch) = delivery.Message.UpdateJsonElement(currentJson, hub.JsonSerializerOptions); try { + (currentJson, var patch) = delivery.Message.UpdateJsonElement(currentJson, hub.JsonSerializerOptions); var changeItem = this.ToChangeItem(Current!.Value!, currentJson.Value, patch, @@ -390,6 +390,20 @@ private void UpdateStream(IMessageDelivery delivery, IMessageH SetCurrent(hub, changeItem); } + catch (StaleStreamStateException stale) + { + // Local JSON cache drifted from the owner's view. Drop our cached snapshot + // and request a fresh Full from the owner via a new SubscribeRequest. + logger.LogWarning(stale, + "[SYNC_STREAM] Stale patch for {StreamId}; requesting fresh snapshot from {Owner}.", + StreamId, StreamIdentity.Owner); + Set(null); + if (Reference is WorkspaceReference wsRef) + { + Host.Post(new SubscribeRequest(StreamId, wsRef) { Subscriber = Configuration.Subscriber! }, + o => o.WithTarget(StreamIdentity.Owner)); + } + } catch (Exception ex) { logger.LogError(ex, "[SYNC_STREAM] Failed to process Patch change for {StreamId}", StreamId); diff --git a/src/MeshWeaver.Graph/VersionLayoutArea.cs b/src/MeshWeaver.Graph/VersionLayoutArea.cs index 193504669..50cb969a9 100644 --- a/src/MeshWeaver.Graph/VersionLayoutArea.cs +++ b/src/MeshWeaver.Graph/VersionLayoutArea.cs @@ -102,21 +102,18 @@ public static class VersionLayoutArea } /// - /// Renders the diff view comparing a historical version to the current version. - /// Reads ?version= query parameter to determine which version to compare. + /// Renders the diff view for a node. Supports two modes: + /// + /// ?from=X&to=Y — compare two historical versions. + /// ?version=X — compare a historical version to the current node. + /// + /// Emits the diff once — the Monaco diff editor is expensive to re-create, so we + /// avoid re-emitting on every node-stream tick. /// [Browsable(false)] public static IObservable VersionDiff(LayoutAreaHost host, RenderingContext _) { var hubPath = host.Hub.Address.ToString(); - var versionStr = host.GetQueryStringParamValue("version"); - - if (!long.TryParse(versionStr, out var targetVersion)) - { - return Observable.Return( - Controls.Html("

Invalid version parameter.

")); - } - var versionQuery = host.Hub.ServiceProvider.GetService(); if (versionQuery == null) { @@ -124,67 +121,110 @@ public static class VersionLayoutArea Controls.Html("

Version history is not available.

")); } - var nodeStream = host.Workspace.GetStream() - ?.Select(nodes => nodes ?? Array.Empty()) - ?? Observable.Return(Array.Empty()); + var options = host.Hub.JsonSerializerOptions; + var fromStr = host.GetQueryStringParamValue("from"); + var toStr = host.GetQueryStringParamValue("to"); - return nodeStream.SelectMany(async nodes => + // Mode 1: from=X&to=Y — compare two historical versions. + if (long.TryParse(fromStr, out var fromVersion) && long.TryParse(toStr, out var toVersion)) { - var currentNode = nodes.FirstOrDefault(n => n.Path == hubPath); - var options = host.Hub.JsonSerializerOptions; - var historicalNode = await versionQuery.GetVersionAsync(hubPath, targetVersion, options); - - if (historicalNode == null) + return Observable.FromAsync(async () => { - return (UiControl?)Controls.Html($"

Version {targetVersion} not found.

"); - } - - var stack = Controls.Stack.WithWidth("100%").WithStyle(MeshNodeLayoutAreas.GetContainerStyle(host)); - - // Back button - var backHref = MeshNodeLayoutAreas.BuildUrl(hubPath, MeshNodeLayoutAreas.VersionsArea); - stack = stack.WithView( - Controls.Stack.WithOrientation(Orientation.Horizontal) - .WithStyle("align-items: center; gap: 8px; margin-bottom: 16px;") - .WithView(Controls.Button("Back to Versions") - .WithAppearance(Appearance.Lightweight) - .WithIconStart(FluentIcons.ArrowLeft()) - .WithNavigateToHref(backHref))); + var fromNode = await versionQuery.GetVersionAsync(hubPath, fromVersion, options); + var toNode = await versionQuery.GetVersionAsync(hubPath, toVersion, options); + if (fromNode == null) + return (UiControl?)Controls.Html($"

Version {fromVersion} not found.

"); + if (toNode == null) + return (UiControl?)Controls.Html($"

Version {toVersion} not found.

"); + + return (UiControl?)BuildDiffStack(host, hubPath, fromNode, toNode, options, + $"Version {fromVersion}", $"Version {toVersion}", + $"Comparing Version {fromVersion} to Version {toVersion}", + restoreVersion: fromVersion); + }); + } - stack = stack.WithView(Controls.Html( - $"

Comparing Version {targetVersion} to Current

")); + // Mode 2: version=X — compare historical version to current. + var versionStr = host.GetQueryStringParamValue("version"); + if (!long.TryParse(versionStr, out var targetVersion)) + { + return Observable.Return( + Controls.Html("

Invalid version parameter. Use ?version=X or ?from=X&to=Y.

")); + } - // Determine content type and extract text for diff - var originalContent = ExtractDiffContent(historicalNode, options); - var modifiedContent = ExtractDiffContent(currentNode, options); - var language = IsMarkdownContent(historicalNode) ? "markdown" : "json"; + var nodeStream = host.Workspace.GetStream() + ?.Select(nodes => nodes ?? Array.Empty()) + ?? Observable.Return(Array.Empty()); - var diffControl = new DiffEditorControl + // Take the first emission that actually contains the node — avoid re-creating + // the Monaco diff editor on every subsequent stream tick. + return nodeStream + .Where(nodes => nodes.Any(n => n.Path == hubPath)) + .Take(1) + .SelectMany(async nodes => { - OriginalContent = originalContent, - ModifiedContent = modifiedContent, - OriginalLabel = $"Version {targetVersion}", - ModifiedLabel = "Current", - Language = language, - Height = "500px" - }; + var currentNode = nodes.First(n => n.Path == hubPath); + var historicalNode = await versionQuery.GetVersionAsync(hubPath, targetVersion, options); - stack = stack.WithView(diffControl); + if (historicalNode == null) + return (UiControl?)Controls.Html($"

Version {targetVersion} not found.

"); - // Restore button - stack = stack.WithView( - Controls.Stack.WithStyle("margin-top: 16px;") - .WithView(Controls.Button($"Restore Version {targetVersion}") - .WithAppearance(Appearance.Accent) - .WithIconStart(FluentIcons.ArrowUndo()) - .WithClickAction(ctx => - { - ctx.Hub.Post(new RollbackNodeRequest(hubPath, targetVersion)); - return Task.CompletedTask; - }))); + return (UiControl?)BuildDiffStack(host, hubPath, historicalNode, currentNode, options, + $"Version {targetVersion}", "Current", + $"Comparing Version {targetVersion} to Current", + restoreVersion: targetVersion); + }); + } - return (UiControl?)stack; + private static UiControl BuildDiffStack( + LayoutAreaHost host, string hubPath, + MeshNode originalNode, MeshNode modifiedNode, + JsonSerializerOptions options, + string originalLabel, string modifiedLabel, + string title, long restoreVersion) + { + var stack = Controls.Stack.WithWidth("100%").WithStyle(MeshNodeLayoutAreas.GetContainerStyle(host)); + + var backHref = MeshNodeLayoutAreas.BuildUrl(hubPath, MeshNodeLayoutAreas.VersionsArea); + stack = stack.WithView( + Controls.Stack.WithOrientation(Orientation.Horizontal) + .WithStyle("align-items: center; gap: 8px; margin-bottom: 16px;") + .WithView(Controls.Button("Back to Versions") + .WithAppearance(Appearance.Lightweight) + .WithIconStart(FluentIcons.ArrowLeft()) + .WithNavigateToHref(backHref))); + + stack = stack.WithView(Controls.Html( + $"

{System.Web.HttpUtility.HtmlEncode(title)}

")); + + var originalContent = ExtractDiffContent(originalNode, options); + var modifiedContent = ExtractDiffContent(modifiedNode, options); + var language = IsMarkdownContent(originalNode) || IsMarkdownContent(modifiedNode) + ? "markdown" + : "json"; + + stack = stack.WithView(new DiffEditorControl + { + OriginalContent = originalContent, + ModifiedContent = modifiedContent, + OriginalLabel = originalLabel, + ModifiedLabel = modifiedLabel, + Language = language, + Height = "600px" }); + + stack = stack.WithView( + Controls.Stack.WithStyle("margin-top: 16px;") + .WithView(Controls.Button($"Restore Version {restoreVersion}") + .WithAppearance(Appearance.Accent) + .WithIconStart(FluentIcons.ArrowUndo()) + .WithClickAction(ctx => + { + ctx.Hub.Post(new RollbackNodeRequest(hubPath, restoreVersion)); + return Task.CompletedTask; + }))); + + return stack; } ///