Restructure solution layout by module

This commit is contained in:
master
2025-10-28 15:10:40 +02:00
parent 95daa159c4
commit d870da18ce
4103 changed files with 192899 additions and 187024 deletions

View File

@@ -0,0 +1,118 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using StellaOps.TaskRunner.Core.Execution;
namespace StellaOps.TaskRunner.Infrastructure.Execution;
public sealed class FilePackRunApprovalStore : IPackRunApprovalStore
{
private readonly string rootPath;
private readonly JsonSerializerOptions serializerOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
public FilePackRunApprovalStore(string rootPath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(rootPath);
this.rootPath = rootPath;
Directory.CreateDirectory(rootPath);
}
public Task SaveAsync(string runId, IReadOnlyList<PackRunApprovalState> approvals, CancellationToken cancellationToken)
{
var path = GetFilePath(runId);
var json = SerializeApprovals(approvals);
File.WriteAllText(path, json);
return Task.CompletedTask;
}
public Task<IReadOnlyList<PackRunApprovalState>> GetAsync(string runId, CancellationToken cancellationToken)
{
var path = GetFilePath(runId);
if (!File.Exists(path))
{
return Task.FromResult((IReadOnlyList<PackRunApprovalState>)Array.Empty<PackRunApprovalState>());
}
var json = File.ReadAllText(path);
var approvals = DeserializeApprovals(json);
return Task.FromResult((IReadOnlyList<PackRunApprovalState>)approvals);
}
public async Task UpdateAsync(string runId, PackRunApprovalState approval, CancellationToken cancellationToken)
{
var approvals = (await GetAsync(runId, cancellationToken).ConfigureAwait(false)).ToList();
var index = approvals.FindIndex(existing => string.Equals(existing.ApprovalId, approval.ApprovalId, StringComparison.Ordinal));
if (index < 0)
{
throw new InvalidOperationException($"Approval '{approval.ApprovalId}' not found for run '{runId}'.");
}
approvals[index] = approval;
await SaveAsync(runId, approvals, cancellationToken).ConfigureAwait(false);
}
private string GetFilePath(string runId)
{
var safeFile = $"{runId}.json";
return Path.Combine(rootPath, safeFile);
}
private string SerializeApprovals(IReadOnlyList<PackRunApprovalState> approvals)
{
var array = new JsonArray();
foreach (var approval in approvals)
{
var node = new JsonObject
{
["approvalId"] = approval.ApprovalId,
["status"] = approval.Status.ToString(),
["requestedAt"] = approval.RequestedAt,
["actorId"] = approval.ActorId,
["completedAt"] = approval.CompletedAt,
["summary"] = approval.Summary,
["requiredGrants"] = new JsonArray(approval.RequiredGrants.Select(grant => (JsonNode)grant).ToArray()),
["stepIds"] = new JsonArray(approval.StepIds.Select(step => (JsonNode)step).ToArray()),
["messages"] = new JsonArray(approval.Messages.Select(message => (JsonNode)message).ToArray()),
["reasonTemplate"] = approval.ReasonTemplate
};
array.Add(node);
}
return array.ToJsonString(serializerOptions);
}
private static IReadOnlyList<PackRunApprovalState> DeserializeApprovals(string json)
{
var array = JsonNode.Parse(json)?.AsArray() ?? new JsonArray();
var list = new List<PackRunApprovalState>(array.Count);
foreach (var entry in array)
{
if (entry is not JsonObject obj)
{
continue;
}
var requiredGrants = obj["requiredGrants"]?.AsArray()?.Select(node => node!.GetValue<string>()).ToList() ?? new List<string>();
var stepIds = obj["stepIds"]?.AsArray()?.Select(node => node!.GetValue<string>()).ToList() ?? new List<string>();
var messages = obj["messages"]?.AsArray()?.Select(node => node!.GetValue<string>()).ToList() ?? new List<string>();
Enum.TryParse(obj["status"]?.GetValue<string>(), ignoreCase: true, out PackRunApprovalStatus status);
list.Add(new PackRunApprovalState(
obj["approvalId"]?.GetValue<string>() ?? string.Empty,
requiredGrants,
stepIds,
messages,
obj["reasonTemplate"]?.GetValue<string>(),
obj["requestedAt"]?.GetValue<DateTimeOffset>() ?? DateTimeOffset.UtcNow,
status,
obj["actorId"]?.GetValue<string>(),
obj["completedAt"]?.GetValue<DateTimeOffset?>(),
obj["summary"]?.GetValue<string>()));
}
return list;
}
}

