feat(workflow): add ArtifactExporter console tool + MSBuild targets

New StellaOps.Workflow.ArtifactExporter project: a post-build console app that
reads the generator's bundled workflow registry from the compiled plugin DLL and
writes canonical JSON (authoritative, fail-build) plus SVG/PNG visual artifacts
(graceful warn) next to each *Workflow.cs source file. Replaces per-csproj
rendering boilerplate with a single targets import.

Key design choices:
- Console app invoked via <Exec>, not an MSBuild ITask DLL — easier to debug,
  no rendering-lib loading into the MSBuild process.
- Links WorkflowRenderGraphCompiler.cs from Engine as a compiled file instead of
  ProjectReference, avoiding EF Core + Oracle transitive deps in the tool.
- Parallel.ForEachAsync across workflows with file-lock + PID-sentinel
  "latest-wins" cross-process coordinator (FileShare.None + FileOptions
  .DeleteOnClose — no thread-affinity issues unlike Mutex).
- Hash-based cache: expected canonical-hash marker injected into
  .definition.json; unchanged workflows skip re-render. First build 167
  workflows in ~143s; no-change rebuild in ~0.1s.
- Atomic write-via-rename on every artifact.

Targets file (StellaOps.Workflow.ArtifactExporter.targets) plugins can import
to get: analyzer wiring + JSON/SVG/PNG export in one <Import>. Configurable via
StellaOpsWorkflowArtifactExport / StellaOpsWorkflowSkipSvg /
StellaOpsWorkflowSkipPng properties. Also surfaces CanonicalTemplates/*.json as
AdditionalFiles so the analyzer's fragment loader can inline runtime-loaded
fragments at compile time.

Verified: builds clean against upstream Abstractions/Contracts/Renderer.ElkSharp/
Renderer.Svg (net10.0, 0 warnings, 0 errors).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-18 17:42:38 +03:00
parent fd689748c9
commit 5892937e39
4 changed files with 760 additions and 0 deletions

View File

@@ -0,0 +1,140 @@
using System.Text;
namespace StellaOps.Workflow.ArtifactExporter;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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:";
/// <summary>
/// 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.
/// </summary>
public static string ExpectedHash(string contentHash)
=> $"{contentHash}:v{RendererCacheVersion}";
/// <summary>
/// Insert the hash marker as an XML comment immediately after the XML
/// declaration (if present), so the resulting SVG remains well-formed.
/// </summary>
public static string InjectHashMarker(string svg, string hash)
{
var marker = $"<!--{HashMarkerPrefix}{hash}-->";
if (svg.StartsWith("<?xml", StringComparison.Ordinal))
{
var endOfDecl = svg.IndexOf("?>", StringComparison.Ordinal);
if (endOfDecl >= 0)
{
return svg.Insert(endOfDecl + 2, "\n" + marker);
}
}
return marker + "\n" + svg;
}
/// <summary>
/// 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.
/// </summary>
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 = $"<!--{HashMarkerPrefix}";
var idx = head.IndexOf(open, StringComparison.Ordinal);
if (idx < 0) return null;
var start = idx + open.Length;
var end = head.IndexOf("-->", start, StringComparison.Ordinal);
return end > start ? head[start..end] : null;
}
catch
{
return null;
}
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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;
}
}
/// <summary>
/// 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.
/// </summary>
public static Dictionary<string, string> BuildSourceIndex(string sourceRoot)
{
var sep = Path.DirectorySeparatorChar;
var objFragment = $"{sep}obj{sep}";
var binFragment = $"{sep}bin{sep}";
var index = new Dictionary<string, string>(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;
}
}

View File

@@ -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<WorkflowCanonicalDefinition>(
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<BundledWorkflowEntry> ReadBundledRegistry(Assembly assembly)
{
var entries = new List<BundledWorkflowEntry>();
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 <path> is required.");
if (string.IsNullOrWhiteSpace(sourceRoot))
throw new InvalidOperationException("--source-root <path> 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);
}
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Workflow.Abstractions\StellaOps.Workflow.Abstractions.csproj" />
<ProjectReference Include="..\StellaOps.Workflow.Contracts\StellaOps.Workflow.Contracts.csproj" />
<ProjectReference Include="..\StellaOps.Workflow.Renderer.ElkSharp\StellaOps.Workflow.Renderer.ElkSharp.csproj" />
<ProjectReference Include="..\StellaOps.Workflow.Renderer.Svg\StellaOps.Workflow.Renderer.Svg.csproj" />
</ItemGroup>
<!-- Link the graph compiler from Engine to avoid pulling in EF Core + Oracle transitive deps.
WorkflowRenderGraphCompiler depends only on Abstractions + Contracts. -->
<ItemGroup>
<Compile Include="..\StellaOps.Workflow.Engine\Engine\Rendering\WorkflowRenderGraphCompiler.cs"
Link="Rendering\WorkflowRenderGraphCompiler.cs" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,70 @@
<!--
StellaOps.Workflow.ArtifactExporter.targets
Import this file from any workflow plugin csproj to get:
1. Roslyn analyzer (StellaOps.Workflow.Analyzer) wired as a build-time code analyzer
2. Post-build artifact export: .definition.json, .definition.svg, .definition.png
written next to each *Workflow.cs source file
Usage in plugin csproj:
<Import Project="path\to\StellaOps.Workflow.ArtifactExporter.targets" />
Configurable properties (set before the Import):
StellaOpsWorkflowArtifactExport = true|false (default: true)
StellaOpsWorkflowSkipSvg = true|false (default: false)
StellaOpsWorkflowSkipPng = true|false (default: false)
-->
<Project>
<PropertyGroup>
<_StellaOpsWorkflowToolsRoot>$(MSBuildThisFileDirectory)</_StellaOpsWorkflowToolsRoot>
<_StellaOpsAnalyzerCsproj>$(_StellaOpsWorkflowToolsRoot)..\StellaOps.Workflow.Analyzer\StellaOps.Workflow.Analyzer.csproj</_StellaOpsAnalyzerCsproj>
<_StellaOpsAnalyzerDll>$(_StellaOpsWorkflowToolsRoot)..\StellaOps.Workflow.Analyzer\bin\$(Configuration)\netstandard2.0\StellaOps.Workflow.Analyzer.dll</_StellaOpsAnalyzerDll>
<_StellaOpsExporterCsproj>$(_StellaOpsWorkflowToolsRoot)StellaOps.Workflow.ArtifactExporter.csproj</_StellaOpsExporterCsproj>
<_StellaOpsExporterDll>$(_StellaOpsWorkflowToolsRoot)bin\$(Configuration)\net10.0\StellaOps.Workflow.ArtifactExporter.dll</_StellaOpsExporterDll>
</PropertyGroup>
<!-- Target 1: Build the Roslyn analyzer before compilation and wire it as an Analyzer -->
<Target Name="_StellaOpsWorkflowBuildAnalyzer" BeforeTargets="BeforeBuild">
<MSBuild Projects="$(_StellaOpsAnalyzerCsproj)"
Properties="Configuration=$(Configuration)" />
</Target>
<ItemGroup>
<Analyzer Include="$(_StellaOpsAnalyzerDll)" />
</ItemGroup>
<!-- Expose canonical JSON fragments to the analyzer's AdditionalTextsProvider.
Plugins that store pre-built WorkflowExpressionDefinition fragments as
embedded resources (loaded at runtime via LoadFragment<T>("<name>.json"))
are surfaced to the generator so the Lazy<T>.Value pattern can inline
the fragment at compile time instead of emitting WF020 "must be on
WorkflowExpr" for the runtime loader.
Scope is deliberately narrow: only files under CanonicalTemplates/
whose name ends in .json. Condition guards other plugin shapes that
have no such directory. -->
<ItemGroup Condition="Exists('$(MSBuildProjectDirectory)\CanonicalTemplates')">
<AdditionalFiles Include="CanonicalTemplates\*.json" />
</ItemGroup>
<!-- Target 2: Export artifacts after build -->
<Target Name="_StellaOpsWorkflowExportArtifacts"
AfterTargets="Build"
Condition="'$(DesignTimeBuild)' != 'true' and '$(StellaOpsWorkflowArtifactExport)' != 'false'">
<!-- Build the exporter tool first -->
<MSBuild Projects="$(_StellaOpsExporterCsproj)"
Properties="Configuration=$(Configuration)" />
<!-- Assemble CLI arguments -->
<PropertyGroup>
<_ExporterArgs>--assembly &quot;$(TargetPath)&quot; --source-root &quot;$(MSBuildProjectDirectory)&quot;</_ExporterArgs>
<_ExporterArgs Condition="'$(StellaOpsWorkflowSkipSvg)' == 'true'">$(_ExporterArgs) --skip-svg</_ExporterArgs>
<_ExporterArgs Condition="'$(StellaOpsWorkflowSkipPng)' == 'true'">$(_ExporterArgs) --skip-png</_ExporterArgs>
</PropertyGroup>
<Exec Command="dotnet exec &quot;$(_StellaOpsExporterDll)&quot; $(_ExporterArgs)"
ConsoleToMSBuild="true" />
</Target>
</Project>