Refactor code structure for improved readability and maintainability
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-06 21:48:12 +02:00
parent f6c22854a4
commit dd0067ea0b
105 changed files with 12662 additions and 427 deletions

View File

@@ -0,0 +1,76 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace StellaOps.TaskRunner.Client.Extensions;
/// <summary>
/// Service collection extensions for registering the TaskRunner client.
/// </summary>
public static class TaskRunnerClientServiceCollectionExtensions
{
/// <summary>
/// Adds the TaskRunner client to the service collection.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configuration">Configuration.</param>
/// <returns>HTTP client builder for further configuration.</returns>
public static IHttpClientBuilder AddTaskRunnerClient(
this IServiceCollection services,
IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
services.Configure<TaskRunnerClientOptions>(
configuration.GetSection(TaskRunnerClientOptions.SectionName));
return services.AddHttpClient<ITaskRunnerClient, TaskRunnerClient>((sp, client) =>
{
var options = configuration
.GetSection(TaskRunnerClientOptions.SectionName)
.Get<TaskRunnerClientOptions>();
if (options is not null && !string.IsNullOrWhiteSpace(options.BaseUrl))
{
client.BaseAddress = new Uri(options.BaseUrl);
}
if (!string.IsNullOrWhiteSpace(options?.UserAgent))
{
client.DefaultRequestHeaders.UserAgent.TryParseAdd(options.UserAgent);
}
});
}
/// <summary>
/// Adds the TaskRunner client to the service collection with custom options.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configureOptions">Options configuration action.</param>
/// <returns>HTTP client builder for further configuration.</returns>
public static IHttpClientBuilder AddTaskRunnerClient(
this IServiceCollection services,
Action<TaskRunnerClientOptions> configureOptions)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configureOptions);
services.Configure(configureOptions);
return services.AddHttpClient<ITaskRunnerClient, TaskRunnerClient>((sp, client) =>
{
var options = new TaskRunnerClientOptions();
configureOptions(options);
if (!string.IsNullOrWhiteSpace(options.BaseUrl))
{
client.BaseAddress = new Uri(options.BaseUrl);
}
if (!string.IsNullOrWhiteSpace(options.UserAgent))
{
client.DefaultRequestHeaders.UserAgent.TryParseAdd(options.UserAgent);
}
});
}
}

View File