View File

@@ -0,0 +1,92 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Planning;
using StellaOps.TaskRunner.Core.TaskPacks;
namespace StellaOps.TaskRunner.Infrastructure.Execution;
public sealed class FilesystemPackRunDispatcher : IPackRunJobDispatcher
{
private readonly string queuePath;
private readonly string archivePath;
private readonly TaskPackManifestLoader manifestLoader = new();
private readonly TaskPackPlanner planner = new();
private readonly JsonSerializerOptions serializerOptions = new(JsonSerializerDefaults.Web);
public FilesystemPackRunDispatcher(string queuePath, string archivePath)
{
this.queuePath = queuePath ?? throw new ArgumentNullException(nameof(queuePath));
this.archivePath = archivePath ?? throw new ArgumentNullException(nameof(archivePath));
Directory.CreateDirectory(queuePath);
Directory.CreateDirectory(archivePath);
}
public async Task<PackRunExecutionContext?> TryDequeueAsync(CancellationToken cancellationToken)
{
var files = Directory.GetFiles(queuePath, "*.json", SearchOption.TopDirectoryOnly)
.OrderBy(path => path, StringComparer.Ordinal)
.ToArray();
foreach (var file in files)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var jobJson = await File.ReadAllTextAsync(file, cancellationToken).ConfigureAwait(false);
var job = JsonSerializer.Deserialize<JobEnvelope>(jobJson, serializerOptions) ?? continue;
var manifestPath = ResolvePath(queuePath, job.ManifestPath);
var inputsPath = job.InputsPath is null ? null : ResolvePath(queuePath, job.InputsPath);
var manifest = manifestLoader.Load(manifestPath);
var inputs = await LoadInputsAsync(inputsPath, cancellationToken).ConfigureAwait(false);
var planResult = planner.Plan(manifest, inputs);
if (!planResult.Success || planResult.Plan is null)
{
throw new InvalidOperationException($"Failed to plan pack for run {job.RunId}: {string.Join(';', planResult.Errors.Select(e => e.Message))}");
}
var archiveFile = Path.Combine(archivePath, Path.GetFileName(file));
File.Move(file, archiveFile, overwrite: true);
var requestedAt = job.RequestedAt ?? DateTimeOffset.UtcNow;
return new PackRunExecutionContext(job.RunId ?? Guid.NewGuid().ToString("n"), planResult.Plan, requestedAt);
}
catch (Exception ex)
{
var failedPath = file + ".failed";
File.Move(file, failedPath, overwrite: true);
Console.Error.WriteLine($"Failed to dequeue job '{file}': {ex.Message}");
}
}
return null;
}
private static string ResolvePath(string root, string relative)
=> Path.IsPathRooted(relative) ? relative : Path.Combine(root, relative);
private static async Task<IDictionary<string, JsonNode?>> LoadInputsAsync(string? path, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
return new Dictionary<string, JsonNode?>(StringComparer.Ordinal);
}
var json = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
var node = JsonNode.Parse(json) as JsonObject;
if (node is null)
{
return new Dictionary<string, JsonNode?>(StringComparer.Ordinal);
}
return node.ToDictionary(
pair => pair.Key,
pair => pair.Value,
StringComparer.Ordinal);
}
private sealed record JobEnvelope(string? RunId, string ManifestPath, string? InputsPath, DateTimeOffset? RequestedAt);
}

View File

