diff --git a/src/MeshWeaver.Compiler.Pipeline/NodeTypeBuildState.cs b/src/MeshWeaver.Compiler.Pipeline/NodeTypeBuildState.cs index 340749b35b..80c28ca5aa 100644 --- a/src/MeshWeaver.Compiler.Pipeline/NodeTypeBuildState.cs +++ b/src/MeshWeaver.Compiler.Pipeline/NodeTypeBuildState.cs @@ -77,13 +77,9 @@ public static class NodeTypeBuildState // the process-local AssemblyLocation when the producer hasn't // populated the store fields yet (Null store path), and finally // to a fresh GUID so the version is never null. - var hashSrc = (!string.IsNullOrEmpty(result.Collection) && !string.IsNullOrEmpty(result.ContentPath)) - ? $"{result.Collection}/{result.ContentPath}" - : result.AssemblyLocation ?? Guid.NewGuid().ToString(); - using var sha = System.Security.Cryptography.SHA256.Create(); - var hash = Convert.ToBase64String( - sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(hashSrc))) - .Replace('+', '-').Replace('/', '_').TrimEnd('=')[..8]; + var hashSrc = ReleaseHashSource(result.Collection, result.ContentPath) + ?? result.AssemblyLocation ?? Guid.NewGuid().ToString(); + var hash = ReleaseVersionHash(hashSrc); var version = $"{DateTime.UtcNow:yyyyMMddHHmmss}-{hash}"; var releaseNamespace = $"{nodeTypePath}/{GraphNodeTypeNames.ReleaseSegment}"; @@ -193,23 +189,176 @@ public static class NodeTypeBuildState : System.Reactive.Disposables.Disposable.Empty, _ => meshService.CreateNode(node).Take(1)) .Select(_ => (string?)releasePath) - .Timeout(TimeSpan.FromSeconds(10), Observable.Return(null)) + // 🚨 The bound must DELIVER its diagnostic (#781). A bare + // `Timeout(_, Observable.Return(null))` made "the create starved" indistinguishable + // from "no release was asked for": the caller stamps the PREVIOUS release path and + // the node settles Ok, current sources, current assembly β€” and a release naming + // yesterday's bytes. That state is stable and looks healthy from every angle, which + // is why it took comparing two timestamps in a node dump to find. + .Timeout(ReleaseCreateBound, Observable.Defer(() => + { + logger?.LogError( + "CompileWatcher: the Release node create for {ReleasePath} did not land " + + "within {Bound}s β€” the compile SUCCEEDED but no release was cut, so " + + "LatestReleasePath still names the PREVIOUS build", + releasePath, ReleaseCreateBound.TotalSeconds); + return Observable.Return(null); + })) .Catch(ex => { - logger?.LogWarning(ex, - "CompileWatcher: failed to create Release node at {ReleasePath}", - releasePath); + logger?.LogError(ex, + "CompileWatcher: failed to create Release node at {ReleasePath} β€” the " + + "compile SUCCEEDED but no release was cut, so LatestReleasePath still " + + "names the PREVIOUS build", releasePath); return Observable.Return(null); }); } catch (Exception ex) { - logger?.LogWarning(ex, - "CompileWatcher: TryCreateReleaseNode threw for {NodeTypePath}", nodeTypePath); + logger?.LogError(ex, + "CompileWatcher: TryCreateReleaseNode threw for {NodeTypePath} β€” no release was " + + "cut, so LatestReleasePath still names the PREVIOUS build", nodeTypePath); return Observable.Return(null); } } + /// How long the release-node create may take before the caller gives up on it. The + /// create is one owner round-trip; a bound nested inside the hub's own request timeout so it + /// can fire FIRST and say WHICH write starved. + internal static readonly TimeSpan ReleaseCreateBound = TimeSpan.FromSeconds(10); + + /// The number of hash characters a minted release version carries. + private const int ReleaseHashLength = 8; + + /// The {yyyyMMddHHmmss}-{hash} length of a minted release version. + private const int ReleaseVersionLength = 14 + 1 + ReleaseHashLength; + + /// + /// The DURABLE identity a release version is minted from β€” the cross-silo assembly-store + /// coordinates, or null when the producer has not populated them (the Null-store path, + /// where the mint falls back to a process-local location and the identity is not derivable + /// from the node at all). Pure. + /// + internal static string? ReleaseHashSource(string? collection, string? contentPath) => + !string.IsNullOrEmpty(collection) && !string.IsNullOrEmpty(contentPath) + ? $"{collection}/{contentPath}" + : null; + + /// + /// The release-version hash for a build identity β€” the ONE function + /// mints with and + /// checks + /// against, so the mint and the check cannot drift into disagreeing about what a release + /// version means. Pure. + /// + internal static string ReleaseVersionHash(string hashSource) => + Convert.ToBase64String( + System.Security.Cryptography.SHA256.HashData( + System.Text.Encoding.UTF8.GetBytes(hashSource))) + .Replace('+', '-').Replace('/', '_').TrimEnd('=')[..ReleaseHashLength]; + + /// + /// 🚨 The cheap comparison issue #781 asked for: does name + /// THESE bytes? A release version is {yyyyMMddHHmmss}-{hash} where the hash is minted + /// from the build's durable store coordinates, so the question is answerable from the node + /// alone β€” no store probe, no I/O. + /// + /// Inconclusive answers TRUE, always. Three of them, each deliberate: + /// + /// No release at all. Absence is not staleness β€” a build ADOPTED from a prebuilt + /// bundle has never had a release, and #1707 slice 3 decided that such a request is + /// satisfied by the build in hand. Answering FALSE here would put a compile back on every + /// install, which is the cost that slice removed. + /// No durable coordinates. The Null-store path mints from a process-local + /// assembly location, which no other process can reproduce. + /// A version this mint did not produce. A different (older, or hand-written) + /// shape is not evidence of anything. + /// + /// FALSE is returned only where the check can actually TELL, and it disagrees β€” which is + /// exactly the #781 state: a current build, and a release naming the previous one. + /// + internal static bool ReleaseNamesBuild( + string? latestReleasePath, string? collection, string? contentPath) + { + if (string.IsNullOrEmpty(latestReleasePath)) + return true; + if (ReleaseHashSource(collection, contentPath) is not { } hashSource) + return true; + var version = latestReleasePath[(latestReleasePath.LastIndexOf('/') + 1)..]; + if (version.Length != ReleaseVersionLength || version[14] != '-') + return true; + for (var i = 0; i < 14; i++) + if (!char.IsAsciiDigit(version[i])) + return true; + return string.Equals( + version[15..], ReleaseVersionHash(hashSource), StringComparison.Ordinal); + } + + /// + /// read off a NodeType's own record: + /// does name the build that + /// / + /// describe? Pure. + /// + internal static bool ReleaseNamesBuild(NodeTypeDefinition def) => + ReleaseNamesBuild( + def.LatestReleasePath, def.LatestAssemblyCollection, def.LatestAssemblyPath); + + /// + /// Cut a Release for the build the node ALREADY holds β€” no compile (#781). + /// + /// A release request answered by the build in hand + /// (NodeTypeCompilationHelpers.IsSatisfiedByCurrentBuild) still has to LEAVE A RELEASE + /// for that build: consuming it against a + /// minted for different bytes is how a consumer following that field adopts the PREVIOUS + /// build while the node reports Ok on the current one. The bytes exist and are addressed by + /// the node's own durable coordinates, so the honest answer is to mint the release from them + /// rather than to recompile bytes that are already correct. + /// + /// Emits the created path, or null when nothing was needed OR the create did not + /// land. A null is never consumed as success: the caller's satisfied branch requires a release + /// that names the build, so a failed cut falls through to the compile path, which cuts one of + /// its own. + /// + internal static IObservable TryCutReleaseForBuildInHand( + IMessageHub hub, + string nodeTypePath, + MeshNode node, + NodeTypeDefinition def, + ILogger? logger) + { + if (ReleaseNamesBuild(def)) + return Observable.Return(null); + logger?.LogInformation( + "[ReleaseRequestWatcher] {NodeTypePath}: the build in hand has no release of its own " + + "(LatestReleasePath '{Stale}' was minted for other bytes) β€” cutting one from the " + + "build itself, no compile", nodeTypePath, def.LatestReleasePath); + // Same credential split as the compile that would otherwise have produced this release: + // EXECUTION is the System's (the compile watcher's terminal writes run under + // ImpersonateAsSystem for exactly this reason β€” a read-only partition must still get its + // release), while ATTRIBUTION stays the requester's, which TryCreateReleaseNode applies on + // top of this scope. Acquired at SUBSCRIBE, never at compose time: this watcher callback is + // a deferred Rx continuation and the ambient context does not flow into it. + var accessService = hub.ServiceProvider.GetService(); + return Observable.Using( + () => accessService?.ImpersonateAsSystem() + ?? (IDisposable)System.Reactive.Disposables.Disposable.Empty, + _ => TryCreateReleaseNode( + hub, + nodeTypePath, + new NodeCompilationResult( + AssemblyLocation: null, + NodeTypeConfigurations: [], + CompiledSources: def.CompiledSources as ImmutableDictionary + ?? def.CompiledSources?.ToImmutableDictionary(), + Collection: def.LatestAssemblyCollection, + ContentPath: def.LatestAssemblyPath, + Version: def.LastCompiledVersion), + node, + activityPath: def.LastCompilationActivityPath, + logger)); + } + /// /// Holds a NodeType MeshNode stream until /// reaches a settled terminal state β€” anything other than diff --git a/src/MeshWeaver.Compiler.Pipeline/NodeTypeCompilationHelpers.cs b/src/MeshWeaver.Compiler.Pipeline/NodeTypeCompilationHelpers.cs index 75099bc93a..dfd4fa427b 100644 --- a/src/MeshWeaver.Compiler.Pipeline/NodeTypeCompilationHelpers.cs +++ b/src/MeshWeaver.Compiler.Pipeline/NodeTypeCompilationHelpers.cs @@ -1717,8 +1717,8 @@ public static IDisposable InstallReleaseRequestWatcher( // def.RequestedReleaseAt re-read, which can have flapped back to // an older value by the time the Update lambda runs on the // action block (see the high-water comment above). - if (node.ContentAs(hub.JsonSerializerOptions)?.RequestedReleaseAt - is not { } triggerAt) return; + if (node.ContentAs(hub.JsonSerializerOptions) + is not { RequestedReleaseAt: { } triggerAt } observed) return; // 🚨 NO high-water advance here. The mark advances ONLY on the // Update's COMMIT path below β€” where LastReleaseRequestHandledAt // is actually stamped. Advancing eagerly at dispatch time lost @@ -1734,9 +1734,32 @@ public static IDisposable InstallReleaseRequestWatcher( logger?.LogInformation( "[ReleaseRequestWatcher] {HubPath}: handling RequestedReleaseAt={Req} (force={Force}, lastHandled={Handled})", hubPath, triggerAt, - node.ContentAs(hub.JsonSerializerOptions)?.RequestedReleaseForce, - node.ContentAs(hub.JsonSerializerOptions)?.LastReleaseRequestHandledAt); - workspace.GetMeshNodeStream().Update(curr => + observed.RequestedReleaseForce, + observed.LastReleaseRequestHandledAt); + // 🚨 MeshWeaver.Plugins#781 β€” a request answered by the build in hand must + // still LEAVE A RELEASE for that build. When the bytes are already correct but + // LatestReleasePath was minted for other bytes, the honest answer is to cut the + // missing release FROM THOSE BYTES β€” not to recompile them (which would put the + // compile #1707 slice 3 removed back on the install path), and not to consume + // the trigger against a release that names the previous build (which is the + // defect: spent request, stale release, and no way to ask again because the + // next plain request is absorbed by the same branch). + // + // The decision below is re-taken authoritatively inside the Update lambda + // against `curr`; this pre-cut is a best-effort on the OBSERVED emission. If the + // state moves in between so the lambda takes another branch, the cut Release + // node is simply history for a build that really was compiled β€” never a phantom + // path, because TryCreateReleaseNode emits only after the create LANDED. + var precut = BuildInHandAnswersRequest(node, observed, GuardsOf(hub)) + ? NodeTypeBuildState.TryCutReleaseForBuildInHand( + hub, hubPath, node, observed, logger) + : Observable.Return(null); + precut + .Take(1) + // Totality guard, as everywhere else in this file: a cut that completed + // empty must not swallow the dispatch below. + .DefaultIfEmpty() + .SelectMany(cutPath => workspace.GetMeshNodeStream().Update(curr => { if (curr.Content is not NodeTypeDefinition def) return curr; // 🚨 #1834 β€” fulfil a pending adoption stamp BEFORE the satisfied-branch @@ -1825,19 +1848,28 @@ def.LastReleaseRequestHandledAt is { } a && a > triggerAt // request's outcome is byte-for-byte what a recompile would have produced. // RequestedReleaseForce stays the user's escape hatch: an explicit force // always compiles. - if (!def.RequestedReleaseForce - && def.CompilationStatus is CompilationStatus.Ok - && !def.IsDirty - && HasUsableBuild(curr, def, GuardsOf(hub))) + // + // 🚨 …and the release the request ASKS FOR has to exist for THESE bytes + // (MeshWeaver.Plugins#781). `cutPath` is the one the pre-cut above just + // minted from the build in hand, if it was needed and landed; folding it in + // HERE is what lets the branch stay a no-compile answer while still leaving + // a release that names the build. A cut that did NOT land leaves the clause + // false, so the request falls through to the compile path below and gets + // its release from the compile's own terminal create. + var satisfiedDef = cutPath is null + ? def + : def with { LatestReleasePath = cutPath }; + if (IsSatisfiedByCurrentBuild(curr, satisfiedDef, GuardsOf(hub))) { logger?.LogInformation( "[ReleaseRequestWatcher] {HubPath}: release request satisfied by the " + "existing current build (adopted or already compiled) β€” no compile " - + "dispatched", hubPath); + + "dispatched (release {Release})", + hubPath, satisfiedDef.LatestReleasePath ?? "(none)"); dispatchHighWater.Advance(triggerAt); return curr with { - Content = def with + Content = satisfiedDef with { LastReleaseRequestHandledAt = def.LastReleaseRequestHandledAt is { } sat && sat > triggerAt @@ -1871,7 +1903,7 @@ def.LastReleaseRequestHandledAt is { } h && h > triggerAt : triggerAt } }; - }).Subscribe( + })).Subscribe( _ => { }, ex => logger?.LogWarning(ex, "[ReleaseRequestWatcher] {HubPath}: failed to dispatch release", hubPath)); @@ -2414,6 +2446,49 @@ internal static bool IsSatisfiedByInFlightCompile(NodeTypeDefinition def, string && def.DispatchedBuildInputs is { } dispatched && string.Equals(dispatched, requestedToken, StringComparison.Ordinal); + /// + /// The BUILD half of #1707 slice 3's branch rule, pure: are the BYTES this request asks for + /// already in hand β€” settled over sources that are not + /// dirty, a build still usable in THIS environment + /// (), and no explicit + /// demanding a fresh compile anyway? + /// + /// Deliberately says nothing about the RELEASE β€” this is the predicate that decides + /// whether a COMPILE is needed, and the watcher uses it to know that it may cut the missing + /// release from the build in hand instead of rebuilding bytes that are already correct. + /// + internal static bool BuildInHandAnswersRequest( + MeshNode node, NodeTypeDefinition def, BuildGuards? guards) + => !def.RequestedReleaseForce + && def.CompilationStatus is CompilationStatus.Ok + && !def.IsDirty + && HasUsableBuild(node, def, guards); + + /// + /// 🚨 THE BRANCH RULE for #1707 slice 3, pure: is a release request already answered by the + /// state the node is SITTING ON β€” so that the trigger may be CONSUMED without dispatching + /// anything at all? + /// + /// Two halves, and the second one is MeshWeaver.Plugins#781. The bytes must be in hand + /// () β€” and the node's + /// must not name a DIFFERENT build. The + /// branch consumes the trigger on the same commit path a dispatch would use, so it can never + /// re-fire; consuming it beside a release minted for other bytes is what leaves + /// latestReleasePath permanently older than lastCompiledVersion with the request + /// spent β€” measured on Publish/Deck, where instances went on binding the previous day's + /// assembly under a green Ok. Worse, the state was then UNREPAIRABLE from the outside: + /// a plain re-request was absorbed by this very branch, so asking again could not fix it. + /// + /// Absence is not staleness β€” see + /// for the three + /// inconclusive cases that all answer TRUE, of which "adopted build, no release yet" is the one + /// slice 3 exists for. + /// + internal static bool IsSatisfiedByCurrentBuild( + MeshNode node, NodeTypeDefinition def, BuildGuards? guards) + => BuildInHandAnswersRequest(node, def, guards) + && NodeTypeBuildState.ReleaseNamesBuild(def); + internal static string BuildInputsToken( string? modulesHash, IReadOnlyDictionary? sources) => $"fw={FrameworkVersion};mod={modulesHash ?? "(none)"};src={SourceToken(sources)}"; diff --git a/src/MeshWeaver.Documentation/Data/Architecture/Postmortems/NodeTypeReleaseRedesign.md b/src/MeshWeaver.Documentation/Data/Architecture/Postmortems/NodeTypeReleaseRedesign.md index 2e03a4cfa3..cc96970484 100644 --- a/src/MeshWeaver.Documentation/Data/Architecture/Postmortems/NodeTypeReleaseRedesign.md +++ b/src/MeshWeaver.Documentation/Data/Architecture/Postmortems/NodeTypeReleaseRedesign.md @@ -346,3 +346,83 @@ un-skipped and running today. NodeType simultaneously will get different auto-stamped versions (timestamp differs). Both compile independently; the latest Succeeded wins active status. This is probably fine. + +--- + +## 2026-09-01 β€” "handled" was never the same fact as "released" (MeshWeaver.Plugins#781) + +**Measured state, `Publish/Deck` on memex-cloud, 2026-08-27:** + +| field | value | +|---|---| +| `lastCompileSucceededAt` | `21:53:01.850Z` | +| `lastCompiledVersion` | `575` | +| `latestAssemblyPath` | `Publish_Deck/v575-…dll` | +| `requestedReleaseAt` | `21:52:59.898Z` | +| `lastReleaseRequestHandledAt` | `21:52:59.898Z` β€” consumed | +| `latestReleasePath` | `…/Release/20260826065548-neI3XM25` β€” **the previous morning** | + +`compilationStatus: Ok`, `compiledSources` identical to `currentSourceVersions`, diagnostics clean. +Every single-field read is healthy. Only holding the RELEASE up against the BUILD shows it, and the +consequence is that every consumer following `LatestReleasePath` β€” the build protocol, a +release-pinned activation, the Configuration pane's "β†’ release" link β€” serves the PREVIOUS assembly +while the type reports Ok on the current one. + +### The two facts the design conflated + +`LastReleaseRequestHandledAt` is stamped **at dispatch**, on the same commit that flips to `Pending`. +That is correct as a re-fire guard and wrong as a delivery receipt, and the field was doing both +jobs. Between dispatch and a Release node existing there are three ways to end up with nothing, and +all three were **silent and indistinguishable from "no release was asked for"**: + +1. `TryCreateReleaseNode`'s bound expired β€” it returned `null` through a bare + `Timeout(_, Observable.Return(null))` with no log line at all. +2. The create faulted or was refused (it runs under the REQUESTER's identity for attribution, so a + partition the requester cannot write refuses it) β€” logged at Warning, swallowed. +3. The request was answered by the build already in hand (#1707 slice 3) β€” which dispatches nothing, + and therefore creates nothing. + +In every case `ApplyCompileSuccess` stamps `LatestReleasePath = releasePath ?? def.LatestReleasePath` +β€” the previous build's release β€” and the trigger is spent. + +**The state was also unrepairable from the outside.** A second, ordinary release request hit case 3: +the bytes ARE current, so the request was consumed again without producing anything. Only +`RequestedReleaseForce` escaped, and only if someone knew to look. + +### The invariant + +> **`LatestReleasePath` must never name a build older than `LastCompiledVersion` once +> `RequestedReleaseAt` has been consumed.** + +It is answerable from the node alone, with no store probe: a release version is +`{yyyyMMddHHmmss}-{hash}` where the hash is minted from the build's DURABLE store coordinates +(`{Collection}/{ContentPath}`), which are exactly what `LatestAssemblyCollection` / +`LatestAssemblyPath` carry. Mint and check are now the same function applied twice +(`NodeTypeBuildState.ReleaseVersionHash`), so they cannot drift into disagreeing about what a +release version means. + +### What was done + +- `NodeTypeBuildState.ReleaseNamesBuild` β€” the cheap comparison, pure. **Inconclusive answers TRUE** + in three cases, each deliberate: no release at all (absence is not staleness β€” an ADOPTED build has + never had one, and calling that stale would put a compile back on every install), no durable + coordinates (the Null-store path mints from a process-local location), and a version shape this + mint did not produce. +- `IsSatisfiedByCurrentBuild` = `BuildInHandAnswersRequest` **and** `ReleaseNamesBuild`. The branch + consumes the trigger on the same commit path a dispatch would use, so it can never re-fire β€” + consuming it beside a release minted for other bytes is what made #781 permanent. +- `TryCutReleaseForBuildInHand` β€” when the bytes are right but the release is not, the release is cut + **from those bytes**, no compile, under the same System execution scope the compile's own release + create uses. A cut that does not land leaves the clause false, so the request falls through to the + compile path and gets its release from the compile's terminal create; it is never consumed for + nothing. +- The bound now **delivers its diagnostic** (an ERROR naming the type and the attempted release), + which is the same rule `ReadBudget` states for nested read bounds: the inner bound is the only one + that knows WHICH write starved, so it must say so rather than degrade to a null. + +### The rule this leaves behind + +A one-shot trigger must be consumed by the thing it asked for, not by the attempt. Where the two +cannot be made the same write, the gap needs a cheap end-state comparison that some later pass can +run β€” otherwise the failure is stable, self-consistent and invisible, and it is found by a human +noticing that a merged fix is not live. diff --git a/src/MeshWeaver.Documentation/Data/WhatsNew/2026-09-01-a-release-now-names-the-build-you-asked-it-to-publish.md b/src/MeshWeaver.Documentation/Data/WhatsNew/2026-09-01-a-release-now-names-the-build-you-asked-it-to-publish.md new file mode 100644 index 0000000000..0ab089b6f8 --- /dev/null +++ b/src/MeshWeaver.Documentation/Data/WhatsNew/2026-09-01-a-release-now-names-the-build-you-asked-it-to-publish.md @@ -0,0 +1,48 @@ +--- +Name: A release now names the build you asked it to publish +Category: Fix +Description: Asking a type for a release could be recorded as done while the newest release still pointed at an older build β€” so instances went on serving the previous assembly under a green status. The request is no longer counted as answered until a release for that exact build exists. +Icon: TagMultiple +Order: -20260901 +--- + +# A release now names the build you asked it to publish + +Every dynamic type on the mesh carries two facts side by side: **the build it last compiled**, and +**the release that build was published as**. Almost everything a reader does with a type follows the +second one β€” a portal deciding which assembly to bind, the "β†’ release" link on the Configuration +pane, an installation adopting a published build. + +The two could drift apart, and nothing said so. + +A release request was recorded as *handled* the moment it was picked up, not when a release actually +existed. If the release could not then be cut β€” the write did not land inside its budget, or was +refused β€” the compile still succeeded and the type still settled **Ok**, over current sources, with a +current assembly. The release beside it went on naming the **previous** build. Every individual field +read healthy; only holding the release up against the build revealed it. + +That state was stable, and it was silent. It was found the way this kind of defect is always found: +somebody noticed that a merged fix was not live. A slide deck kept showing pre-fix behaviour for a +day while its type reported a clean compile of the fixed source. + +Worse, it could not be repaired by asking again. A second, ordinary release request was answered +"you already have this build" β€” true about the bytes, and the reason the request was consumed a +second time without producing anything. Only forcing a rebuild got out of it, and only if you knew +to look. + +**Three things changed.** + +- **A request is not answered until a release for that build exists.** The check is exact rather than + chronological: a release version is minted from the build's own storage identity, so the platform + can ask "does this release name these bytes?" from the type's record alone, with no lookup. +- **When the bytes are already right, the missing release is cut from them** β€” no rebuild. A type + whose build arrived prepackaged, or was produced by another replica, still gets a proper release + without paying for a compile it does not need. Absence of a release is treated as absence, never as + staleness, so installations that legitimately have no release yet behave exactly as before. +- **A release that cannot be cut now says so, loudly.** Giving up used to be indistinguishable from + never having been asked. It is now an error in the log naming the type and the release that was + attempted, so the next occurrence is one search away instead of a comparison between two + timestamps. + +A forced rebuild is unchanged, and so is everything about how compiles are scheduled. What changed is +that "published" is now a statement the platform will not make until it is true. diff --git a/test/MeshWeaver.Compiler.Pipeline.Test/ReleaseNamesTheBuildTest.cs b/test/MeshWeaver.Compiler.Pipeline.Test/ReleaseNamesTheBuildTest.cs new file mode 100644 index 0000000000..db95883405 --- /dev/null +++ b/test/MeshWeaver.Compiler.Pipeline.Test/ReleaseNamesTheBuildTest.cs @@ -0,0 +1,153 @@ +using System.Collections.Immutable; +using MeshWeaver.Graph.Configuration; +using MeshWeaver.Mesh; +using MeshWeaver.Mesh.Services; +using Xunit; + +namespace MeshWeaver.Graph.Test; + +/// +/// 🚨 A consumed release request must LEAVE A RELEASE FOR THE BUILD IT ASKED FOR +/// (MeshWeaver.Plugins#781). +/// +/// Measured on memex-cloud, Publish/Deck, 2026-08-27: the node settled +/// CompilationStatus.Ok over current sources, lastCompiledVersion 575, +/// latestAssemblyPath naming v575's bytes β€” and latestReleasePath naming a release cut +/// the PREVIOUS MORNING, with lastReleaseRequestHandledAt equal to requestedReleaseAt, +/// i.e. the request spent. Nothing retried, nothing errored, and every single-field check reads +/// healthy; only comparing the release against the build reveals it. A consumer following +/// LatestReleasePath β€” the build protocol, the release-pinned activation, the GUI's +/// "β†’ release" link β€” therefore adopts the PREVIOUS build while the node reports Ok on the +/// current one. Silent wrongness, self-stabilising, discovered by somebody noticing that a merged +/// fix was not live. +/// +/// The invariant, from the issue: latestReleasePath must never be older than +/// lastCompiledVersion while requestedReleaseAt has been consumed. It is +/// answerable from the node alone: a release version is {yyyyMMddHHmmss}-{hash} and the hash +/// is minted from the build's durable assembly-store coordinates, so the mint and the check are the +/// same function applied twice. +/// +public class ReleaseNamesTheBuildTest +{ + private const string Collection = "assemblies"; + private const string Bytes575 = "Publish_Deck/v575-s8929555-aadb349047af.dll"; + private const string Bytes569 = "Publish_Deck/v569-s8929555-0be1c4d21f77.dll"; + private const string SourcePath = "Publish/Deck/Source/Deck"; + + /// A release path exactly as TryCreateReleaseNode mints it for + /// β€” same hash function, so this is the real shape. + private static string ReleasePathFor(string contentPath, string minted) => + "Publish/Deck/Release/" + minted + "-" + + NodeTypeBuildState.ReleaseVersionHash($"{Collection}/{contentPath}"); + + private static MeshNode Node() => new("Deck", "Publish"); + + /// The #781 shape: settled Ok, sources clean, a usable build of v575's bytes β€” and + /// whatever release path the case under test wants to put beside it. + private static NodeTypeDefinition Def(string? latestReleasePath) => new() + { + CompilationStatus = CompilationStatus.Ok, + CompiledFrameworkVersion = NodeTypeCompilationHelpers.FrameworkVersion, + LatestAssemblyCollection = Collection, + LatestAssemblyPath = Bytes575, + LastCompiledVersion = 575, + CompiledSources = ImmutableDictionary.Empty.Add(SourcePath, 42), + CurrentSourceVersions = ImmutableDictionary.Empty.Add(SourcePath, 42), + LatestReleasePath = latestReleasePath, + }; + + [Fact] + public void AReleaseMintedForTheseBytes_NamesThem() + => NodeTypeBuildState + .ReleaseNamesBuild(Def(ReleasePathFor(Bytes575, "20260827215301"))) + .Should().BeTrue( + "the mint and the check are the same hash over the same durable coordinates β€” if " + + "this ever disagrees the two have drifted and every verdict below is worthless"); + + [Fact] + public void AReleaseMintedForOTHERBytes_IsDetected() + => NodeTypeBuildState + .ReleaseNamesBuild(Def(ReleasePathFor(Bytes569, "20260826065548"))) + .Should().BeFalse( + "this is the measured #781 state: build v575, release cut for v569's bytes the " + + "previous morning"); + + [Fact] + public void NoReleaseAtAll_IsInconclusive_NotStale() + => NodeTypeBuildState.ReleaseNamesBuild(Def(null)).Should().BeTrue( + "absence is not staleness β€” a build ADOPTED from a prebuilt bundle has never had a " + + "release, and #1707 slice 3 deliberately answers such a request from the build in " + + "hand; calling it stale would put a compile back on every install"); + + [Fact] + public void NoDurableCoordinates_IsInconclusive() + => NodeTypeBuildState + .ReleaseNamesBuild(Def(ReleasePathFor(Bytes569, "20260826065548")) with + { + LatestAssemblyCollection = null + }) + .Should().BeTrue( + "the Null-store path mints from a process-local assembly location no other " + + "process can reproduce β€” the check cannot tell, so it must not accuse"); + + [Fact] + public void AVersionThisMintDidNotProduce_IsInconclusive() + => NodeTypeBuildState + .ReleaseNamesBuild(Def("Publish/Deck/Release/hand-written-v1")) + .Should().BeTrue("a foreign version shape is not evidence of anything"); + + /// + /// 🚨 THE DEFECT. A release request answered by the build in hand is consumed on the same + /// commit path a dispatch would use β€” so it can never re-fire. Consuming it while the node's + /// release names DIFFERENT bytes is what makes #781 permanent: the request is spent, the + /// release stays older than the build, and a plain (unforced) re-request is absorbed the same + /// way, so the state cannot be repaired by asking again. + /// + [Fact] + public void ARequestIsNotSatisfiedWhileTheReleaseNamesOtherBytes() + => NodeTypeCompilationHelpers + .IsSatisfiedByCurrentBuild( + Node(), Def(ReleasePathFor(Bytes569, "20260826065548")), guards: null) + .Should().BeFalse( + "the request asks for a RELEASE of this build; consuming it against a release " + + "minted for other bytes produces nothing and can never be re-asked"); + + [Fact] + public void ARequestISSatisfiedWhenTheReleaseNamesTheBuild() + => NodeTypeCompilationHelpers + .IsSatisfiedByCurrentBuild( + Node(), Def(ReleasePathFor(Bytes575, "20260827215301")), guards: null) + .Should().BeTrue( + "#1707 slice 3 stands where it is honest: the release the request asks for " + + "already exists and names these exact bytes"); + + [Fact] + public void AnAdoptedBuildWithNoReleaseIsStillSatisfied() + => NodeTypeCompilationHelpers + .IsSatisfiedByCurrentBuild(Node(), Def(null), guards: null) + .Should().BeTrue( + "the install path adopts prebuilt bytes and then requests a release; answering " + + "that from the build in hand is #1707 slice 3's whole point, and this test is " + + "what stops the #781 fix from quietly putting the compile back"); + + [Fact] + public void AForcedRequestAlwaysCompiles() + => NodeTypeCompilationHelpers + .IsSatisfiedByCurrentBuild( + Node(), + Def(ReleasePathFor(Bytes575, "20260827215301")) with { RequestedReleaseForce = true }, + guards: null) + .Should().BeFalse("force is the user's escape hatch and outranks every optimisation"); + + [Fact] + public void ADirtyDefinitionIsNeverSatisfied() + => NodeTypeCompilationHelpers + .IsSatisfiedByCurrentBuild( + Node(), + Def(ReleasePathFor(Bytes575, "20260827215301")) with + { + CurrentSourceVersions = ImmutableDictionary.Empty.Add(SourcePath, 43) + }, + guards: null) + .Should().BeFalse("the sources moved since the build β€” this request must compile them"); +}