@@ -0,0 +1,124 @@
using StellaOps.TaskRunner.Client.Models;
namespace StellaOps.TaskRunner.Client;
/// <summary>
/// Client interface for the TaskRunner WebService API.
/// </summary>
public interface ITaskRunnerClient
{
#region Pack Runs
/// <summary>
/// Creates a new pack run.
/// </summary>
/// <param name="request">Run creation request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Created run response.</returns>
Task<CreatePackRunResponse> CreateRunAsync(
CreatePackRunRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the current state of a pack run.
/// </summary>
/// <param name="runId">Run identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Pack run state or null if not found.</returns>
Task<PackRunState?> GetRunAsync(
string runId,
CancellationToken cancellationToken = default);
/// <summary>
/// Cancels a running pack run.
/// </summary>
/// <param name="runId">Run identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Cancel response.</returns>
Task<CancelRunResponse> CancelRunAsync(
string runId,
CancellationToken cancellationToken = default);
#endregion
#region Approvals
/// <summary>
/// Applies an approval decision to a pending approval gate.
/// </summary>
/// <param name="runId">Run identifier.</param>
/// <param name="approvalId">Approval gate identifier.</param>
/// <param name="request">Decision request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Approval decision response.</returns>
Task<ApprovalDecisionResponse> ApplyApprovalDecisionAsync(
string runId,
string approvalId,
ApprovalDecisionRequest request,
CancellationToken cancellationToken = default);
#endregion
#region Logs
/// <summary>
/// Streams log entries for a pack run as NDJSON.
/// </summary>
/// <param name="runId">Run identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Async enumerable of log entries.</returns>
IAsyncEnumerable<RunLogEntry> StreamLogsAsync(
string runId,
CancellationToken cancellationToken = default);
#endregion
#region Artifacts
/// <summary>
/// Lists artifacts produced by a pack run.
/// </summary>
/// <param name="runId">Run identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Artifact list response.</returns>
Task<ArtifactListResponse> ListArtifactsAsync(
string runId,
CancellationToken cancellationToken = default);
#endregion
#region Simulation
/// <summary>
/// Simulates a task pack execution without running it.
/// </summary>
/// <param name="request">Simulation request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Simulation result.</returns>
Task<SimulatePackResponse> SimulateAsync(
SimulatePackRequest request,
CancellationToken cancellationToken = default);
#endregion
#region Metadata
/// <summary>
/// Gets OpenAPI metadata including spec URL, version, and signature.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>OpenAPI metadata.</returns>
Task<OpenApiMetadata> GetOpenApiMetadataAsync(CancellationToken cancellationToken = default);
#endregion
}
/// <summary>
/// OpenAPI metadata from /.well-known/openapi endpoint.
/// </summary>
public sealed record OpenApiMetadata(
[property: System.Text.Json.Serialization.JsonPropertyName("specUrl")] string SpecUrl,
[property: System.Text.Json.Serialization.JsonPropertyName("version")] string Version,
[property: System.Text.Json.Serialization.JsonPropertyName("buildVersion")] string BuildVersion,
[property: System.Text.Json.Serialization.JsonPropertyName("eTag")] string ETag,
[property: System.Text.Json.Serialization.JsonPropertyName("signature")] string Signature);

View File

@@ -0,0 +1,230 @@
using StellaOps.TaskRunner.Client.Models;
namespace StellaOps.TaskRunner.Client.Lifecycle;
/// <summary>
/// Helper methods for pack run lifecycle operations.
/// </summary>
public static class PackRunLifecycleHelper
{
/// <summary>
/// Terminal statuses for pack runs.
/// </summary>
public static readonly IReadOnlySet<string> TerminalStatuses = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"completed",
"failed",
"cancelled",
"rejected"
};
/// <summary>
/// Creates a run and waits for it to reach a terminal state.
/// </summary>
/// <param name="client">TaskRunner client.</param>
/// <param name="request">Run creation request.</param>
/// <param name="pollInterval">Interval between status checks (default: 2 seconds).</param>
/// <param name="timeout">Maximum time to wait (default: 30 minutes).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Final pack run state.</returns>
public static async Task<PackRunState> CreateAndWaitAsync(
ITaskRunnerClient client,
CreatePackRunRequest request,
TimeSpan? pollInterval = null,
TimeSpan? timeout = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(client);
ArgumentNullException.ThrowIfNull(request);
var interval = pollInterval ?? TimeSpan.FromSeconds(2);
var maxWait = timeout ?? TimeSpan.FromMinutes(30);
var createResponse = await client.CreateRunAsync(request, cancellationToken).ConfigureAwait(false);
return await WaitForCompletionAsync(client, createResponse.RunId, interval, maxWait, cancellationToken)
.ConfigureAwait(false);
}
/// <summary>
/// Waits for a pack run to reach a terminal state.
/// </summary>
/// <param name="client">TaskRunner client.</param>
/// <param name="runId">Run identifier.</param>
/// <param name="pollInterval">Interval between status checks (default: 2 seconds).</param>
/// <param name="timeout">Maximum time to wait (default: 30 minutes).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Final pack run state.</returns>
public static async Task<PackRunState> WaitForCompletionAsync(
ITaskRunnerClient client,
string runId,
TimeSpan? pollInterval = null,
TimeSpan? timeout = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(client);
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
var interval = pollInterval ?? TimeSpan.FromSeconds(2);
var maxWait = timeout ?? TimeSpan.FromMinutes(30);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(maxWait);
while (true)
{
var state = await client.GetRunAsync(runId, cts.Token).ConfigureAwait(false);
if (state is null)
{
throw new InvalidOperationException($"Run '{runId}' not found.");
}
if (TerminalStatuses.Contains(state.Status))
{
return state;
}
await Task.Delay(interval, cts.Token).ConfigureAwait(false);
}
}
/// <summary>
/// Waits for a pack run to reach a pending approval state.
/// </summary>
/// <param name="client">TaskRunner client.</param>
/// <param name="runId">Run identifier.</param>
/// <param name="pollInterval">Interval between status checks (default: 2 seconds).</param>
/// <param name="timeout">Maximum time to wait (default: 10 minutes).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Pack run state with pending approvals, or null if run completed without approvals.</returns>
public static async Task<PackRunState?> WaitForApprovalAsync(
ITaskRunnerClient client,
string runId,
TimeSpan? pollInterval = null,
TimeSpan? timeout = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(client);
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
var interval = pollInterval ?? TimeSpan.FromSeconds(2);
var maxWait = timeout ?? TimeSpan.FromMinutes(10);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(maxWait);
while (true)
{
var state = await client.GetRunAsync(runId, cts.Token).ConfigureAwait(false);
if (state is null)
{
throw new InvalidOperationException($"Run '{runId}' not found.");
}
if (TerminalStatuses.Contains(state.Status))
{
return null; // Completed without needing approval
}
if (state.PendingApprovals is { Count: > 0 })
{
return state;
}
await Task.Delay(interval, cts.Token).ConfigureAwait(false);
}
}
/// <summary>
/// Approves all pending approvals for a run.
/// </summary>
/// <param name="client">TaskRunner client.</param>
/// <param name="runId">Run identifier.</param>
/// <param name="planHash">Expected plan hash.</param>
/// <param name="actorId">Actor applying the approval.</param>
/// <param name="summary">Approval summary.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Number of approvals applied.</returns>
public static async Task<int> ApproveAllAsync(
ITaskRunnerClient client,
string runId,
string planHash,
string? actorId = null,
string? summary = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(client);
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
ArgumentException.ThrowIfNullOrWhiteSpace(planHash);
var state = await client.GetRunAsync(runId, cancellationToken).ConfigureAwait(false);
if (state?.PendingApprovals is null or { Count: 0 })
{
return 0;
}
var count = 0;
foreach (var approval in state.PendingApprovals)
{
var request = new ApprovalDecisionRequest("approved", planHash, actorId, summary);
await client.ApplyApprovalDecisionAsync(runId, approval.ApprovalId, request, cancellationToken)
.ConfigureAwait(false);
count++;
}
return count;
}
/// <summary>
/// Creates a run, auto-approves when needed, and waits for completion.
/// </summary>
/// <param name="client">TaskRunner client.</param>
/// <param name="request">Run creation request.</param>
/// <param name="actorId">Actor for auto-approval.</param>
/// <param name="pollInterval">Interval between status checks.</param>
/// <param name="timeout">Maximum time to wait.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Final pack run state.</returns>
public static async Task<PackRunState> CreateRunAndAutoApproveAsync(
ITaskRunnerClient client,
CreatePackRunRequest request,
string? actorId = null,
TimeSpan? pollInterval = null,
TimeSpan? timeout = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(client);
ArgumentNullException.ThrowIfNull(request);
var interval = pollInterval ?? TimeSpan.FromSeconds(2);
var maxWait = timeout ?? TimeSpan.FromMinutes(30);
var createResponse = await client.CreateRunAsync(request, cancellationToken).ConfigureAwait(false);
var runId = createResponse.RunId;
var planHash = createResponse.PlanHash;
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(maxWait);
while (true)
{
var state = await client.GetRunAsync(runId, cts.Token).ConfigureAwait(false);
if (state is null)
{
throw new InvalidOperationException($"Run '{runId}' not found.");
}
if (TerminalStatuses.Contains(state.Status))
{
return state;
}
if (state.PendingApprovals is { Count: > 0 })
{
await ApproveAllAsync(client, runId, planHash, actorId, "Auto-approved by SDK", cts.Token)
.ConfigureAwait(false);
}
await Task.Delay(interval, cts.Token).ConfigureAwait(false);
}
}
}

