diff --git a/docs/plans/2026-07-08-script-artifact-invalidation-contract.md b/docs/plans/2026-07-08-script-artifact-invalidation-contract.md new file mode 100644 index 00000000..81bce194 --- /dev/null +++ b/docs/plans/2026-07-08-script-artifact-invalidation-contract.md @@ -0,0 +1,58 @@ +# Script-Artifact Invalidation Contract (`ScriptArtifactsChanged`) + +**Status:** Seam + first producer shipped in PLAN-05 (arch-review 05, Task 14). Consumer + additional producers land in **plan 06**. + +**Problem.** When a script-bearing artifact's definition changes at runtime — an Inbound API method's script, a shared script, or a template's scripts — any node that has already **compiled and cached** a handler/delegate for it keeps serving the stale version until a process restart. This is the root of two recorded project-memory gotchas (a DB-direct edit to a broken inbound method keeps returning "Script compilation failed" from `_knownBadMethods`; a non-compiling update leaves the old delegate serving). A bundle import is a third mutation path with the same hazard. + +The fix is a small, explicit contract for "a script artifact changed → invalidate anything compiled from it," decoupled so producers (importer, management edits, failover) and consumers (compiled-handler caches) never reference each other. + +## The seam (Commons) + +```csharp +// Commons/Messages/Scripting/ScriptArtifactsChanged.cs +public sealed record ScriptArtifactsChanged( + string ArtifactKind, // ScriptArtifactKinds.ApiMethod | SharedScript | Template + IReadOnlyList Names, // post-resolution (renamed) names + string Source, // "BundleImport" | "Management" | "FailoverActivation" + DateTimeOffset OccurredAtUtc); + +public static class ScriptArtifactKinds +{ + public const string ApiMethod = "ApiMethod"; + public const string SharedScript = "SharedScript"; + public const string Template = "Template"; +} + +// Commons/Interfaces/Scripting/IScriptArtifactChangeBus.cs +public interface IScriptArtifactChangeBus +{ + void Publish(ScriptArtifactsChanged notification); + IDisposable Subscribe(Action handler); +} +``` + +## Semantics (normative) + +1. **Advisory, at-least-once.** A notification is a hint to invalidate, not a command with delivery guarantees. Duplicates are allowed; over-notification (e.g. an `Overwrite` resolution that actually fell through to `Add`) is allowed and harmless. +2. **Published only AFTER commit.** A producer publishes *after* its mutating transaction commits — never inside it. A notification for a rolled-back write is worse than a missed one. +3. **Never faults the producer.** The bus swallows-and-logs any subscriber-handler exception; the producer additionally guards its own publish call. A misbehaving consumer can never turn a committed write into a reported failure. +4. **In-process, per node.** `InProcessScriptArtifactChangeBus` (Host) is node-local: a lock-free copy-on-write subscriber list. It does **not** cross the cluster. Cross-node and failover freshness are the **consumer's** responsibility — every consumer MUST also self-heal without a notification (e.g. a content-hash / revision check on next use), because a notification published on node A does not reach node B, and a node that was down during the change never hears it. +5. **Names are post-resolution.** `Names` carries the artifact's persisted name after import name-resolution (a `Rename` carries `RenameTo`). + +## What plan 05 ships + +- The Commons seam (record + kinds + interface). +- `InProcessScriptArtifactChangeBus` in the Host, registered as a central-role singleton. +- The **Transport bundle importer as the first producer**: `BundleImporter.ApplyAsync` collects the Overwrite/Rename resolutions for `ApiMethod` / `SharedScript` / `Template` and publishes one `ScriptArtifactsChanged` per kind **after** `tx.CommitAsync`. The importer's dependency on the bus is optional (`IScriptArtifactChangeBus?`) so Transport-only hosts (and unit tests) run without it. + +## Handoff to plan 06 + +Plan 06 implements the consumer side and the remaining producers: + +| Item | Responsibility | +|---|---| +| **(a) Inbound API consumer** | `InboundScriptExecutor` subscribes to the bus and, on an `ApiMethod` notification, evicts the named entries from `_scriptHandlers` **and** `_knownBadMethods` — closing the "DB-direct edit keeps returning compile error" and "non-compiling update keeps old handler" gotchas. | +| **(b) Management producers** | Publish `ScriptArtifactsChanged` from the `ManagementActor` `UpdateApiMethod` / shared-script / template-script edit paths (`Source = "Management"`), so a UI/CLI edit invalidates the same caches a bundle import does. | +| **(c) Cross-node / failover freshness** | A revision-keyed handler cache (compile keyed by code hash — see PLAN-05 Task 15's `ScriptCompileVerdictCache`) so a node that missed the in-process notification still self-heals on next use, and a newly-active failover node never serves a stale compile. | + +Until (a)–(c) land, plan 05's publisher is a no-op in practice (no subscribers), but the seam is stable and the producer is correct, so plan 06 is purely additive. diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Scripting/IScriptArtifactChangeBus.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Scripting/IScriptArtifactChangeBus.cs new file mode 100644 index 00000000..ff782431 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Scripting/IScriptArtifactChangeBus.cs @@ -0,0 +1,27 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Scripting; + +namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Scripting; + +/// +/// In-process, per-node publish/subscribe seam for +/// . Producers (e.g. the Transport bundle +/// importer, the ManagementActor script-editing paths) publish AFTER their mutating +/// transaction commits; consumers (e.g. the Inbound API compiled-handler cache) +/// subscribe and evict. +/// +/// A conforming implementation MUST swallow-and-log any exception thrown by a +/// subscriber's handler — a misbehaving consumer must never fault a producer's +/// commit path. +/// +/// +public interface IScriptArtifactChangeBus +{ + /// Publishes a change notification to every current subscriber. Never throws on a subscriber fault. + /// The change notification to deliver. + void Publish(ScriptArtifactsChanged notification); + + /// Subscribes a handler; dispose the returned token to unsubscribe. + /// The handler invoked for each published notification. + /// A disposable that removes the subscription when disposed. + IDisposable Subscribe(Action handler); +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Scripting/ScriptArtifactsChanged.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Scripting/ScriptArtifactsChanged.cs new file mode 100644 index 00000000..ce59beb3 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Scripting/ScriptArtifactsChanged.cs @@ -0,0 +1,41 @@ +namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Scripting; + +/// +/// Advisory notification that one or more script-bearing artifacts changed on this +/// node. It is at-least-once and is published only AFTER the mutating +/// transaction commits — never inside it (a notification for a rolled-back write is +/// worse than a missed one). +/// +/// Consumers MUST invalidate any compiled/cached artifact keyed by these names, and +/// MUST ALSO be able to self-heal without the notification (e.g. a +/// content-hash / revision check on next use) — this bus is in-process, per +/// node; cross-node and failover propagation is the consumer's responsibility. +/// See docs/plans/2026-07-08-script-artifact-invalidation-contract.md. +/// +/// +/// One of — the kind of artifact that changed. +/// The post-resolution (renamed) artifact names that changed. +/// Origin of the change: "BundleImport", "Management", or "FailoverActivation". +/// When the committing transaction completed. +public sealed record ScriptArtifactsChanged( + string ArtifactKind, + IReadOnlyList Names, + string Source, + DateTimeOffset OccurredAtUtc); + +/// +/// The canonical set of values — +/// the artifact classes that carry compiled/cached script and therefore need +/// invalidation when their definition changes. +/// +public static class ScriptArtifactKinds +{ + /// An Inbound API method (its script handler is compiled and cached). + public const string ApiMethod = "ApiMethod"; + + /// A shared script (compiled inline at every call site that references it). + public const string SharedScript = "SharedScript"; + + /// A template (its scripts flatten into every instance built from it). + public const string Template = "Template"; +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs index 6bea9790..d658e7fb 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs @@ -90,6 +90,13 @@ try // TransportOptions from ScadaBridge:Transport via BindConfiguration; no // explicit Configure needed. builder.Services.AddTransport(); + // Script-artifact invalidation bus (#05-T14): in-process, per-node pub/sub + // for ScriptArtifactsChanged. The Transport bundle importer publishes + // post-commit; the Inbound API compiled-handler cache subscribes as the + // consumer (wired in plan 06). Central-only — it lives beside the importer. + builder.Services.AddSingleton< + ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Scripting.IScriptArtifactChangeBus, + ZB.MOM.WW.ScadaBridge.Host.Services.InProcessScriptArtifactChangeBus>(); // Audit Log — central node owns the AuditLogIngestActor singleton + // IAuditLogRepository. The site writer chain is still registered (lazy // singletons) but is never resolved on a central node. diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/Services/InProcessScriptArtifactChangeBus.cs b/src/ZB.MOM.WW.ScadaBridge.Host/Services/InProcessScriptArtifactChangeBus.cs new file mode 100644 index 00000000..832fd975 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Host/Services/InProcessScriptArtifactChangeBus.cs @@ -0,0 +1,112 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Scripting; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Scripting; + +namespace ZB.MOM.WW.ScadaBridge.Host.Services; + +/// +/// In-process, per-node : a lock-free +/// copy-on-write subscriber list. snapshots the current +/// handlers and invokes each, swallowing and logging any handler fault so a +/// misbehaving consumer can never fault a producer's post-commit publish. +/// +/// This is deliberately node-local — cross-node / failover propagation is the +/// consumer's responsibility (content-hash / revision self-heal). See +/// docs/plans/2026-07-08-script-artifact-invalidation-contract.md. +/// +/// +public sealed class InProcessScriptArtifactChangeBus : IScriptArtifactChangeBus +{ + private readonly Lock _writeLock = new(); + private readonly ILogger _logger; + + // Copy-on-write: readers (Publish) take the reference without locking; writers + // (Subscribe/unsubscribe) swap in a new immutable array under _writeLock. + private volatile Action[] _handlers = + Array.Empty>(); + + /// Initializes the bus with a logger for swallowed handler faults. + /// Logger used to record subscriber handler exceptions. + public InProcessScriptArtifactChangeBus(ILogger logger) => + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + /// + public void Publish(ScriptArtifactsChanged notification) + { + ArgumentNullException.ThrowIfNull(notification); + + // Snapshot without locking — the array is never mutated in place. + var handlers = _handlers; + foreach (var handler in handlers) + { + try + { + handler(notification); + } + catch (Exception ex) + { + // A bad consumer must NEVER fault the producer's commit path. + _logger.LogError( + ex, + "Script-artifact-change subscriber threw handling {Kind} ({Count} names) from {Source}; ignored.", + notification.ArtifactKind, + notification.Names.Count, + notification.Source); + } + } + } + + /// + public IDisposable Subscribe(Action handler) + { + ArgumentNullException.ThrowIfNull(handler); + + lock (_writeLock) + { + var updated = new Action[_handlers.Length + 1]; + Array.Copy(_handlers, updated, _handlers.Length); + updated[^1] = handler; + _handlers = updated; + } + + return new Subscription(this, handler); + } + + private void Unsubscribe(Action handler) + { + lock (_writeLock) + { + var index = Array.IndexOf(_handlers, handler); + if (index < 0) + { + return; + } + + var updated = new Action[_handlers.Length - 1]; + Array.Copy(_handlers, 0, updated, 0, index); + Array.Copy(_handlers, index + 1, updated, index, _handlers.Length - index - 1); + _handlers = updated; + } + } + + private sealed class Subscription : IDisposable + { + private readonly InProcessScriptArtifactChangeBus _bus; + private Action? _handler; + + public Subscription(InProcessScriptArtifactChangeBus bus, Action handler) + { + _bus = bus; + _handler = handler; + } + + public void Dispose() + { + // Idempotent: only the first Dispose removes the handler. + var handler = Interlocked.Exchange(ref _handler, null); + if (handler is not null) + { + _bus.Unsubscribe(handler); + } + } + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs index 3be5b05b..1bab567a 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs @@ -12,8 +12,10 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Scripting; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Scripting; using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; @@ -80,6 +82,7 @@ public sealed class BundleImporter : IBundleImporter // in a host that registers Transport without DeploymentManager — staleness is // then best-effort empty (informational only, never gates the import). private readonly IStaleInstanceProbe? _staleInstanceProbe; + private readonly IScriptArtifactChangeBus? _scriptArtifactChangeBus; private readonly ILogger? _logger; /// @@ -103,6 +106,7 @@ public sealed class BundleImporter : IBundleImporter /// EF Core context used to commit the import transaction. /// Validates template references before applying. /// Optional: recomputes a deployed instance's current revision hash so the importer can enumerate instances stale-ed by a template overwrite. Null when no flattening pipeline is registered (Transport-without-DeploymentManager hosts) — staleness is then skipped. + /// Optional: node-local bus notified AFTER a successful commit that script-bearing artifacts (ApiMethod / SharedScript / Template) were overwritten or renamed, so compiled-handler caches can invalidate. Null on hosts that don't register it (staleness/invalidation then relies on the consumer's own self-heal). /// Optional logger. public BundleImporter( BundleSerializer bundleSerializer, @@ -123,6 +127,7 @@ public sealed class BundleImporter : IBundleImporter ScadaBridgeDbContext dbContext, SemanticValidator semanticValidator, IStaleInstanceProbe? staleInstanceProbe = null, + IScriptArtifactChangeBus? scriptArtifactChangeBus = null, ILogger? logger = null) { _bundleSerializer = bundleSerializer ?? throw new ArgumentNullException(nameof(bundleSerializer)); @@ -143,6 +148,7 @@ public sealed class BundleImporter : IBundleImporter _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext)); _semanticValidator = semanticValidator ?? throw new ArgumentNullException(nameof(semanticValidator)); _staleInstanceProbe = staleInstanceProbe; + _scriptArtifactChangeBus = scriptArtifactChangeBus; _logger = logger; } @@ -1112,6 +1118,14 @@ public sealed class BundleImporter : IBundleImporter await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false); await tx.CommitAsync(ct).ConfigureAwait(false); + // #05-T14: the write is now durable — advise any node-local compiled- + // artifact cache that script-bearing artifacts changed so it can + // invalidate by name. Published AFTER commit (never inside the + // transaction — a notification for a rolled-back write is worse than a + // missed one) and defensively guarded so a bad subscriber can never + // turn a committed import into a reported failure. + PublishScriptArtifactChanges(resolutions); + // T-007: zero out the decrypted plaintext BEFORE remove so any // caller-held reference (e.g. the Razor page that built the // ImportPreview) sees the cleared buffer too. Remove drops the @@ -1537,6 +1551,57 @@ public sealed class BundleImporter : IBundleImporter /// will still fall through to the unresolved-audit path — call sites are /// not rewritten in v1. /// + /// + /// #05-T14 — publishes one per script-bearing + /// artifact kind (ApiMethod / SharedScript / Template) whose resolution was an + /// Overwrite or Rename, using the post-resolution (renamed) names. Adds are + /// excluded — nothing is cached under a brand-new name yet. Over-approximation is + /// safe (an Overwrite that actually fell through to Add just triggers a harmless + /// consumer re-check), which matches the bus's advisory, at-least-once contract. + /// Fully guarded: never throws (it runs after the transaction has committed). + /// + private void PublishScriptArtifactChanges(IReadOnlyList resolutions) + { + if (_scriptArtifactChangeBus is null) + { + return; + } + + try + { + PublishKind(resolutions, "ApiMethod", ScriptArtifactKinds.ApiMethod); + PublishKind(resolutions, "SharedScript", ScriptArtifactKinds.SharedScript); + PublishKind(resolutions, "Template", ScriptArtifactKinds.Template); + } + catch (Exception ex) + { + // The import already committed — a publish fault must never surface as + // an import failure. Swallow-and-log (the bus itself also guards + // per-subscriber, so this only catches pathological cases). + _logger?.LogError(ex, "Publishing script-artifact-change notifications after a committed bundle import failed; ignored."); + } + + void PublishKind(IReadOnlyList all, string entityType, string kind) + { + var names = all + .Where(r => string.Equals(r.EntityType, entityType, StringComparison.Ordinal) + && (r.Action is ResolutionAction.Overwrite or ResolutionAction.Rename)) + .Select(r => r.Action == ResolutionAction.Rename ? r.RenameTo ?? r.Name : r.Name) + .Distinct(StringComparer.Ordinal) + .ToList(); + if (names.Count == 0) + { + return; + } + + _scriptArtifactChangeBus!.Publish(new ScriptArtifactsChanged( + ArtifactKind: kind, + Names: names, + Source: "BundleImport", + OccurredAtUtc: _timeProvider.GetUtcNow())); + } + } + private static Template BuildTemplate(TemplateDto dto, string? overrideName) { var t = new Template(overrideName ?? dto.Name) { Description = dto.Description }; diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/InProcessScriptArtifactChangeBusTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/InProcessScriptArtifactChangeBusTests.cs new file mode 100644 index 00000000..44e705ef --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/InProcessScriptArtifactChangeBusTests.cs @@ -0,0 +1,85 @@ +using Microsoft.Extensions.Logging.Abstractions; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Scripting; +using ZB.MOM.WW.ScadaBridge.Host.Services; + +namespace ZB.MOM.WW.ScadaBridge.Host.Tests; + +/// +/// #05-T14: unit tests for the in-process script-artifact change bus — delivery, +/// unsubscribe, and the swallow-and-log guard that keeps a faulting subscriber from +/// faulting the producer. +/// +public sealed class InProcessScriptArtifactChangeBusTests +{ + private static InProcessScriptArtifactChangeBus NewBus() => + new(NullLogger.Instance); + + private static ScriptArtifactsChanged Sample(string kind = ScriptArtifactKinds.ApiMethod) => + new(kind, new[] { "M1" }, "Management", DateTimeOffset.UnixEpoch); + + [Fact] + public void Publish_delivers_to_all_current_subscribers() + { + var bus = NewBus(); + var a = new List(); + var b = new List(); + bus.Subscribe(a.Add); + bus.Subscribe(b.Add); + + var n = Sample(); + bus.Publish(n); + + Assert.Same(n, Assert.Single(a)); + Assert.Same(n, Assert.Single(b)); + } + + [Fact] + public void Disposing_subscription_stops_delivery() + { + var bus = NewBus(); + var received = new List(); + var token = bus.Subscribe(received.Add); + + bus.Publish(Sample()); + token.Dispose(); + bus.Publish(Sample()); + + Assert.Single(received); // only the pre-dispose publish landed. + } + + [Fact] + public void Dispose_is_idempotent() + { + var bus = NewBus(); + var received = new List(); + var token = bus.Subscribe(received.Add); + + token.Dispose(); + token.Dispose(); // must not throw nor corrupt the handler list. + + bus.Publish(Sample()); + Assert.Empty(received); + } + + [Fact] + public void A_faulting_subscriber_does_not_prevent_delivery_to_others_nor_throw() + { + var bus = NewBus(); + var good = new List(); + bus.Subscribe(_ => throw new InvalidOperationException("bad consumer")); + bus.Subscribe(good.Add); + + // Publish must not propagate the subscriber fault... + var ex = Record.Exception(() => bus.Publish(Sample())); + Assert.Null(ex); + // ...and the well-behaved subscriber still receives it. + Assert.Single(good); + } + + [Fact] + public void Publish_with_no_subscribers_is_a_noop() + { + var bus = NewBus(); + Assert.Null(Record.Exception(() => bus.Publish(Sample()))); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterApplyTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterApplyTests.cs index 5c16c0a3..8f16e58d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterApplyTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterApplyTests.cs @@ -5,12 +5,15 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment; using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Scripting; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Scripting; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase; @@ -42,6 +45,7 @@ namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import; public sealed class BundleImporterApplyTests : IDisposable { private readonly ServiceProvider _provider; + private readonly RecordingScriptArtifactChangeBus _artifactBus = new(); public BundleImporterApplyTests() { @@ -89,6 +93,10 @@ public sealed class BundleImporterApplyTests : IDisposable services.AddScoped(); services.AddScoped(); services.AddTransport(); + // #05-T14: a recording bus so the post-commit ScriptArtifactsChanged + // publish is observable. BundleImporter takes IScriptArtifactChangeBus? + // optionally, so this registration flows in via DI activation. + services.AddSingleton(_artifactBus); // #16 (M8 D2): the stale-instance probe is implemented in DeploymentManager // and the flatten/hash primitives in TemplateEngine — register both so // BundleImporter resolves a real IStaleInstanceProbe and ApplyAsync can @@ -1324,4 +1332,128 @@ public sealed class BundleImporterApplyTests : IDisposable Assert.Contains(deployedInstanceId, result.StaleInstanceIds); Assert.DoesNotContain(notDeployedInstanceId, result.StaleInstanceIds); } + + // ============ #05-T14: post-commit ScriptArtifactsChanged publish ============ + + [Fact] + public async Task ApplyAsync_publishes_ScriptArtifactsChanged_per_kind_after_commit() + { + // Seed a Template, a SharedScript and an ApiMethod; Overwrite all three. + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + var t = new Template("Pump") { Description = "tpl" }; + t.Scripts.Add(new TemplateScript("init", "return 1;")); + ctx.Templates.Add(t); + ctx.SharedScripts.Add(new SharedScript("HelperFn", "return 1;")); + ctx.ApiMethods.Add(new ApiMethod("DoThing", "return 1;") { TimeoutSeconds = 30 }); + await ctx.SaveChangesAsync(); + } + + Guid sessionId; + await using (var scope = _provider.CreateAsyncScope()) + { + var exporter = scope.ServiceProvider.GetRequiredService(); + var ctx = scope.ServiceProvider.GetRequiredService(); + var selection = new ExportSelection( + TemplateIds: await ctx.Templates.Select(t => t.Id).ToListAsync(), + SharedScriptIds: await ctx.SharedScripts.Select(s => s.Id).ToListAsync(), + ExternalSystemIds: Array.Empty(), + DatabaseConnectionIds: Array.Empty(), + NotificationListIds: Array.Empty(), + SmtpConfigurationIds: Array.Empty(), + ApiMethodIds: await ctx.ApiMethods.Select(m => m.Id).ToListAsync(), + IncludeDependencies: false); + var stream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev", + passphrase: null, cancellationToken: CancellationToken.None); + using var ms = new MemoryStream(); + await stream.CopyToAsync(ms); + ms.Position = 0; + var importer = scope.ServiceProvider.GetRequiredService(); + var session = await importer.LoadAsync(ms, passphrase: null); + sessionId = session.SessionId; + } + + // Act — Overwrite all three (they still exist in the target). + await using (var scope = _provider.CreateAsyncScope()) + { + var importer = scope.ServiceProvider.GetRequiredService(); + await importer.ApplyAsync(sessionId, + new List + { + new("Template", "Pump", ResolutionAction.Overwrite, null), + new("SharedScript", "HelperFn", ResolutionAction.Overwrite, null), + new("ApiMethod", "DoThing", ResolutionAction.Overwrite, null), + }, + user: "bob"); + } + + // Assert — exactly one notification per kind, post-commit, with the names. + var byKind = _artifactBus.Received.ToDictionary(n => n.ArtifactKind); + Assert.Equal(3, _artifactBus.Received.Count); + Assert.Equal(new[] { "DoThing" }, byKind[ScriptArtifactKinds.ApiMethod].Names); + Assert.Equal(new[] { "HelperFn" }, byKind[ScriptArtifactKinds.SharedScript].Names); + Assert.Equal(new[] { "Pump" }, byKind[ScriptArtifactKinds.Template].Names); + Assert.All(_artifactBus.Received, n => Assert.Equal("BundleImport", n.Source)); + } + + [Fact] + public async Task ApplyAsync_failed_apply_publishes_nothing() + { + // A template whose script calls an unresolved helper fails semantic + // validation BEFORE commit, so nothing is published. + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + var t = new Template("Bad"); + t.Scripts.Add(new TemplateScript("init", "var x = UnknownHelper();")); + ctx.Templates.Add(t); + await ctx.SaveChangesAsync(); + } + + Guid sessionId; + await using (var scope = _provider.CreateAsyncScope()) + { + var exporter = scope.ServiceProvider.GetRequiredService(); + var ctx = scope.ServiceProvider.GetRequiredService(); + var selection = new ExportSelection( + TemplateIds: await ctx.Templates.Select(t => t.Id).ToListAsync(), + SharedScriptIds: Array.Empty(), + ExternalSystemIds: Array.Empty(), + DatabaseConnectionIds: Array.Empty(), + NotificationListIds: Array.Empty(), + SmtpConfigurationIds: Array.Empty(), + ApiMethodIds: Array.Empty(), + IncludeDependencies: false); + var stream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev", + passphrase: null, cancellationToken: CancellationToken.None); + using var ms = new MemoryStream(); + await stream.CopyToAsync(ms); + ms.Position = 0; + var importer = scope.ServiceProvider.GetRequiredService(); + sessionId = (await importer.LoadAsync(ms, passphrase: null)).SessionId; + } + + await using (var scope = _provider.CreateAsyncScope()) + { + var importer = scope.ServiceProvider.GetRequiredService(); + await Assert.ThrowsAsync(() => + importer.ApplyAsync(sessionId, + new List { new("Template", "Bad", ResolutionAction.Add, null) }, + user: "bob")); + } + + Assert.Empty(_artifactBus.Received); + } + + /// Records every published notification for assertion. + private sealed class RecordingScriptArtifactChangeBus : IScriptArtifactChangeBus + { + public List Received { get; } = new(); + + public void Publish(ScriptArtifactsChanged notification) => Received.Add(notification); + + public IDisposable Subscribe(Action handler) => + throw new NotSupportedException("The recording bus does not support subscriptions."); + } }