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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 "$(TargetPath)" --source-root "$(MSBuildProjectDirectory)"</_ExporterArgs>
|
||||
<_ExporterArgs Condition="'$(StellaOpsWorkflowSkipSvg)' == 'true'">$(_ExporterArgs) --skip-svg</_ExporterArgs>
|
||||
<_ExporterArgs Condition="'$(StellaOpsWorkflowSkipPng)' == 'true'">$(_ExporterArgs) --skip-png</_ExporterArgs>
|
||||
</PropertyGroup>
|
||||
|
||||
<Exec Command="dotnet exec "$(_StellaOpsExporterDll)" $(_ExporterArgs)"
|
||||
ConsoleToMSBuild="true" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user