View File

@@ -0,0 +1,174 @@
using System.Text.Json.Serialization;
namespace StellaOps.TaskRunner.Client.Models;
/// <summary>
/// Request to create a new pack run.
/// </summary>
public sealed record CreatePackRunRequest(
[property: JsonPropertyName("packId")] string PackId,
[property: JsonPropertyName("packVersion")] string? PackVersion = null,
[property: JsonPropertyName("inputs")] IReadOnlyDictionary<string, object>? Inputs = null,
[property: JsonPropertyName("tenantId")] string? TenantId = null,
[property: JsonPropertyName("correlationId")] string? CorrelationId = null);
/// <summary>
/// Response from creating a pack run.
/// </summary>
public sealed record CreatePackRunResponse(
[property: JsonPropertyName("runId")] string RunId,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("planHash")] string PlanHash,
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt);
/// <summary>
/// Pack run state.
/// </summary>
public sealed record PackRunState(
[property: JsonPropertyName("runId")] string RunId,
[property: JsonPropertyName("packId")] string PackId,
[property: JsonPropertyName("packVersion")] string PackVersion,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("planHash")] string PlanHash,
[property: JsonPropertyName("currentStepId")] string? CurrentStepId,
[property: JsonPropertyName("steps")] IReadOnlyList<PackRunStepState> Steps,
[property: JsonPropertyName("pendingApprovals")] IReadOnlyList<PendingApproval>? PendingApprovals,
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt,
[property: JsonPropertyName("startedAt")] DateTimeOffset? StartedAt,
[property: JsonPropertyName("completedAt")] DateTimeOffset? CompletedAt,
[property: JsonPropertyName("error")] PackRunError? Error);
/// <summary>
/// State of a single step in a pack run.
/// </summary>
public sealed record PackRunStepState(
[property: JsonPropertyName("stepId")] string StepId,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("startedAt")] DateTimeOffset? StartedAt,
[property: JsonPropertyName("completedAt")] DateTimeOffset? CompletedAt,
[property: JsonPropertyName("retryCount")] int RetryCount,
[property: JsonPropertyName("outputs")] IReadOnlyDictionary<string, object>? Outputs);
/// <summary>
/// Pending approval gate.
/// </summary>
public sealed record PendingApproval(
[property: JsonPropertyName("approvalId")] string ApprovalId,
[property: JsonPropertyName("stepId")] string StepId,
[property: JsonPropertyName("message")] string? Message,
[property: JsonPropertyName("requiredGrants")] IReadOnlyList<string> RequiredGrants,
[property: JsonPropertyName("requestedAt")] DateTimeOffset RequestedAt);
/// <summary>
/// Pack run error information.
/// </summary>
public sealed record PackRunError(
[property: JsonPropertyName("code")] string Code,
[property: JsonPropertyName("message")] string Message,
[property: JsonPropertyName("stepId")] string? StepId);
/// <summary>
/// Request to apply an approval decision.
/// </summary>
public sealed record ApprovalDecisionRequest(
[property: JsonPropertyName("decision")] string Decision,
[property: JsonPropertyName("planHash")] string PlanHash,
[property: JsonPropertyName("actorId")] string? ActorId = null,
[property: JsonPropertyName("summary")] string? Summary = null);
/// <summary>
/// Response from applying an approval decision.
/// </summary>
public sealed record ApprovalDecisionResponse(
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("resumed")] bool Resumed);
/// <summary>
/// Request to simulate a task pack.
/// </summary>
public sealed record SimulatePackRequest(
[property: JsonPropertyName("manifest")] string Manifest,
[property: JsonPropertyName("inputs")] IReadOnlyDictionary<string, object>? Inputs = null);
/// <summary>
/// Simulation result for a task pack.
/// </summary>
public sealed record SimulatePackResponse(
[property: JsonPropertyName("valid")] bool Valid,
[property: JsonPropertyName("planHash")] string? PlanHash,
[property: JsonPropertyName("steps")] IReadOnlyList<SimulatedStep> Steps,
[property: JsonPropertyName("errors")] IReadOnlyList<string>? Errors);
/// <summary>
/// Simulated step in a pack run.
/// </summary>
public sealed record SimulatedStep(
[property: JsonPropertyName("stepId")] string StepId,
[property: JsonPropertyName("kind")] string Kind,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("loopInfo")] LoopInfo? LoopInfo,
[property: JsonPropertyName("conditionalInfo")] ConditionalInfo? ConditionalInfo,
[property: JsonPropertyName("policyInfo")] PolicyInfo? PolicyInfo);
/// <summary>
/// Loop step simulation info.
/// </summary>
public sealed record LoopInfo(
[property: JsonPropertyName("itemsExpression")] string? ItemsExpression,
[property: JsonPropertyName("iterator")] string Iterator,
[property: JsonPropertyName("maxIterations")] int MaxIterations);
/// <summary>
/// Conditional step simulation info.
/// </summary>
public sealed record ConditionalInfo(
[property: JsonPropertyName("branches")] IReadOnlyList<BranchInfo> Branches,
[property: JsonPropertyName("hasElse")] bool HasElse);
/// <summary>
/// Conditional branch info.
/// </summary>
public sealed record BranchInfo(
[property: JsonPropertyName("condition")] string Condition,
[property: JsonPropertyName("stepCount")] int StepCount);
/// <summary>
/// Policy gate simulation info.
/// </summary>
public sealed record PolicyInfo(
[property: JsonPropertyName("policyId")] string PolicyId,
[property: JsonPropertyName("failureAction")] string FailureAction);
/// <summary>
/// Artifact metadata.
/// </summary>
public sealed record ArtifactInfo(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("path")] string Path,
[property: JsonPropertyName("size")] long Size,
[property: JsonPropertyName("sha256")] string Sha256,
[property: JsonPropertyName("contentType")] string? ContentType,
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt);
/// <summary>
/// List of artifacts.
/// </summary>
public sealed record ArtifactListResponse(
[property: JsonPropertyName("artifacts")] IReadOnlyList<ArtifactInfo> Artifacts);
/// <summary>
/// Run log entry.
/// </summary>
public sealed record RunLogEntry(
[property: JsonPropertyName("timestamp")] DateTimeOffset Timestamp,
[property: JsonPropertyName("level")] string Level,
[property: JsonPropertyName("stepId")] string? StepId,
[property: JsonPropertyName("message")] string Message,
[property: JsonPropertyName("traceId")] string? TraceId);
/// <summary>
/// Cancel run response.
/// </summary>
public sealed record CancelRunResponse(
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("message")] string? Message);

