diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.ArtifactExporter/ArtifactCache.cs b/src/Workflow/__Libraries/StellaOps.Workflow.ArtifactExporter/ArtifactCache.cs
new file mode 100644
index 000000000..7a0eb6379
--- /dev/null
+++ b/src/Workflow/__Libraries/StellaOps.Workflow.ArtifactExporter/ArtifactCache.cs
@@ -0,0 +1,140 @@
+using System.Text;
+
+namespace StellaOps.Workflow.ArtifactExporter;
+
+///
+/// Content-addressed cache logic for rendered workflow artifacts. The hash of
+/// the canonical definition JSON is embedded as an XML comment at the top of
+/// the generated SVG; on subsequent builds the exporter compares that marker
+/// against the current entry's hash to decide whether to re-render.
+///
+///
+/// Extracted into a public static class so the behavior can be unit-tested.
+/// Exporter top-level code calls these helpers; there is no hidden state.
+///
+public static class ArtifactCache
+{
+ // Bump when the renderer's output format changes — invalidates all caches.
+ public const string RendererCacheVersion = "1";
+
+ public const string HashMarkerPrefix = "stellaops-render-hash:";
+
+ ///
+ /// Produce the composite cache key stored alongside rendered SVGs.
+ /// Combines the canonical content hash with the renderer version so
+ /// a renderer change invalidates previously cached artifacts.
+ ///
+ public static string ExpectedHash(string contentHash)
+ => $"{contentHash}:v{RendererCacheVersion}";
+
+ ///
+ /// Insert the hash marker as an XML comment immediately after the XML
+ /// declaration (if present), so the resulting SVG remains well-formed.
+ ///
+ public static string InjectHashMarker(string svg, string hash)
+ {
+ var marker = $"";
+ if (svg.StartsWith("", StringComparison.Ordinal);
+ if (endOfDecl >= 0)
+ {
+ return svg.Insert(endOfDecl + 2, "\n" + marker);
+ }
+ }
+ return marker + "\n" + svg;
+ }
+
+ ///
+ /// Read the cached hash from an existing SVG file. Returns null if the
+ /// file doesn't exist, can't be read, or contains no marker.
+ ///
+ public static string? ReadCachedHash(string svgPath)
+ {
+ if (!File.Exists(svgPath)) return null;
+ try
+ {
+ // The marker is always within the first few hundred bytes.
+ using var stream = File.OpenRead(svgPath);
+ var buffer = new byte[2048];
+ var read = stream.Read(buffer, 0, buffer.Length);
+ var head = Encoding.UTF8.GetString(buffer, 0, read);
+
+ var open = $"", start, StringComparison.Ordinal);
+ return end > start ? head[start..end] : null;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Decide whether rendering can be skipped for a given workflow entry.
+ /// Returns true when the stamped SVG matches the expected hash and — if
+ /// PNG output is requested — the PNG also exists.
+ ///
+ public static bool IsCacheHit(string svgPath, string? pngPath, string contentHash)
+ {
+ if (ReadCachedHash(svgPath) != ExpectedHash(contentHash))
+ {
+ return false;
+ }
+ // pngPath is null when the caller passed --skip-png; otherwise the PNG
+ // must exist for a cache hit, because a missing PNG would mean the
+ // previous render was partial.
+ return pngPath is null || File.Exists(pngPath);
+ }
+
+ ///
+ /// Atomic text write — writes to {path}.tmp then renames to {path}. If the
+ /// write is interrupted (cancellation, process kill), the final path is
+ /// either fully replaced or untouched, never half-written. Without this,
+ /// a partial hash-stamped SVG could cause a false cache hit.
+ ///
+ public static async Task WriteTextAtomicAsync(string path, string content, CancellationToken ct)
+ {
+ var tmp = path + ".tmp";
+ try
+ {
+ await File.WriteAllTextAsync(tmp, content, ct);
+ File.Move(tmp, path, overwrite: true);
+ }
+ catch
+ {
+ try { File.Delete(tmp); } catch { }
+ throw;
+ }
+ }
+
+ ///
+ /// Build a single dictionary of class-name -> source-file path by scanning
+ /// the source root once. Skips obj/bin output. O(n) vs O(n*m) per-entry scans.
+ ///
+ public static Dictionary BuildSourceIndex(string sourceRoot)
+ {
+ var sep = Path.DirectorySeparatorChar;
+ var objFragment = $"{sep}obj{sep}";
+ var binFragment = $"{sep}bin{sep}";
+
+ var index = new Dictionary(StringComparer.Ordinal);
+ foreach (var file in Directory.EnumerateFiles(sourceRoot, "*Workflow.cs", SearchOption.AllDirectories))
+ {
+ if (file.Contains(objFragment, StringComparison.Ordinal) ||
+ file.Contains(binFragment, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ var className = Path.GetFileNameWithoutExtension(file);
+ // First writer wins — multiple matches would be ambiguous, mirror previous behavior.
+ index.TryAdd(className, file);
+ }
+
+ return index;
+ }
+}
diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.ArtifactExporter/Program.cs b/src/Workflow/__Libraries/StellaOps.Workflow.ArtifactExporter/Program.cs
new file mode 100644
index 000000000..d8a4828aa
--- /dev/null
+++ b/src/Workflow/__Libraries/StellaOps.Workflow.ArtifactExporter/Program.cs
@@ -0,0 +1,525 @@
+using System.Diagnostics;
+using System.Globalization;
+using System.Reflection;
+using System.Runtime.Loader;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization.Metadata;
+
+using StellaOps.ElkSharp;
+using StellaOps.Workflow.Abstractions;
+using StellaOps.Workflow.ArtifactExporter;
+using StellaOps.Workflow.Contracts;
+using StellaOps.Workflow.Engine.Rendering;
+using StellaOps.Workflow.Renderer.ElkSharp;
+using StellaOps.Workflow.Renderer.Svg;
+
+// ---------------------------------------------------------------------------
+// StellaOps.Workflow.ArtifactExporter
+//
+// Post-build tool that reads the Roslyn-generated _BundledCanonicalWorkflowRegistry
+// from a compiled plugin DLL and writes sibling artifacts next to each workflow
+// source file:
+// {ClassName}.definition.json (canonical JSON — authoritative, sync)
+// {ClassName}.definition.svg (rendered graph — hash-stamped, cacheable)
+// {ClassName}.definition.png (rasterized — optional, graceful fail)
+//
+// Renders run in parallel with per-item progress output. When the canonical
+// content hash matches a previously stamped SVG (and the PNG still exists),
+// the render is skipped. Bump RendererCacheVersion to invalidate all caches.
+// ---------------------------------------------------------------------------
+
+var options = ExporterOptions.Parse(args);
+
+// Latest-wins coordinator: when a second build starts while a first is still
+// running for the same plugin, the newer build preempts the older — it signals
+// via the sentinel PID file, the older notices and cancels its render loop,
+// and the newer proceeds. Different plugins don't interact (separate sentinels).
+using var coordinator = new LatestWinsCoordinator(options.AssemblyPath);
+var cancellationToken = coordinator.CancellationToken;
+
+using var resolver = new PluginAssemblyResolver(options.AssemblyPath);
+var assembly = resolver.Load();
+
+var entries = ReadBundledRegistry(assembly);
+if (entries.Count == 0)
+{
+ Console.Error.WriteLine("[warn] No bundled workflows found in registry.");
+ return 0;
+}
+
+Console.WriteLine($"Found {entries.Count} bundled workflow(s) in {Path.GetFileName(options.AssemblyPath)}");
+
+var TolerantJsonOptions = BuildTolerantJsonOptions();
+
+// Preload *Workflow.cs index once — avoids O(n*m) directory scans per entry.
+var sourceIndex = ArtifactCache.BuildSourceIndex(options.SourceRoot);
+
+var jsonCount = 0;
+var svgCount = 0;
+var pngCount = 0;
+var skippedCount = 0;
+var warnCount = 0;
+var processedCount = 0;
+
+var parallelOptions = new ParallelOptions
+{
+ MaxDegreeOfParallelism = Math.Max(1, Environment.ProcessorCount),
+ CancellationToken = cancellationToken,
+};
+
+var startedAt = DateTime.UtcNow;
+
+try
+{
+ await Parallel.ForEachAsync(entries, parallelOptions, async (entry, ct) =>
+{
+ var n = Interlocked.Increment(ref processedCount);
+
+ if (string.IsNullOrEmpty(entry.CanonicalDefinitionJson))
+ {
+ return;
+ }
+
+ var className = entry.WorkflowName + "Workflow";
+ if (!sourceIndex.TryGetValue(className, out var sourceFile))
+ {
+ Console.Error.WriteLine($"[{n}/{entries.Count}] [warn] {className}.cs not found under {options.SourceRoot}");
+ Interlocked.Increment(ref warnCount);
+ return;
+ }
+
+ var sourceDir = Path.GetDirectoryName(sourceFile)!;
+ var baseName = Path.GetFileNameWithoutExtension(sourceFile);
+
+ // 1. JSON (always write — cheap and authoritative)
+ var jsonPath = Path.Combine(sourceDir, $"{baseName}.definition.json");
+ await ArtifactCache.WriteTextAtomicAsync(jsonPath, entry.CanonicalDefinitionJson, ct);
+ Interlocked.Increment(ref jsonCount);
+
+ if (options.SkipSvg)
+ {
+ Console.WriteLine($"[{n}/{entries.Count}] {className} — json");
+ return;
+ }
+
+ // 2. Hash-based cache check — skip render if inputs + renderer unchanged.
+ var svgPath = Path.Combine(sourceDir, $"{baseName}.definition.svg");
+ var pngPath = Path.Combine(sourceDir, $"{baseName}.definition.png");
+
+ if (ArtifactCache.IsCacheHit(svgPath, options.SkipPng ? null : pngPath, entry.ContentHash))
+ {
+ Console.WriteLine($"[{n}/{entries.Count}] {className} — cached");
+ Interlocked.Increment(ref skippedCount);
+ return;
+ }
+
+ // 3. Render (fresh instances per iteration — no shared state across threads).
+ try
+ {
+ var definition = JsonSerializer.Deserialize(
+ entry.CanonicalDefinitionJson,
+ TolerantJsonOptions);
+
+ if (definition is null)
+ {
+ return;
+ }
+
+ definition = EnsureRenderDefaults(definition);
+ var descriptor = new WorkflowDefinitionDescriptor
+ {
+ WorkflowName = entry.WorkflowName,
+ WorkflowVersion = entry.WorkflowVersion,
+ DisplayName = definition.DisplayName ?? entry.WorkflowName,
+ };
+ var runtimeDef = new WorkflowRuntimeDefinition
+ {
+ Registration = new WorkflowRegistration
+ {
+ WorkflowType = typeof(object),
+ StartRequestType = typeof(object),
+ Definition = descriptor,
+ BindStartRequest = _ => new object(),
+ ExtractBusinessReference = _ => null,
+ },
+ Descriptor = descriptor,
+ ExecutionKind = WorkflowRuntimeExecutionKind.Declarative,
+ CanonicalDefinition = definition,
+ };
+
+ var graphCompiler = new WorkflowRenderGraphCompiler();
+ var layoutEngine = new ElkSharpWorkflowRenderLayoutEngine(new ElkSharpLayeredLayoutEngine());
+ var svgRenderer = new WorkflowRenderSvgRenderer();
+
+ var renderGraph = graphCompiler.Compile(runtimeDef);
+ var layoutResult = await layoutEngine.LayoutAsync(renderGraph, cancellationToken: ct);
+ var svgDoc = svgRenderer.Render(layoutResult, $"{entry.WorkflowName} v{entry.WorkflowVersion}");
+
+ var stampedSvg = ArtifactCache.InjectHashMarker(svgDoc.Svg, ArtifactCache.ExpectedHash(entry.ContentHash));
+ await ArtifactCache.WriteTextAtomicAsync(svgPath, stampedSvg, ct);
+ Interlocked.Increment(ref svgCount);
+
+ var status = "svg";
+
+ if (!options.SkipPng)
+ {
+ try
+ {
+ var stampedDoc = svgDoc with { Svg = stampedSvg };
+ var pngExporter = new WorkflowRenderPngExporter();
+ var tmpPng = pngPath + ".tmp";
+ try
+ {
+ await pngExporter.ExportAsync(stampedDoc, tmpPng, cancellationToken: ct);
+ File.Move(tmpPng, pngPath, overwrite: true);
+ }
+ catch
+ {
+ try { File.Delete(tmpPng); } catch { }
+ throw;
+ }
+ Interlocked.Increment(ref pngCount);
+ status = "svg+png";
+ }
+ catch (OperationCanceledException) { throw; }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"[{n}/{entries.Count}] [warn] PNG export failed for {className}: {ex.Message}");
+ Interlocked.Increment(ref warnCount);
+ }
+ }
+
+ Console.WriteLine($"[{n}/{entries.Count}] {className} — {status}");
+ }
+ catch (OperationCanceledException) { throw; }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"[{n}/{entries.Count}] [warn] Rendering failed for {className}: {ex.Message}");
+ Interlocked.Increment(ref warnCount);
+ }
+});
+}
+catch (OperationCanceledException)
+{
+ // Preempted by a newer build — exit quickly so the successor's WaitForExit
+ // returns and the new render proceeds.
+ var elapsedCancel = DateTime.UtcNow - startedAt;
+ Console.WriteLine(
+ $"[preempt] exited early after {processedCount}/{entries.Count} entries in {elapsedCancel.TotalSeconds:F1}s");
+ return 0;
+}
+
+var elapsed = DateTime.UtcNow - startedAt;
+Console.WriteLine(
+ $"Exported: {jsonCount} JSON, {svgCount} SVG, {pngCount} PNG " +
+ $"({skippedCount} cached, {warnCount} warning(s)) in {elapsed.TotalSeconds:F1}s " +
+ $"(x{parallelOptions.MaxDegreeOfParallelism} parallel)");
+return 0;
+
+// ---------------------------------------------------------------------------
+// JSON deserialization — tolerant options + placeholder synthesis
+//
+// The Roslyn analyzer can emit JSON that omits members marked `required` in the
+// contracts (e.g. WorkflowStartDeclaration.InitializeStateExpression) when it
+// hits DSL patterns it doesn't yet understand. Tolerating this here — rather
+// than relaxing the runtime contract — lets the exporter render whatever the
+// generator *did* manage to extract while keeping the runtime invariants intact.
+// ---------------------------------------------------------------------------
+
+static JsonSerializerOptions BuildTolerantJsonOptions()
+{
+ var resolver = new DefaultJsonTypeInfoResolver();
+ resolver.Modifiers.Add(typeInfo =>
+ {
+ foreach (var prop in typeInfo.Properties)
+ {
+ // Suppress required-member enforcement during artifact export.
+ prop.IsRequired = false;
+ }
+ });
+
+ return new JsonSerializerOptions(JsonSerializerDefaults.Web)
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ TypeInfoResolver = resolver,
+ };
+}
+
+static WorkflowCanonicalDefinition EnsureRenderDefaults(WorkflowCanonicalDefinition definition)
+{
+ // If the analyzer failed to emit Start, supply a null-expression placeholder so
+ // the graph compiler doesn't NRE. The alternative is to drop the render entirely,
+ // but a partial SVG is more useful feedback than nothing.
+ if (definition.Start is null)
+ {
+ definition = definition with
+ {
+ Start = new WorkflowStartDeclaration
+ {
+ InitializeStateExpression = new WorkflowNullExpressionDefinition(),
+ },
+ };
+ }
+ else if (definition.Start.InitializeStateExpression is null)
+ {
+ definition = definition with
+ {
+ Start = definition.Start with
+ {
+ InitializeStateExpression = new WorkflowNullExpressionDefinition(),
+ },
+ };
+ }
+
+ return definition;
+}
+
+// ---------------------------------------------------------------------------
+// Registry reflection
+// ---------------------------------------------------------------------------
+
+static IReadOnlyList ReadBundledRegistry(Assembly assembly)
+{
+ var entries = new List();
+
+ var registryType = assembly.GetType("StellaOps.Workflow.Generated._BundledCanonicalWorkflowRegistry");
+ if (registryType is null)
+ {
+ return entries;
+ }
+
+ var entriesField = registryType.GetField("Entries", BindingFlags.Public | BindingFlags.Static);
+ if (entriesField?.GetValue(null) is not System.Collections.IEnumerable entriesCollection)
+ {
+ return entries;
+ }
+
+ foreach (var entryObj in entriesCollection)
+ {
+ if (entryObj is null) continue;
+ var entryType = entryObj.GetType();
+
+ var name = entryType.GetProperty("WorkflowName")?.GetValue(entryObj) as string ?? "";
+ var version = entryType.GetProperty("WorkflowVersion")?.GetValue(entryObj) as string ?? "";
+ var json = entryType.GetProperty("CanonicalDefinitionJson")?.GetValue(entryObj) as string ?? "";
+ var hash = entryType.GetProperty("CanonicalContentHash")?.GetValue(entryObj) as string ?? "";
+
+ if (!string.IsNullOrEmpty(name))
+ {
+ entries.Add(new BundledWorkflowEntry(name, version, json, hash));
+ }
+ }
+
+ return entries;
+}
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+file sealed record BundledWorkflowEntry(
+ string WorkflowName,
+ string WorkflowVersion,
+ string CanonicalDefinitionJson,
+ string ContentHash);
+
+file sealed record ExporterOptions
+{
+ public required string AssemblyPath { get; init; }
+ public required string SourceRoot { get; init; }
+ public bool SkipSvg { get; init; }
+ public bool SkipPng { get; init; }
+
+ public static ExporterOptions Parse(string[] args)
+ {
+ string? assemblyPath = null;
+ string? sourceRoot = null;
+ var skipSvg = false;
+ var skipPng = false;
+
+ for (var i = 0; i < args.Length; i++)
+ {
+ switch (args[i])
+ {
+ case "--assembly": assemblyPath = args[++i]; break;
+ case "--source-root": sourceRoot = args[++i]; break;
+ case "--skip-svg": skipSvg = true; break;
+ case "--skip-png": skipPng = true; break;
+ }
+ }
+
+ if (string.IsNullOrWhiteSpace(assemblyPath))
+ throw new InvalidOperationException("--assembly is required.");
+ if (string.IsNullOrWhiteSpace(sourceRoot))
+ throw new InvalidOperationException("--source-root is required.");
+
+ return new ExporterOptions
+ {
+ AssemblyPath = Path.GetFullPath(assemblyPath),
+ SourceRoot = Path.GetFullPath(sourceRoot),
+ SkipSvg = skipSvg,
+ SkipPng = skipPng,
+ };
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Latest-wins cross-process coordinator.
+//
+// Protocol:
+// - A per-plugin PID sentinel file sits in %TEMP%.
+// - On startup we read the previous PID (if any), overwrite the sentinel with
+// our own PID, and if the previous process is still alive we wait for it
+// to exit (it will notice the PID change and cancel its own run). If it
+// hasn't exited within a grace window we kill it.
+// - While running, a watcher task polls the sentinel; if a newer process
+// overwrites it with its own PID, we trigger cancellation and exit.
+//
+// Different plugins use different sentinel files, so they don't interact.
+// ---------------------------------------------------------------------------
+
+file sealed class LatestWinsCoordinator : IDisposable
+{
+ private readonly string sentinelPath;
+ private readonly string pluginName;
+ private readonly CancellationTokenSource cts = new();
+ private readonly CancellationTokenSource watcherStop = new();
+ private readonly Task watcherTask;
+
+ public CancellationToken CancellationToken => cts.Token;
+
+ public LatestWinsCoordinator(string assemblyPath)
+ {
+ pluginName = Path.GetFileNameWithoutExtension(assemblyPath);
+ sentinelPath = Path.Combine(Path.GetTempPath(), $"StellaOpsArtifactExporter-{pluginName}.pid");
+
+ var prevPid = ReadPid();
+
+ // Overwrite sentinel with our PID — this signals the predecessor.
+ WriteOwnPid();
+
+ // Wait for the predecessor to exit so we don't race on the same output files.
+ if (prevPid is int pid && pid != Environment.ProcessId)
+ {
+ WaitForPredecessor(pid);
+ // Predecessor may have deleted the sentinel on exit; re-stamp ours.
+ WriteOwnPid();
+ }
+
+ watcherTask = Task.Run(WatchForNewerOwner);
+ }
+
+ private void WaitForPredecessor(int pid)
+ {
+ Process prev;
+ try { prev = Process.GetProcessById(pid); }
+ catch (ArgumentException) { return; /* already gone */ }
+
+ if (prev.HasExited) return;
+
+ Console.WriteLine($"[preempt] newer build starting; stopping previous exporter pid={pid} for {pluginName}");
+
+ // Short graceful window — predecessor's watcher polls at 200ms so it
+ // typically observes the takeover almost immediately. If in-flight renders
+ // (ELK layout, SVG/PNG) keep it busy past the window, force-kill so the
+ // new build doesn't stall.
+ const int gracefulExitMs = 2000;
+ if (!prev.WaitForExit(gracefulExitMs))
+ {
+ Console.WriteLine($"[preempt] previous exporter pid={pid} still busy after {gracefulExitMs}ms - killing");
+ try { prev.Kill(entireProcessTree: true); prev.WaitForExit(TimeSpan.FromSeconds(2)); }
+ catch { /* best effort */ }
+ }
+ }
+
+ private async Task WatchForNewerOwner()
+ {
+ var myPid = Environment.ProcessId;
+ while (!watcherStop.IsCancellationRequested)
+ {
+ var pid = ReadPid();
+ if (pid.HasValue && pid.Value != myPid)
+ {
+ Console.WriteLine($"[preempt] newer build (pid={pid}) took over — cancelling this run");
+ cts.Cancel();
+ return;
+ }
+
+ try { await Task.Delay(100, watcherStop.Token); }
+ catch (OperationCanceledException) { return; }
+ }
+ }
+
+ private int? ReadPid()
+ {
+ try
+ {
+ if (!File.Exists(sentinelPath)) return null;
+ var text = File.ReadAllText(sentinelPath).Trim();
+ return int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var pid) ? pid : null;
+ }
+ catch (IOException) { return null; }
+ catch (UnauthorizedAccessException) { return null; }
+ }
+
+ private void WriteOwnPid()
+ {
+ try
+ {
+ File.WriteAllText(sentinelPath, Environment.ProcessId.ToString(CultureInfo.InvariantCulture));
+ }
+ catch (IOException) { /* best effort — a concurrent writer is acceptable here */ }
+ }
+
+ public void Dispose()
+ {
+ watcherStop.Cancel();
+ try { watcherTask.Wait(TimeSpan.FromSeconds(1)); } catch { }
+
+ // Only delete if we still own the sentinel — avoid racing a successor.
+ if (ReadPid() == Environment.ProcessId)
+ {
+ try { File.Delete(sentinelPath); } catch { }
+ }
+
+ cts.Dispose();
+ watcherStop.Dispose();
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Assembly resolver (proven pattern from the old CanonicalValidator tool)
+// ---------------------------------------------------------------------------
+
+file sealed class PluginAssemblyResolver : IDisposable
+{
+ private readonly string targetPath;
+ private readonly AssemblyDependencyResolver resolver;
+
+ public PluginAssemblyResolver(string assemblyPath)
+ {
+ targetPath = Path.GetFullPath(assemblyPath);
+ resolver = new AssemblyDependencyResolver(assemblyPath);
+ AssemblyLoadContext.Default.Resolving += Resolve;
+ }
+
+ public Assembly Load()
+ {
+ var existing = AppDomain.CurrentDomain.GetAssemblies()
+ .FirstOrDefault(a => string.Equals(a.Location, targetPath, StringComparison.OrdinalIgnoreCase));
+ return existing ?? AssemblyLoadContext.Default.LoadFromAssemblyPath(targetPath);
+ }
+
+ public void Dispose() => AssemblyLoadContext.Default.Resolving -= Resolve;
+
+ private Assembly? Resolve(AssemblyLoadContext context, AssemblyName name)
+ {
+ var path = resolver.ResolveAssemblyToPath(name);
+ if (path is null) return null;
+
+ var existing = AppDomain.CurrentDomain.GetAssemblies()
+ .FirstOrDefault(a => string.Equals(a.Location, path, StringComparison.OrdinalIgnoreCase));
+ return existing ?? context.LoadFromAssemblyPath(path);
+ }
+}
diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.ArtifactExporter/StellaOps.Workflow.ArtifactExporter.csproj b/src/Workflow/__Libraries/StellaOps.Workflow.ArtifactExporter/StellaOps.Workflow.ArtifactExporter.csproj
new file mode 100644
index 000000000..76071bd56
--- /dev/null
+++ b/src/Workflow/__Libraries/StellaOps.Workflow.ArtifactExporter/StellaOps.Workflow.ArtifactExporter.csproj
@@ -0,0 +1,25 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.ArtifactExporter/StellaOps.Workflow.ArtifactExporter.targets b/src/Workflow/__Libraries/StellaOps.Workflow.ArtifactExporter/StellaOps.Workflow.ArtifactExporter.targets
new file mode 100644
index 000000000..add81bfe4
--- /dev/null
+++ b/src/Workflow/__Libraries/StellaOps.Workflow.ArtifactExporter/StellaOps.Workflow.ArtifactExporter.targets
@@ -0,0 +1,70 @@
+
+
+
+
+ <_StellaOpsWorkflowToolsRoot>$(MSBuildThisFileDirectory)
+ <_StellaOpsAnalyzerCsproj>$(_StellaOpsWorkflowToolsRoot)..\StellaOps.Workflow.Analyzer\StellaOps.Workflow.Analyzer.csproj
+ <_StellaOpsAnalyzerDll>$(_StellaOpsWorkflowToolsRoot)..\StellaOps.Workflow.Analyzer\bin\$(Configuration)\netstandard2.0\StellaOps.Workflow.Analyzer.dll
+ <_StellaOpsExporterCsproj>$(_StellaOpsWorkflowToolsRoot)StellaOps.Workflow.ArtifactExporter.csproj
+ <_StellaOpsExporterDll>$(_StellaOpsWorkflowToolsRoot)bin\$(Configuration)\net10.0\StellaOps.Workflow.ArtifactExporter.dll
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <_ExporterArgs>--assembly "$(TargetPath)" --source-root "$(MSBuildProjectDirectory)"
+ <_ExporterArgs Condition="'$(StellaOpsWorkflowSkipSvg)' == 'true'">$(_ExporterArgs) --skip-svg
+ <_ExporterArgs Condition="'$(StellaOpsWorkflowSkipPng)' == 'true'">$(_ExporterArgs) --skip-png
+
+
+
+
+
+