Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user