View File

@@ -0,0 +1,171 @@
using System.Runtime.CompilerServices;
namespace StellaOps.TaskRunner.Client.Pagination;
/// <summary>
/// Generic paginator for API responses.
/// </summary>
/// <typeparam name="T">Type of items being paginated.</typeparam>
public sealed class Paginator<T>
{
private readonly Func<int, int, CancellationToken, Task<PagedResponse<T>>> _fetchPage;
private readonly int _pageSize;
/// <summary>
/// Initializes a new paginator.
/// </summary>
/// <param name="fetchPage">Function to fetch a page (offset, limit, cancellationToken) -> page.</param>
/// <param name="pageSize">Number of items per page (default: 50).</param>
public Paginator(
Func<int, int, CancellationToken, Task<PagedResponse<T>>> fetchPage,
int pageSize = 50)
{
_fetchPage = fetchPage ?? throw new ArgumentNullException(nameof(fetchPage));
_pageSize = pageSize > 0 ? pageSize : throw new ArgumentOutOfRangeException(nameof(pageSize));
}
/// <summary>
/// Iterates through all pages asynchronously.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Async enumerable of items.</returns>
public async IAsyncEnumerable<T> GetAllAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var offset = 0;
while (true)
{
var page = await _fetchPage(offset, _pageSize, cancellationToken).ConfigureAwait(false);
foreach (var item in page.Items)
{
yield return item;
}
if (!page.HasMore || page.Items.Count == 0)
{
break;
}
offset += page.Items.Count;
}
}
/// <summary>
/// Collects all items into a list.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of all items.</returns>
public async Task<IReadOnlyList<T>> CollectAsync(CancellationToken cancellationToken = default)
{
var items = new List<T>();
await foreach (var item in GetAllAsync(cancellationToken).ConfigureAwait(false))
{
items.Add(item);
}
return items;
}
/// <summary>
/// Gets a single page.
/// </summary>
/// <param name="pageNumber">Page number (1-based).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Single page response.</returns>
public Task<PagedResponse<T>> GetPageAsync(int pageNumber, CancellationToken cancellationToken = default)
{
if (pageNumber < 1)
{
throw new ArgumentOutOfRangeException(nameof(pageNumber), "Page number must be >= 1.");
}
var offset = (pageNumber - 1) * _pageSize;
return _fetchPage(offset, _pageSize, cancellationToken);
}
}
/// <summary>
/// Paginated response wrapper.
/// </summary>
/// <typeparam name="T">Type of items.</typeparam>
public sealed record PagedResponse<T>(
IReadOnlyList<T> Items,
int TotalCount,
bool HasMore)
{
/// <summary>
/// Creates an empty page.
/// </summary>
public static PagedResponse<T> Empty { get; } = new([], 0, false);
/// <summary>
/// Current page number (1-based) based on offset and page size.
/// </summary>
public int PageNumber(int offset, int pageSize)
=> pageSize > 0 ? (offset / pageSize) + 1 : 1;
}
/// <summary>
/// Extension methods for creating paginators.
/// </summary>
public static class PaginatorExtensions
{
/// <summary>
/// Creates a paginator from a fetch function.
/// </summary>
public static Paginator<T> Paginate<T>(
this Func<int, int, CancellationToken, Task<PagedResponse<T>>> fetchPage,
int pageSize = 50)
=> new(fetchPage, pageSize);
/// <summary>
/// Takes the first N items from an async enumerable.
/// </summary>
public static async IAsyncEnumerable<T> TakeAsync<T>(
this IAsyncEnumerable<T> source,
int count,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(source);
if (count <= 0)
{
yield break;
}
var taken = 0;
await foreach (var item in source.WithCancellation(cancellationToken).ConfigureAwait(false))
{
yield return item;
taken++;
if (taken >= count)
{
break;
}
}
}
/// <summary>
/// Skips the first N items from an async enumerable.
/// </summary>
public static async IAsyncEnumerable<T> SkipAsync<T>(
this IAsyncEnumerable<T> source,
int count,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(source);
var skipped = 0;
await foreach (var item in source.WithCancellation(cancellationToken).ConfigureAwait(false))
{
if (skipped < count)
{
skipped++;
continue;
}
yield return item;
}
}
}

