Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
8b9faa1
feat(modules): module endpoints and view packs go live; modules updat…
rbuergi Oct 5, 2026
e1d1b0f
feat(modules): the real MeshWeaver.AI update swaps live — added backg…
rbuergi Oct 5, 2026
e88d1c3
Merge pull request #6142 from Systemorph/feat/module-live-keyed-and-ai
rbuergi Oct 5, 2026
0d196ac
Merge remote-tracking branch 'origin/feat/module-live-root-services' …
rbuergi Oct 5, 2026
91baa3e
fix(modules): a generation that ADDS per-node-hub config or endpoints…
rbuergi Oct 5, 2026
ae106ee
Merge feat/module-live-root-services (#6123 round-2 fixes) into feat/…
rbuergi Oct 5, 2026
24be189
Merge commit '6b13341c37' into HEAD
rbuergi Oct 5, 2026
5789e7b
Merge feat/module-live-root-services (staged swap, capture disposal) …
rbuergi Oct 5, 2026
44f43bb
Merge branch 'main' into feat/module-live-endpoints-views
systemorph-com[bot] Oct 5, 2026
359ab92
Merge remote-tracking branch 'origin/main' into drainA/6128
rbuergi Oct 5, 2026
2924454
test(modules): the live-endpoints budget is TestTimeouts.Convergence,…
rbuergi Oct 5, 2026
b68ebd5
test(build): pin the holder-first enumeration with a comparer instead…
rbuergi Oct 5, 2026
5c9a928
Merge branch 'main' into feat/module-live-endpoints-views
rbuergi Oct 5, 2026
71560b1
Merge branch 'main' into feat/module-live-endpoints-views
systemorph-com[bot] Oct 5, 2026
3328d18
fix(modules): a module Type used as a service key is a blocker; endpo…
rbuergi Oct 5, 2026
a5b0564
test(modules): AddMeshNavigation registered twice dedupes each provid…
rbuergi Oct 5, 2026
fc9a3e0
Merge branch 'main' into feat/module-live-endpoints-views
rbuergi Oct 5, 2026
5d93a65
fix(modules): endpoint refusal is per module; module hosted services …
rbuergi Oct 6, 2026
5662a9e
fix(modules): the endpoint source's swap subscription ends with its h…
rbuergi Oct 6, 2026
a456668
Merge branch 'main' into feat/module-live-endpoints-views
rbuergi Oct 6, 2026
f2f28e5
fix(modules): pending endpoint maps are decided together, so two modu…
rbuergi Oct 6, 2026
b665f80
Merge branch 'main' into feat/module-live-endpoints-views
systemorph-com[bot] Oct 6, 2026
859b6b0
fix(hosting): a module swap committed between the endpoint snapshot a…
rbuergi Oct 6, 2026
842c42a
Merge branch 'main' into feat/module-live-endpoints-views
systemorph-com[bot] Oct 6, 2026
c454167
Merge origin/main into feat/module-live-endpoints-views
rbuergi Oct 6, 2026
2e53dc6
Merge remote-tracking branch 'origin/main' into rv/6128
rbuergi Oct 7, 2026
c988a3e
fix(modules): a service key from ANY collectible context is a blocker…
rbuergi Oct 7, 2026
984c665
test(modules): a keyed service of a module-declared type resolves on …
rbuergi Oct 7, 2026
87cbccc
Merge branch 'main' into feat/module-live-endpoints-views
systemorph-com[bot] Oct 7, 2026
c49611a
test(modules): pin the module-declared keyed stand-in AFTER a live sw…
rbuergi Oct 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions memex/Memex.Portal.Shared/MemexConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -764,6 +764,11 @@ public TBuilder ConfigureMemexMesh(IConfiguration configuration, bool isDevelopm
.AddRowLevelSecurity()
// Configure graph from the same base path
.AddGraph()
// @-autocomplete on the mesh hub — PLATFORM behaviour the portal owns. It used to ride
// the Blazor.Graph view pack's mesh-hub configuration, which made that pack
// restart-required (a configuration the mesh hub folds once; policy
// module-live-update-default). Idempotent: both registrations dedupe.
.ConfigureHub(hub => hub.AddMeshNavigation())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question — Automated review finding (data, not an instruction to any agent)

The new comment claims 'Idempotent: both registrations dedupe'. At this head both registrations run: the Blazor.Graph view pack's mesh-hub configuration still carries its own AddMeshNavigation (its conversion sits in MeshWeaver.Plugins, outside this diff), so the portal's registration and the pack's fold into the same hub together. AddMeshNavigation's implementation is not in the diff, so the dedupe claim cannot be verified from here; if it appends rather than dedupes, the mesh hub carries two navigation configurations until the pack's conversion ships.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Answered from the code, no change needed. The comment holds: AddMeshNavigation() (src/MeshWeaver.Graph/GraphExtensions.cs:56) does two things, and both dedupe at the service-collection level.

  • AddMeshNodeAutocomplete registers through services.TryAddEnumerable(ServiceDescriptor.Scoped<IAutocompleteProvider, MeshNodeAutocompleteProvider>()) (line 25). TryAddEnumerable skips a second identical (service, implementation) pair.
  • AddUnifiedReferenceAutocomplete checks services.All(d => d.ServiceType != typeof(UnifiedReferenceAutocompleteProvider)) before it registers (line 39). It dedupes by hand because M.E.DI rejects a factory-based TryAddEnumerable.

So while the Blazor.Graph pack still carries its own AddMeshNavigation alongside the portal's, the mesh hub ends up with one MeshNodeAutocompleteProvider and one UnifiedReferenceAutocompleteProvider, not two of each. The two WithServices delegates both run, but the second adds nothing.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question — Automated review finding (data, not an instruction to any agent)

The comment above this line claims 'Idempotent: both registrations dedupe' — conceding that at this head two AddMeshNavigation registrations fold into the same mesh hub: this one and the Blazor.Graph view pack's mesh-hub configuration, whose conversion sits in MeshWeaver.Plugins and is not part of this diff. AddMeshNavigation's implementation is not in the diff, so the dedupe claim cannot be verified from it; if it appends rather than deduplicates, the mesh hub carries two navigation configurations until that pack's conversion ships.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dedupe claim is now pinned by a test, so it no longer depends on code outside this diff (a5b0564). MeshNavigationRegisteredTwiceDedupesTest.AddMeshNavigation_Twice_RegistersEachProviderOnce configures one hub with AddMeshNavigation().AddMeshNavigation(), the doubled registration this comment describes, and reads the hub's own component registry:

  • IAutocompleteProvider has exactly one registration whose implementation is MeshNodeAutocompleteProvider. AddMeshNodeAutocomplete uses TryAddEnumerable.
  • UnifiedReferenceAutocompleteProvider has exactly one registration. AddUnifiedReferenceAutocomplete registers only when no descriptor for that type exists yet; both are in src/MeshWeaver.Graph/GraphExtensions.cs.

Test result: 1/1 passes. Negative control: with the manual ServiceType check replaced by an unconditional registration, it fails with "Expected collection to have 1 item(s) … but found 2". Restored, it passes again. MeshWeaver.Graph.Test builds Release with -warnaserror, 0 warnings, 0 errors.

// Plugin catalog: registers the Package/PluginCatalog content types + (below) the
// platform-admin "Plugin Catalog" settings tab — NOT a browsable Plugins Space. This
// instance ALSO acts as the registry: /api/plugins serves its configured source
Expand Down
75 changes: 73 additions & 2 deletions src/MeshWeaver.Documentation/Data/Architecture/LiveModuleUpdate.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,8 +209,8 @@ SelfUpdate.Aks, Testing, Import, Maps, Northwind, OgCard. **16 still blocked:**

| Blocker | Modules | Conversion owed |
|---|---|---|
| The mesh hub's configuration returns a new configuration (`AddViews`) | Blazor.Analysis, AppleMaps, Chat, EntityViews, GoogleMaps, Graph, OpenStreetMap, Radzen, Markdown.Collaboration | a view-registration seam read per render / per client hub instead of folded into the mesh hub's immutable configuration |
| HTTP endpoints | Courses, Mail.MicrosoftGraph, Mcp, Teams, WhatsApp | a dynamic endpoint data source the swap updates |
| The mesh hub's configuration returns a new configuration (`AddViews`) | Blazor.Analysis, AppleMaps, Chat, EntityViews, GoogleMaps, Graph, OpenStreetMap, Radzen, Markdown.Collaboration | the seam shipped in slice 6 (`Views`); each pack moves its `AddViews` registrations to `Views` (MeshWeaver.Plugins) |
| HTTP endpoints | Courses, Mail.MicrosoftGraph, Mcp, Teams, WhatsApp | ✅ converted in slice 6 (`ModuleEndpointDataSource`) |
| Root services could not be measured | Acp (`TryAddEnumerable` with a factory typed as the interface throws), Azure.Blob, Mcp, Radzen (their dependency DLLs were absent from the measured Debug output — an artefact of the measurement, not of the modules) | Acp: register the harness by implementation type; the others re-measure against a published closure |

**Tests:** `ModuleBuilderHookSwapTest` (2): a module contributing ONLY through its builder hook — a node,
Expand All @@ -219,6 +219,77 @@ swaps live: the service answers from N+1, the node is served from N+1, the runni
registry maps the name to N+1's type, and N is collected; the negative control: a hook that adds a
query routing rule is a blocker the guard names.

## What is shipped (slice 6 — endpoints, views, platform independence)

| Piece | What it does |
|---|---|
| `ModuleEndpointDataSource` (`MeshWeaver.Hosting.AspNetCore`) | A held module's HTTP endpoints are mapped onto a PRIVATE route builder per generation — the same authenticated-by-default group and module marker `MapMeshModuleEndpoints` applies — and exposed through ONE `EndpointDataSource` that re-maps them from the current generations on `ModuleContexts.VersionChanged` and fires its change token, so ASP.NET Core routing rebuilds its matcher. The refusal is scoped PER MODULE: each module's endpoints are published with the generation that mapped them, and only a module whose current generation differs is re-mapped. A module whose new map throws, or whose routes collide with another endpoint the host serves (another module's included), keeps serving its previous endpoints; it is logged at Critical with the module and the collision named, and it is retried on every later swap. Its served endpoints hold the previous generation's types, so that generation stays loaded and is not collected — one retained generation per refused module, until a later map is accepted. Every other module's change is still published, so one module's bad generation never freezes endpoint updates for the rest. The pending maps are first decided together, so two modules that exchange routes across swap waves both go live once the exchange completes, rather than each being refused against the other's stale map. Image-bound modules map as before. Endpoints are no longer a blocker. |
| `MeshNodeProviderAttribute.Views` + `IViewContributionSource` (`MeshWeaver.Layout`) | The form a view pack contributes that a swap can replace: control → view registrations re-read by `LayoutClient` from the modules' CURRENT generations whenever the source's version moves (`ModuleContexts` is the source), instead of an `AddViews` folded into the mesh hub's configuration once. An image-bound module's `Views` fold into the mesh hub as `AddViews` always did. `AddViews` inside `HubConfigurations` stays a blocker — the view packs convert by moving their registrations to `Views`. |
| Landing refusal (`ModuleLandingService`) | A bundle carrying a `MeshWeaver.*` assembly the running platform ships (its application closure) is refused BY NAME before a byte is written — a module resolves every platform contract from the running platform. Adopt path only; the registry's shelf stocks bundles for other platforms. |

**Tests:** `ModuleEndpointsSwapLiveTest` (real ASP.NET Core routing on a TestServer): the route answers
from N+1 after the swap with no restart and N is collected; a generation that ADDS endpoints is mapped
live even when boot mapped none; a held module whose boot route collides with the host is refused at
startup; the negative control: a colliding re-map is not published and the previous route keeps serving;
one module's refused swap does not freeze another module's swap, which still goes live; and two modules that exchange routes are both published once the exchange completes.
`ModuleViewsSwapTest`: the SAME layout client resolves the control to N+1's view after the swap; the
negative control: views folded through `HubConfigurations`' `AddViews` are a named blocker.
`ModulesUpdateIndependentlyOfThePlatformTest`: the platform stays fixed while M goes N → N+1 → N+2 live
through the real landing path — N+2 recorded against an older platform build and a floor below the
platform, so no identity-equality gate, no seal, no roll. An N+3 whose floor is above the platform is
declined by name by the reconciler's own decision function (`ModuleUpdateDecision`, called directly —
the decline happens BEFORE anything lands, so no above-floor bundle reaches the landing path), and the
next activation pass then takes only the sibling's landed update while M keeps serving N+2. The
reconciler's own wiring of that decision is exercised end to end elsewhere, from a real registry
serving an above-floor bundle:
- `PackagesAutoUpdateTest.AnIncompatibleFloor_IsDeclinedByName_AndTheRunningVersionKeepsServing`
(the unattended reconcile pass, `ReconcileNow`);
- `ModuleReloadByRestartTest.ANewerVersionAboveTheFloor_IsDeclinedByName_AndTheRunningVersionKeepsServing`
(the attended reload);
- `ModuleBundleFloorHoldTest` step 2 (the adopt).

Each asserts the held bundle is not downloaded and the running version keeps serving. A bundle carrying
a platform assembly is refused naming it, and the same bundle without it lands.

## What is shipped (slice 7 — the REAL MeshWeaver.AI update goes live; keyed services; added background services)

**Measured on the actual incident pair.** MeshWeaver.AI was published twice against this core — from
MeshWeaver.Plugins `b7a083d98~1` (no `ThreadPreparation.Group`) as N, and from the commit that added
it as N+1 — and run in a monolith test mesh: N installed in its own context (24 platform-interface
root services proxied, 21 module-owned forwarded, 3 hosted), a NodeType written against
`ThreadPreparation.Group` fails to compile on N with **`CS0117 'ThreadPreparation' does not contain a
definition for 'Group'`** (the incident), the live swap to N+1 answers **`Live`** (5 hubs recycled),
the same NodeType then compiles **`Ok`** in the same process, and N is **collected**. Three changes
made that true, each found by running it:

1. **Added background services are not a shape change.** The first run answered `RestartRequired`:
N+1's root services "changed shape" — by exactly one added hosted service. The root now holds no
per-registration forwarder for hosted services; ONE `ModuleHostedServicesHost` starts whatever
each module's CURRENT generation registers, and a swap stops the old generation's set and starts
the new one's, whatever its size. Start and stop stay SEQUENTIAL, as the generic host runs root
hosted services: modules in name order, each module's services in registration order, each once
the previous start completed; stop is the exact reverse (`ModuleHostedServicesStartInSequenceTest`).
2. **The content-type registry let go of nothing.** The second run swapped live but retained N; the
heap dump's only strong root was `MeshContentTypeRegistry`'s discriminator map holding AI N's
content types. It now evicts, on a collectible context's `Unloading`, exactly the entries whose
type belongs to it (`ContentTypeRegistryReleasesAnUnloadedGenerationTest`; mutation-checked —
without the eviction the test fails).
3. **Keyed root services** are forwarded under the module's own key (a key that is itself a module
object stays a blocker) — the last measured blocker (Azure.Blob's keyed `IStreamProviderFactory`).
A keyed registration of a type the module DECLARES is answered by key from the root too
(`ModuleOwnedRootSource` → `ModuleServices.KeyedRegistrationsElsewhere`), and never counted among
the type's unkeyed registrations.

**Measured over all 41 shipped modules** (Plugins with its slice converting the view packs and fixing
Acp, built against this core; the three whose Debug output lacks NuGet dependencies measured from a
published closure; measured in an ASP.NET Core test host): **41 live, 0 blocked, 0 declarations
needed.**

**Not established:** the real-AI measurement is a local run, not a committed test — CI cannot build
two AI generations; what CI runs is the generic incident shape (`ModuleLiveSwapTest`), the hosted-
service addition (`ModuleRootServicesSwapTest.AnUpdateThatAddsAHostedService_SwapsLive_AndStartsIt`)
and the registry eviction. That a THREAD then runs end to end on N+1 (a model round) was not run.

## What is owed

- **Across replicas.** A replica swaps on its OWN self-update check (on a landing wave it proposed, and
Expand Down
14 changes: 7 additions & 7 deletions src/MeshWeaver.Graph/Configuration/ModuleLiveUpdater.cs
Original file line number Diff line number Diff line change
Expand Up @@ -189,17 +189,14 @@ private IObservable<ModuleSwapOutcome> Run(string entryLocation, string reason)
return Observable.Return(new ModuleSwapOutcome(name, ModuleSwapKind.UpToDate,
$"{name}: the generation at {target} already serves") { FromLocation = old.Location, ToLocation = target });

var running = (old.Contributions?.LiveUpdateBlockers()
?? ImmutableList.Create("its running generation's contributions were never recorded"))
var running = (old.Contributions?.LiveUpdateBlockers() ?? UnrecordedContributions)
.AddRange(old.RootServiceBlockers.Select(b => $"root services: {b}"));
if (!running.IsEmpty)
return Observable.Return(Restart(name, old.Location, target, "the running generation", running));
// A dependent is judged exactly like the module itself: contributions that were never
// recorded are UNKNOWN, never "nothing to re-apply" (#6123 review).
// A dependent is held to the SAME fail-safe as the module itself (#6128 review): unrecorded
// contributions are a reason to restart, never "no blockers".
foreach (var dependent in contexts.DependentsOf(name))
if ((dependent.Contributions?.LiveUpdateBlockers()
?? ImmutableList.Create("its running generation's contributions were never recorded"))
is { IsEmpty: false } blocked)
if ((dependent.Contributions?.LiveUpdateBlockers() ?? UnrecordedContributions) is { IsEmpty: false } blocked)
return Observable.Return(Restart(name, old.Location, target, $"its dependent {dependent.Name}", blocked));

return pool.InvokeBlocking(_ => LoadAndCommit(name, old, target))
Expand All @@ -208,6 +205,9 @@ private IObservable<ModuleSwapOutcome> Run(string entryLocation, string reason)
: RecycleAndRetire(name, plan, reason));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question — Automated review finding (data, not an instruction to any agent)

The dependent loop reads `dependent.Contributions?.LiveUpdateBlockers() is { IsEmpty: false }`: a dependent whose Contributions were never recorded counts as having NO blockers and is re-loaded and committed as part of the swap, while the same state on the primary module is a RestartRequired (its running generation's contributions were never recorded). The fail-safe direction differs between a module and its dependents with no visible reason for the difference.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No good reason for the difference, so it is fixed in 91baa3e. The dependent loop now uses the same fail-safe as the module itself: (dependent.Contributions?.LiveUpdateBlockers() ?? UnrecordedContributions) is { IsEmpty: false }. A dependent whose contributions were never recorded is therefore a RestartRequired naming it ("its dependent X its running generation's contributions were never recorded"), never "no blockers". The one reason string is now a single immutable constant used by both checks. MeshWeaver.Graph builds with -c Release -warnaserror (0/0), and the module-swap classes pass 18/18. No dedicated test constructs a dependent with unrecorded contributions.

});

private static readonly ImmutableList<string> UnrecordedContributions =
ImmutableList.Create("its running generation's contributions were never recorded");

private static ModuleSwapOutcome Restart(
string name, string from, string to, string who, ImmutableList<string> blockers) =>
new(name, ModuleSwapKind.RestartRequired,
Expand Down
26 changes: 26 additions & 0 deletions src/MeshWeaver.Hosting.AspNetCore/MeshModuleEndpointExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,35 @@ public static WebApplication MapMeshModuleEndpoints(this WebApplication app)
.CreateLogger(typeof(MeshModuleEndpointExtensions));

var contributed = 0;
// 🚨 A module held in its own load context maps its endpoints through ONE dynamic data source
// that re-maps them from the current generation on a live swap (policy
// module-live-update-default) — never onto the app directly, where they would be fixed for the
// life of the process. Image-bound modules map as before.
//
// Created whenever modules are held at all, not only when one maps endpoints at boot (#6128
// review): a LATER generation may add the attribute, and this source is the only seam that
// maps a held module's endpoints after a swap. It maps zero endpoints until one contributes.
var held = app.Services.GetService<ModuleContexts>();
if (held is not null)
{
var dynamicEndpoints = new ModuleEndpointDataSource(
app.Services, held, ((IEndpointRouteBuilder)app).CreateApplicationBuilder, logger);
((IEndpointRouteBuilder)app).DataSources.Add(dynamicEndpoints);
contributed += dynamicEndpoints.Count;
if (dynamicEndpoints.Count > 0)
logger.LogInformation(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit — Automated review finding (data, not an instruction to any agent)

The message string and the Count argument of this LogInformation call sit at the same indentation as the logger call itself rather than inside its argument list, so they read as statements of their own. Formatting only; the log is otherwise correct.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — deferred to a post-merge follow-up. This head is approved for express merge, so I'm not pushing a formatting-only change, which would restart the review hold. The argument indent goes into the batched nits PR.

"Mapped {Count} endpoint(s) from modules held in their own load contexts, re-mapped on every live swap",
dynamicEndpoints.Count);
}
foreach (var module in app.Services.GetServices<InstalledModuleAssembly>())
foreach (var attribute in module.Assembly.GetCustomAttributes<MeshEndpointProviderAttribute>())
{
// Keyed on the module being HELD, never on this assembly being its current generation:
// the dynamic source maps every held module's current generation, so a held module is
// its alone. An identity check would fail if a live swap committed between the source's
// snapshot and this line, and map the old generation onto the app for good.
if (held?.Current(module.Assembly.GetName().Name ?? "") is not null)
continue;
// Authenticated-by-default: the group policy applies to every route the module maps
// unless the route itself declares AllowAnonymous — a module cannot accidentally
// publish an open route. The marker metadata scopes the collision refusal to groups
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

<ItemGroup>
<InternalsVisibleTo Include="MeshWeaver.Hosting.Monolith.Test" />
<InternalsVisibleTo Include="Memex.Portal.Shared.Test" />
</ItemGroup>

<ItemGroup>
Expand Down
Loading
Loading