@@ -0,0 +1,73 @@
using System.Net.Http.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.TaskRunner.Core.Execution;
namespace StellaOps.TaskRunner.Infrastructure.Execution;
public sealed class HttpPackRunNotificationPublisher : IPackRunNotificationPublisher
{
private readonly IHttpClientFactory httpClientFactory;
private readonly NotificationOptions options;
private readonly ILogger<HttpPackRunNotificationPublisher> logger;
public HttpPackRunNotificationPublisher(
IHttpClientFactory httpClientFactory,
IOptions<NotificationOptions> options,
ILogger<HttpPackRunNotificationPublisher> logger)
{
this.httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
this.options = options?.Value ?? throw new ArgumentNullException(nameof(options));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task PublishApprovalRequestedAsync(string runId, ApprovalNotification notification, CancellationToken cancellationToken)
{
if (options.ApprovalEndpoint is null)
{
logger.LogWarning("Approval endpoint not configured; skipping approval notification for run {RunId}.", runId);
return;
}
var client = httpClientFactory.CreateClient("taskrunner-notifications");
var payload = new
{
runId,
notification.ApprovalId,
notification.RequiredGrants,
notification.Messages,
notification.StepIds,
notification.ReasonTemplate
};
var response = await client.PostAsJsonAsync(options.ApprovalEndpoint, payload, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
}
public async Task PublishPolicyGatePendingAsync(string runId, PolicyGateNotification notification, CancellationToken cancellationToken)
{
if (options.PolicyEndpoint is null)
{
logger.LogDebug("Policy endpoint not configured; skipping policy notification for run {RunId} step {StepId}.", runId, notification.StepId);
return;
}
var client = httpClientFactory.CreateClient("taskrunner-notifications");
var payload = new
{
runId,
notification.StepId,
notification.Message,
Parameters = notification.Parameters.Select(parameter => new
{
parameter.Name,
parameter.RequiresRuntimeValue,
parameter.Expression,
parameter.Error
})
};
var response = await client.PostAsJsonAsync(options.PolicyEndpoint, payload, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
}
}

View File

@@ -0,0 +1,34 @@
using Microsoft.Extensions.Logging;
using StellaOps.TaskRunner.Core.Execution;
namespace StellaOps.TaskRunner.Infrastructure.Execution;
public sealed class LoggingPackRunNotificationPublisher : IPackRunNotificationPublisher
{
private readonly ILogger<LoggingPackRunNotificationPublisher> logger;
public LoggingPackRunNotificationPublisher(ILogger<LoggingPackRunNotificationPublisher> logger)
{
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public Task PublishApprovalRequestedAsync(string runId, ApprovalNotification notification, CancellationToken cancellationToken)
{
logger.LogInformation(
"Run {RunId}: approval {ApprovalId} requires grants {Grants}.",
runId,
notification.ApprovalId,
string.Join(",", notification.RequiredGrants));
return Task.CompletedTask;
}
public Task PublishPolicyGatePendingAsync(string runId, PolicyGateNotification notification, CancellationToken cancellationToken)
{
logger.LogDebug(
"Run {RunId}: policy gate {StepId} pending (parameters: {Parameters}).",
runId,
notification.StepId,
string.Join(",", notification.Parameters.Select(p => p.Name)));
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,9 @@
using StellaOps.TaskRunner.Core.Execution;
namespace StellaOps.TaskRunner.Infrastructure.Execution;
public sealed class NoopPackRunJobDispatcher : IPackRunJobDispatcher
{
public Task<PackRunExecutionContext?> TryDequeueAsync(CancellationToken cancellationToken)
=> Task.FromResult<PackRunExecutionContext?>(null);
}

View File

@@ -0,0 +1,8 @@
namespace StellaOps.TaskRunner.Infrastructure.Execution;
public sealed class NotificationOptions
{
public Uri? ApprovalEndpoint { get; set; }
public Uri? PolicyEndpoint { get; set; }
}

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
<ProjectReference Include="..\StellaOps.TaskRunner.Core\StellaOps.TaskRunner.Core.csproj" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
</Project>