View File

@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<Description>SDK client for StellaOps TaskRunner WebService API</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,153 @@
using System.Runtime.CompilerServices;
using System.Text.Json;
using StellaOps.TaskRunner.Client.Models;
namespace StellaOps.TaskRunner.Client.Streaming;
/// <summary>
/// Helper for reading NDJSON streaming logs.
/// </summary>
public static class StreamingLogReader
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
/// <summary>
/// Reads log entries from an NDJSON stream.
/// </summary>
/// <param name="stream">The input stream containing NDJSON log entries.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Async enumerable of log entries.</returns>
public static async IAsyncEnumerable<RunLogEntry> ReadAsync(
Stream stream,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(stream);
using var reader = new StreamReader(stream);
string? line;
while ((line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false)) is not null)
{
cancellationToken.ThrowIfCancellationRequested();
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
RunLogEntry? entry;
try
{
entry = JsonSerializer.Deserialize<RunLogEntry>(line, JsonOptions);
}
catch (JsonException)
{
continue;
}
if (entry is not null)
{
yield return entry;
}
}
}
/// <summary>
/// Collects all log entries from a stream into a list.
/// </summary>
/// <param name="stream">The input stream containing NDJSON log entries.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of all log entries.</returns>
public static async Task<IReadOnlyList<RunLogEntry>> CollectAsync(
Stream stream,
CancellationToken cancellationToken = default)
{
var entries = new List<RunLogEntry>();
await foreach (var entry in ReadAsync(stream, cancellationToken).ConfigureAwait(false))
{
entries.Add(entry);
}
return entries;
}
/// <summary>
/// Filters log entries by level.
/// </summary>
/// <param name="entries">Source log entries.</param>
/// <param name="levels">Log levels to include (e.g., "error", "warning").</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Filtered log entries.</returns>
public static async IAsyncEnumerable<RunLogEntry> FilterByLevelAsync(
IAsyncEnumerable<RunLogEntry> entries,
IReadOnlySet<string> levels,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(entries);
ArgumentNullException.ThrowIfNull(levels);
await foreach (var entry in entries.WithCancellation(cancellationToken).ConfigureAwait(false))
{
if (levels.Contains(entry.Level, StringComparer.OrdinalIgnoreCase))
{
yield return entry;
}
}
}
/// <summary>
/// Filters log entries by step ID.
/// </summary>
/// <param name="entries">Source log entries.</param>
/// <param name="stepId">Step ID to filter by.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Filtered log entries.</returns>
public static async IAsyncEnumerable<RunLogEntry> FilterByStepAsync(
IAsyncEnumerable<RunLogEntry> entries,
string stepId,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(entries);
ArgumentException.ThrowIfNullOrWhiteSpace(stepId);
await foreach (var entry in entries.WithCancellation(cancellationToken).ConfigureAwait(false))
{
if (string.Equals(entry.StepId, stepId, StringComparison.Ordinal))
{
yield return entry;
}
}
}
/// <summary>
/// Groups log entries by step ID.
/// </summary>
/// <param name="entries">Source log entries.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Dictionary of step ID to log entries.</returns>
public static async Task<IReadOnlyDictionary<string, IReadOnlyList<RunLogEntry>>> GroupByStepAsync(
IAsyncEnumerable<RunLogEntry> entries,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(entries);
var groups = new Dictionary<string, List<RunLogEntry>>(StringComparer.Ordinal);
await foreach (var entry in entries.WithCancellation(cancellationToken).ConfigureAwait(false))
{
var key = entry.StepId ?? "(global)";
if (!groups.TryGetValue(key, out var list))
{
list = [];
groups[key] = list;
}
list.Add(entry);
}
return groups.ToDictionary(
kvp => kvp.Key,
kvp => (IReadOnlyList<RunLogEntry>)kvp.Value,
StringComparer.Ordinal);
}
}

View File

@@ -0,0 +1,292 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.TaskRunner.Client.Models;
namespace StellaOps.TaskRunner.Client;
/// <summary>
/// HTTP implementation of <see cref="ITaskRunnerClient"/>.
/// </summary>
public sealed class TaskRunnerClient : ITaskRunnerClient
{
private static readonly MediaTypeHeaderValue JsonMediaType = new("application/json");
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly HttpClient _httpClient;
private readonly IOptionsMonitor<TaskRunnerClientOptions> _options;
private readonly ILogger<TaskRunnerClient>? _logger;
/// <summary>
/// Initializes a new instance of the <see cref="TaskRunnerClient"/> class.
/// </summary>
public TaskRunnerClient(
HttpClient httpClient,
IOptionsMonitor<TaskRunnerClientOptions> options,
ILogger<TaskRunnerClient>? logger = null)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger;
}
#region Pack Runs
/// <inheritdoc />
public async Task<CreatePackRunResponse> CreateRunAsync(
CreatePackRunRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var url = BuildUrl("/runs");
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = JsonContent.Create(request, JsonMediaType, JsonOptions)
};
using var response = await SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<CreatePackRunResponse>(JsonOptions, cancellationToken)
.ConfigureAwait(false);
return result ?? throw new InvalidOperationException("Response did not contain expected data.");
}
/// <inheritdoc />
public async Task<PackRunState?> GetRunAsync(
string runId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
var url = BuildUrl($"/runs/{Uri.EscapeDataString(runId)}");
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, url);
using var response = await SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<PackRunState>(JsonOptions, cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<CancelRunResponse> CancelRunAsync(
string runId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
var url = BuildUrl($"/runs/{Uri.EscapeDataString(runId)}/cancel");
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, url);
using var response = await SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<CancelRunResponse>(JsonOptions, cancellationToken)
.ConfigureAwait(false);
return result ?? throw new InvalidOperationException("Response did not contain expected data.");
}
#endregion
#region Approvals
/// <inheritdoc />
public async Task<ApprovalDecisionResponse> ApplyApprovalDecisionAsync(
string runId,
string approvalId,
ApprovalDecisionRequest request,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
ArgumentException.ThrowIfNullOrWhiteSpace(approvalId);
ArgumentNullException.ThrowIfNull(request);
var url = BuildUrl($"/runs/{Uri.EscapeDataString(runId)}/approvals/{Uri.EscapeDataString(approvalId)}");
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = JsonContent.Create(request, JsonMediaType, JsonOptions)
};
using var response = await SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<ApprovalDecisionResponse>(JsonOptions, cancellationToken)
.ConfigureAwait(false);
return result ?? throw new InvalidOperationException("Response did not contain expected data.");
}
#endregion
#region Logs
/// <inheritdoc />
public async IAsyncEnumerable<RunLogEntry> StreamLogsAsync(
string runId,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
var url = BuildUrl($"/runs/{Uri.EscapeDataString(runId)}/logs");
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, url);
httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/x-ndjson"));
// Use longer timeout for streaming
var streamingTimeout = TimeSpan.FromSeconds(_options.CurrentValue.StreamingTimeoutSeconds);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(streamingTimeout);
using var response = await _httpClient.SendAsync(
httpRequest,
HttpCompletionOption.ResponseHeadersRead,
cts.Token).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cts.Token).ConfigureAwait(false);
using var reader = new StreamReader(stream, Encoding.UTF8);
string? line;
while ((line = await reader.ReadLineAsync(cts.Token).ConfigureAwait(false)) is not null)
{
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
RunLogEntry? entry;
try
{
entry = JsonSerializer.Deserialize<RunLogEntry>(line, JsonOptions);
}
catch (JsonException ex)
{
_logger?.LogWarning(ex, "Failed to parse log entry: {Line}", line);
continue;
}
if (entry is not null)
{
yield return entry;
}
}
}
#endregion
#region Artifacts
/// <inheritdoc />
public async Task<ArtifactListResponse> ListArtifactsAsync(
string runId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
var url = BuildUrl($"/runs/{Uri.EscapeDataString(runId)}/artifacts");
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, url);
using var response = await SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<ArtifactListResponse>(JsonOptions, cancellationToken)
.ConfigureAwait(false);
return result ?? new ArtifactListResponse([]);
}
#endregion
#region Simulation
/// <inheritdoc />
public async Task<SimulatePackResponse> SimulateAsync(
SimulatePackRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var url = BuildUrl("/simulations");
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = JsonContent.Create(request, JsonMediaType, JsonOptions)
};
using var response = await SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<SimulatePackResponse>(JsonOptions, cancellationToken)
.ConfigureAwait(false);
return result ?? throw new InvalidOperationException("Response did not contain expected data.");
}
#endregion
#region Metadata
/// <inheritdoc />
public async Task<OpenApiMetadata> GetOpenApiMetadataAsync(CancellationToken cancellationToken = default)
{
var options = _options.CurrentValue;
var url = new Uri(new Uri(options.BaseUrl), "/.well-known/openapi");
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, url);
using var response = await SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<OpenApiMetadata>(JsonOptions, cancellationToken)
.ConfigureAwait(false);
return result ?? throw new InvalidOperationException("Response did not contain expected data.");
}
#endregion
#region Helpers
private Uri BuildUrl(string path)
{
var options = _options.CurrentValue;
var baseUrl = options.BaseUrl.TrimEnd('/');
var apiPath = options.ApiPath.TrimEnd('/');
return new Uri($"{baseUrl}{apiPath}{path}");
}
private async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
var options = _options.CurrentValue;
if (!string.IsNullOrWhiteSpace(options.UserAgent))
{
request.Headers.UserAgent.TryParseAdd(options.UserAgent);
}
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(TimeSpan.FromSeconds(options.TimeoutSeconds));
return await _httpClient.SendAsync(request, cts.Token).ConfigureAwait(false);
}
#endregion
}

View File

@@ -0,0 +1,42 @@
namespace StellaOps.TaskRunner.Client;
/// <summary>
/// Configuration options for the TaskRunner client.
/// </summary>
public sealed class TaskRunnerClientOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "TaskRunner:Client";
/// <summary>
/// Base URL for the TaskRunner API (e.g., "https://taskrunner.example.com").
/// </summary>
public string BaseUrl { get; set; } = string.Empty;
/// <summary>
/// API version path prefix (default: "/v1/task-runner").
/// </summary>
public string ApiPath { get; set; } = "/v1/task-runner";
/// <summary>
/// Timeout for HTTP requests in seconds (default: 30).
/// </summary>
public int TimeoutSeconds { get; set; } = 30;
/// <summary>
/// Timeout for streaming log requests in seconds (default: 300).
/// </summary>
public int StreamingTimeoutSeconds { get; set; } = 300;
/// <summary>
/// Maximum number of retry attempts for transient failures (default: 3).
/// </summary>
public int MaxRetries { get; set; } = 3;
/// <summary>
/// User-Agent header value for requests.
/// </summary>
public string? UserAgent { get; set; }
}