refactor(jobengine): delete TaskRunner service

- Remove TaskRunner source, tests, libraries (3 directories)
- Remove from compose, services-matrix, nginx, hosts, smoke tests
- Remove CLI commands, UI references, Authority scopes
- Remove docs, OpenAPI spec, QA state files
- Leave task_runner_id DB columns as nullable legacy
- PacksRegistry preserved (independent service)
- Eliminates 2 containers (taskrunner-web + taskrunner-worker)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-08 14:11:20 +03:00
parent 80c33d3c59
commit 0e25344bd7
208 changed files with 57 additions and 29942 deletions

View File

@@ -647,10 +647,10 @@ public static class StellaOpsScopes
public const string SmRemoteSign = "sm-remote:sign";
public const string SmRemoteVerify = "sm-remote:verify";
// TaskRunner scopes
public const string TaskRunnerRead = "taskrunner:read";
public const string TaskRunnerOperate = "taskrunner:operate";
public const string TaskRunnerAdmin = "taskrunner:admin";
// TaskRunner scopes — REMOVED (service deleted, constants kept for DB/migration backward compat)
// public const string TaskRunnerRead = "taskrunner:read";
// public const string TaskRunnerOperate = "taskrunner:operate";
// public const string TaskRunnerAdmin = "taskrunner:admin";
// Integration catalog scopes
/// <summary>

View File

@@ -72,7 +72,7 @@ internal static class CommandFactory
root.Add(BuildTenantsCommand(services, options, verboseOption, cancellationToken));
root.Add(BuildPolicyCommand(services, options, verboseOption, cancellationToken));
root.Add(ToolsCommandGroup.BuildToolsCommand(loggerFactory, cancellationToken));
root.Add(BuildTaskRunnerCommand(services, verboseOption, cancellationToken));
root.Add(BuildFindingsCommand(services, verboseOption, cancellationToken));
root.Add(BuildAdviseCommand(services, options, verboseOption, cancellationToken));
root.Add(BuildConfigCommand(services, options, verboseOption, cancellationToken));
@@ -4108,56 +4108,6 @@ flowchart TB
DateTimeOffset DecidedAt,
string Reason);
private static Command BuildTaskRunnerCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
{
var taskRunner = new Command("task-runner", "Interact with Task Runner operations.");
var simulate = new Command("simulate", "Simulate a task pack and inspect the execution graph.");
var manifestOption = new Option<string>("--manifest")
{
Description = "Path to the task pack manifest (YAML).",
Arity = ArgumentArity.ExactlyOne
};
var inputsOption = new Option<string?>("--inputs")
{
Description = "Optional JSON file containing Task Pack input values."
};
var formatOption = new Option<string?>("--format")
{
Description = "Output format: table or json."
};
var outputOption = new Option<string?>("--output")
{
Description = "Write JSON payload to the specified file."
};
simulate.Add(manifestOption);
simulate.Add(inputsOption);
simulate.Add(formatOption);
simulate.Add(outputOption);
simulate.SetAction((parseResult, _) =>
{
var manifestPath = parseResult.GetValue(manifestOption) ?? string.Empty;
var inputsPath = parseResult.GetValue(inputsOption);
var selectedFormat = parseResult.GetValue(formatOption);
var output = parseResult.GetValue(outputOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleTaskRunnerSimulateAsync(
services,
manifestPath,
inputsPath,
selectedFormat,
output,
verbose,
cancellationToken);
});
taskRunner.Add(simulate);
return taskRunner;
}
private static Command BuildFindingsCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
{
var findings = new Command("findings", "Inspect policy findings.");

View File

@@ -396,126 +396,6 @@ internal static partial class CommandHandlers
}
}
public static async Task HandleTaskRunnerSimulateAsync(
IServiceProvider services,
string manifestPath,
string? inputsPath,
string? format,
string? outputPath,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("task-runner-simulate");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.taskrunner.simulate", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "task-runner simulate");
using var duration = CliMetrics.MeasureCommandDuration("task-runner simulate");
try
{
if (string.IsNullOrWhiteSpace(manifestPath))
{
throw new ArgumentException("Manifest path must be provided.", nameof(manifestPath));
}
var manifestFullPath = Path.GetFullPath(manifestPath);
if (!File.Exists(manifestFullPath))
{
throw new FileNotFoundException("Manifest file not found.", manifestFullPath);
}
activity?.SetTag("stellaops.cli.manifest_path", manifestFullPath);
var manifest = await File.ReadAllTextAsync(manifestFullPath, cancellationToken).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(manifest))
{
throw new InvalidOperationException("Manifest file was empty.");
}
JsonObject? inputsObject = null;
if (!string.IsNullOrWhiteSpace(inputsPath))
{
var inputsFullPath = Path.GetFullPath(inputsPath!);
if (!File.Exists(inputsFullPath))
{
throw new FileNotFoundException("Inputs file not found.", inputsFullPath);
}
await using var stream = File.OpenRead(inputsFullPath);
var parsed = await JsonNode.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
if (parsed is JsonObject obj)
{
inputsObject = obj;
}
else
{
throw new InvalidOperationException("Simulation inputs must be a JSON object.");
}
activity?.SetTag("stellaops.cli.inputs_path", inputsFullPath);
}
var request = new TaskRunnerSimulationRequest(manifest, inputsObject);
var result = await client.SimulateTaskRunnerAsync(request, cancellationToken).ConfigureAwait(false);
activity?.SetTag("stellaops.cli.plan_hash", result.PlanHash);
activity?.SetTag("stellaops.cli.pending_approvals", result.HasPendingApprovals);
activity?.SetTag("stellaops.cli.step_count", result.Steps.Count);
var outputFormat = DetermineTaskRunnerSimulationFormat(format, outputPath);
var payload = BuildTaskRunnerSimulationPayload(result);
if (!string.IsNullOrWhiteSpace(outputPath))
{
await WriteSimulationOutputAsync(outputPath!, payload, cancellationToken).ConfigureAwait(false);
logger.LogInformation("Simulation payload written to {Path}.", Path.GetFullPath(outputPath!));
}
if (outputFormat == TaskRunnerSimulationOutputFormat.Json)
{
Console.WriteLine(JsonSerializer.Serialize(payload, SimulationJsonOptions));
}
else
{
RenderTaskRunnerSimulationResult(result);
}
var outcome = result.HasPendingApprovals ? "pending-approvals" : "ok";
CliMetrics.RecordTaskRunnerSimulation(outcome);
Environment.ExitCode = 0;
}
catch (FileNotFoundException ex)
{
logger.LogError(ex.Message);
CliMetrics.RecordTaskRunnerSimulation("error");
Environment.ExitCode = 66;
}
catch (ArgumentException ex)
{
logger.LogError(ex.Message);
CliMetrics.RecordTaskRunnerSimulation("error");
Environment.ExitCode = 64;
}
catch (InvalidOperationException ex)
{
logger.LogError(ex, "Task Runner simulation failed.");
CliMetrics.RecordTaskRunnerSimulation("error");
Environment.ExitCode = 1;
}
catch (Exception ex)
{
logger.LogError(ex, "Task Runner simulation failed.");
CliMetrics.RecordTaskRunnerSimulation("error");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
private static void RenderEntryTrace(EntryTraceResponseModel result, bool includeNdjson, bool includeSemantic)
{
@@ -5874,121 +5754,6 @@ internal static partial class CommandHandlers
return null;
}
private static TaskRunnerSimulationOutputFormat DetermineTaskRunnerSimulationFormat(string? value, string? outputPath)
{
if (!string.IsNullOrWhiteSpace(value))
{
return value.Trim().ToLowerInvariant() switch
{
"table" => TaskRunnerSimulationOutputFormat.Table,
"json" => TaskRunnerSimulationOutputFormat.Json,
_ => throw new ArgumentException("Invalid format. Use 'table' or 'json'.")
};
}
if (!string.IsNullOrWhiteSpace(outputPath))
{
return TaskRunnerSimulationOutputFormat.Json;
}
return TaskRunnerSimulationOutputFormat.Table;
}
private static object BuildTaskRunnerSimulationPayload(TaskRunnerSimulationResult result)
=> new
{
planHash = result.PlanHash,
failurePolicy = new
{
result.FailurePolicy.MaxAttempts,
result.FailurePolicy.BackoffSeconds,
result.FailurePolicy.ContinueOnError
},
hasPendingApprovals = result.HasPendingApprovals,
steps = result.Steps,
outputs = result.Outputs
};
private static void RenderTaskRunnerSimulationResult(TaskRunnerSimulationResult result)
{
var console = AnsiConsole.Console;
var table = new Table
{
Border = TableBorder.Rounded
};
table.AddColumn("Step");
table.AddColumn("Kind");
table.AddColumn("Status");
table.AddColumn("Reason");
table.AddColumn("MaxParallel");
table.AddColumn("ContinueOnError");
table.AddColumn("Approval");
foreach (var (step, depth) in FlattenTaskRunnerSimulationSteps(result.Steps))
{
var indent = new string(' ', depth * 2);
table.AddRow(
Markup.Escape($"{indent}{step.Id}"),
Markup.Escape(step.Kind),
Markup.Escape(step.Status),
Markup.Escape(string.IsNullOrWhiteSpace(step.StatusReason) ? "-" : step.StatusReason!),
step.MaxParallel?.ToString(CultureInfo.InvariantCulture) ?? "-",
step.ContinueOnError ? "yes" : "no",
Markup.Escape(string.IsNullOrWhiteSpace(step.ApprovalId) ? "-" : step.ApprovalId!));
}
console.Write(table);
if (result.Outputs.Count > 0)
{
var outputsTable = new Table
{
Border = TableBorder.Rounded
};
outputsTable.AddColumn("Name");
outputsTable.AddColumn("Type");
outputsTable.AddColumn("Requires Runtime");
outputsTable.AddColumn("Path");
outputsTable.AddColumn("Expression");
foreach (var output in result.Outputs)
{
outputsTable.AddRow(
Markup.Escape(output.Name),
Markup.Escape(output.Type),
output.RequiresRuntimeValue ? "yes" : "no",
Markup.Escape(string.IsNullOrWhiteSpace(output.PathExpression) ? "-" : output.PathExpression!),
Markup.Escape(string.IsNullOrWhiteSpace(output.ValueExpression) ? "-" : output.ValueExpression!));
}
console.WriteLine();
console.Write(outputsTable);
}
console.WriteLine();
console.MarkupLine($"[grey]Plan Hash:[/] {Markup.Escape(result.PlanHash)}");
console.MarkupLine($"[grey]Pending Approvals:[/] {(result.HasPendingApprovals ? "yes" : "no")}");
console.Write(new Text($"Plan Hash: {result.PlanHash}{Environment.NewLine}"));
console.Write(new Text($"Pending Approvals: {(result.HasPendingApprovals ? "yes" : "no")}{Environment.NewLine}"));
}
private static IEnumerable<(TaskRunnerSimulationStep Step, int Depth)> FlattenTaskRunnerSimulationSteps(
IReadOnlyList<TaskRunnerSimulationStep> steps,
int depth = 0)
{
for (var i = 0; i < steps.Count; i++)
{
var step = steps[i];
yield return (step, depth);
foreach (var child in FlattenTaskRunnerSimulationSteps(step.Children, depth + 1))
{
yield return child;
}
}
}
private static PolicySimulationOutputFormat DeterminePolicySimulationFormat(string? value, string? outputPath)
{
if (!string.IsNullOrWhiteSpace(value))
@@ -7310,12 +7075,6 @@ internal static partial class CommandHandlers
private static readonly IReadOnlyDictionary<string, string> EmptyLabelSelectors =
new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(0, StringComparer.OrdinalIgnoreCase));
private enum TaskRunnerSimulationOutputFormat
{
Table,
Json
}
private enum PolicySimulationOutputFormat
{
Table,

View File

@@ -1,652 +0,0 @@
// -----------------------------------------------------------------------------
// TaskRunnerCommandGroup.cs
// Sprint: SPRINT_20260117_021_CLI_taskrunner
// Tasks: TRN-001 through TRN-005 - TaskRunner management commands
// Description: CLI commands for TaskRunner service operations
// -----------------------------------------------------------------------------
using System.CommandLine;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.Cli.Commands;
/// <summary>
/// Command group for TaskRunner operations.
/// Implements status, tasks, artifacts, and logs commands.
/// </summary>
public static class TaskRunnerCommandGroup
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
/// <summary>
/// Build the 'taskrunner' command group.
/// </summary>
public static Command BuildTaskRunnerCommand(Option<bool> verboseOption, CancellationToken cancellationToken)
{
var taskrunnerCommand = new Command("taskrunner", "TaskRunner service operations");
taskrunnerCommand.Add(BuildStatusCommand(verboseOption, cancellationToken));
taskrunnerCommand.Add(BuildTasksCommand(verboseOption, cancellationToken));
taskrunnerCommand.Add(BuildArtifactsCommand(verboseOption, cancellationToken));
taskrunnerCommand.Add(BuildLogsCommand(verboseOption, cancellationToken));
return taskrunnerCommand;
}
#region TRN-001 - Status Command
private static Command BuildStatusCommand(Option<bool> verboseOption, CancellationToken cancellationToken)
{
var formatOption = new Option<string>("--format", ["-f"])
{
Description = "Output format: table (default), json"
};
formatOption.SetDefaultValue("table");
var statusCommand = new Command("status", "Show TaskRunner service status")
{
formatOption,
verboseOption
};
statusCommand.SetAction((parseResult, ct) =>
{
var format = parseResult.GetValue(formatOption) ?? "table";
var verbose = parseResult.GetValue(verboseOption);
var status = new TaskRunnerStatus
{
Health = "healthy",
Version = "2.1.0",
Uptime = TimeSpan.FromDays(12).Add(TimeSpan.FromHours(5)),
Workers = new WorkerPoolStatus
{
Total = 8,
Active = 3,
Idle = 5,
MaxCapacity = 16
},
Queue = new QueueStatus
{
Pending = 12,
Running = 3,
Completed24h = 847,
Failed24h = 3
}
};
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
{
Console.WriteLine(JsonSerializer.Serialize(status, JsonOptions));
return Task.FromResult(0);
}
Console.WriteLine("TaskRunner Status");
Console.WriteLine("=================");
Console.WriteLine();
Console.WriteLine($"Health: {status.Health}");
Console.WriteLine($"Version: {status.Version}");
Console.WriteLine($"Uptime: {status.Uptime.Days}d {status.Uptime.Hours}h");
Console.WriteLine();
Console.WriteLine("Worker Pool:");
Console.WriteLine($" Total: {status.Workers.Total}");
Console.WriteLine($" Active: {status.Workers.Active}");
Console.WriteLine($" Idle: {status.Workers.Idle}");
Console.WriteLine($" Capacity: {status.Workers.MaxCapacity}");
Console.WriteLine();
Console.WriteLine("Queue:");
Console.WriteLine($" Pending: {status.Queue.Pending}");
Console.WriteLine($" Running: {status.Queue.Running}");
Console.WriteLine($" Completed/24h: {status.Queue.Completed24h}");
Console.WriteLine($" Failed/24h: {status.Queue.Failed24h}");
return Task.FromResult(0);
});
return statusCommand;
}
#endregion
#region TRN-002/TRN-003 - Tasks Commands
private static Command BuildTasksCommand(Option<bool> verboseOption, CancellationToken cancellationToken)
{
var tasksCommand = new Command("tasks", "Task operations");
tasksCommand.Add(BuildTasksListCommand(verboseOption));
tasksCommand.Add(BuildTasksShowCommand(verboseOption));
tasksCommand.Add(BuildTasksCancelCommand(verboseOption));
return tasksCommand;
}
private static Command BuildTasksListCommand(Option<bool> verboseOption)
{
var statusOption = new Option<string?>("--status", ["-s"])
{
Description = "Filter by status: pending, running, completed, failed"
};
var typeOption = new Option<string?>("--type", ["-t"])
{
Description = "Filter by task type"
};
var fromOption = new Option<string?>("--from")
{
Description = "Start time filter"
};
var toOption = new Option<string?>("--to")
{
Description = "End time filter"
};
var limitOption = new Option<int>("--limit", ["-n"])
{
Description = "Maximum number of tasks to show"
};
limitOption.SetDefaultValue(20);
var formatOption = new Option<string>("--format", ["-f"])
{
Description = "Output format: table (default), json"
};
formatOption.SetDefaultValue("table");
var listCommand = new Command("list", "List tasks")
{
statusOption,
typeOption,
fromOption,
toOption,
limitOption,
formatOption,
verboseOption
};
listCommand.SetAction((parseResult, ct) =>
{
var status = parseResult.GetValue(statusOption);
var type = parseResult.GetValue(typeOption);
var from = parseResult.GetValue(fromOption);
var to = parseResult.GetValue(toOption);
var limit = parseResult.GetValue(limitOption);
var format = parseResult.GetValue(formatOption) ?? "table";
var verbose = parseResult.GetValue(verboseOption);
var tasks = GetSampleTasks()
.Where(t => string.IsNullOrEmpty(status) || t.Status.Equals(status, StringComparison.OrdinalIgnoreCase))
.Where(t => string.IsNullOrEmpty(type) || t.Type.Equals(type, StringComparison.OrdinalIgnoreCase))
.Take(limit)
.ToList();
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
{
Console.WriteLine(JsonSerializer.Serialize(tasks, JsonOptions));
return Task.FromResult(0);
}
Console.WriteLine("Tasks");
Console.WriteLine("=====");
Console.WriteLine();
Console.WriteLine($"{"ID",-20} {"Type",-15} {"Status",-12} {"Duration",-10} {"Started"}");
Console.WriteLine(new string('-', 75));
foreach (var task in tasks)
{
var duration = task.Duration.HasValue ? $"{task.Duration.Value.TotalSeconds:F0}s" : "-";
Console.WriteLine($"{task.Id,-20} {task.Type,-15} {task.Status,-12} {duration,-10} {task.StartedAt:HH:mm:ss}");
}
Console.WriteLine();
Console.WriteLine($"Total: {tasks.Count} tasks");
return Task.FromResult(0);
});
return listCommand;
}
private static Command BuildTasksShowCommand(Option<bool> verboseOption)
{
var taskIdArg = new Argument<string>("task-id")
{
Description = "Task ID to show"
};
var formatOption = new Option<string>("--format", ["-f"])
{
Description = "Output format: text (default), json"
};
formatOption.SetDefaultValue("text");
var showCommand = new Command("show", "Show task details")
{
taskIdArg,
formatOption,
verboseOption
};
showCommand.SetAction((parseResult, ct) =>
{
var taskId = parseResult.GetValue(taskIdArg) ?? string.Empty;
var format = parseResult.GetValue(formatOption) ?? "text";
var verbose = parseResult.GetValue(verboseOption);
var task = new TaskDetails
{
Id = taskId,
Type = "scan",
Status = "completed",
StartedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
CompletedAt = DateTimeOffset.UtcNow.AddMinutes(-2),
Duration = TimeSpan.FromMinutes(3),
Input = new { Image = "myapp:v1.2.3", ScanType = "full" },
Steps = [
new TaskStep { Name = "pull-image", Status = "completed", Duration = TimeSpan.FromSeconds(15) },
new TaskStep { Name = "generate-sbom", Status = "completed", Duration = TimeSpan.FromSeconds(45) },
new TaskStep { Name = "vuln-scan", Status = "completed", Duration = TimeSpan.FromMinutes(2) },
new TaskStep { Name = "upload-results", Status = "completed", Duration = TimeSpan.FromSeconds(5) }
],
Artifacts = ["sbom.json", "vulns.json", "scan-report.html"]
};
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
{
Console.WriteLine(JsonSerializer.Serialize(task, JsonOptions));
return Task.FromResult(0);
}
Console.WriteLine($"Task Details: {taskId}");
Console.WriteLine(new string('=', 15 + taskId.Length));
Console.WriteLine();
Console.WriteLine($"Type: {task.Type}");
Console.WriteLine($"Status: {task.Status}");
Console.WriteLine($"Started: {task.StartedAt:u}");
Console.WriteLine($"Completed: {task.CompletedAt:u}");
Console.WriteLine($"Duration: {task.Duration?.TotalMinutes:F1} minutes");
Console.WriteLine();
Console.WriteLine("Steps:");
foreach (var step in task.Steps)
{
var icon = step.Status == "completed" ? "✓" : step.Status == "running" ? "▶" : "○";
Console.WriteLine($" {icon} {step.Name}: {step.Duration?.TotalSeconds:F0}s");
}
Console.WriteLine();
Console.WriteLine("Artifacts:");
foreach (var artifact in task.Artifacts)
{
Console.WriteLine($" • {artifact}");
}
return Task.FromResult(0);
});
return showCommand;
}
private static Command BuildTasksCancelCommand(Option<bool> verboseOption)
{
var taskIdArg = new Argument<string>("task-id")
{
Description = "Task ID to cancel"
};
var gracefulOption = new Option<bool>("--graceful")
{
Description = "Graceful shutdown (wait for current step)"
};
var forceOption = new Option<bool>("--force")
{
Description = "Force immediate termination"
};
var cancelCommand = new Command("cancel", "Cancel a task")
{
taskIdArg,
gracefulOption,
forceOption,
verboseOption
};
cancelCommand.SetAction((parseResult, ct) =>
{
var taskId = parseResult.GetValue(taskIdArg) ?? string.Empty;
var graceful = parseResult.GetValue(gracefulOption);
var force = parseResult.GetValue(forceOption);
var verbose = parseResult.GetValue(verboseOption);
Console.WriteLine("Task Cancellation");
Console.WriteLine("=================");
Console.WriteLine();
Console.WriteLine($"Task ID: {taskId}");
Console.WriteLine($"Mode: {(force ? "force" : graceful ? "graceful" : "default")}");
Console.WriteLine();
if (force)
{
Console.WriteLine("Task terminated immediately.");
}
else if (graceful)
{
Console.WriteLine("Waiting for current step to complete...");
Console.WriteLine("Task cancelled gracefully.");
}
else
{
Console.WriteLine("Task cancellation requested.");
}
Console.WriteLine($"Final Status: cancelled");
return Task.FromResult(0);
});
return cancelCommand;
}
#endregion
#region TRN-004 - Artifacts Commands
private static Command BuildArtifactsCommand(Option<bool> verboseOption, CancellationToken cancellationToken)
{
var artifactsCommand = new Command("artifacts", "Task artifact operations");
artifactsCommand.Add(BuildArtifactsListCommand(verboseOption));
artifactsCommand.Add(BuildArtifactsGetCommand(verboseOption));
return artifactsCommand;
}
private static Command BuildArtifactsListCommand(Option<bool> verboseOption)
{
var taskOption = new Option<string>("--task", ["-t"])
{
Description = "Task ID to list artifacts for",
Required = true
};
var formatOption = new Option<string>("--format", ["-f"])
{
Description = "Output format: table (default), json"
};
formatOption.SetDefaultValue("table");
var listCommand = new Command("list", "List task artifacts")
{
taskOption,
formatOption,
verboseOption
};
listCommand.SetAction((parseResult, ct) =>
{
var taskId = parseResult.GetValue(taskOption) ?? string.Empty;
var format = parseResult.GetValue(formatOption) ?? "table";
var verbose = parseResult.GetValue(verboseOption);
var artifacts = new List<ArtifactInfo>
{
new() { Id = "art-001", Name = "sbom.json", Type = "application/json", Size = "245 KB", Digest = "sha256:abc123..." },
new() { Id = "art-002", Name = "vulns.json", Type = "application/json", Size = "128 KB", Digest = "sha256:def456..." },
new() { Id = "art-003", Name = "scan-report.html", Type = "text/html", Size = "89 KB", Digest = "sha256:ghi789..." }
};
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
{
Console.WriteLine(JsonSerializer.Serialize(artifacts, JsonOptions));
return Task.FromResult(0);
}
Console.WriteLine($"Artifacts for Task: {taskId}");
Console.WriteLine(new string('=', 20 + taskId.Length));
Console.WriteLine();
Console.WriteLine($"{"ID",-12} {"Name",-25} {"Type",-20} {"Size",-10} {"Digest"}");
Console.WriteLine(new string('-', 85));
foreach (var artifact in artifacts)
{
Console.WriteLine($"{artifact.Id,-12} {artifact.Name,-25} {artifact.Type,-20} {artifact.Size,-10} {artifact.Digest}");
}
return Task.FromResult(0);
});
return listCommand;
}
private static Command BuildArtifactsGetCommand(Option<bool> verboseOption)
{
var artifactIdArg = new Argument<string>("artifact-id")
{
Description = "Artifact ID to download"
};
var outputOption = new Option<string?>("--output", ["-o"])
{
Description = "Output file path"
};
var getCommand = new Command("get", "Download an artifact")
{
artifactIdArg,
outputOption,
verboseOption
};
getCommand.SetAction((parseResult, ct) =>
{
var artifactId = parseResult.GetValue(artifactIdArg) ?? string.Empty;
var output = parseResult.GetValue(outputOption);
var verbose = parseResult.GetValue(verboseOption);
var outputPath = output ?? $"{artifactId}.bin";
Console.WriteLine("Downloading Artifact");
Console.WriteLine("====================");
Console.WriteLine();
Console.WriteLine($"Artifact ID: {artifactId}");
Console.WriteLine($"Output: {outputPath}");
Console.WriteLine();
Console.WriteLine("Downloading... done");
Console.WriteLine("Verifying digest... ✓ verified");
Console.WriteLine();
Console.WriteLine($"Artifact saved to: {outputPath}");
return Task.FromResult(0);
});
return getCommand;
}
#endregion
#region TRN-005 - Logs Command
private static Command BuildLogsCommand(Option<bool> verboseOption, CancellationToken cancellationToken)
{
var taskIdArg = new Argument<string>("task-id")
{
Description = "Task ID to show logs for"
};
var followOption = new Option<bool>("--follow", ["-f"])
{
Description = "Stream logs continuously"
};
var stepOption = new Option<string?>("--step", ["-s"])
{
Description = "Filter by step name"
};
var levelOption = new Option<string?>("--level", ["-l"])
{
Description = "Filter by log level: error, warn, info, debug"
};
var outputOption = new Option<string?>("--output", ["-o"])
{
Description = "Save logs to file"
};
var logsCommand = new Command("logs", "Show task logs")
{
taskIdArg,
followOption,
stepOption,
levelOption,
outputOption,
verboseOption
};
logsCommand.SetAction((parseResult, ct) =>
{
var taskId = parseResult.GetValue(taskIdArg) ?? string.Empty;
var follow = parseResult.GetValue(followOption);
var step = parseResult.GetValue(stepOption);
var level = parseResult.GetValue(levelOption);
var output = parseResult.GetValue(outputOption);
var verbose = parseResult.GetValue(verboseOption);
Console.WriteLine($"Logs for Task: {taskId}");
Console.WriteLine(new string('-', 50));
var logs = new[]
{
"[10:25:01] INFO [pull-image] Pulling image myapp:v1.2.3...",
"[10:25:15] INFO [pull-image] Image pulled successfully",
"[10:25:16] INFO [generate-sbom] Generating SBOM...",
"[10:25:45] INFO [generate-sbom] Found 847 components",
"[10:25:46] INFO [vuln-scan] Starting vulnerability scan...",
"[10:27:30] WARN [vuln-scan] Found 3 high severity vulnerabilities",
"[10:27:45] INFO [vuln-scan] Scan complete: 847 components, 3 high, 12 medium, 45 low",
"[10:27:46] INFO [upload-results] Uploading results...",
"[10:27:50] INFO [upload-results] Results uploaded successfully"
};
foreach (var log in logs)
{
if (!string.IsNullOrEmpty(step) && !log.Contains($"[{step}]"))
continue;
if (!string.IsNullOrEmpty(level) && !log.Contains(level.ToUpperInvariant()))
continue;
Console.WriteLine(log);
}
if (follow)
{
Console.WriteLine();
Console.WriteLine("(streaming logs... press Ctrl+C to stop)");
}
if (!string.IsNullOrEmpty(output))
{
Console.WriteLine();
Console.WriteLine($"Logs saved to: {output}");
}
return Task.FromResult(0);
});
return logsCommand;
}
#endregion
#region Sample Data
private static List<TaskInfo> GetSampleTasks()
{
var now = DateTimeOffset.UtcNow;
return
[
new TaskInfo { Id = "task-001", Type = "scan", Status = "running", StartedAt = now.AddMinutes(-2), Duration = null },
new TaskInfo { Id = "task-002", Type = "attest", Status = "running", StartedAt = now.AddMinutes(-1), Duration = null },
new TaskInfo { Id = "task-003", Type = "scan", Status = "pending", StartedAt = now, Duration = null },
new TaskInfo { Id = "task-004", Type = "scan", Status = "completed", StartedAt = now.AddMinutes(-10), Duration = TimeSpan.FromMinutes(3) },
new TaskInfo { Id = "task-005", Type = "verify", Status = "completed", StartedAt = now.AddMinutes(-15), Duration = TimeSpan.FromSeconds(45) },
new TaskInfo { Id = "task-006", Type = "attest", Status = "failed", StartedAt = now.AddMinutes(-20), Duration = TimeSpan.FromMinutes(2) }
];
}
#endregion
#region DTOs
private sealed class TaskRunnerStatus
{
public string Health { get; set; } = string.Empty;
public string Version { get; set; } = string.Empty;
public TimeSpan Uptime { get; set; }
public WorkerPoolStatus Workers { get; set; } = new();
public QueueStatus Queue { get; set; } = new();
}
private sealed class WorkerPoolStatus
{
public int Total { get; set; }
public int Active { get; set; }
public int Idle { get; set; }
public int MaxCapacity { get; set; }
}
private sealed class QueueStatus
{
public int Pending { get; set; }
public int Running { get; set; }
public int Completed24h { get; set; }
public int Failed24h { get; set; }
}
private sealed class TaskInfo
{
public string Id { get; set; } = string.Empty;
public string Type { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public DateTimeOffset StartedAt { get; set; }
public TimeSpan? Duration { get; set; }
}
private sealed class TaskDetails
{
public string Id { get; set; } = string.Empty;
public string Type { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public DateTimeOffset StartedAt { get; set; }
public DateTimeOffset? CompletedAt { get; set; }
public TimeSpan? Duration { get; set; }
public object? Input { get; set; }
public List<TaskStep> Steps { get; set; } = [];
public string[] Artifacts { get; set; } = [];
}
private sealed class TaskStep
{
public string Name { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public TimeSpan? Duration { get; set; }
}
private sealed class ArtifactInfo
{
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string Type { get; set; } = string.Empty;
public string Size { get; set; } = string.Empty;
public string Digest { get; set; } = string.Empty;
}
#endregion
}

View File

@@ -693,64 +693,6 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
return MapPolicySimulation(document);
}
public async Task<TaskRunnerSimulationResult> SimulateTaskRunnerAsync(TaskRunnerSimulationRequest request, CancellationToken cancellationToken)
{
EnsureBackendConfigured();
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
if (string.IsNullOrWhiteSpace(request.Manifest))
{
throw new ArgumentException("Manifest must be provided.", nameof(request));
}
var requestDocument = new TaskRunnerSimulationRequestDocument
{
Manifest = request.Manifest,
Inputs = request.Inputs
};
using var httpRequest = CreateRequest(HttpMethod.Post, "api/task-runner/simulations");
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
httpRequest.Content = JsonContent.Create(requestDocument, options: SerializerOptions);
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException(failure);
}
TaskRunnerSimulationResponseDocument? document;
try
{
document = await response.Content.ReadFromJsonAsync<TaskRunnerSimulationResponseDocument>(SerializerOptions, cancellationToken).ConfigureAwait(false);
}
catch (JsonException ex)
{
var raw = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Failed to parse task runner simulation response: {ex.Message}", ex)
{
Data = { ["payload"] = raw }
};
}
if (document is null)
{
throw new InvalidOperationException("Task runner simulation response was empty.");
}
if (document.FailurePolicy is null)
{
throw new InvalidOperationException("Task runner simulation response missing failure policy.");
}
return MapTaskRunnerSimulation(document);
}
public async Task<PolicyFindingsPage> GetPolicyFindingsAsync(PolicyFindingsQuery query, CancellationToken cancellationToken)
{
if (query is null)
@@ -3257,64 +3199,6 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
string.IsNullOrWhiteSpace(document.ManifestDigest) ? null : document.ManifestDigest);
}
private static TaskRunnerSimulationResult MapTaskRunnerSimulation(TaskRunnerSimulationResponseDocument document)
{
var failurePolicyDocument = document.FailurePolicy ?? throw new InvalidOperationException("Task runner simulation response missing failure policy.");
var steps = document.Steps is null
? new List<TaskRunnerSimulationStep>()
: document.Steps
.Where(step => step is not null)
.Select(step => MapTaskRunnerSimulationStep(step!))
.ToList();
var outputs = document.Outputs is null
? new List<TaskRunnerSimulationOutput>()
: document.Outputs
.Where(output => output is not null)
.Select(output => new TaskRunnerSimulationOutput(
output!.Name ?? string.Empty,
output.Type ?? string.Empty,
output.RequiresRuntimeValue,
NormalizeOptionalString(output.PathExpression),
NormalizeOptionalString(output.ValueExpression)))
.ToList();
return new TaskRunnerSimulationResult(
document.PlanHash ?? string.Empty,
new TaskRunnerSimulationFailurePolicy(
failurePolicyDocument.MaxAttempts,
failurePolicyDocument.BackoffSeconds,
failurePolicyDocument.ContinueOnError),
steps,
outputs,
document.HasPendingApprovals);
}
private static TaskRunnerSimulationStep MapTaskRunnerSimulationStep(TaskRunnerSimulationStepDocument document)
{
var children = document.Children is null
? new List<TaskRunnerSimulationStep>()
: document.Children
.Where(child => child is not null)
.Select(child => MapTaskRunnerSimulationStep(child!))
.ToList();
return new TaskRunnerSimulationStep(
document.Id ?? string.Empty,
document.TemplateId ?? string.Empty,
document.Kind ?? string.Empty,
document.Enabled,
document.Status ?? string.Empty,
NormalizeOptionalString(document.StatusReason),
NormalizeOptionalString(document.Uses),
NormalizeOptionalString(document.ApprovalId),
NormalizeOptionalString(document.GateMessage),
document.MaxParallel,
document.ContinueOnError,
children);
}
private void EnsureBackendConfigured()
{
if (_httpClient.BaseAddress is null)

View File

@@ -32,8 +32,6 @@ internal interface IBackendOperationsClient
Task<PolicySimulationResult> SimulatePolicyAsync(string policyId, PolicySimulationInput input, CancellationToken cancellationToken);
Task<TaskRunnerSimulationResult> SimulateTaskRunnerAsync(TaskRunnerSimulationRequest request, CancellationToken cancellationToken);
Task<PolicyActivationResult> ActivatePolicyRevisionAsync(string policyId, int version, PolicyActivationRequest request, CancellationToken cancellationToken);
Task<OfflineKitDownloadResult> DownloadOfflineKitAsync(string? bundleId, string destinationDirectory, bool overwrite, bool resume, CancellationToken cancellationToken);

View File

@@ -1,36 +0,0 @@
using System.Collections.Generic;
using System.Text.Json.Nodes;
namespace StellaOps.Cli.Services.Models;
internal sealed record TaskRunnerSimulationRequest(string Manifest, JsonObject? Inputs);
internal sealed record TaskRunnerSimulationResult(
string PlanHash,
TaskRunnerSimulationFailurePolicy FailurePolicy,
IReadOnlyList<TaskRunnerSimulationStep> Steps,
IReadOnlyList<TaskRunnerSimulationOutput> Outputs,
bool HasPendingApprovals);
internal sealed record TaskRunnerSimulationFailurePolicy(int MaxAttempts, int BackoffSeconds, bool ContinueOnError);
internal sealed record TaskRunnerSimulationStep(
string Id,
string TemplateId,
string Kind,
bool Enabled,
string Status,
string? StatusReason,
string? Uses,
string? ApprovalId,
string? GateMessage,
int? MaxParallel,
bool ContinueOnError,
IReadOnlyList<TaskRunnerSimulationStep> Children);
internal sealed record TaskRunnerSimulationOutput(
string Name,
string Type,
bool RequiresRuntimeValue,
string? PathExpression,
string? ValueExpression);

View File

@@ -1,73 +0,0 @@
using System.Collections.Generic;
using System.Text.Json.Nodes;
namespace StellaOps.Cli.Services.Models.Transport;
internal sealed class TaskRunnerSimulationRequestDocument
{
public string Manifest { get; set; } = string.Empty;
public JsonObject? Inputs { get; set; }
}
internal sealed class TaskRunnerSimulationResponseDocument
{
public string PlanHash { get; set; } = string.Empty;
public TaskRunnerSimulationFailurePolicyDocument? FailurePolicy { get; set; }
public List<TaskRunnerSimulationStepDocument>? Steps { get; set; }
public List<TaskRunnerSimulationOutputDocument>? Outputs { get; set; }
public bool HasPendingApprovals { get; set; }
}
internal sealed class TaskRunnerSimulationFailurePolicyDocument
{
public int MaxAttempts { get; set; }
public int BackoffSeconds { get; set; }
public bool ContinueOnError { get; set; }
}
internal sealed class TaskRunnerSimulationStepDocument
{
public string Id { get; set; } = string.Empty;
public string TemplateId { get; set; } = string.Empty;
public string Kind { get; set; } = string.Empty;
public bool Enabled { get; set; }
public string Status { get; set; } = string.Empty;
public string? StatusReason { get; set; }
public string? Uses { get; set; }
public string? ApprovalId { get; set; }
public string? GateMessage { get; set; }
public int? MaxParallel { get; set; }
public bool ContinueOnError { get; set; }
public List<TaskRunnerSimulationStepDocument>? Children { get; set; }
}
internal sealed class TaskRunnerSimulationOutputDocument
{
public string Name { get; set; } = string.Empty;
public string Type { get; set; } = string.Empty;
public bool RequiresRuntimeValue { get; set; }
public string? PathExpression { get; set; }
public string? ValueExpression { get; set; }
}

View File

@@ -47,7 +47,7 @@ internal static class CliMetrics
private static readonly Counter<long> OfflineKitDownloadCounter = Meter.CreateCounter<long>("stellaops.cli.offline.kit.download.count");
private static readonly Counter<long> OfflineKitImportCounter = Meter.CreateCounter<long>("stellaops.cli.offline.kit.import.count");
private static readonly Counter<long> PolicySimulationCounter = Meter.CreateCounter<long>("stellaops.cli.policy.simulate.count");
private static readonly Counter<long> TaskRunnerSimulationCounter = Meter.CreateCounter<long>("stellaops.cli.taskrunner.simulate.count");
private static readonly Counter<long> PolicyActivationCounter = Meter.CreateCounter<long>("stellaops.cli.policy.activate.count");
private static readonly Counter<long> SourcesDryRunCounter = Meter.CreateCounter<long>("stellaops.cli.sources.dryrun.count");
private static readonly Counter<long> AocVerifyCounter = Meter.CreateCounter<long>("stellaops.cli.aoc.verify.count");
@@ -101,10 +101,6 @@ internal static class CliMetrics
=> PolicySimulationCounter.Add(1, WithSealedModeTag(
Tag("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)));
public static void RecordTaskRunnerSimulation(string outcome)
=> TaskRunnerSimulationCounter.Add(1, WithSealedModeTag(
Tag("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)));
public static void RecordPolicyActivation(string outcome)
=> PolicyActivationCounter.Add(1, WithSealedModeTag(
Tag("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)));

View File

@@ -857,13 +857,6 @@
"removeIn": "3.0",
"reason": "Incident commands consolidated under admin"
},
{
"old": "taskrunner status",
"new": "admin taskrunner status",
"type": "deprecated",
"removeIn": "3.0",
"reason": "Task runner consolidated under admin"
},
{
"old": "observability metrics",
"new": "admin observability metrics",

View File

@@ -3046,179 +3046,6 @@ public sealed class CommandHandlersTests
}
}
[Fact]
public async Task HandleTaskRunnerSimulateAsync_WritesInteractiveSummary()
{
var originalExit = Environment.ExitCode;
var originalConsole = AnsiConsole.Console;
var console = new TestConsole();
console.Width(120);
console.Interactive();
console.EmitAnsiSequences();
AnsiConsole.Console = console;
const string manifest = """
apiVersion: stellaops.io/pack.v1
kind: TaskPack
metadata:
name: sample-pack
spec:
steps:
- id: prepare
run:
uses: builtin:prepare
- id: approval
gate:
approval:
id: security-review
message: Security approval required.
""";
using var manifestFile = new TempFile("pack.yaml", Encoding.UTF8.GetBytes(manifest));
var simulationResult = new TaskRunnerSimulationResult(
"hash-abc123",
new TaskRunnerSimulationFailurePolicy(3, 15, false),
new[]
{
new TaskRunnerSimulationStep(
"prepare",
"prepare",
"Run",
true,
"succeeded",
null,
"builtin:prepare",
null,
null,
null,
false,
Array.Empty<TaskRunnerSimulationStep>()),
new TaskRunnerSimulationStep(
"approval",
"approval",
"GateApproval",
true,
"pending",
"requires-approval",
null,
"security-review",
"Security approval required.",
null,
false,
Array.Empty<TaskRunnerSimulationStep>())
},
new[]
{
new TaskRunnerSimulationOutput("bundlePath", "file", false, "artifacts/report.json", null)
},
true);
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null))
{
TaskRunnerSimulationResult = simulationResult
};
var provider = BuildServiceProvider(backend);
try
{
await CommandHandlers.HandleTaskRunnerSimulateAsync(
provider,
manifestFile.Path,
inputsPath: null,
format: null,
outputPath: null,
verbose: false,
cancellationToken: CancellationToken.None);
Assert.Equal(0, Environment.ExitCode);
Assert.NotNull(backend.LastTaskRunnerSimulationRequest);
Assert.Contains("approval", console.Output, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Plan Hash", console.Output, StringComparison.OrdinalIgnoreCase);
}
finally
{
AnsiConsole.Console = originalConsole;
Environment.ExitCode = originalExit;
}
}
[Fact]
public async Task HandleTaskRunnerSimulateAsync_WritesJsonOutput()
{
var originalExit = Environment.ExitCode;
var originalOut = Console.Out;
const string manifest = """
apiVersion: stellaops.io/pack.v1
kind: TaskPack
metadata:
name: sample-pack
spec:
steps:
- id: prepare
run:
uses: builtin:prepare
""";
using var manifestFile = new TempFile("pack.yaml", Encoding.UTF8.GetBytes(manifest));
using var inputsFile = new TempFile("inputs.json", Encoding.UTF8.GetBytes("{\"dryRun\":false}"));
using var outputDirectory = new TempDirectory();
var outputPath = Path.Combine(outputDirectory.Path, "simulation.json");
var simulationResult = new TaskRunnerSimulationResult(
"hash-xyz789",
new TaskRunnerSimulationFailurePolicy(2, 10, true),
Array.Empty<TaskRunnerSimulationStep>(),
Array.Empty<TaskRunnerSimulationOutput>(),
false);
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null))
{
TaskRunnerSimulationResult = simulationResult
};
var provider = BuildServiceProvider(backend);
using var writer = new StringWriter();
Console.SetOut(writer);
try
{
await CommandHandlers.HandleTaskRunnerSimulateAsync(
provider,
manifestFile.Path,
inputsFile.Path,
format: "json",
outputPath: outputPath,
verbose: false,
cancellationToken: CancellationToken.None);
Assert.Equal(0, Environment.ExitCode);
Assert.NotNull(backend.LastTaskRunnerSimulationRequest);
var consoleOutput = writer.ToString();
using (var consoleJson = JsonDocument.Parse(consoleOutput))
{
Assert.Equal("hash-xyz789", consoleJson.RootElement.GetProperty("planHash").GetString());
}
var fileOutput = await File.ReadAllTextAsync(outputPath);
using (var fileJson = JsonDocument.Parse(fileOutput))
{
Assert.Equal("hash-xyz789", fileJson.RootElement.GetProperty("planHash").GetString());
}
Assert.True(backend.LastTaskRunnerSimulationRequest!.Inputs!.TryGetPropertyValue("dryRun", out var dryRunNode));
Assert.False(dryRunNode!.GetValue<bool>());
}
finally
{
Console.SetOut(originalOut);
Environment.ExitCode = originalExit;
}
}
[Fact]
public async Task HandlePolicyActivateAsync_DisplaysInteractiveSummary()
{
@@ -4514,14 +4341,6 @@ spec:
null);
public PolicyApiException? SimulationException { get; set; }
public (string PolicyId, PolicySimulationInput Input)? LastPolicySimulation { get; private set; }
public TaskRunnerSimulationRequest? LastTaskRunnerSimulationRequest { get; private set; }
public TaskRunnerSimulationResult TaskRunnerSimulationResult { get; set; } = new(
string.Empty,
new TaskRunnerSimulationFailurePolicy(1, 0, false),
Array.Empty<TaskRunnerSimulationStep>(),
Array.Empty<TaskRunnerSimulationOutput>(),
false);
public Exception? TaskRunnerSimulationException { get; set; }
public OfflineKitStatus? OfflineStatus { get; set; }
public PolicyActivationResult ActivationResult { get; set; } = new PolicyActivationResult(
"activated",
@@ -4631,17 +4450,6 @@ spec:
return Task.FromResult(SimulationResult);
}
public Task<TaskRunnerSimulationResult> SimulateTaskRunnerAsync(TaskRunnerSimulationRequest request, CancellationToken cancellationToken)
{
LastTaskRunnerSimulationRequest = request;
if (TaskRunnerSimulationException is not null)
{
throw TaskRunnerSimulationException;
}
return Task.FromResult(TaskRunnerSimulationResult);
}
public Task<PolicyActivationResult> ActivatePolicyRevisionAsync(string policyId, int version, PolicyActivationRequest request, CancellationToken cancellationToken)
{
LastPolicyActivation = (policyId, version, request);

View File

@@ -116,7 +116,7 @@ public class FullConsolidationTests
[InlineData("doctor run", "admin doctor run")]
[InlineData("db migrate", "admin db migrate")]
[InlineData("incidents list", "admin incidents list")]
[InlineData("taskrunner status", "admin taskrunner status")]
[InlineData("observability metrics", "admin observability metrics")]
public void AdminConsolidation_ShouldMapCorrectly(string oldPath, string newPath)
{

View File

@@ -148,7 +148,7 @@ public class HelpTextTests
// Arrange
var expectedSubcommands = new[]
{
"system", "doctor", "db", "incidents", "taskrunner"
"system", "doctor", "db", "incidents"
};
// Act
@@ -419,7 +419,6 @@ public class HelpTextTests
doctor Diagnostics (from: doctor)
db Database operations (from: db)
incidents Incident management (from: incidents)
taskrunner Task runner (from: taskrunner)
""";
private static string GetToolsHelpText() =>

View File

@@ -1,30 +0,0 @@
# TaskRunner Module Charter
## Mission
- Orchestrate deterministic task-pack execution, evidence, and replayable run logs.
## Responsibilities
- Define pack run lifecycle, persistence, and evidence outputs.
- Ensure canonical plan hashing and deterministic event emission.
- Maintain offline-first execution and bounded resource usage.
## Required Reading
- docs/README.md
- docs/07_HIGH_LEVEL_ARCHITECTURE.md
- docs/modules/platform/architecture-overview.md
- docs/modules/taskrunner/architecture.md
## Working Agreement
- Use TimeProvider and IGuidGenerator for all timestamps and IDs.
- Use RFC 8785 canonical JSON for hashes and signatures.
- Propagate CancellationToken and avoid network by default.
## Testing Strategy
- Unit tests for plan hashing, persistence, and evidence outputs.
- Determinism tests for run logs and identifiers.
- Integration tests for API and worker loops.
## Service Endpoints
- Development: https://localhost:10180, http://localhost:10181
- Local alias: https://taskrunner.stella-ops.local, http://taskrunner.stella-ops.local
- Env var: STELLAOPS_TASKRUNNER_URL

View File

@@ -1,21 +1,21 @@
# JobEngine
**Container(s):** stellaops-scheduler-web, stellaops-scheduler-worker, stellaops-taskrunner-web, stellaops-taskrunner-worker, stellaops-packsregistry-web, stellaops-packsregistry-worker
**Slot:** 19 (scheduler), 18 (taskrunner), 34 (packsregistry) | **Port:** 8080 | **Consumer Group:** scheduler, taskrunner, packsregistry
**Resource Tier:** medium (scheduler), light (taskrunner, packsregistry)
**Container(s):** stellaops-scheduler-web, stellaops-scheduler-worker, stellaops-packsregistry-web, stellaops-packsregistry-worker
**Slot:** 19 (scheduler), 34 (packsregistry) | **Port:** 8080 | **Consumer Group:** scheduler, packsregistry
**Resource Tier:** medium (scheduler), light (packsregistry)
## Purpose
The JobEngine module provides scheduled scan orchestration, task execution, and pack registry management. The Scheduler manages scan schedules (CRON-based), graph jobs, policy simulation runs, vulnerability resolver jobs, and failure signatures. The TaskRunner executes task packs with air-gap-aware egress policies, simulation, and attestation. The PacksRegistry stores and serves versioned task pack bundles.
The JobEngine module provides scheduled scan orchestration and pack registry management. The Scheduler manages scan schedules (CRON-based), graph jobs, policy simulation runs, vulnerability resolver jobs, and failure signatures. The PacksRegistry stores and serves versioned task pack bundles.
> **Note:** TaskRunner (Slot 18) was removed. The `task_runner_id` DB columns remain as nullable legacy fields.
## API Surface
- `scheduler` (via Router) schedule CRUD, run history, graph jobs, policy runs, policy simulations, failure signatures, event webhooks, scripts endpoint
- `taskrunner` (via Router) — task pack execution, simulation, planning, incident mode, artifact management
- `packsregistry` (via Router) — pack upload, download, version listing, approval workflow
- `scheduler` (via Router) -- schedule CRUD, run history, graph jobs, policy runs, policy simulations, failure signatures, event webhooks, scripts endpoint
- `packsregistry` (via Router) -- pack upload, download, version listing, approval workflow
## Storage
PostgreSQL schema `scheduler` (Scheduler); PostgreSQL for TaskRunner and PacksRegistry; Valkey queue for job dispatch; seed-fs object store for artifacts
PostgreSQL schema `scheduler` (Scheduler); PostgreSQL for PacksRegistry; Valkey queue for job dispatch; seed-fs object store for artifacts
## Background Workers
- Scheduler: `SchedulerWorkerHostedService` picks up scheduled jobs from Valkey and dispatches scan runs
- TaskRunner: worker process for pack execution
- Scheduler: `SchedulerWorkerHostedService` -- picks up scheduled jobs from Valkey and dispatches scan runs
- PacksRegistry: worker process for background pack processing

View File

@@ -1,61 +0,0 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Infrastructure.Postgres.Options;
using StellaOps.TaskRunner.Core.Evidence;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Persistence.Postgres;
using StellaOps.TaskRunner.Persistence.Postgres.Repositories;
namespace StellaOps.TaskRunner.Persistence.Extensions;
/// <summary>
/// Extension methods for configuring TaskRunner persistence services.
/// </summary>
public static class TaskRunnerPersistenceExtensions
{
/// <summary>
/// Adds TaskRunner PostgreSQL persistence services.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configuration">Configuration root.</param>
/// <param name="sectionName">Configuration section name for PostgreSQL options.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddTaskRunnerPersistence(
this IServiceCollection services,
IConfiguration configuration,
string sectionName = "Postgres:TaskRunner")
{
services.Configure<PostgresOptions>(sectionName, configuration.GetSection(sectionName));
services.AddSingleton<TaskRunnerDataSource>();
// Register repositories as scoped (per-request lifetime)
services.AddScoped<IPackRunStateStore, PostgresPackRunStateStore>();
services.AddScoped<IPackRunApprovalStore, PostgresPackRunApprovalStore>();
services.AddScoped<IPackRunLogStore, PostgresPackRunLogStore>();
services.AddScoped<IPackRunEvidenceStore, PostgresPackRunEvidenceStore>();
return services;
}
/// <summary>
/// Adds TaskRunner PostgreSQL persistence services with explicit options.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configureOptions">Options configuration action.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddTaskRunnerPersistence(
this IServiceCollection services,
Action<PostgresOptions> configureOptions)
{
services.Configure(configureOptions);
services.AddSingleton<TaskRunnerDataSource>();
// Register repositories as scoped (per-request lifetime)
services.AddScoped<IPackRunStateStore, PostgresPackRunStateStore>();
services.AddScoped<IPackRunApprovalStore, PostgresPackRunApprovalStore>();
services.AddScoped<IPackRunLogStore, PostgresPackRunLogStore>();
services.AddScoped<IPackRunEvidenceStore, PostgresPackRunEvidenceStore>();
return services;
}
}

View File

@@ -1,221 +0,0 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.TaskRunner.Core.Execution;
using System.Text.Json;
namespace StellaOps.TaskRunner.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL implementation of <see cref="IPackRunApprovalStore"/>.
/// </summary>
public sealed class PostgresPackRunApprovalStore : RepositoryBase<TaskRunnerDataSource>, IPackRunApprovalStore
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
private bool _tableInitialized;
public PostgresPackRunApprovalStore(TaskRunnerDataSource dataSource, ILogger<PostgresPackRunApprovalStore> logger)
: base(dataSource, logger)
{
}
public async Task SaveAsync(string runId, IReadOnlyList<PackRunApprovalState> approvals, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
ArgumentNullException.ThrowIfNull(approvals);
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
// Delete existing approvals for this run, then insert all new ones
const string deleteSql = "DELETE FROM taskrunner.pack_run_approvals WHERE run_id = @run_id";
await using (var deleteCmd = CreateCommand(deleteSql, connection))
{
AddParameter(deleteCmd, "@run_id", runId);
await deleteCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
if (approvals.Count == 0)
{
return;
}
const string insertSql = @"
INSERT INTO taskrunner.pack_run_approvals (
run_id, approval_id, required_grants, step_ids, messages, reason_template,
requested_at, status, actor_id, completed_at, summary
) VALUES (
@run_id, @approval_id, @required_grants, @step_ids, @messages, @reason_template,
@requested_at, @status, @actor_id, @completed_at, @summary
)";
foreach (var approval in approvals)
{
await using var insertCmd = CreateCommand(insertSql, connection);
AddParameter(insertCmd, "@run_id", runId);
AddParameter(insertCmd, "@approval_id", approval.ApprovalId);
AddJsonbParameter(insertCmd, "@required_grants", JsonSerializer.Serialize(approval.RequiredGrants, JsonOptions));
AddJsonbParameter(insertCmd, "@step_ids", JsonSerializer.Serialize(approval.StepIds, JsonOptions));
AddJsonbParameter(insertCmd, "@messages", JsonSerializer.Serialize(approval.Messages, JsonOptions));
AddParameter(insertCmd, "@reason_template", (object?)approval.ReasonTemplate ?? DBNull.Value);
AddParameter(insertCmd, "@requested_at", approval.RequestedAt);
AddParameter(insertCmd, "@status", approval.Status.ToString());
AddParameter(insertCmd, "@actor_id", (object?)approval.ActorId ?? DBNull.Value);
AddParameter(insertCmd, "@completed_at", (object?)approval.CompletedAt ?? DBNull.Value);
AddParameter(insertCmd, "@summary", (object?)approval.Summary ?? DBNull.Value);
await insertCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
}
public async Task<IReadOnlyList<PackRunApprovalState>> GetAsync(string runId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
const string sql = @"
SELECT approval_id, required_grants, step_ids, messages, reason_template,
requested_at, status, actor_id, completed_at, summary
FROM taskrunner.pack_run_approvals
WHERE run_id = @run_id
ORDER BY requested_at";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@run_id", runId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
var results = new List<PackRunApprovalState>();
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(MapApprovalState(reader));
}
return results;
}
public async Task UpdateAsync(string runId, PackRunApprovalState approval, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
ArgumentNullException.ThrowIfNull(approval);
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
const string sql = @"
UPDATE taskrunner.pack_run_approvals
SET required_grants = @required_grants,
step_ids = @step_ids,
messages = @messages,
reason_template = @reason_template,
requested_at = @requested_at,
status = @status,
actor_id = @actor_id,
completed_at = @completed_at,
summary = @summary
WHERE run_id = @run_id AND approval_id = @approval_id";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@run_id", runId);
AddParameter(command, "@approval_id", approval.ApprovalId);
AddJsonbParameter(command, "@required_grants", JsonSerializer.Serialize(approval.RequiredGrants, JsonOptions));
AddJsonbParameter(command, "@step_ids", JsonSerializer.Serialize(approval.StepIds, JsonOptions));
AddJsonbParameter(command, "@messages", JsonSerializer.Serialize(approval.Messages, JsonOptions));
AddParameter(command, "@reason_template", (object?)approval.ReasonTemplate ?? DBNull.Value);
AddParameter(command, "@requested_at", approval.RequestedAt);
AddParameter(command, "@status", approval.Status.ToString());
AddParameter(command, "@actor_id", (object?)approval.ActorId ?? DBNull.Value);
AddParameter(command, "@completed_at", (object?)approval.CompletedAt ?? DBNull.Value);
AddParameter(command, "@summary", (object?)approval.Summary ?? DBNull.Value);
var rowsAffected = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
if (rowsAffected == 0)
{
throw new InvalidOperationException($"Approval '{approval.ApprovalId}' not found for run '{runId}'.");
}
}
private static PackRunApprovalState MapApprovalState(NpgsqlDataReader reader)
{
var approvalId = reader.GetString(0);
var requiredGrantsJson = reader.GetString(1);
var stepIdsJson = reader.GetString(2);
var messagesJson = reader.GetString(3);
var reasonTemplate = reader.IsDBNull(4) ? null : reader.GetString(4);
var requestedAt = reader.GetFieldValue<DateTimeOffset>(5);
var statusString = reader.GetString(6);
var actorId = reader.IsDBNull(7) ? null : reader.GetString(7);
var completedAt = reader.IsDBNull(8) ? (DateTimeOffset?)null : reader.GetFieldValue<DateTimeOffset>(8);
var summary = reader.IsDBNull(9) ? null : reader.GetString(9);
var requiredGrants = JsonSerializer.Deserialize<List<string>>(requiredGrantsJson, JsonOptions)
?? new List<string>();
var stepIds = JsonSerializer.Deserialize<List<string>>(stepIdsJson, JsonOptions)
?? new List<string>();
var messages = JsonSerializer.Deserialize<List<string>>(messagesJson, JsonOptions)
?? new List<string>();
if (!Enum.TryParse<PackRunApprovalStatus>(statusString, ignoreCase: true, out var status))
{
status = PackRunApprovalStatus.Pending;
}
return new PackRunApprovalState(
approvalId,
requiredGrants,
stepIds,
messages,
reasonTemplate,
requestedAt,
status,
actorId,
completedAt,
summary);
}
private async Task EnsureTableAsync(CancellationToken cancellationToken)
{
if (_tableInitialized)
{
return;
}
const string ddl = @"
CREATE SCHEMA IF NOT EXISTS taskrunner;
CREATE TABLE IF NOT EXISTS taskrunner.pack_run_approvals (
run_id TEXT NOT NULL,
approval_id TEXT NOT NULL,
required_grants JSONB NOT NULL,
step_ids JSONB NOT NULL,
messages JSONB NOT NULL,
reason_template TEXT,
requested_at TIMESTAMPTZ NOT NULL,
status TEXT NOT NULL,
actor_id TEXT,
completed_at TIMESTAMPTZ,
summary TEXT,
PRIMARY KEY (run_id, approval_id)
);
CREATE INDEX IF NOT EXISTS idx_pack_run_approvals_status ON taskrunner.pack_run_approvals (status);
CREATE INDEX IF NOT EXISTS idx_pack_run_approvals_requested_at ON taskrunner.pack_run_approvals (requested_at);";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(ddl, connection);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
_tableInitialized = true;
}
}

View File

@@ -1,294 +0,0 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.TaskRunner.Core.Evidence;
using System.Text.Json;
namespace StellaOps.TaskRunner.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL implementation of <see cref="IPackRunEvidenceStore"/>.
/// </summary>
public sealed class PostgresPackRunEvidenceStore : RepositoryBase<TaskRunnerDataSource>, IPackRunEvidenceStore
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
private bool _tableInitialized;
public PostgresPackRunEvidenceStore(TaskRunnerDataSource dataSource, ILogger<PostgresPackRunEvidenceStore> logger)
: base(dataSource, logger)
{
}
public async Task StoreAsync(PackRunEvidenceSnapshot snapshot, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(snapshot);
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
const string sql = @"
INSERT INTO taskrunner.pack_run_evidence (
snapshot_id, tenant_id, run_id, plan_hash, created_at, kind, materials_json, root_hash, metadata_json
) VALUES (
@snapshot_id, @tenant_id, @run_id, @plan_hash, @created_at, @kind, @materials_json, @root_hash, @metadata_json
)
ON CONFLICT (snapshot_id)
DO UPDATE SET
tenant_id = EXCLUDED.tenant_id,
run_id = EXCLUDED.run_id,
plan_hash = EXCLUDED.plan_hash,
created_at = EXCLUDED.created_at,
kind = EXCLUDED.kind,
materials_json = EXCLUDED.materials_json,
root_hash = EXCLUDED.root_hash,
metadata_json = EXCLUDED.metadata_json";
var materialsJson = JsonSerializer.Serialize(snapshot.Materials, JsonOptions);
var metadataJson = snapshot.Metadata is null
? null
: JsonSerializer.Serialize(snapshot.Metadata, JsonOptions);
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@snapshot_id", snapshot.SnapshotId);
AddParameter(command, "@tenant_id", snapshot.TenantId);
AddParameter(command, "@run_id", snapshot.RunId);
AddParameter(command, "@plan_hash", snapshot.PlanHash);
AddParameter(command, "@created_at", snapshot.CreatedAt);
AddParameter(command, "@kind", snapshot.Kind.ToString());
AddJsonbParameter(command, "@materials_json", materialsJson);
AddParameter(command, "@root_hash", snapshot.RootHash);
if (metadataJson is not null)
{
AddJsonbParameter(command, "@metadata_json", metadataJson);
}
else
{
AddParameter(command, "@metadata_json", DBNull.Value);
}
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
public async Task<PackRunEvidenceSnapshot?> GetAsync(Guid snapshotId, CancellationToken cancellationToken = default)
{
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
const string sql = @"
SELECT snapshot_id, tenant_id, run_id, plan_hash, created_at, kind, materials_json, root_hash, metadata_json
FROM taskrunner.pack_run_evidence
WHERE snapshot_id = @snapshot_id";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@snapshot_id", snapshotId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return null;
}
return MapSnapshot(reader);
}
public async Task<IReadOnlyList<PackRunEvidenceSnapshot>> ListByRunAsync(
string tenantId,
string runId,
CancellationToken cancellationToken = default)
{
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
const string sql = @"
SELECT snapshot_id, tenant_id, run_id, plan_hash, created_at, kind, materials_json, root_hash, metadata_json
FROM taskrunner.pack_run_evidence
WHERE LOWER(tenant_id) = LOWER(@tenant_id) AND run_id = @run_id
ORDER BY created_at";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@tenant_id", tenantId);
AddParameter(command, "@run_id", runId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
var results = new List<PackRunEvidenceSnapshot>();
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(MapSnapshot(reader));
}
return results;
}
public async Task<IReadOnlyList<PackRunEvidenceSnapshot>> GetByRunIdAsync(
string runId,
CancellationToken cancellationToken = default)
{
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
const string sql = @"
SELECT snapshot_id, tenant_id, run_id, plan_hash, created_at, kind, materials_json, root_hash, metadata_json
FROM taskrunner.pack_run_evidence
WHERE run_id = @run_id
ORDER BY created_at";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@run_id", runId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
var results = new List<PackRunEvidenceSnapshot>();
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(MapSnapshot(reader));
}
return results;
}
public async Task<IReadOnlyList<PackRunEvidenceSnapshot>> ListByKindAsync(
string tenantId,
string runId,
PackRunEvidenceSnapshotKind kind,
CancellationToken cancellationToken = default)
{
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
const string sql = @"
SELECT snapshot_id, tenant_id, run_id, plan_hash, created_at, kind, materials_json, root_hash, metadata_json
FROM taskrunner.pack_run_evidence
WHERE LOWER(tenant_id) = LOWER(@tenant_id) AND run_id = @run_id AND kind = @kind
ORDER BY created_at";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@tenant_id", tenantId);
AddParameter(command, "@run_id", runId);
AddParameter(command, "@kind", kind.ToString());
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
var results = new List<PackRunEvidenceSnapshot>();
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(MapSnapshot(reader));
}
return results;
}
public async Task<PackRunEvidenceVerificationResult> VerifyAsync(
Guid snapshotId,
CancellationToken cancellationToken = default)
{
var snapshot = await GetAsync(snapshotId, cancellationToken).ConfigureAwait(false);
if (snapshot is null)
{
return new PackRunEvidenceVerificationResult(
Valid: false,
SnapshotId: snapshotId,
ExpectedHash: string.Empty,
ComputedHash: string.Empty,
Error: "Snapshot not found");
}
// Recompute by creating a new snapshot with same materials
var recomputed = PackRunEvidenceSnapshot.Create(
snapshot.TenantId,
snapshot.RunId,
snapshot.PlanHash,
snapshot.Kind,
snapshot.Materials,
snapshot.Metadata);
var valid = string.Equals(snapshot.RootHash, recomputed.RootHash, StringComparison.Ordinal);
return new PackRunEvidenceVerificationResult(
Valid: valid,
SnapshotId: snapshotId,
ExpectedHash: snapshot.RootHash,
ComputedHash: recomputed.RootHash,
Error: valid ? null : "Root hash mismatch");
}
private static PackRunEvidenceSnapshot MapSnapshot(NpgsqlDataReader reader)
{
var snapshotId = reader.GetGuid(0);
var tenantId = reader.GetString(1);
var runId = reader.GetString(2);
var planHash = reader.GetString(3);
var createdAt = reader.GetFieldValue<DateTimeOffset>(4);
var kindString = reader.GetString(5);
var materialsJson = reader.GetString(6);
var rootHash = reader.GetString(7);
var metadataJson = reader.IsDBNull(8) ? null : reader.GetString(8);
if (!Enum.TryParse<PackRunEvidenceSnapshotKind>(kindString, ignoreCase: true, out var kind))
{
kind = PackRunEvidenceSnapshotKind.RunCompletion;
}
var materials = JsonSerializer.Deserialize<List<PackRunEvidenceMaterial>>(materialsJson, JsonOptions)
?? new List<PackRunEvidenceMaterial>();
IReadOnlyDictionary<string, string>? metadata = null;
if (metadataJson is not null)
{
metadata = JsonSerializer.Deserialize<Dictionary<string, string>>(metadataJson, JsonOptions);
}
return new PackRunEvidenceSnapshot(
snapshotId,
tenantId,
runId,
planHash,
createdAt,
kind,
materials,
rootHash,
metadata);
}
private async Task EnsureTableAsync(CancellationToken cancellationToken)
{
if (_tableInitialized)
{
return;
}
const string ddl = @"
CREATE SCHEMA IF NOT EXISTS taskrunner;
CREATE TABLE IF NOT EXISTS taskrunner.pack_run_evidence (
snapshot_id UUID PRIMARY KEY,
tenant_id TEXT NOT NULL,
run_id TEXT NOT NULL,
plan_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
kind TEXT NOT NULL,
materials_json JSONB NOT NULL,
root_hash TEXT NOT NULL,
metadata_json JSONB
);
CREATE INDEX IF NOT EXISTS idx_pack_run_evidence_run_id ON taskrunner.pack_run_evidence (run_id);
CREATE INDEX IF NOT EXISTS idx_pack_run_evidence_tenant_run ON taskrunner.pack_run_evidence (tenant_id, run_id);
CREATE INDEX IF NOT EXISTS idx_pack_run_evidence_kind ON taskrunner.pack_run_evidence (tenant_id, run_id, kind);
CREATE INDEX IF NOT EXISTS idx_pack_run_evidence_created_at ON taskrunner.pack_run_evidence (created_at);";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(ddl, connection);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
_tableInitialized = true;
}
}

View File

@@ -1,157 +0,0 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.TaskRunner.Core.Execution;
using System.Runtime.CompilerServices;
using System.Text.Json;
namespace StellaOps.TaskRunner.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL implementation of <see cref="IPackRunLogStore"/>.
/// </summary>
public sealed class PostgresPackRunLogStore : RepositoryBase<TaskRunnerDataSource>, IPackRunLogStore
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
private bool _tableInitialized;
public PostgresPackRunLogStore(TaskRunnerDataSource dataSource, ILogger<PostgresPackRunLogStore> logger)
: base(dataSource, logger)
{
}
public async Task AppendAsync(string runId, PackRunLogEntry entry, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
ArgumentNullException.ThrowIfNull(entry);
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
const string sql = @"
INSERT INTO taskrunner.pack_run_logs (run_id, timestamp, level, event_type, message, step_id, metadata)
VALUES (@run_id, @timestamp, @level, @event_type, @message, @step_id, @metadata)";
var metadataJson = entry.Metadata is null
? null
: JsonSerializer.Serialize(entry.Metadata, JsonOptions);
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@run_id", runId);
AddParameter(command, "@timestamp", entry.Timestamp);
AddParameter(command, "@level", entry.Level);
AddParameter(command, "@event_type", entry.EventType);
AddParameter(command, "@message", entry.Message);
AddParameter(command, "@step_id", (object?)entry.StepId ?? DBNull.Value);
if (metadataJson is not null)
{
AddJsonbParameter(command, "@metadata", metadataJson);
}
else
{
AddParameter(command, "@metadata", DBNull.Value);
}
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
public async IAsyncEnumerable<PackRunLogEntry> ReadAsync(
string runId,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
const string sql = @"
SELECT timestamp, level, event_type, message, step_id, metadata
FROM taskrunner.pack_run_logs
WHERE run_id = @run_id
ORDER BY timestamp, id";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@run_id", runId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
yield return MapLogEntry(reader);
}
}
public async Task<bool> ExistsAsync(string runId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
const string sql = @"
SELECT EXISTS(SELECT 1 FROM taskrunner.pack_run_logs WHERE run_id = @run_id)";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@run_id", runId);
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return result is true;
}
private static PackRunLogEntry MapLogEntry(NpgsqlDataReader reader)
{
var timestamp = reader.GetFieldValue<DateTimeOffset>(0);
var level = reader.GetString(1);
var eventType = reader.GetString(2);
var message = reader.GetString(3);
var stepId = reader.IsDBNull(4) ? null : reader.GetString(4);
var metadataJson = reader.IsDBNull(5) ? null : reader.GetString(5);
IReadOnlyDictionary<string, string>? metadata = null;
if (metadataJson is not null)
{
metadata = JsonSerializer.Deserialize<Dictionary<string, string>>(metadataJson, JsonOptions);
}
return new PackRunLogEntry(timestamp, level, eventType, message, stepId, metadata);
}
private async Task EnsureTableAsync(CancellationToken cancellationToken)
{
if (_tableInitialized)
{
return;
}
const string ddl = @"
CREATE SCHEMA IF NOT EXISTS taskrunner;
CREATE TABLE IF NOT EXISTS taskrunner.pack_run_logs (
id BIGSERIAL PRIMARY KEY,
run_id TEXT NOT NULL,
timestamp TIMESTAMPTZ NOT NULL,
level TEXT NOT NULL,
event_type TEXT NOT NULL,
message TEXT NOT NULL,
step_id TEXT,
metadata JSONB
);
CREATE INDEX IF NOT EXISTS idx_pack_run_logs_run_id ON taskrunner.pack_run_logs (run_id);
CREATE INDEX IF NOT EXISTS idx_pack_run_logs_timestamp ON taskrunner.pack_run_logs (timestamp);
CREATE INDEX IF NOT EXISTS idx_pack_run_logs_run_timestamp ON taskrunner.pack_run_logs (run_id, timestamp, id);";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(ddl, connection);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
_tableInitialized = true;
}
}

View File

@@ -1,174 +0,0 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Planning;
using System.Text.Json;
namespace StellaOps.TaskRunner.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL implementation of <see cref="IPackRunStateStore"/>.
/// </summary>
public sealed class PostgresPackRunStateStore : RepositoryBase<TaskRunnerDataSource>, IPackRunStateStore
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
private bool _tableInitialized;
public PostgresPackRunStateStore(TaskRunnerDataSource dataSource, ILogger<PostgresPackRunStateStore> logger)
: base(dataSource, logger)
{
}
public async Task<PackRunState?> GetAsync(string runId, CancellationToken cancellationToken)
{
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
const string sql = @"
SELECT run_id, plan_hash, plan_json, failure_policy_json, requested_at, created_at, updated_at, steps_json, tenant_id
FROM taskrunner.pack_run_state
WHERE run_id = @run_id";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@run_id", runId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return null;
}
return MapPackRunState(reader);
}
public async Task SaveAsync(PackRunState state, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(state);
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
const string sql = @"
INSERT INTO taskrunner.pack_run_state (run_id, plan_hash, plan_json, failure_policy_json, requested_at, created_at, updated_at, steps_json, tenant_id)
VALUES (@run_id, @plan_hash, @plan_json, @failure_policy_json, @requested_at, @created_at, @updated_at, @steps_json, @tenant_id)
ON CONFLICT (run_id)
DO UPDATE SET
plan_hash = EXCLUDED.plan_hash,
plan_json = EXCLUDED.plan_json,
failure_policy_json = EXCLUDED.failure_policy_json,
requested_at = EXCLUDED.requested_at,
updated_at = EXCLUDED.updated_at,
steps_json = EXCLUDED.steps_json,
tenant_id = EXCLUDED.tenant_id";
var planJson = JsonSerializer.Serialize(state.Plan, JsonOptions);
var failurePolicyJson = JsonSerializer.Serialize(state.FailurePolicy, JsonOptions);
var stepsJson = JsonSerializer.Serialize(state.Steps, JsonOptions);
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@run_id", state.RunId);
AddParameter(command, "@plan_hash", state.PlanHash);
AddJsonbParameter(command, "@plan_json", planJson);
AddJsonbParameter(command, "@failure_policy_json", failurePolicyJson);
AddParameter(command, "@requested_at", state.RequestedAt);
AddParameter(command, "@created_at", state.CreatedAt);
AddParameter(command, "@updated_at", state.UpdatedAt);
AddJsonbParameter(command, "@steps_json", stepsJson);
AddParameter(command, "@tenant_id", (object?)state.TenantId ?? DBNull.Value);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<PackRunState>> ListAsync(CancellationToken cancellationToken)
{
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
const string sql = @"
SELECT run_id, plan_hash, plan_json, failure_policy_json, requested_at, created_at, updated_at, steps_json, tenant_id
FROM taskrunner.pack_run_state
ORDER BY created_at DESC";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
var results = new List<PackRunState>();
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(MapPackRunState(reader));
}
return results;
}
private static PackRunState MapPackRunState(NpgsqlDataReader reader)
{
var runId = reader.GetString(0);
var planHash = reader.GetString(1);
var planJson = reader.GetString(2);
var failurePolicyJson = reader.GetString(3);
var requestedAt = reader.GetFieldValue<DateTimeOffset>(4);
var createdAt = reader.GetFieldValue<DateTimeOffset>(5);
var updatedAt = reader.GetFieldValue<DateTimeOffset>(6);
var stepsJson = reader.GetString(7);
var tenantId = reader.IsDBNull(8) ? null : reader.GetString(8);
var plan = JsonSerializer.Deserialize<TaskPackPlan>(planJson, JsonOptions)
?? throw new InvalidOperationException($"Failed to deserialize plan for run '{runId}'");
var failurePolicy = JsonSerializer.Deserialize<TaskPackPlanFailurePolicy>(failurePolicyJson, JsonOptions)
?? throw new InvalidOperationException($"Failed to deserialize failure policy for run '{runId}'");
var steps = JsonSerializer.Deserialize<Dictionary<string, PackRunStepStateRecord>>(stepsJson, JsonOptions)
?? new Dictionary<string, PackRunStepStateRecord>(StringComparer.Ordinal);
return new PackRunState(
runId,
planHash,
plan,
failurePolicy,
requestedAt,
createdAt,
updatedAt,
steps,
tenantId);
}
private async Task EnsureTableAsync(CancellationToken cancellationToken)
{
if (_tableInitialized)
{
return;
}
const string ddl = @"
CREATE SCHEMA IF NOT EXISTS taskrunner;
CREATE TABLE IF NOT EXISTS taskrunner.pack_run_state (
run_id TEXT PRIMARY KEY,
plan_hash TEXT NOT NULL,
plan_json JSONB NOT NULL,
failure_policy_json JSONB NOT NULL,
requested_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
steps_json JSONB NOT NULL,
tenant_id TEXT
);
CREATE INDEX IF NOT EXISTS idx_pack_run_state_tenant_id ON taskrunner.pack_run_state (tenant_id);
CREATE INDEX IF NOT EXISTS idx_pack_run_state_created_at ON taskrunner.pack_run_state (created_at DESC);";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(ddl, connection);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
_tableInitialized = true;
}
}

View File

@@ -1,44 +0,0 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Connections;
using StellaOps.Infrastructure.Postgres.Options;
namespace StellaOps.TaskRunner.Persistence.Postgres;
/// <summary>
/// PostgreSQL data source for TaskRunner module.
/// </summary>
public sealed class TaskRunnerDataSource : DataSourceBase
{
/// <summary>
/// Default schema name for TaskRunner tables.
/// </summary>
public const string DefaultSchemaName = "taskrunner";
/// <summary>
/// Creates a new TaskRunner data source.
/// </summary>
public TaskRunnerDataSource(IOptions<PostgresOptions> options, ILogger<TaskRunnerDataSource> logger)
: base(CreateOptions(options.Value), logger)
{
}
/// <inheritdoc />
protected override string ModuleName => "TaskRunner";
/// <inheritdoc />
protected override void ConfigureDataSourceBuilder(NpgsqlDataSourceBuilder builder)
{
base.ConfigureDataSourceBuilder(builder);
}
private static PostgresOptions CreateOptions(PostgresOptions baseOptions)
{
if (string.IsNullOrWhiteSpace(baseOptions.SchemaName))
{
baseOptions.SchemaName = DefaultSchemaName;
}
return baseOptions;
}
}

View File

@@ -1,26 +0,0 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>StellaOps.TaskRunner.Persistence</RootNamespace>
<AssemblyName>StellaOps.TaskRunner.Persistence</AssemblyName>
<Description>Consolidated persistence layer for StellaOps TaskRunner module (EF Core + Raw SQL)</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Npgsql" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
<ProjectReference Include="..\..\StellaOps.TaskRunner\StellaOps.TaskRunner.Core\StellaOps.TaskRunner.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,8 +0,0 @@
# StellaOps.TaskRunner.Persistence Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/TaskRunner/__Libraries/StellaOps.TaskRunner.Persistence/StellaOps.TaskRunner.Persistence.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |

View File

@@ -1,172 +0,0 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Planning;
using StellaOps.TaskRunner.Persistence.Postgres;
using StellaOps.TaskRunner.Persistence.Postgres.Repositories;
using StellaOps.Infrastructure.Postgres.Options;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.TaskRunner.Persistence.Tests;
[Collection(TaskRunnerPostgresCollection.Name)]
public sealed class PostgresPackRunStateStoreTests : IAsyncLifetime
{
private readonly TaskRunnerPostgresFixture _fixture;
private readonly PostgresPackRunStateStore _store;
private readonly TaskRunnerDataSource _dataSource;
public PostgresPackRunStateStoreTests(TaskRunnerPostgresFixture fixture)
{
_fixture = fixture;
var options = Options.Create(new PostgresOptions
{
ConnectionString = fixture.ConnectionString,
SchemaName = TaskRunnerDataSource.DefaultSchemaName,
AutoMigrate = false
});
_dataSource = new TaskRunnerDataSource(options, NullLogger<TaskRunnerDataSource>.Instance);
_store = new PostgresPackRunStateStore(_dataSource, NullLogger<PostgresPackRunStateStore>.Instance);
}
public async ValueTask InitializeAsync()
{
await _fixture.TruncateAllTablesAsync();
}
public async ValueTask DisposeAsync()
{
await _dataSource.DisposeAsync();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetAsync_ReturnsNullForUnknownRunId()
{
// Act
var result = await _store.GetAsync("nonexistent-run-id", CancellationToken.None);
// Assert
result.Should().BeNull();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task SaveAndGet_RoundTripsState()
{
// Arrange
var runId = "run-" + Guid.NewGuid().ToString("N")[..8];
var state = CreateState(runId);
// Act
await _store.SaveAsync(state, CancellationToken.None);
var fetched = await _store.GetAsync(runId, CancellationToken.None);
// Assert
fetched.Should().NotBeNull();
fetched!.RunId.Should().Be(runId);
fetched.PlanHash.Should().Be("sha256:plan123");
fetched.Plan.Metadata.Name.Should().Be("test-pack");
fetched.Steps.Should().HaveCount(1);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task SaveAsync_UpdatesExistingState()
{
// Arrange
var runId = "run-" + Guid.NewGuid().ToString("N")[..8];
var state1 = CreateState(runId, "sha256:hash1");
var state2 = CreateState(runId, "sha256:hash2");
// Act
await _store.SaveAsync(state1, CancellationToken.None);
await _store.SaveAsync(state2, CancellationToken.None);
var fetched = await _store.GetAsync(runId, CancellationToken.None);
// Assert
fetched.Should().NotBeNull();
fetched!.PlanHash.Should().Be("sha256:hash2");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ListAsync_ReturnsAllStates()
{
// Arrange
var state1 = CreateState("run-list-1");
var state2 = CreateState("run-list-2");
await _store.SaveAsync(state1, CancellationToken.None);
await _store.SaveAsync(state2, CancellationToken.None);
// Act
var states = await _store.ListAsync(CancellationToken.None);
// Assert
states.Should().HaveCountGreaterThanOrEqualTo(2);
states.Select(s => s.RunId).Should().Contain("run-list-1", "run-list-2");
}
private static PackRunState CreateState(string runId, string planHash = "sha256:plan123")
{
var now = DateTimeOffset.UtcNow;
var metadata = new TaskPackPlanMetadata(
Name: "test-pack",
Version: "1.0.0",
Description: "Test pack for integration tests",
Tags: ["test"]);
var plan = new TaskPackPlan(
metadata: metadata,
inputs: new Dictionary<string, System.Text.Json.Nodes.JsonNode?>(),
steps: [],
hash: planHash,
approvals: [],
secrets: [],
outputs: [],
failurePolicy: null);
var failurePolicy = new TaskPackPlanFailurePolicy(
MaxAttempts: 3,
BackoffSeconds: 30,
ContinueOnError: false);
var stepState = new PackRunStepStateRecord(
StepId: "step-1",
Kind: PackRunStepKind.Run,
Enabled: true,
ContinueOnError: false,
MaxParallel: null,
ApprovalId: null,
GateMessage: null,
Status: PackRunStepExecutionStatus.Pending,
Attempts: 0,
LastTransitionAt: null,
NextAttemptAt: null,
StatusReason: null);
var steps = new Dictionary<string, PackRunStepStateRecord>(StringComparer.Ordinal)
{
["step-1"] = stepState
};
return new PackRunState(
RunId: runId,
PlanHash: planHash,
Plan: plan,
FailurePolicy: failurePolicy,
RequestedAt: now,
CreatedAt: now,
UpdatedAt: now,
Steps: steps,
TenantId: "test-tenant");
}
}

View File

@@ -1,23 +0,0 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.TaskRunner.__Libraries\StellaOps.TaskRunner.Persistence\StellaOps.TaskRunner.Persistence.csproj" />
<ProjectReference Include="..\..\StellaOps.TaskRunner\StellaOps.TaskRunner.Core\StellaOps.TaskRunner.Core.csproj" />
<ProjectReference Include="..\..\..\__Tests\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,8 +0,0 @@
# StellaOps.TaskRunner.Persistence.Tests Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/TaskRunner/__Tests/StellaOps.TaskRunner.Persistence.Tests/StellaOps.TaskRunner.Persistence.Tests.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |

View File

@@ -1,30 +0,0 @@
using System.Reflection;
using StellaOps.TaskRunner.Persistence.Postgres;
using StellaOps.Infrastructure.Postgres.Testing;
using Xunit;
namespace StellaOps.TaskRunner.Persistence.Tests;
/// <summary>
/// PostgreSQL integration test fixture for the TaskRunner module.
/// Runs migrations from embedded resources and provides test isolation.
/// </summary>
public sealed class TaskRunnerPostgresFixture : PostgresIntegrationFixture, ICollectionFixture<TaskRunnerPostgresFixture>
{
protected override Assembly? GetMigrationAssembly()
=> typeof(TaskRunnerDataSource).Assembly;
protected override string GetModuleName() => "TaskRunner";
protected override string? GetResourcePrefix() => "Migrations";
}
/// <summary>
/// Collection definition for TaskRunner PostgreSQL integration tests.
/// Tests in this collection share a single PostgreSQL container instance.
/// </summary>
[CollectionDefinition(Name)]
public sealed class TaskRunnerPostgresCollection : ICollectionFixture<TaskRunnerPostgresFixture>
{
public const string Name = "TaskRunnerPostgres";
}

View File

@@ -1,32 +0,0 @@
# Task Runner Service ??? Agent Charter
## Mission
Execute Task Packs safely and deterministically. Provide remote pack execution, approvals, logging, artifact capture, and policy gates in support of Epic???12, honoring the imposed rule to propagate similar work where needed.
## Responsibilities
- Validate Task Packs, enforce RBAC/approvals, orchestrate steps, manage artifacts/logs, stream status.
- Integrate with JobEngine, Authority, Policy Engine, Export Center, Notifications, and CLI.
- Guarantee reproducible runs, provenance manifests, and secure handling of secrets and networks.
## Module Layout
- `StellaOps.TaskRunner.Core/` ??? execution engine, step DSL, policy gates.
- `StellaOps.TaskRunner.Infrastructure/` ??? storage adapters, artifact handling, external clients.
- `StellaOps.TaskRunner.WebService/` ??? run management APIs and simulation endpoints.
- `StellaOps.TaskRunner.Worker/` ??? background executors, approvals, and telemetry loops.
- `StellaOps.TaskRunner.Tests/` ??? unit tests for core/infrastructure code paths.
- `StellaOps.TaskRunner.sln` ??? module solution.
## Required Reading
- `docs/modules/platform/architecture.md`
- `docs/modules/platform/architecture-overview.md`
- `docs/modules/taskrunner/architecture.md`
- `docs-archived/product/advisories/27-Nov-2025-superseded/28-Nov-2025 - Task Pack Orchestration and Automation.md`
- `docs/modules/packs-registry/guides/spec.md`, `docs/modules/packs-registry/guides/authoring-guide.md`, `docs/modules/packs-registry/guides/runbook.md`
## Working Agreement
- 1. Update task status to `DOING`/`DONE` in both correspoding sprint file `/docs/implplan/SPRINT_*.md` and the local `TASKS.md` when you start or finish work.
- 2. Review this charter and the Required Reading documents before coding; confirm prerequisites are met.
- 3. Keep changes deterministic (stable ordering, timestamps, hashes) and align with offline/air-gap expectations; enforce plan-hash binding for every run.
- 4. Coordinate doc updates, tests, and cross-guild communication whenever contracts or workflows change; sync sprint Decisions/Risks when advisory-driven changes land.
- 5. Revert to `TODO` if you pause the task without shipping changes; leave notes in commit/PR descriptions for context.

View File

@@ -1,76 +0,0 @@
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

@@ -1,124 +0,0 @@
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

@@ -1,231 +0,0 @@
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

@@ -1,174 +0,0 @@
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

@@ -1,171 +0,0 @@
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

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

View File

@@ -1,154 +0,0 @@
using StellaOps.TaskRunner.Client.Models;
using System.Runtime.CompilerServices;
using System.Text.Json;
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

@@ -1,8 +0,0 @@
# StellaOps.TaskRunner.Client Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Client/StellaOps.TaskRunner.Client.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |

View File

@@ -1,293 +0,0 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.TaskRunner.Client.Models;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
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

@@ -1,42 +0,0 @@
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; }
}

View File

@@ -1,15 +0,0 @@
namespace StellaOps.TaskRunner.Core.AirGap;
/// <summary>
/// Provider for retrieving air-gap sealed mode status.
/// </summary>
public interface IAirGapStatusProvider
{
/// <summary>
/// Gets the current sealed mode status of the environment.
/// </summary>
/// <param name="tenantId">Optional tenant ID for multi-tenant environments.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The sealed mode status.</returns>
Task<SealedModeStatus> GetStatusAsync(string? tenantId = null, CancellationToken cancellationToken = default);
}

View File

@@ -1,125 +0,0 @@
using StellaOps.TaskRunner.Core.Events;
using StellaOps.TaskRunner.Core.TaskPacks;
namespace StellaOps.TaskRunner.Core.AirGap;
/// <summary>
/// Audit logger for sealed install enforcement decisions.
/// </summary>
public interface ISealedInstallAuditLogger
{
/// <summary>
/// Logs an enforcement decision.
/// </summary>
Task LogEnforcementAsync(
TaskPackManifest manifest,
SealedInstallEnforcementResult result,
string? tenantId = null,
string? runId = null,
string? actor = null,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Implementation of sealed install audit logger using timeline events.
/// </summary>
public sealed class SealedInstallAuditLogger : ISealedInstallAuditLogger
{
private readonly IPackRunTimelineEventEmitter _eventEmitter;
public SealedInstallAuditLogger(IPackRunTimelineEventEmitter eventEmitter)
{
_eventEmitter = eventEmitter ?? throw new ArgumentNullException(nameof(eventEmitter));
}
/// <inheritdoc />
public async Task LogEnforcementAsync(
TaskPackManifest manifest,
SealedInstallEnforcementResult result,
string? tenantId = null,
string? runId = null,
string? actor = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(manifest);
ArgumentNullException.ThrowIfNull(result);
var effectiveTenantId = tenantId ?? "default";
var effectiveRunId = runId ?? Guid.NewGuid().ToString("n");
var now = DateTimeOffset.UtcNow;
var eventType = result.Allowed
? PackRunEventTypes.SealedInstallAllowed
: PackRunEventTypes.SealedInstallDenied;
var severity = result.Allowed
? PackRunEventSeverity.Info
: PackRunEventSeverity.Warning;
var attributes = new Dictionary<string, string>(StringComparer.Ordinal)
{
["pack_name"] = manifest.Metadata.Name,
["pack_version"] = manifest.Metadata.Version,
["decision"] = result.Allowed ? "allowed" : "denied",
["sealed_install_required"] = manifest.Spec.SealedInstall.ToString().ToLowerInvariant()
};
if (!string.IsNullOrWhiteSpace(result.ErrorCode))
{
attributes["error_code"] = result.ErrorCode;
}
object payload;
if (result.Allowed)
{
payload = new
{
event_type = "sealed_install_enforcement",
pack_id = manifest.Metadata.Name,
pack_version = manifest.Metadata.Version,
decision = "allowed",
reason = result.Message
};
}
else
{
payload = new
{
event_type = "sealed_install_enforcement",
pack_id = manifest.Metadata.Name,
pack_version = manifest.Metadata.Version,
decision = "denied",
reason = result.ErrorCode,
message = result.Message,
violation = result.Violation is not null
? new
{
required_sealed = result.Violation.RequiredSealed,
actual_sealed = result.Violation.ActualSealed,
recommendation = result.Violation.Recommendation
}
: null,
requirement_violations = result.RequirementViolations?.Select(v => new
{
requirement = v.Requirement,
expected = v.Expected,
actual = v.Actual,
message = v.Message
}).ToList()
};
}
var timelineEvent = PackRunTimelineEvent.Create(
tenantId: effectiveTenantId,
eventType: eventType,
source: "StellaOps.TaskRunner.SealedInstallEnforcer",
occurredAt: now,
runId: effectiveRunId,
actor: actor,
severity: severity,
attributes: attributes,
payload: payload);
await _eventEmitter.EmitAsync(timelineEvent, cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -1,22 +0,0 @@
using StellaOps.TaskRunner.Core.TaskPacks;
namespace StellaOps.TaskRunner.Core.AirGap;
/// <summary>
/// Enforces sealed install requirements for task packs.
/// Per sealed-install-enforcement.md contract.
/// </summary>
public interface ISealedInstallEnforcer
{
/// <summary>
/// Enforces sealed install requirements for a task pack.
/// </summary>
/// <param name="manifest">The task pack manifest.</param>
/// <param name="tenantId">Optional tenant ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Enforcement result indicating whether execution is allowed.</returns>
Task<SealedInstallEnforcementResult> EnforceAsync(
TaskPackManifest manifest,
string? tenantId = null,
CancellationToken cancellationToken = default);
}

View File

@@ -1,118 +0,0 @@
namespace StellaOps.TaskRunner.Core.AirGap;
/// <summary>
/// Result of sealed install enforcement check.
/// Per sealed-install-enforcement.md contract.
/// </summary>
public sealed record SealedInstallEnforcementResult(
/// <summary>Whether execution is allowed.</summary>
bool Allowed,
/// <summary>Error code if denied.</summary>
string? ErrorCode,
/// <summary>Human-readable message.</summary>
string Message,
/// <summary>Detailed violation information.</summary>
SealedInstallViolation? Violation,
/// <summary>Requirement violations if any.</summary>
IReadOnlyList<RequirementViolation>? RequirementViolations)
{
/// <summary>
/// Creates an allowed result.
/// </summary>
public static SealedInstallEnforcementResult CreateAllowed(string message)
=> new(true, null, message, null, null);
/// <summary>
/// Creates a denied result.
/// </summary>
public static SealedInstallEnforcementResult CreateDenied(
string errorCode,
string message,
SealedInstallViolation? violation = null,
IReadOnlyList<RequirementViolation>? requirementViolations = null)
=> new(false, errorCode, message, violation, requirementViolations);
}
/// <summary>
/// Details about a sealed install violation.
/// </summary>
public sealed record SealedInstallViolation(
/// <summary>Pack ID that requires sealed install.</summary>
string PackId,
/// <summary>Pack version.</summary>
string? PackVersion,
/// <summary>Whether pack requires sealed install.</summary>
bool RequiredSealed,
/// <summary>Actual sealed status of environment.</summary>
bool ActualSealed,
/// <summary>Recommendation for resolving the violation.</summary>
string Recommendation);
/// <summary>
/// Details about a requirement violation.
/// </summary>
public sealed record RequirementViolation(
/// <summary>Name of the requirement that was violated.</summary>
string Requirement,
/// <summary>Expected value.</summary>
string Expected,
/// <summary>Actual value.</summary>
string Actual,
/// <summary>Human-readable message describing the violation.</summary>
string Message);
/// <summary>
/// Error codes for sealed install enforcement.
/// </summary>
public static class SealedInstallErrorCodes
{
/// <summary>Pack requires sealed but environment is not sealed.</summary>
public const string SealedInstallViolation = "SEALED_INSTALL_VIOLATION";
/// <summary>Sealed requirements not met.</summary>
public const string SealedRequirementsViolation = "SEALED_REQUIREMENTS_VIOLATION";
/// <summary>Bundle version below minimum required.</summary>
public const string BundleVersionViolation = "BUNDLE_VERSION_VIOLATION";
/// <summary>Advisory data too stale.</summary>
public const string AdvisoryStalenessViolation = "ADVISORY_STALENESS_VIOLATION";
/// <summary>Time anchor missing or invalid.</summary>
public const string TimeAnchorViolation = "TIME_ANCHOR_VIOLATION";
/// <summary>Bundle signature verification failed.</summary>
public const string SignatureVerificationViolation = "SIGNATURE_VERIFICATION_VIOLATION";
}
/// <summary>
/// CLI exit codes for sealed install enforcement.
/// </summary>
public static class SealedInstallExitCodes
{
/// <summary>Pack requires sealed but environment is not.</summary>
public const int SealedInstallViolation = 40;
/// <summary>Bundle version below minimum.</summary>
public const int BundleVersionViolation = 41;
/// <summary>Advisory data too stale.</summary>
public const int AdvisoryStalenessViolation = 42;
/// <summary>Time anchor missing or invalid.</summary>
public const int TimeAnchorViolation = 43;
/// <summary>Bundle signature verification failed.</summary>
public const int SignatureVerificationViolation = 44;
}

View File

@@ -1,297 +0,0 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.TaskRunner.Core.TaskPacks;
namespace StellaOps.TaskRunner.Core.AirGap;
/// <summary>
/// Enforces sealed install requirements for task packs.
/// Per sealed-install-enforcement.md contract.
/// </summary>
public sealed class SealedInstallEnforcer : ISealedInstallEnforcer
{
private readonly IAirGapStatusProvider _statusProvider;
private readonly IOptions<SealedInstallEnforcementOptions> _options;
private readonly ILogger<SealedInstallEnforcer> _logger;
public SealedInstallEnforcer(
IAirGapStatusProvider statusProvider,
IOptions<SealedInstallEnforcementOptions> options,
ILogger<SealedInstallEnforcer> logger)
{
_statusProvider = statusProvider ?? throw new ArgumentNullException(nameof(statusProvider));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<SealedInstallEnforcementResult> EnforceAsync(
TaskPackManifest manifest,
string? tenantId = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(manifest);
var options = _options.Value;
// Check if enforcement is enabled
if (!options.Enabled)
{
_logger.LogDebug("Sealed install enforcement is disabled.");
return SealedInstallEnforcementResult.CreateAllowed("Enforcement disabled");
}
// Check for development bypass
if (options.BypassForDevelopment && IsDevelopmentEnvironment())
{
_logger.LogWarning("Sealed install enforcement bypassed for development environment.");
return SealedInstallEnforcementResult.CreateAllowed("Development bypass active");
}
// If pack doesn't require sealed install, allow
if (!manifest.Spec.SealedInstall)
{
_logger.LogDebug(
"Pack {PackName} v{PackVersion} does not require sealed install.",
manifest.Metadata.Name,
manifest.Metadata.Version);
return SealedInstallEnforcementResult.CreateAllowed("Pack does not require sealed install");
}
// Get environment sealed status
SealedModeStatus status;
try
{
status = await _statusProvider.GetStatusAsync(tenantId, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get air-gap status. Denying sealed install pack.");
return SealedInstallEnforcementResult.CreateDenied(
SealedInstallErrorCodes.SealedInstallViolation,
"Failed to verify sealed mode status",
new SealedInstallViolation(
manifest.Metadata.Name,
manifest.Metadata.Version,
RequiredSealed: true,
ActualSealed: false,
Recommendation: "Ensure the AirGap controller is accessible: stella airgap status"));
}
// Core check: environment must be sealed
if (!status.Sealed)
{
_logger.LogWarning(
"Sealed install violation: Pack {PackName} v{PackVersion} requires sealed environment but environment is {Mode}.",
manifest.Metadata.Name,
manifest.Metadata.Version,
status.Mode);
return SealedInstallEnforcementResult.CreateDenied(
SealedInstallErrorCodes.SealedInstallViolation,
"Pack requires sealed environment but environment is not sealed",
new SealedInstallViolation(
manifest.Metadata.Name,
manifest.Metadata.Version,
RequiredSealed: true,
ActualSealed: false,
Recommendation: "Activate sealed mode with: stella airgap seal"));
}
// Check sealed requirements if specified
var requirements = manifest.Spec.SealedRequirements ?? SealedRequirements.Default;
var violations = ValidateRequirements(requirements, status, options);
if (violations.Count > 0)
{
_logger.LogWarning(
"Sealed requirements violation for pack {PackName} v{PackVersion}: {ViolationCount} requirement(s) not met.",
manifest.Metadata.Name,
manifest.Metadata.Version,
violations.Count);
return SealedInstallEnforcementResult.CreateDenied(
SealedInstallErrorCodes.SealedRequirementsViolation,
"Sealed requirements not met",
violation: null,
requirementViolations: violations);
}
_logger.LogInformation(
"Sealed install requirements satisfied for pack {PackName} v{PackVersion}.",
manifest.Metadata.Name,
manifest.Metadata.Version);
return SealedInstallEnforcementResult.CreateAllowed("Sealed install requirements satisfied");
}
private List<RequirementViolation> ValidateRequirements(
SealedRequirements requirements,
SealedModeStatus status,
SealedInstallEnforcementOptions options)
{
var violations = new List<RequirementViolation>();
// Bundle version check
if (!string.IsNullOrWhiteSpace(requirements.MinBundleVersion) &&
!string.IsNullOrWhiteSpace(status.BundleVersion))
{
if (!IsVersionSatisfied(status.BundleVersion, requirements.MinBundleVersion))
{
violations.Add(new RequirementViolation(
Requirement: "min_bundle_version",
Expected: requirements.MinBundleVersion,
Actual: status.BundleVersion,
Message: $"Bundle version {status.BundleVersion} < required {requirements.MinBundleVersion}"));
}
}
// Advisory staleness check
var effectiveStaleness = status.AdvisoryStalenessHours;
var maxStaleness = requirements.MaxAdvisoryStalenessHours;
// Apply grace period if configured
if (options.StalenessGracePeriodHours > 0)
{
maxStaleness += options.StalenessGracePeriodHours;
}
if (effectiveStaleness > maxStaleness)
{
if (options.DenyOnStaleness)
{
violations.Add(new RequirementViolation(
Requirement: "max_advisory_staleness_hours",
Expected: requirements.MaxAdvisoryStalenessHours.ToString(),
Actual: effectiveStaleness.ToString(),
Message: $"Advisory data is {effectiveStaleness}h old, max allowed is {requirements.MaxAdvisoryStalenessHours}h"));
}
else if (effectiveStaleness > options.StalenessWarningThresholdHours)
{
_logger.LogWarning(
"Advisory data is {Staleness}h old, approaching max allowed {MaxStaleness}h.",
effectiveStaleness,
requirements.MaxAdvisoryStalenessHours);
}
}
// Time anchor check
if (requirements.RequireTimeAnchor)
{
if (status.TimeAnchor is null)
{
violations.Add(new RequirementViolation(
Requirement: "require_time_anchor",
Expected: "valid time anchor",
Actual: "missing",
Message: "Valid time anchor required but not present"));
}
else if (!status.TimeAnchor.Valid)
{
violations.Add(new RequirementViolation(
Requirement: "require_time_anchor",
Expected: "valid time anchor",
Actual: "invalid",
Message: "Time anchor present but invalid or expired"));
}
else if (status.TimeAnchor.ExpiresAt.HasValue &&
status.TimeAnchor.ExpiresAt.Value < DateTimeOffset.UtcNow)
{
violations.Add(new RequirementViolation(
Requirement: "require_time_anchor",
Expected: "non-expired time anchor",
Actual: $"expired at {status.TimeAnchor.ExpiresAt.Value:O}",
Message: "Time anchor has expired"));
}
}
return violations;
}
private static bool IsVersionSatisfied(string actual, string required)
{
// Try semantic version comparison
if (Version.TryParse(NormalizeVersion(actual), out var actualVersion) &&
Version.TryParse(NormalizeVersion(required), out var requiredVersion))
{
return actualVersion >= requiredVersion;
}
// Fall back to string comparison
return string.Compare(actual, required, StringComparison.OrdinalIgnoreCase) >= 0;
}
private static string NormalizeVersion(string version)
{
// Strip common prefixes like 'v' and suffixes like '-beta'
var normalized = version.TrimStart('v', 'V');
var dashIndex = normalized.IndexOf('-', StringComparison.Ordinal);
if (dashIndex > 0)
{
normalized = normalized[..dashIndex];
}
return normalized;
}
private static bool IsDevelopmentEnvironment()
{
var env = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ??
Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
return string.Equals(env, "Development", StringComparison.OrdinalIgnoreCase);
}
}
/// <summary>
/// Configuration options for sealed install enforcement.
/// </summary>
public sealed class SealedInstallEnforcementOptions
{
/// <summary>
/// Whether enforcement is enabled.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Grace period for advisory staleness in hours.
/// </summary>
public int StalenessGracePeriodHours { get; set; } = 24;
/// <summary>
/// Warning threshold for staleness in hours.
/// </summary>
public int StalenessWarningThresholdHours { get; set; } = 120;
/// <summary>
/// Whether to deny on staleness violation (false = warn only).
/// </summary>
public bool DenyOnStaleness { get; set; } = true;
/// <summary>
/// Whether to use heuristic detection when AirGap controller is unavailable.
/// </summary>
public bool UseHeuristicDetection { get; set; } = true;
/// <summary>
/// Heuristic score threshold to consider environment sealed.
/// </summary>
public double HeuristicThreshold { get; set; } = 0.7;
/// <summary>
/// Bypass enforcement in development environments (DANGEROUS).
/// </summary>
public bool BypassForDevelopment { get; set; }
/// <summary>
/// Log all enforcement decisions.
/// </summary>
public bool LogAllDecisions { get; set; } = true;
/// <summary>
/// Audit retention in days.
/// </summary>
public int AuditRetentionDays { get; set; } = 365;
}

View File

@@ -1,88 +0,0 @@
namespace StellaOps.TaskRunner.Core.AirGap;
/// <summary>
/// Represents the sealed mode status of the air-gap environment.
/// Per sealed-install-enforcement.md contract.
/// </summary>
public sealed record SealedModeStatus(
/// <summary>Whether the environment is currently sealed.</summary>
bool Sealed,
/// <summary>Current mode (sealed, unsealed, transitioning).</summary>
string Mode,
/// <summary>When the environment was sealed.</summary>
DateTimeOffset? SealedAt,
/// <summary>Identity that sealed the environment.</summary>
string? SealedBy,
/// <summary>Air-gap bundle version currently installed.</summary>
string? BundleVersion,
/// <summary>Digest of the bundle.</summary>
string? BundleDigest,
/// <summary>When advisories were last updated.</summary>
DateTimeOffset? LastAdvisoryUpdate,
/// <summary>Hours since last advisory update.</summary>
int AdvisoryStalenessHours,
/// <summary>Time anchor information.</summary>
TimeAnchorInfo? TimeAnchor,
/// <summary>Whether egress is blocked.</summary>
bool EgressBlocked,
/// <summary>Network policy in effect.</summary>
string? NetworkPolicy)
{
/// <summary>
/// Creates an unsealed status (environment not in air-gap mode).
/// </summary>
public static SealedModeStatus Unsealed() => new(
Sealed: false,
Mode: "unsealed",
SealedAt: null,
SealedBy: null,
BundleVersion: null,
BundleDigest: null,
LastAdvisoryUpdate: null,
AdvisoryStalenessHours: 0,
TimeAnchor: null,
EgressBlocked: false,
NetworkPolicy: null);
/// <summary>
/// Creates a status indicating the provider is unavailable.
/// </summary>
public static SealedModeStatus Unavailable() => new(
Sealed: false,
Mode: "unavailable",
SealedAt: null,
SealedBy: null,
BundleVersion: null,
BundleDigest: null,
LastAdvisoryUpdate: null,
AdvisoryStalenessHours: 0,
TimeAnchor: null,
EgressBlocked: false,
NetworkPolicy: null);
}
/// <summary>
/// Time anchor information for sealed environments.
/// </summary>
public sealed record TimeAnchorInfo(
/// <summary>The anchor timestamp.</summary>
DateTimeOffset Timestamp,
/// <summary>Signature of the time anchor.</summary>
string? Signature,
/// <summary>Whether the time anchor is valid.</summary>
bool Valid,
/// <summary>When the time anchor expires.</summary>
DateTimeOffset? ExpiresAt);

View File

@@ -1,39 +0,0 @@
using System.Text.Json.Serialization;
namespace StellaOps.TaskRunner.Core.AirGap;
/// <summary>
/// Sealed install requirements specified in a task pack manifest.
/// Per sealed-install-enforcement.md contract.
/// </summary>
public sealed record SealedRequirements(
/// <summary>Minimum air-gap bundle version required.</summary>
[property: JsonPropertyName("min_bundle_version")]
string? MinBundleVersion,
/// <summary>Maximum age of advisory data in hours (default: 168).</summary>
[property: JsonPropertyName("max_advisory_staleness_hours")]
int MaxAdvisoryStalenessHours,
/// <summary>Whether a valid time anchor is required (default: true).</summary>
[property: JsonPropertyName("require_time_anchor")]
bool RequireTimeAnchor,
/// <summary>Maximum allowed offline duration in hours (default: 720).</summary>
[property: JsonPropertyName("allowed_offline_duration_hours")]
int AllowedOfflineDurationHours,
/// <summary>Whether bundle signature verification is required (default: true).</summary>
[property: JsonPropertyName("require_signature_verification")]
bool RequireSignatureVerification)
{
/// <summary>
/// Default sealed requirements.
/// </summary>
public static SealedRequirements Default => new(
MinBundleVersion: null,
MaxAdvisoryStalenessHours: 168,
RequireTimeAnchor: true,
AllowedOfflineDurationHours: 720,
RequireSignatureVerification: true);
}

View File

@@ -1,576 +0,0 @@
using Microsoft.Extensions.Logging;
using StellaOps.TaskRunner.Core.Events;
using System.Text;
using System.Text.Json;
namespace StellaOps.TaskRunner.Core.Attestation;
/// <summary>
/// Service for generating and verifying pack run attestations.
/// Per TASKRUN-OBS-54-001.
/// </summary>
public interface IPackRunAttestationService
{
/// <summary>
/// Generates an attestation for a pack run.
/// </summary>
Task<PackRunAttestationResult> GenerateAsync(
PackRunAttestationRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Verifies a pack run attestation.
/// </summary>
Task<PackRunAttestationVerificationResult> VerifyAsync(
PackRunAttestationVerificationRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets an attestation by ID.
/// </summary>
Task<PackRunAttestation?> GetAsync(
Guid attestationId,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists attestations for a run.
/// </summary>
Task<IReadOnlyList<PackRunAttestation>> ListByRunAsync(
string tenantId,
string runId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the DSSE envelope for an attestation.
/// </summary>
Task<PackRunDsseEnvelope?> GetEnvelopeAsync(
Guid attestationId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Store for pack run attestations.
/// </summary>
public interface IPackRunAttestationStore
{
/// <summary>
/// Stores an attestation.
/// </summary>
Task StoreAsync(
PackRunAttestation attestation,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets an attestation by ID.
/// </summary>
Task<PackRunAttestation?> GetAsync(
Guid attestationId,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists attestations for a run.
/// </summary>
Task<IReadOnlyList<PackRunAttestation>> ListByRunAsync(
string tenantId,
string runId,
CancellationToken cancellationToken = default);
/// <summary>
/// Updates attestation status.
/// </summary>
Task UpdateStatusAsync(
Guid attestationId,
PackRunAttestationStatus status,
string? error = null,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Signing provider for pack run attestations.
/// </summary>
public interface IPackRunAttestationSigner
{
/// <summary>
/// Signs an in-toto statement.
/// </summary>
Task<PackRunDsseEnvelope> SignAsync(
byte[] statementBytes,
CancellationToken cancellationToken = default);
/// <summary>
/// Verifies a DSSE envelope signature.
/// </summary>
Task<bool> VerifyAsync(
PackRunDsseEnvelope envelope,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the current signing key ID.
/// </summary>
string GetKeyId();
}
/// <summary>
/// Default implementation of pack run attestation service.
/// </summary>
public sealed class PackRunAttestationService : IPackRunAttestationService
{
private readonly IPackRunAttestationStore _store;
private readonly IPackRunAttestationSigner? _signer;
private readonly IPackRunTimelineEventEmitter? _timelineEmitter;
private readonly ILogger<PackRunAttestationService> _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
public PackRunAttestationService(
IPackRunAttestationStore store,
ILogger<PackRunAttestationService> logger,
IPackRunAttestationSigner? signer = null,
IPackRunTimelineEventEmitter? timelineEmitter = null)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_signer = signer;
_timelineEmitter = timelineEmitter;
}
/// <inheritdoc />
public async Task<PackRunAttestationResult> GenerateAsync(
PackRunAttestationRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
try
{
// Build provenance predicate
var buildDefinition = new PackRunBuildDefinition(
BuildType: "https://stellaops.io/pack-run/v1",
ExternalParameters: request.ExternalParameters,
InternalParameters: new Dictionary<string, object>
{
["planHash"] = request.PlanHash
},
ResolvedDependencies: request.ResolvedDependencies);
var runDetails = new PackRunDetails(
Builder: new PackRunBuilder(
Id: request.BuilderId ?? "https://stellaops.io/task-runner",
Version: new Dictionary<string, string>
{
["stellaops.task-runner"] = GetVersion()
},
BuilderDependencies: null),
Metadata: new PackRunProvMetadata(
InvocationId: request.RunId,
StartedOn: request.StartedAt,
FinishedOn: request.CompletedAt),
Byproducts: null);
var predicate = new PackRunProvenancePredicate(
BuildDefinition: buildDefinition,
RunDetails: runDetails);
var predicateJson = JsonSerializer.Serialize(predicate, JsonOptions);
// Build in-toto statement
var statement = new PackRunInTotoStatement(
Type: InTotoStatementTypes.V1,
Subject: request.Subjects,
PredicateType: PredicateTypes.PackRunProvenance,
Predicate: predicate);
var statementJson = JsonSerializer.Serialize(statement, JsonOptions);
var statementBytes = Encoding.UTF8.GetBytes(statementJson);
// Sign if signer is available
PackRunDsseEnvelope? envelope = null;
PackRunAttestationStatus status = PackRunAttestationStatus.Pending;
string? error = null;
if (_signer is not null)
{
try
{
envelope = await _signer.SignAsync(statementBytes, cancellationToken);
status = PackRunAttestationStatus.Signed;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to sign attestation for run {RunId}", request.RunId);
error = ex.Message;
status = PackRunAttestationStatus.Failed;
}
}
// Create attestation record
var attestation = new PackRunAttestation(
AttestationId: Guid.NewGuid(),
TenantId: request.TenantId,
RunId: request.RunId,
PlanHash: request.PlanHash,
CreatedAt: DateTimeOffset.UtcNow,
Subjects: request.Subjects,
PredicateType: PredicateTypes.PackRunProvenance,
PredicateJson: predicateJson,
Envelope: envelope,
Status: status,
Error: error,
EvidenceSnapshotId: request.EvidenceSnapshotId,
Metadata: request.Metadata);
// Store attestation
await _store.StoreAsync(attestation, cancellationToken);
// Emit timeline event
if (_timelineEmitter is not null)
{
var eventType = status == PackRunAttestationStatus.Signed
? PackRunAttestationEventTypes.AttestationCreated
: PackRunAttestationEventTypes.AttestationFailed;
await _timelineEmitter.EmitAsync(
PackRunTimelineEvent.Create(
tenantId: request.TenantId,
eventType: eventType,
source: "taskrunner-attestation",
occurredAt: DateTimeOffset.UtcNow,
runId: request.RunId,
planHash: request.PlanHash,
attributes: new Dictionary<string, string>
{
["attestationId"] = attestation.AttestationId.ToString(),
["predicateType"] = attestation.PredicateType,
["subjectCount"] = request.Subjects.Count.ToString(),
["status"] = status.ToString()
},
evidencePointer: envelope is not null
? PackRunEvidencePointer.Attestation(
request.RunId,
envelope.ComputeDigest())
: null),
cancellationToken);
}
_logger.LogInformation(
"Generated attestation {AttestationId} for run {RunId} with {SubjectCount} subjects, status {Status}",
attestation.AttestationId,
request.RunId,
request.Subjects.Count,
status);
return new PackRunAttestationResult(
Success: status != PackRunAttestationStatus.Failed,
Attestation: attestation,
Error: error);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to generate attestation for run {RunId}", request.RunId);
return new PackRunAttestationResult(
Success: false,
Attestation: null,
Error: ex.Message);
}
}
/// <inheritdoc />
public async Task<PackRunAttestationVerificationResult> VerifyAsync(
PackRunAttestationVerificationRequest request,
CancellationToken cancellationToken = default)
{
var errors = new List<string>();
var signatureStatus = PackRunSignatureVerificationStatus.NotVerified;
var subjectStatus = PackRunSubjectVerificationStatus.NotVerified;
var revocationStatus = PackRunRevocationStatus.NotChecked;
var attestation = await _store.GetAsync(request.AttestationId, cancellationToken);
if (attestation is null)
{
return new PackRunAttestationVerificationResult(
Valid: false,
AttestationId: request.AttestationId,
SignatureStatus: PackRunSignatureVerificationStatus.NotVerified,
SubjectStatus: PackRunSubjectVerificationStatus.NotVerified,
RevocationStatus: PackRunRevocationStatus.NotChecked,
Errors: ["Attestation not found"],
VerifiedAt: DateTimeOffset.UtcNow);
}
// Verify signature
if (request.VerifySignature && attestation.Envelope is not null && _signer is not null)
{
try
{
var signatureValid = await _signer.VerifyAsync(attestation.Envelope, cancellationToken);
signatureStatus = signatureValid
? PackRunSignatureVerificationStatus.Valid
: PackRunSignatureVerificationStatus.Invalid;
if (!signatureValid)
{
errors.Add("Signature verification failed");
}
}
catch (Exception ex)
{
signatureStatus = PackRunSignatureVerificationStatus.Invalid;
errors.Add($"Signature verification error: {ex.Message}");
}
}
else if (request.VerifySignature && attestation.Envelope is null)
{
signatureStatus = PackRunSignatureVerificationStatus.Invalid;
errors.Add("No envelope available for signature verification");
}
// Verify subjects
if (request.VerifySubjects && request.ExpectedSubjects is not null)
{
var expectedSet = request.ExpectedSubjects
.Select(s => $"{s.Name}:{string.Join(",", s.Digest.OrderBy(d => d.Key).Select(d => $"{d.Key}={d.Value}"))}")
.ToHashSet();
var actualSet = attestation.Subjects
.Select(s => $"{s.Name}:{string.Join(",", s.Digest.OrderBy(d => d.Key).Select(d => $"{d.Key}={d.Value}"))}")
.ToHashSet();
if (expectedSet.SetEquals(actualSet))
{
subjectStatus = PackRunSubjectVerificationStatus.Match;
}
else if (expectedSet.IsSubsetOf(actualSet))
{
subjectStatus = PackRunSubjectVerificationStatus.Match;
}
else
{
var missing = expectedSet.Except(actualSet).ToList();
if (missing.Count > 0)
{
subjectStatus = PackRunSubjectVerificationStatus.Missing;
errors.Add($"Missing subjects: {string.Join(", ", missing)}");
}
else
{
subjectStatus = PackRunSubjectVerificationStatus.Mismatch;
errors.Add("Subject digest mismatch");
}
}
}
// Check revocation
if (request.CheckRevocation)
{
revocationStatus = attestation.Status == PackRunAttestationStatus.Revoked
? PackRunRevocationStatus.Revoked
: PackRunRevocationStatus.NotRevoked;
if (attestation.Status == PackRunAttestationStatus.Revoked)
{
errors.Add("Attestation has been revoked");
}
}
var valid = errors.Count == 0 &&
(signatureStatus is PackRunSignatureVerificationStatus.Valid or PackRunSignatureVerificationStatus.NotVerified) &&
(subjectStatus is PackRunSubjectVerificationStatus.Match or PackRunSubjectVerificationStatus.NotVerified) &&
(revocationStatus is PackRunRevocationStatus.NotRevoked or PackRunRevocationStatus.NotChecked);
return new PackRunAttestationVerificationResult(
Valid: valid,
AttestationId: request.AttestationId,
SignatureStatus: signatureStatus,
SubjectStatus: subjectStatus,
RevocationStatus: revocationStatus,
Errors: errors.Count > 0 ? errors : null,
VerifiedAt: DateTimeOffset.UtcNow);
}
/// <inheritdoc />
public Task<PackRunAttestation?> GetAsync(
Guid attestationId,
CancellationToken cancellationToken = default)
=> _store.GetAsync(attestationId, cancellationToken);
/// <inheritdoc />
public Task<IReadOnlyList<PackRunAttestation>> ListByRunAsync(
string tenantId,
string runId,
CancellationToken cancellationToken = default)
=> _store.ListByRunAsync(tenantId, runId, cancellationToken);
/// <inheritdoc />
public async Task<PackRunDsseEnvelope?> GetEnvelopeAsync(
Guid attestationId,
CancellationToken cancellationToken = default)
{
var attestation = await _store.GetAsync(attestationId, cancellationToken);
return attestation?.Envelope;
}
private static string GetVersion()
{
var assembly = typeof(PackRunAttestationService).Assembly;
var version = assembly.GetName().Version;
return version?.ToString() ?? "0.0.0";
}
}
/// <summary>
/// Attestation event types for timeline.
/// </summary>
public static class PackRunAttestationEventTypes
{
/// <summary>Attestation created successfully.</summary>
public const string AttestationCreated = "pack.attestation.created";
/// <summary>Attestation creation failed.</summary>
public const string AttestationFailed = "pack.attestation.failed";
/// <summary>Attestation verified.</summary>
public const string AttestationVerified = "pack.attestation.verified";
/// <summary>Attestation verification failed.</summary>
public const string AttestationVerificationFailed = "pack.attestation.verification_failed";
/// <summary>Attestation revoked.</summary>
public const string AttestationRevoked = "pack.attestation.revoked";
}
/// <summary>
/// In-memory attestation store for testing.
/// </summary>
public sealed class InMemoryPackRunAttestationStore : IPackRunAttestationStore
{
private readonly Dictionary<Guid, PackRunAttestation> _attestations = new();
private readonly object _lock = new();
/// <inheritdoc />
public Task StoreAsync(
PackRunAttestation attestation,
CancellationToken cancellationToken = default)
{
lock (_lock)
{
_attestations[attestation.AttestationId] = attestation;
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<PackRunAttestation?> GetAsync(
Guid attestationId,
CancellationToken cancellationToken = default)
{
lock (_lock)
{
_attestations.TryGetValue(attestationId, out var attestation);
return Task.FromResult(attestation);
}
}
/// <inheritdoc />
public Task<IReadOnlyList<PackRunAttestation>> ListByRunAsync(
string tenantId,
string runId,
CancellationToken cancellationToken = default)
{
lock (_lock)
{
var results = _attestations.Values
.Where(a => a.TenantId == tenantId && a.RunId == runId)
.OrderBy(a => a.CreatedAt)
.ToList();
return Task.FromResult<IReadOnlyList<PackRunAttestation>>(results);
}
}
/// <inheritdoc />
public Task UpdateStatusAsync(
Guid attestationId,
PackRunAttestationStatus status,
string? error = null,
CancellationToken cancellationToken = default)
{
lock (_lock)
{
if (_attestations.TryGetValue(attestationId, out var attestation))
{
_attestations[attestationId] = attestation with
{
Status = status,
Error = error
};
}
}
return Task.CompletedTask;
}
/// <summary>Gets all attestations (for testing).</summary>
public IReadOnlyList<PackRunAttestation> GetAll()
{
lock (_lock) { return _attestations.Values.ToList(); }
}
/// <summary>Clears all attestations (for testing).</summary>
public void Clear()
{
lock (_lock) { _attestations.Clear(); }
}
/// <summary>Gets attestation count.</summary>
public int Count
{
get { lock (_lock) { return _attestations.Count; } }
}
}
/// <summary>
/// Stub signer for testing (does not perform real cryptographic signing).
/// </summary>
public sealed class StubPackRunAttestationSigner : IPackRunAttestationSigner
{
private readonly string _keyId;
public StubPackRunAttestationSigner(string keyId = "test-key-001")
{
_keyId = keyId;
}
/// <inheritdoc />
public Task<PackRunDsseEnvelope> SignAsync(
byte[] statementBytes,
CancellationToken cancellationToken = default)
{
var payload = Convert.ToBase64String(statementBytes);
// Create stub signature (not cryptographically valid)
var sigBytes = System.Security.Cryptography.SHA256.HashData(statementBytes);
var sig = Convert.ToBase64String(sigBytes);
var envelope = new PackRunDsseEnvelope(
PayloadType: PackRunDsseEnvelope.InTotoPayloadType,
Payload: payload,
Signatures: [new PackRunDsseSignature(_keyId, sig)]);
return Task.FromResult(envelope);
}
/// <inheritdoc />
public Task<bool> VerifyAsync(
PackRunDsseEnvelope envelope,
CancellationToken cancellationToken = default)
{
// Stub always returns true for testing
return Task.FromResult(true);
}
/// <inheritdoc />
public string GetKeyId() => _keyId;
}

View File

@@ -1,526 +0,0 @@
using StellaOps.TaskRunner.Core.Evidence;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.TaskRunner.Core.Attestation;
/// <summary>
/// DSSE attestation for pack run execution.
/// Per TASKRUN-OBS-54-001.
/// </summary>
public sealed record PackRunAttestation(
/// <summary>Unique attestation identifier.</summary>
Guid AttestationId,
/// <summary>Tenant scope.</summary>
string TenantId,
/// <summary>Run ID this attestation covers.</summary>
string RunId,
/// <summary>Plan hash that was executed.</summary>
string PlanHash,
/// <summary>When the attestation was created.</summary>
DateTimeOffset CreatedAt,
/// <summary>Subjects covered by this attestation (produced artifacts).</summary>
IReadOnlyList<PackRunAttestationSubject> Subjects,
/// <summary>Predicate type URI.</summary>
string PredicateType,
/// <summary>Predicate content as JSON.</summary>
string PredicateJson,
/// <summary>DSSE envelope containing signature.</summary>
PackRunDsseEnvelope? Envelope,
/// <summary>Attestation status.</summary>
PackRunAttestationStatus Status,
/// <summary>Error message if signing failed.</summary>
string? Error,
/// <summary>Reference to evidence snapshot.</summary>
Guid? EvidenceSnapshotId,
/// <summary>Attestation metadata.</summary>
IReadOnlyDictionary<string, string>? Metadata)
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
/// <summary>
/// Computes the canonical statement digest.
/// </summary>
public string ComputeStatementDigest()
{
var statement = new PackRunInTotoStatement(
Type: InTotoStatementTypes.V01,
Subject: Subjects,
PredicateType: PredicateType,
Predicate: JsonSerializer.Deserialize<JsonElement>(PredicateJson, JsonOptions));
var json = JsonSerializer.Serialize(statement, JsonOptions);
var bytes = Encoding.UTF8.GetBytes(json);
var hash = SHA256.HashData(bytes);
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
/// <summary>
/// Serializes to JSON.
/// </summary>
public string ToJson() => JsonSerializer.Serialize(this, JsonOptions);
/// <summary>
/// Deserializes from JSON.
/// </summary>
public static PackRunAttestation? FromJson(string json)
=> JsonSerializer.Deserialize<PackRunAttestation>(json, JsonOptions);
}
/// <summary>
/// Attestation status.
/// </summary>
public enum PackRunAttestationStatus
{
/// <summary>Attestation is pending signing.</summary>
Pending,
/// <summary>Attestation is signed and valid.</summary>
Signed,
/// <summary>Attestation signing failed.</summary>
Failed,
/// <summary>Attestation signature was revoked.</summary>
Revoked
}
/// <summary>
/// Subject covered by attestation (an artifact).
/// </summary>
public sealed record PackRunAttestationSubject(
/// <summary>Subject name (artifact path or identifier).</summary>
[property: JsonPropertyName("name")]
string Name,
/// <summary>Subject digest (sha256 -> hash).</summary>
[property: JsonPropertyName("digest")]
IReadOnlyDictionary<string, string> Digest)
{
/// <summary>
/// Creates a subject from an artifact reference.
/// </summary>
public static PackRunAttestationSubject FromArtifact(PackRunArtifactReference artifact)
{
var digest = new Dictionary<string, string>();
// Parse sha256:abcdef format and extract just the hash
if (artifact.Sha256.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
{
digest["sha256"] = artifact.Sha256[7..];
}
else
{
digest["sha256"] = artifact.Sha256;
}
return new PackRunAttestationSubject(artifact.Name, digest);
}
/// <summary>
/// Creates a subject from a material.
/// </summary>
public static PackRunAttestationSubject FromMaterial(PackRunEvidenceMaterial material)
{
var digest = new Dictionary<string, string>();
if (material.Sha256.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
{
digest["sha256"] = material.Sha256[7..];
}
else
{
digest["sha256"] = material.Sha256;
}
return new PackRunAttestationSubject(material.CanonicalPath, digest);
}
}
/// <summary>
/// In-toto statement wrapper for pack runs.
/// </summary>
public sealed record PackRunInTotoStatement(
/// <summary>Statement type (always _type).</summary>
[property: JsonPropertyName("_type")]
string Type,
/// <summary>Subjects covered.</summary>
[property: JsonPropertyName("subject")]
IReadOnlyList<PackRunAttestationSubject> Subject,
/// <summary>Predicate type URI.</summary>
[property: JsonPropertyName("predicateType")]
string PredicateType,
/// <summary>Predicate content.</summary>
[property: JsonPropertyName("predicate")]
object Predicate);
/// <summary>
/// Standard in-toto statement type URIs.
/// </summary>
public static class InTotoStatementTypes
{
/// <summary>In-toto statement v0.1.</summary>
public const string V01 = "https://in-toto.io/Statement/v0.1";
/// <summary>In-toto statement v1.0.</summary>
public const string V1 = "https://in-toto.io/Statement/v1";
}
/// <summary>
/// Standard predicate type URIs.
/// </summary>
public static class PredicateTypes
{
/// <summary>SLSA Provenance v0.2.</summary>
public const string SlsaProvenanceV02 = "https://slsa.dev/provenance/v0.2";
/// <summary>SLSA Provenance v1.0.</summary>
public const string SlsaProvenanceV1 = "https://slsa.dev/provenance/v1";
/// <summary>StellaOps Pack Run provenance.</summary>
public const string PackRunProvenance = "https://stellaops.io/attestation/pack-run/v1";
/// <summary>StellaOps Pack Run completion.</summary>
public const string PackRunCompletion = "https://stellaops.io/attestation/pack-run-completion/v1";
}
/// <summary>
/// DSSE envelope for pack run attestation.
/// </summary>
public sealed record PackRunDsseEnvelope(
/// <summary>Payload type (usually application/vnd.in-toto+json).</summary>
[property: JsonPropertyName("payloadType")]
string PayloadType,
/// <summary>Base64-encoded payload.</summary>
[property: JsonPropertyName("payload")]
string Payload,
/// <summary>Signatures on the envelope.</summary>
[property: JsonPropertyName("signatures")]
IReadOnlyList<PackRunDsseSignature> Signatures)
{
/// <summary>Standard payload type for in-toto attestations.</summary>
public const string InTotoPayloadType = "application/vnd.in-toto+json";
/// <summary>
/// Computes the envelope digest.
/// </summary>
public string ComputeDigest()
{
var json = JsonSerializer.Serialize(this, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
});
var bytes = Encoding.UTF8.GetBytes(json);
var hash = SHA256.HashData(bytes);
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
}
/// <summary>
/// Signature in a DSSE envelope.
/// </summary>
public sealed record PackRunDsseSignature(
/// <summary>Key identifier.</summary>
[property: JsonPropertyName("keyid")]
string? KeyId,
/// <summary>Base64-encoded signature.</summary>
[property: JsonPropertyName("sig")]
string Sig);
/// <summary>
/// Pack run provenance predicate per SLSA Provenance v1.
/// </summary>
public sealed record PackRunProvenancePredicate(
/// <summary>Build definition describing what was run.</summary>
[property: JsonPropertyName("buildDefinition")]
PackRunBuildDefinition BuildDefinition,
/// <summary>Run details describing the actual execution.</summary>
[property: JsonPropertyName("runDetails")]
PackRunDetails RunDetails);
/// <summary>
/// Build definition for pack run provenance.
/// </summary>
public sealed record PackRunBuildDefinition(
/// <summary>Build type identifier.</summary>
[property: JsonPropertyName("buildType")]
string BuildType,
/// <summary>External parameters (e.g., pack manifest URL).</summary>
[property: JsonPropertyName("externalParameters")]
IReadOnlyDictionary<string, object>? ExternalParameters,
/// <summary>Internal parameters resolved during build.</summary>
[property: JsonPropertyName("internalParameters")]
IReadOnlyDictionary<string, object>? InternalParameters,
/// <summary>Dependencies resolved during build.</summary>
[property: JsonPropertyName("resolvedDependencies")]
IReadOnlyList<PackRunDependency>? ResolvedDependencies);
/// <summary>
/// Resolved dependency in provenance.
/// </summary>
public sealed record PackRunDependency(
/// <summary>Dependency URI.</summary>
[property: JsonPropertyName("uri")]
string Uri,
/// <summary>Dependency digest.</summary>
[property: JsonPropertyName("digest")]
IReadOnlyDictionary<string, string>? Digest,
/// <summary>Dependency name.</summary>
[property: JsonPropertyName("name")]
string? Name,
/// <summary>Media type.</summary>
[property: JsonPropertyName("mediaType")]
string? MediaType);
/// <summary>
/// Run details for pack run provenance.
/// </summary>
public sealed record PackRunDetails(
/// <summary>Builder information.</summary>
[property: JsonPropertyName("builder")]
PackRunBuilder Builder,
/// <summary>Run metadata.</summary>
[property: JsonPropertyName("metadata")]
PackRunProvMetadata Metadata,
/// <summary>By-products of the run.</summary>
[property: JsonPropertyName("byproducts")]
IReadOnlyList<PackRunByproduct>? Byproducts);
/// <summary>
/// Builder information.
/// </summary>
public sealed record PackRunBuilder(
/// <summary>Builder ID (URI).</summary>
[property: JsonPropertyName("id")]
string Id,
/// <summary>Builder version.</summary>
[property: JsonPropertyName("version")]
IReadOnlyDictionary<string, string>? Version,
/// <summary>Builder dependencies.</summary>
[property: JsonPropertyName("builderDependencies")]
IReadOnlyList<PackRunDependency>? BuilderDependencies);
/// <summary>
/// Provenance metadata.
/// </summary>
public sealed record PackRunProvMetadata(
/// <summary>Invocation ID.</summary>
[property: JsonPropertyName("invocationId")]
string? InvocationId,
/// <summary>When the build started.</summary>
[property: JsonPropertyName("startedOn")]
DateTimeOffset? StartedOn,
/// <summary>When the build finished.</summary>
[property: JsonPropertyName("finishedOn")]
DateTimeOffset? FinishedOn);
/// <summary>
/// By-product of the build.
/// </summary>
public sealed record PackRunByproduct(
/// <summary>By-product URI.</summary>
[property: JsonPropertyName("uri")]
string? Uri,
/// <summary>By-product digest.</summary>
[property: JsonPropertyName("digest")]
IReadOnlyDictionary<string, string>? Digest,
/// <summary>By-product name.</summary>
[property: JsonPropertyName("name")]
string? Name,
/// <summary>By-product media type.</summary>
[property: JsonPropertyName("mediaType")]
string? MediaType);
/// <summary>
/// Request to generate an attestation for a pack run.
/// </summary>
public sealed record PackRunAttestationRequest(
/// <summary>Run ID to attest.</summary>
string RunId,
/// <summary>Tenant ID.</summary>
string TenantId,
/// <summary>Plan hash.</summary>
string PlanHash,
/// <summary>Subjects (artifacts) to attest.</summary>
IReadOnlyList<PackRunAttestationSubject> Subjects,
/// <summary>Evidence snapshot ID to link.</summary>
Guid? EvidenceSnapshotId,
/// <summary>Run started at.</summary>
DateTimeOffset StartedAt,
/// <summary>Run completed at.</summary>
DateTimeOffset? CompletedAt,
/// <summary>Builder ID.</summary>
string? BuilderId,
/// <summary>External parameters.</summary>
IReadOnlyDictionary<string, object>? ExternalParameters,
/// <summary>Resolved dependencies.</summary>
IReadOnlyList<PackRunDependency>? ResolvedDependencies,
/// <summary>Additional metadata.</summary>
IReadOnlyDictionary<string, string>? Metadata);
/// <summary>
/// Result of attestation generation.
/// </summary>
public sealed record PackRunAttestationResult(
/// <summary>Whether attestation generation succeeded.</summary>
bool Success,
/// <summary>Generated attestation.</summary>
PackRunAttestation? Attestation,
/// <summary>Error message if failed.</summary>
string? Error);
/// <summary>
/// Request to verify a pack run attestation.
/// </summary>
public sealed record PackRunAttestationVerificationRequest(
/// <summary>Attestation ID to verify.</summary>
Guid AttestationId,
/// <summary>Expected subjects to verify against.</summary>
IReadOnlyList<PackRunAttestationSubject>? ExpectedSubjects,
/// <summary>Whether to verify signature.</summary>
bool VerifySignature,
/// <summary>Whether to verify subjects match.</summary>
bool VerifySubjects,
/// <summary>Whether to check revocation status.</summary>
bool CheckRevocation);
/// <summary>
/// Result of attestation verification.
/// </summary>
public sealed record PackRunAttestationVerificationResult(
/// <summary>Whether verification passed.</summary>
bool Valid,
/// <summary>Attestation that was verified.</summary>
Guid AttestationId,
/// <summary>Signature verification status.</summary>
PackRunSignatureVerificationStatus SignatureStatus,
/// <summary>Subject verification status.</summary>
PackRunSubjectVerificationStatus SubjectStatus,
/// <summary>Revocation status.</summary>
PackRunRevocationStatus RevocationStatus,
/// <summary>Verification errors.</summary>
IReadOnlyList<string>? Errors,
/// <summary>When verification was performed.</summary>
DateTimeOffset VerifiedAt);
/// <summary>
/// Signature verification status.
/// </summary>
public enum PackRunSignatureVerificationStatus
{
/// <summary>Not verified.</summary>
NotVerified,
/// <summary>Signature is valid.</summary>
Valid,
/// <summary>Signature is invalid.</summary>
Invalid,
/// <summary>Key not found.</summary>
KeyNotFound,
/// <summary>Key expired.</summary>
KeyExpired
}
/// <summary>
/// Subject verification status.
/// </summary>
public enum PackRunSubjectVerificationStatus
{
/// <summary>Not verified.</summary>
NotVerified,
/// <summary>All subjects match.</summary>
Match,
/// <summary>Subjects do not match.</summary>
Mismatch,
/// <summary>Missing expected subjects.</summary>
Missing
}
/// <summary>
/// Revocation status.
/// </summary>
public enum PackRunRevocationStatus
{
/// <summary>Not checked.</summary>
NotChecked,
/// <summary>Not revoked.</summary>
NotRevoked,
/// <summary>Revoked.</summary>
Revoked,
/// <summary>Revocation check failed.</summary>
CheckFailed
}

View File

@@ -1,23 +0,0 @@
namespace StellaOps.TaskRunner.Core.Configuration;
/// <summary>
/// Worker configuration for queue paths, artifacts, and execution persistence.
/// Kept in Core so infrastructure helpers can share deterministic paths without
/// referencing the worker assembly.
/// </summary>
public sealed class PackRunWorkerOptions
{
public TimeSpan IdleDelay { get; set; } = TimeSpan.FromSeconds(1);
public string QueuePath { get; set; } = Path.Combine(AppContext.BaseDirectory, "queue");
public string ArchivePath { get; set; } = Path.Combine(AppContext.BaseDirectory, "queue", "archive");
public string ApprovalStorePath { get; set; } = Path.Combine(AppContext.BaseDirectory, "approvals");
public string RunStatePath { get; set; } = Path.Combine(AppContext.BaseDirectory, "state", "runs");
public string ArtifactsPath { get; set; } = Path.Combine(AppContext.BaseDirectory, "artifacts");
public string LogsPath { get; set; } = Path.Combine(AppContext.BaseDirectory, "logs", "runs");
}

View File

@@ -1,196 +0,0 @@
namespace StellaOps.TaskRunner.Core.Events;
/// <summary>
/// Sink for pack run timeline events (Kafka, NATS, file, etc.).
/// Per TASKRUN-OBS-52-001.
/// </summary>
public interface IPackRunTimelineEventSink
{
/// <summary>
/// Writes a timeline event to the sink.
/// </summary>
Task<PackRunTimelineSinkWriteResult> WriteAsync(
PackRunTimelineEvent evt,
CancellationToken cancellationToken = default);
/// <summary>
/// Writes multiple timeline events to the sink.
/// </summary>
Task<PackRunTimelineSinkBatchWriteResult> WriteBatchAsync(
IEnumerable<PackRunTimelineEvent> events,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of writing to pack run timeline sink.
/// </summary>
public sealed record PackRunTimelineSinkWriteResult(
/// <summary>Whether the event was written successfully.</summary>
bool Success,
/// <summary>Assigned sequence number if applicable.</summary>
long? Sequence,
/// <summary>Whether the event was deduplicated.</summary>
bool Deduplicated,
/// <summary>Error message if write failed.</summary>
string? Error);
/// <summary>
/// Result of batch writing to pack run timeline sink.
/// </summary>
public sealed record PackRunTimelineSinkBatchWriteResult(
/// <summary>Number of events written successfully.</summary>
int Written,
/// <summary>Number of events deduplicated.</summary>
int Deduplicated,
/// <summary>Number of events that failed.</summary>
int Failed);
/// <summary>
/// In-memory pack run timeline event sink for testing.
/// </summary>
public sealed class InMemoryPackRunTimelineEventSink : IPackRunTimelineEventSink
{
private readonly List<PackRunTimelineEvent> _events = new();
private readonly HashSet<Guid> _seenIds = new();
private readonly object _lock = new();
private long _sequence;
public Task<PackRunTimelineSinkWriteResult> WriteAsync(
PackRunTimelineEvent evt,
CancellationToken cancellationToken = default)
{
lock (_lock)
{
if (!_seenIds.Add(evt.EventId))
{
return Task.FromResult(new PackRunTimelineSinkWriteResult(
Success: true,
Sequence: null,
Deduplicated: true,
Error: null));
}
var seq = ++_sequence;
var eventWithSeq = evt.WithSequence(seq);
_events.Add(eventWithSeq);
return Task.FromResult(new PackRunTimelineSinkWriteResult(
Success: true,
Sequence: seq,
Deduplicated: false,
Error: null));
}
}
public Task<PackRunTimelineSinkBatchWriteResult> WriteBatchAsync(
IEnumerable<PackRunTimelineEvent> events,
CancellationToken cancellationToken = default)
{
var written = 0;
var deduplicated = 0;
lock (_lock)
{
foreach (var evt in events)
{
if (!_seenIds.Add(evt.EventId))
{
deduplicated++;
continue;
}
var seq = ++_sequence;
_events.Add(evt.WithSequence(seq));
written++;
}
}
return Task.FromResult(new PackRunTimelineSinkBatchWriteResult(written, deduplicated, 0));
}
/// <summary>Gets all events (for testing).</summary>
public IReadOnlyList<PackRunTimelineEvent> GetEvents()
{
lock (_lock) { return _events.ToList(); }
}
/// <summary>Gets events for a tenant (for testing).</summary>
public IReadOnlyList<PackRunTimelineEvent> GetEvents(string tenantId)
{
lock (_lock) { return _events.Where(e => e.TenantId == tenantId).ToList(); }
}
/// <summary>Gets events for a run (for testing).</summary>
public IReadOnlyList<PackRunTimelineEvent> GetEventsForRun(string runId)
{
lock (_lock) { return _events.Where(e => e.RunId == runId).ToList(); }
}
/// <summary>Gets events by type (for testing).</summary>
public IReadOnlyList<PackRunTimelineEvent> GetEventsByType(string eventType)
{
lock (_lock) { return _events.Where(e => e.EventType == eventType).ToList(); }
}
/// <summary>Gets step events for a run (for testing).</summary>
public IReadOnlyList<PackRunTimelineEvent> GetStepEvents(string runId, string stepId)
{
lock (_lock)
{
return _events
.Where(e => e.RunId == runId && e.StepId == stepId)
.ToList();
}
}
/// <summary>Clears all events (for testing).</summary>
public void Clear()
{
lock (_lock)
{
_events.Clear();
_seenIds.Clear();
_sequence = 0;
}
}
/// <summary>Gets the current event count.</summary>
public int Count
{
get { lock (_lock) { return _events.Count; } }
}
}
/// <summary>
/// Null sink that discards all events.
/// </summary>
public sealed class NullPackRunTimelineEventSink : IPackRunTimelineEventSink
{
public static NullPackRunTimelineEventSink Instance { get; } = new();
private NullPackRunTimelineEventSink() { }
public Task<PackRunTimelineSinkWriteResult> WriteAsync(
PackRunTimelineEvent evt,
CancellationToken cancellationToken = default)
{
return Task.FromResult(new PackRunTimelineSinkWriteResult(
Success: true,
Sequence: null,
Deduplicated: false,
Error: null));
}
public Task<PackRunTimelineSinkBatchWriteResult> WriteBatchAsync(
IEnumerable<PackRunTimelineEvent> events,
CancellationToken cancellationToken = default)
{
var count = events.Count();
return Task.FromResult(new PackRunTimelineSinkBatchWriteResult(count, 0, 0));
}
}

View File

@@ -1,347 +0,0 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.TaskRunner.Core.Events;
/// <summary>
/// Timeline event for pack run audit trail, observability, and evidence chain tracking.
/// Per TASKRUN-OBS-52-001 and timeline-event.schema.json.
/// </summary>
public sealed record PackRunTimelineEvent(
/// <summary>Monotonically increasing sequence number for ordering.</summary>
long? EventSeq,
/// <summary>Globally unique event identifier.</summary>
Guid EventId,
/// <summary>Tenant scope for multi-tenant isolation.</summary>
string TenantId,
/// <summary>Event type identifier following namespace convention.</summary>
string EventType,
/// <summary>Service or component that emitted this event.</summary>
string Source,
/// <summary>When the event actually occurred.</summary>
DateTimeOffset OccurredAt,
/// <summary>When the event was received by timeline indexer.</summary>
DateTimeOffset? ReceivedAt,
/// <summary>Correlation ID linking related events across services.</summary>
string? CorrelationId,
/// <summary>OpenTelemetry trace ID for distributed tracing.</summary>
string? TraceId,
/// <summary>OpenTelemetry span ID within the trace.</summary>
string? SpanId,
/// <summary>User, service account, or system that triggered the event.</summary>
string? Actor,
/// <summary>Event severity level.</summary>
PackRunEventSeverity Severity,
/// <summary>Key-value attributes for filtering and querying.</summary>
IReadOnlyDictionary<string, string>? Attributes,
/// <summary>SHA-256 hash of the raw payload for integrity.</summary>
string? PayloadHash,
/// <summary>Original event payload as JSON string.</summary>
string? RawPayloadJson,
/// <summary>Canonicalized JSON for deterministic hashing.</summary>
string? NormalizedPayloadJson,
/// <summary>Reference to associated evidence bundle or attestation.</summary>
PackRunEvidencePointer? EvidencePointer,
/// <summary>Run ID for this pack run.</summary>
string RunId,
/// <summary>Plan hash for the pack run.</summary>
string? PlanHash,
/// <summary>Step ID if this event is associated with a step.</summary>
string? StepId,
/// <summary>Project ID scope within tenant.</summary>
string? ProjectId)
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
private static readonly JsonSerializerOptions CanonicalJsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
/// <summary>
/// Creates a new timeline event with generated ID.
/// </summary>
public static PackRunTimelineEvent Create(
string tenantId,
string eventType,
string source,
DateTimeOffset occurredAt,
string runId,
string? planHash = null,
string? stepId = null,
string? actor = null,
PackRunEventSeverity severity = PackRunEventSeverity.Info,
IReadOnlyDictionary<string, string>? attributes = null,
string? correlationId = null,
string? traceId = null,
string? spanId = null,
string? projectId = null,
object? payload = null,
PackRunEvidencePointer? evidencePointer = null)
{
string? rawPayload = null;
string? normalizedPayload = null;
string? payloadHash = null;
if (payload is not null)
{
rawPayload = JsonSerializer.Serialize(payload, JsonOptions);
normalizedPayload = NormalizeJson(rawPayload);
payloadHash = ComputeHash(normalizedPayload);
}
return new PackRunTimelineEvent(
EventSeq: null,
EventId: Guid.NewGuid(),
TenantId: tenantId,
EventType: eventType,
Source: source,
OccurredAt: occurredAt,
ReceivedAt: null,
CorrelationId: correlationId,
TraceId: traceId,
SpanId: spanId,
Actor: actor,
Severity: severity,
Attributes: attributes,
PayloadHash: payloadHash,
RawPayloadJson: rawPayload,
NormalizedPayloadJson: normalizedPayload,
EvidencePointer: evidencePointer,
RunId: runId,
PlanHash: planHash,
StepId: stepId,
ProjectId: projectId);
}
/// <summary>
/// Serializes the event to JSON.
/// </summary>
public string ToJson() => JsonSerializer.Serialize(this, JsonOptions);
/// <summary>
/// Parses a timeline event from JSON.
/// </summary>
public static PackRunTimelineEvent? FromJson(string json)
=> JsonSerializer.Deserialize<PackRunTimelineEvent>(json, JsonOptions);
/// <summary>
/// Creates a copy with received timestamp set.
/// </summary>
public PackRunTimelineEvent WithReceivedAt(DateTimeOffset receivedAt)
=> this with { ReceivedAt = receivedAt };
/// <summary>
/// Creates a copy with sequence number set.
/// </summary>
public PackRunTimelineEvent WithSequence(long seq)
=> this with { EventSeq = seq };
/// <summary>
/// Generates an idempotency key for this event.
/// </summary>
public string GenerateIdempotencyKey()
=> $"timeline:pack:{TenantId}:{EventType}:{EventId}";
private static string NormalizeJson(string json)
{
using var doc = JsonDocument.Parse(json);
return JsonSerializer.Serialize(doc.RootElement, CanonicalJsonOptions);
}
private static string ComputeHash(string content)
{
var bytes = Encoding.UTF8.GetBytes(content);
var hash = SHA256.HashData(bytes);
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
}
/// <summary>
/// Event severity level for pack run timeline events.
/// </summary>
public enum PackRunEventSeverity
{
Debug,
Info,
Warning,
Error,
Critical
}
/// <summary>
/// Reference to associated evidence bundle or attestation.
/// </summary>
public sealed record PackRunEvidencePointer(
/// <summary>Type of evidence being referenced.</summary>
PackRunEvidencePointerType Type,
/// <summary>Evidence bundle identifier.</summary>
Guid? BundleId,
/// <summary>Content digest of the evidence bundle.</summary>
string? BundleDigest,
/// <summary>Subject URI for the attestation.</summary>
string? AttestationSubject,
/// <summary>Digest of the attestation envelope.</summary>
string? AttestationDigest,
/// <summary>URI to the evidence manifest.</summary>
string? ManifestUri,
/// <summary>Path within evidence locker storage.</summary>
string? LockerPath)
{
/// <summary>
/// Creates a bundle evidence pointer.
/// </summary>
public static PackRunEvidencePointer Bundle(Guid bundleId, string? bundleDigest = null)
=> new(PackRunEvidencePointerType.Bundle, bundleId, bundleDigest, null, null, null, null);
/// <summary>
/// Creates an attestation evidence pointer.
/// </summary>
public static PackRunEvidencePointer Attestation(string subject, string? digest = null)
=> new(PackRunEvidencePointerType.Attestation, null, null, subject, digest, null, null);
/// <summary>
/// Creates a manifest evidence pointer.
/// </summary>
public static PackRunEvidencePointer Manifest(string uri, string? lockerPath = null)
=> new(PackRunEvidencePointerType.Manifest, null, null, null, null, uri, lockerPath);
/// <summary>
/// Creates an artifact evidence pointer.
/// </summary>
public static PackRunEvidencePointer Artifact(string lockerPath, string? digest = null)
=> new(PackRunEvidencePointerType.Artifact, null, digest, null, null, null, lockerPath);
}
/// <summary>
/// Type of evidence being referenced.
/// </summary>
public enum PackRunEvidencePointerType
{
Bundle,
Attestation,
Manifest,
Artifact
}
/// <summary>
/// Pack run timeline event types.
/// </summary>
public static class PackRunEventTypes
{
/// <summary>Prefix for all pack run events.</summary>
public const string Prefix = "pack.";
/// <summary>Pack run started.</summary>
public const string PackStarted = "pack.started";
/// <summary>Pack run completed successfully.</summary>
public const string PackCompleted = "pack.completed";
/// <summary>Pack run failed.</summary>
public const string PackFailed = "pack.failed";
/// <summary>Pack run paused (awaiting approvals/gates).</summary>
public const string PackPaused = "pack.paused";
/// <summary>Step started execution.</summary>
public const string StepStarted = "pack.step.started";
/// <summary>Step completed successfully.</summary>
public const string StepCompleted = "pack.step.completed";
/// <summary>Step failed.</summary>
public const string StepFailed = "pack.step.failed";
/// <summary>Step scheduled for retry.</summary>
public const string StepRetryScheduled = "pack.step.retry_scheduled";
/// <summary>Step skipped.</summary>
public const string StepSkipped = "pack.step.skipped";
/// <summary>Approval gate satisfied.</summary>
public const string ApprovalSatisfied = "pack.approval.satisfied";
/// <summary>Policy gate evaluated.</summary>
public const string PolicyEvaluated = "pack.policy.evaluated";
/// <summary>Sealed install enforcement performed.</summary>
public const string SealedInstallEnforcement = "pack.sealed_install.enforcement";
/// <summary>Sealed install enforcement denied execution.</summary>
public const string SealedInstallDenied = "pack.sealed_install.denied";
/// <summary>Sealed install enforcement allowed execution.</summary>
public const string SealedInstallAllowed = "pack.sealed_install.allowed";
/// <summary>Sealed install requirements warning.</summary>
public const string SealedInstallWarning = "pack.sealed_install.warning";
/// <summary>Attestation created successfully (per TASKRUN-OBS-54-001).</summary>
public const string AttestationCreated = "pack.attestation.created";
/// <summary>Attestation creation failed.</summary>
public const string AttestationFailed = "pack.attestation.failed";
/// <summary>Attestation verified successfully.</summary>
public const string AttestationVerified = "pack.attestation.verified";
/// <summary>Attestation verification failed.</summary>
public const string AttestationVerificationFailed = "pack.attestation.verification_failed";
/// <summary>Attestation was revoked.</summary>
public const string AttestationRevoked = "pack.attestation.revoked";
/// <summary>Incident mode activated (per TASKRUN-OBS-55-001).</summary>
public const string IncidentModeActivated = "pack.incident.activated";
/// <summary>Incident mode deactivated.</summary>
public const string IncidentModeDeactivated = "pack.incident.deactivated";
/// <summary>Incident mode escalated to higher level.</summary>
public const string IncidentModeEscalated = "pack.incident.escalated";
/// <summary>SLO breach detected triggering incident mode.</summary>
public const string SloBreachDetected = "pack.incident.slo_breach";
/// <summary>Checks if the event type is a pack run event.</summary>
public static bool IsPackRunEvent(string eventType) =>
eventType.StartsWith(Prefix, StringComparison.Ordinal);
}

View File

@@ -1,603 +0,0 @@
using Microsoft.Extensions.Logging;
namespace StellaOps.TaskRunner.Core.Events;
/// <summary>
/// Service for emitting pack run timeline events with trace IDs, deduplication, and retries.
/// Per TASKRUN-OBS-52-001.
/// </summary>
public interface IPackRunTimelineEventEmitter
{
/// <summary>
/// Emits a timeline event.
/// </summary>
Task<PackRunTimelineEmitResult> EmitAsync(
PackRunTimelineEvent evt,
CancellationToken cancellationToken = default);
/// <summary>
/// Emits multiple timeline events in batch.
/// </summary>
Task<PackRunTimelineBatchEmitResult> EmitBatchAsync(
IEnumerable<PackRunTimelineEvent> events,
CancellationToken cancellationToken = default);
/// <summary>
/// Emits a pack.started event.
/// </summary>
Task<PackRunTimelineEmitResult> EmitPackStartedAsync(
string tenantId,
string runId,
string planHash,
string? actor = null,
string? correlationId = null,
string? traceId = null,
string? projectId = null,
IReadOnlyDictionary<string, string>? attributes = null,
PackRunEvidencePointer? evidencePointer = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Emits a pack.completed event.
/// </summary>
Task<PackRunTimelineEmitResult> EmitPackCompletedAsync(
string tenantId,
string runId,
string planHash,
string? actor = null,
string? correlationId = null,
string? traceId = null,
string? projectId = null,
IReadOnlyDictionary<string, string>? attributes = null,
PackRunEvidencePointer? evidencePointer = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Emits a pack.failed event.
/// </summary>
Task<PackRunTimelineEmitResult> EmitPackFailedAsync(
string tenantId,
string runId,
string planHash,
string? failureReason = null,
string? actor = null,
string? correlationId = null,
string? traceId = null,
string? projectId = null,
IReadOnlyDictionary<string, string>? attributes = null,
PackRunEvidencePointer? evidencePointer = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Emits a pack.step.started event.
/// </summary>
Task<PackRunTimelineEmitResult> EmitStepStartedAsync(
string tenantId,
string runId,
string planHash,
string stepId,
int attempt,
string? actor = null,
string? correlationId = null,
string? traceId = null,
string? projectId = null,
IReadOnlyDictionary<string, string>? attributes = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Emits a pack.step.completed event.
/// </summary>
Task<PackRunTimelineEmitResult> EmitStepCompletedAsync(
string tenantId,
string runId,
string planHash,
string stepId,
int attempt,
double? durationMs = null,
string? actor = null,
string? correlationId = null,
string? traceId = null,
string? projectId = null,
IReadOnlyDictionary<string, string>? attributes = null,
PackRunEvidencePointer? evidencePointer = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Emits a pack.step.failed event.
/// </summary>
Task<PackRunTimelineEmitResult> EmitStepFailedAsync(
string tenantId,
string runId,
string planHash,
string stepId,
int attempt,
string? error = null,
string? actor = null,
string? correlationId = null,
string? traceId = null,
string? projectId = null,
IReadOnlyDictionary<string, string>? attributes = null,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of timeline event emission.
/// </summary>
public sealed record PackRunTimelineEmitResult(
/// <summary>Whether the event was emitted successfully.</summary>
bool Success,
/// <summary>The emitted event (with sequence if assigned).</summary>
PackRunTimelineEvent Event,
/// <summary>Whether the event was deduplicated.</summary>
bool Deduplicated,
/// <summary>Error message if emission failed.</summary>
string? Error);
/// <summary>
/// Result of batch timeline event emission.
/// </summary>
public sealed record PackRunTimelineBatchEmitResult(
/// <summary>Number of events emitted successfully.</summary>
int Emitted,
/// <summary>Number of events deduplicated.</summary>
int Deduplicated,
/// <summary>Number of events that failed.</summary>
int Failed,
/// <summary>Errors encountered.</summary>
IReadOnlyList<string> Errors)
{
/// <summary>Total events processed.</summary>
public int Total => Emitted + Deduplicated + Failed;
/// <summary>Whether any events were emitted.</summary>
public bool HasEmitted => Emitted > 0;
/// <summary>Whether any errors occurred.</summary>
public bool HasErrors => Failed > 0 || Errors.Count > 0;
/// <summary>Creates an empty result.</summary>
public static PackRunTimelineBatchEmitResult Empty => new(0, 0, 0, []);
}
/// <summary>
/// Default implementation of pack run timeline event emitter.
/// </summary>
public sealed class PackRunTimelineEventEmitter : IPackRunTimelineEventEmitter
{
private const string Source = "taskrunner-worker";
private readonly IPackRunTimelineEventSink _sink;
private readonly TimeProvider _timeProvider;
private readonly ILogger<PackRunTimelineEventEmitter> _logger;
private readonly PackRunTimelineEmitterOptions _options;
public PackRunTimelineEventEmitter(
IPackRunTimelineEventSink sink,
TimeProvider timeProvider,
ILogger<PackRunTimelineEventEmitter> logger,
PackRunTimelineEmitterOptions? options = null)
{
_sink = sink ?? throw new ArgumentNullException(nameof(sink));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_options = options ?? PackRunTimelineEmitterOptions.Default;
}
public async Task<PackRunTimelineEmitResult> EmitAsync(
PackRunTimelineEvent evt,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(evt);
var eventWithReceived = evt.WithReceivedAt(_timeProvider.GetUtcNow());
try
{
var result = await EmitWithRetryAsync(eventWithReceived, cancellationToken);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to emit timeline event {EventId} type {EventType} for tenant {TenantId} run {RunId}",
evt.EventId, evt.EventType, evt.TenantId, evt.RunId);
return new PackRunTimelineEmitResult(
Success: false,
Event: eventWithReceived,
Deduplicated: false,
Error: ex.Message);
}
}
public async Task<PackRunTimelineBatchEmitResult> EmitBatchAsync(
IEnumerable<PackRunTimelineEvent> events,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(events);
var emitted = 0;
var deduplicated = 0;
var failed = 0;
var errors = new List<string>();
// Order by occurredAt then eventId for deterministic fan-out
var ordered = events
.OrderBy(e => e.OccurredAt)
.ThenBy(e => e.EventId)
.ToList();
foreach (var evt in ordered)
{
var result = await EmitAsync(evt, cancellationToken);
if (result.Success)
{
if (result.Deduplicated)
deduplicated++;
else
emitted++;
}
else
{
failed++;
if (result.Error is not null)
errors.Add($"{evt.EventId}: {result.Error}");
}
}
return new PackRunTimelineBatchEmitResult(emitted, deduplicated, failed, errors);
}
public Task<PackRunTimelineEmitResult> EmitPackStartedAsync(
string tenantId,
string runId,
string planHash,
string? actor = null,
string? correlationId = null,
string? traceId = null,
string? projectId = null,
IReadOnlyDictionary<string, string>? attributes = null,
PackRunEvidencePointer? evidencePointer = null,
CancellationToken cancellationToken = default)
{
var attrs = MergeAttributes(attributes, new Dictionary<string, string>
{
["runId"] = runId,
["planHash"] = planHash
});
var evt = PackRunTimelineEvent.Create(
tenantId: tenantId,
eventType: PackRunEventTypes.PackStarted,
source: Source,
occurredAt: _timeProvider.GetUtcNow(),
runId: runId,
planHash: planHash,
actor: actor,
severity: PackRunEventSeverity.Info,
attributes: attrs,
correlationId: correlationId,
traceId: traceId,
projectId: projectId,
evidencePointer: evidencePointer);
return EmitAsync(evt, cancellationToken);
}
public Task<PackRunTimelineEmitResult> EmitPackCompletedAsync(
string tenantId,
string runId,
string planHash,
string? actor = null,
string? correlationId = null,
string? traceId = null,
string? projectId = null,
IReadOnlyDictionary<string, string>? attributes = null,
PackRunEvidencePointer? evidencePointer = null,
CancellationToken cancellationToken = default)
{
var attrs = MergeAttributes(attributes, new Dictionary<string, string>
{
["runId"] = runId,
["planHash"] = planHash
});
var evt = PackRunTimelineEvent.Create(
tenantId: tenantId,
eventType: PackRunEventTypes.PackCompleted,
source: Source,
occurredAt: _timeProvider.GetUtcNow(),
runId: runId,
planHash: planHash,
actor: actor,
severity: PackRunEventSeverity.Info,
attributes: attrs,
correlationId: correlationId,
traceId: traceId,
projectId: projectId,
evidencePointer: evidencePointer);
return EmitAsync(evt, cancellationToken);
}
public Task<PackRunTimelineEmitResult> EmitPackFailedAsync(
string tenantId,
string runId,
string planHash,
string? failureReason = null,
string? actor = null,
string? correlationId = null,
string? traceId = null,
string? projectId = null,
IReadOnlyDictionary<string, string>? attributes = null,
PackRunEvidencePointer? evidencePointer = null,
CancellationToken cancellationToken = default)
{
var attrDict = new Dictionary<string, string>
{
["runId"] = runId,
["planHash"] = planHash
};
if (!string.IsNullOrWhiteSpace(failureReason))
{
attrDict["failureReason"] = failureReason;
}
var attrs = MergeAttributes(attributes, attrDict);
var evt = PackRunTimelineEvent.Create(
tenantId: tenantId,
eventType: PackRunEventTypes.PackFailed,
source: Source,
occurredAt: _timeProvider.GetUtcNow(),
runId: runId,
planHash: planHash,
actor: actor,
severity: PackRunEventSeverity.Error,
attributes: attrs,
correlationId: correlationId,
traceId: traceId,
projectId: projectId,
payload: failureReason != null ? new { reason = failureReason } : null,
evidencePointer: evidencePointer);
return EmitAsync(evt, cancellationToken);
}
public Task<PackRunTimelineEmitResult> EmitStepStartedAsync(
string tenantId,
string runId,
string planHash,
string stepId,
int attempt,
string? actor = null,
string? correlationId = null,
string? traceId = null,
string? projectId = null,
IReadOnlyDictionary<string, string>? attributes = null,
CancellationToken cancellationToken = default)
{
var attrs = MergeAttributes(attributes, new Dictionary<string, string>
{
["runId"] = runId,
["planHash"] = planHash,
["stepId"] = stepId,
["attempt"] = attempt.ToString()
});
var evt = PackRunTimelineEvent.Create(
tenantId: tenantId,
eventType: PackRunEventTypes.StepStarted,
source: Source,
occurredAt: _timeProvider.GetUtcNow(),
runId: runId,
planHash: planHash,
stepId: stepId,
actor: actor,
severity: PackRunEventSeverity.Info,
attributes: attrs,
correlationId: correlationId,
traceId: traceId,
projectId: projectId,
payload: new { stepId, attempt });
return EmitAsync(evt, cancellationToken);
}
public Task<PackRunTimelineEmitResult> EmitStepCompletedAsync(
string tenantId,
string runId,
string planHash,
string stepId,
int attempt,
double? durationMs = null,
string? actor = null,
string? correlationId = null,
string? traceId = null,
string? projectId = null,
IReadOnlyDictionary<string, string>? attributes = null,
PackRunEvidencePointer? evidencePointer = null,
CancellationToken cancellationToken = default)
{
var attrDict = new Dictionary<string, string>
{
["runId"] = runId,
["planHash"] = planHash,
["stepId"] = stepId,
["attempt"] = attempt.ToString()
};
if (durationMs.HasValue)
{
attrDict["durationMs"] = durationMs.Value.ToString("F2");
}
var attrs = MergeAttributes(attributes, attrDict);
var evt = PackRunTimelineEvent.Create(
tenantId: tenantId,
eventType: PackRunEventTypes.StepCompleted,
source: Source,
occurredAt: _timeProvider.GetUtcNow(),
runId: runId,
planHash: planHash,
stepId: stepId,
actor: actor,
severity: PackRunEventSeverity.Info,
attributes: attrs,
correlationId: correlationId,
traceId: traceId,
projectId: projectId,
payload: new { stepId, attempt, durationMs },
evidencePointer: evidencePointer);
return EmitAsync(evt, cancellationToken);
}
public Task<PackRunTimelineEmitResult> EmitStepFailedAsync(
string tenantId,
string runId,
string planHash,
string stepId,
int attempt,
string? error = null,
string? actor = null,
string? correlationId = null,
string? traceId = null,
string? projectId = null,
IReadOnlyDictionary<string, string>? attributes = null,
CancellationToken cancellationToken = default)
{
var attrDict = new Dictionary<string, string>
{
["runId"] = runId,
["planHash"] = planHash,
["stepId"] = stepId,
["attempt"] = attempt.ToString()
};
if (!string.IsNullOrWhiteSpace(error))
{
attrDict["error"] = error;
}
var attrs = MergeAttributes(attributes, attrDict);
var evt = PackRunTimelineEvent.Create(
tenantId: tenantId,
eventType: PackRunEventTypes.StepFailed,
source: Source,
occurredAt: _timeProvider.GetUtcNow(),
runId: runId,
planHash: planHash,
stepId: stepId,
actor: actor,
severity: PackRunEventSeverity.Error,
attributes: attrs,
correlationId: correlationId,
traceId: traceId,
projectId: projectId,
payload: new { stepId, attempt, error });
return EmitAsync(evt, cancellationToken);
}
private async Task<PackRunTimelineEmitResult> EmitWithRetryAsync(
PackRunTimelineEvent evt,
CancellationToken cancellationToken)
{
var attempt = 0;
var delay = _options.RetryDelay;
while (true)
{
try
{
var sinkResult = await _sink.WriteAsync(evt, cancellationToken);
if (sinkResult.Deduplicated)
{
_logger.LogDebug(
"Timeline event {EventId} deduplicated",
evt.EventId);
return new PackRunTimelineEmitResult(
Success: true,
Event: evt,
Deduplicated: true,
Error: null);
}
_logger.LogInformation(
"Emitted timeline event {EventId} type {EventType} tenant {TenantId} run {RunId} seq {Seq}",
evt.EventId, evt.EventType, evt.TenantId, evt.RunId, sinkResult.Sequence);
return new PackRunTimelineEmitResult(
Success: true,
Event: sinkResult.Sequence.HasValue ? evt.WithSequence(sinkResult.Sequence.Value) : evt,
Deduplicated: false,
Error: null);
}
catch (Exception ex) when (attempt < _options.MaxRetries && IsTransient(ex))
{
attempt++;
_logger.LogWarning(ex,
"Transient failure emitting timeline event {EventId}, attempt {Attempt}/{MaxRetries}",
evt.EventId, attempt, _options.MaxRetries);
await Task.Delay(delay, cancellationToken);
delay = TimeSpan.FromMilliseconds(delay.TotalMilliseconds * 2);
}
}
}
private static IReadOnlyDictionary<string, string> MergeAttributes(
IReadOnlyDictionary<string, string>? existing,
Dictionary<string, string> additional)
{
if (existing is null || existing.Count == 0)
return additional;
var merged = new Dictionary<string, string>(existing);
foreach (var (key, value) in additional)
{
merged.TryAdd(key, value);
}
return merged;
}
private static bool IsTransient(Exception ex)
{
return ex is TimeoutException or
TaskCanceledException or
System.Net.Http.HttpRequestException or
System.IO.IOException;
}
}
/// <summary>
/// Options for pack run timeline event emitter.
/// </summary>
public sealed record PackRunTimelineEmitterOptions(
/// <summary>Maximum retry attempts for transient failures.</summary>
int MaxRetries,
/// <summary>Base delay between retries.</summary>
TimeSpan RetryDelay,
/// <summary>Whether to include evidence pointers.</summary>
bool IncludeEvidencePointers)
{
/// <summary>Default emitter options.</summary>
public static PackRunTimelineEmitterOptions Default => new(
MaxRetries: 3,
RetryDelay: TimeSpan.FromSeconds(1),
IncludeEvidencePointers: true);
}

View File

@@ -1,243 +0,0 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.TaskRunner.Core.Evidence;
/// <summary>
/// Evidence for bundle import operations.
/// Per TASKRUN-AIRGAP-58-001.
/// </summary>
public sealed record BundleImportEvidence(
/// <summary>Unique import job identifier.</summary>
string JobId,
/// <summary>Tenant that initiated the import.</summary>
string TenantId,
/// <summary>Bundle source path or URL.</summary>
string SourcePath,
/// <summary>When the import started.</summary>
DateTimeOffset StartedAt,
/// <summary>When the import completed.</summary>
DateTimeOffset? CompletedAt,
/// <summary>Final status of the import.</summary>
BundleImportStatus Status,
/// <summary>Error message if failed.</summary>
string? ErrorMessage,
/// <summary>Actor who initiated the import.</summary>
string? InitiatedBy,
/// <summary>Input bundle manifest.</summary>
BundleImportInputManifest? InputManifest,
/// <summary>Output files with hashes.</summary>
IReadOnlyList<BundleImportOutputFile> OutputFiles,
/// <summary>Import transcript log entries.</summary>
IReadOnlyList<BundleImportTranscriptEntry> Transcript,
/// <summary>Validation results.</summary>
BundleImportValidationResult? ValidationResult,
/// <summary>Computed hashes for evidence chain.</summary>
BundleImportHashChain HashChain);
/// <summary>
/// Bundle import status.
/// </summary>
public enum BundleImportStatus
{
/// <summary>Import is pending.</summary>
Pending,
/// <summary>Import is in progress.</summary>
InProgress,
/// <summary>Import completed successfully.</summary>
Completed,
/// <summary>Import failed.</summary>
Failed,
/// <summary>Import was cancelled.</summary>
Cancelled,
/// <summary>Import is partially complete.</summary>
PartiallyComplete
}
/// <summary>
/// Input bundle manifest from the import source.
/// </summary>
public sealed record BundleImportInputManifest(
/// <summary>Bundle format version.</summary>
string FormatVersion,
/// <summary>Bundle identifier.</summary>
string BundleId,
/// <summary>Bundle version.</summary>
string BundleVersion,
/// <summary>When the bundle was created.</summary>
DateTimeOffset CreatedAt,
/// <summary>Who created the bundle.</summary>
string? CreatedBy,
/// <summary>Total size in bytes.</summary>
long TotalSizeBytes,
/// <summary>Number of items in the bundle.</summary>
int ItemCount,
/// <summary>SHA-256 of the manifest.</summary>
string ManifestSha256,
/// <summary>Bundle signature if present.</summary>
string? Signature,
/// <summary>Signature verification status.</summary>
bool? SignatureValid);
/// <summary>
/// Output file from bundle import.
/// </summary>
public sealed record BundleImportOutputFile(
/// <summary>Relative path within staging directory.</summary>
string RelativePath,
/// <summary>SHA-256 hash of the file.</summary>
string Sha256,
/// <summary>Size in bytes.</summary>
long SizeBytes,
/// <summary>Media type.</summary>
string MediaType,
/// <summary>When the file was staged.</summary>
DateTimeOffset StagedAt,
/// <summary>Source item identifier in the bundle.</summary>
string? SourceItemId);
/// <summary>
/// Transcript entry for bundle import.
/// </summary>
public sealed record BundleImportTranscriptEntry(
/// <summary>When the entry was recorded.</summary>
DateTimeOffset Timestamp,
/// <summary>Log level.</summary>
string Level,
/// <summary>Event type.</summary>
string EventType,
/// <summary>Message.</summary>
string Message,
/// <summary>Additional data.</summary>
IReadOnlyDictionary<string, string>? Data);
/// <summary>
/// Bundle import validation result.
/// </summary>
public sealed record BundleImportValidationResult(
/// <summary>Whether validation passed.</summary>
bool Valid,
/// <summary>Checksum verification passed.</summary>
bool ChecksumValid,
/// <summary>Signature verification passed.</summary>
bool? SignatureValid,
/// <summary>Format validation passed.</summary>
bool FormatValid,
/// <summary>Validation errors.</summary>
IReadOnlyList<string>? Errors,
/// <summary>Validation warnings.</summary>
IReadOnlyList<string>? Warnings);
/// <summary>
/// Hash chain for bundle import evidence.
/// </summary>
public sealed record BundleImportHashChain(
/// <summary>Hash of all input files.</summary>
string InputsHash,
/// <summary>Hash of all output files.</summary>
string OutputsHash,
/// <summary>Hash of the transcript.</summary>
string TranscriptHash,
/// <summary>Combined root hash.</summary>
string RootHash,
/// <summary>Algorithm used.</summary>
string Algorithm)
{
/// <summary>
/// Computes hash chain from import evidence data.
/// </summary>
public static BundleImportHashChain Compute(
BundleImportInputManifest? input,
IReadOnlyList<BundleImportOutputFile> outputs,
IReadOnlyList<BundleImportTranscriptEntry> transcript)
{
// Compute input hash
var inputJson = input is not null
? JsonSerializer.Serialize(input, JsonOptions)
: "null";
var inputsHash = ComputeSha256(inputJson);
// Compute outputs hash (sorted for determinism)
var sortedOutputs = outputs
.OrderBy(o => o.RelativePath, StringComparer.Ordinal)
.Select(o => o.Sha256)
.ToList();
var outputsJson = JsonSerializer.Serialize(sortedOutputs, JsonOptions);
var outputsHash = ComputeSha256(outputsJson);
// Compute transcript hash
var transcriptJson = JsonSerializer.Serialize(transcript, JsonOptions);
var transcriptHash = ComputeSha256(transcriptJson);
// Compute root hash
var combined = $"{inputsHash}|{outputsHash}|{transcriptHash}";
var rootHash = ComputeSha256(combined);
return new BundleImportHashChain(
InputsHash: inputsHash,
OutputsHash: outputsHash,
TranscriptHash: transcriptHash,
RootHash: rootHash,
Algorithm: "sha256");
}
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
private static string ComputeSha256(string content)
{
var bytes = Encoding.UTF8.GetBytes(content);
var hash = SHA256.HashData(bytes);
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
}

View File

@@ -1,383 +0,0 @@
using Microsoft.Extensions.Logging;
using StellaOps.TaskRunner.Core.Events;
using System.Globalization;
namespace StellaOps.TaskRunner.Core.Evidence;
/// <summary>
/// Service for capturing bundle import evidence.
/// Per TASKRUN-AIRGAP-58-001.
/// </summary>
public interface IBundleImportEvidenceService
{
/// <summary>
/// Captures evidence for a bundle import operation.
/// </summary>
Task<BundleImportEvidenceResult> CaptureAsync(
BundleImportEvidence evidence,
CancellationToken cancellationToken = default);
/// <summary>
/// Exports evidence to a portable bundle format.
/// </summary>
Task<PortableEvidenceBundleResult> ExportToPortableBundleAsync(
string jobId,
string outputPath,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets evidence for a bundle import job.
/// </summary>
Task<BundleImportEvidence?> GetAsync(
string jobId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of capturing bundle import evidence.
/// </summary>
public sealed record BundleImportEvidenceResult(
/// <summary>Whether capture was successful.</summary>
bool Success,
/// <summary>The captured snapshot.</summary>
PackRunEvidenceSnapshot? Snapshot,
/// <summary>Evidence pointer for linking.</summary>
PackRunEvidencePointer? EvidencePointer,
/// <summary>Error message if capture failed.</summary>
string? Error);
/// <summary>
/// Result of exporting to portable bundle.
/// </summary>
public sealed record PortableEvidenceBundleResult(
/// <summary>Whether export was successful.</summary>
bool Success,
/// <summary>Path to the exported bundle.</summary>
string? OutputPath,
/// <summary>SHA-256 of the bundle.</summary>
string? BundleSha256,
/// <summary>Size in bytes.</summary>
long SizeBytes,
/// <summary>Error message if export failed.</summary>
string? Error);
/// <summary>
/// Default implementation of bundle import evidence service.
/// </summary>
public sealed class BundleImportEvidenceService : IBundleImportEvidenceService
{
private readonly IPackRunEvidenceStore _store;
private readonly IPackRunTimelineEventEmitter? _timelineEmitter;
private readonly ILogger<BundleImportEvidenceService> _logger;
public BundleImportEvidenceService(
IPackRunEvidenceStore store,
ILogger<BundleImportEvidenceService> logger,
IPackRunTimelineEventEmitter? timelineEmitter = null)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timelineEmitter = timelineEmitter;
}
/// <inheritdoc />
public async Task<BundleImportEvidenceResult> CaptureAsync(
BundleImportEvidence evidence,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(evidence);
try
{
var materials = new List<PackRunEvidenceMaterial>();
// Add input manifest
if (evidence.InputManifest is not null)
{
materials.Add(PackRunEvidenceMaterial.FromJson(
"input",
"manifest.json",
evidence.InputManifest,
new Dictionary<string, string>
{
["bundleId"] = evidence.InputManifest.BundleId,
["bundleVersion"] = evidence.InputManifest.BundleVersion
}));
}
// Add output files as materials
foreach (var output in evidence.OutputFiles)
{
materials.Add(new PackRunEvidenceMaterial(
Section: "output",
Path: output.RelativePath,
Sha256: output.Sha256,
SizeBytes: output.SizeBytes,
MediaType: output.MediaType,
Attributes: new Dictionary<string, string>
{
["stagedAt"] = output.StagedAt.ToString("O", CultureInfo.InvariantCulture)
}));
}
// Add transcript
materials.Add(PackRunEvidenceMaterial.FromJson(
"transcript",
"import-log.json",
evidence.Transcript));
// Add validation result
if (evidence.ValidationResult is not null)
{
materials.Add(PackRunEvidenceMaterial.FromJson(
"validation",
"result.json",
evidence.ValidationResult));
}
// Add hash chain
materials.Add(PackRunEvidenceMaterial.FromJson(
"hashchain",
"chain.json",
evidence.HashChain));
// Create metadata
var metadata = new Dictionary<string, string>
{
["jobId"] = evidence.JobId,
["status"] = evidence.Status.ToString(),
["sourcePath"] = evidence.SourcePath,
["startedAt"] = evidence.StartedAt.ToString("O", CultureInfo.InvariantCulture),
["outputCount"] = evidence.OutputFiles.Count.ToString(),
["rootHash"] = evidence.HashChain.RootHash
};
if (evidence.CompletedAt.HasValue)
{
metadata["completedAt"] = evidence.CompletedAt.Value.ToString("O", CultureInfo.InvariantCulture);
metadata["durationMs"] = ((evidence.CompletedAt.Value - evidence.StartedAt).TotalMilliseconds).ToString("F0");
}
if (!string.IsNullOrWhiteSpace(evidence.InitiatedBy))
{
metadata["initiatedBy"] = evidence.InitiatedBy;
}
// Create snapshot
var snapshot = PackRunEvidenceSnapshot.Create(
tenantId: evidence.TenantId,
runId: evidence.JobId,
planHash: evidence.HashChain.RootHash,
kind: PackRunEvidenceSnapshotKind.BundleImport,
materials: materials,
metadata: metadata);
// Store snapshot
await _store.StoreAsync(snapshot, cancellationToken);
var evidencePointer = PackRunEvidencePointer.Bundle(
snapshot.SnapshotId,
snapshot.RootHash);
// Emit timeline event
if (_timelineEmitter is not null)
{
await _timelineEmitter.EmitAsync(
PackRunTimelineEvent.Create(
tenantId: evidence.TenantId,
eventType: "bundle.import.evidence_captured",
source: "taskrunner-bundle-import",
occurredAt: DateTimeOffset.UtcNow,
runId: evidence.JobId,
planHash: evidence.HashChain.RootHash,
attributes: new Dictionary<string, string>
{
["snapshotId"] = snapshot.SnapshotId.ToString(),
["rootHash"] = snapshot.RootHash,
["status"] = evidence.Status.ToString(),
["outputCount"] = evidence.OutputFiles.Count.ToString()
},
evidencePointer: evidencePointer),
cancellationToken);
}
_logger.LogInformation(
"Captured bundle import evidence for job {JobId} with {OutputCount} outputs, root hash {RootHash}",
evidence.JobId,
evidence.OutputFiles.Count,
evidence.HashChain.RootHash);
return new BundleImportEvidenceResult(
Success: true,
Snapshot: snapshot,
EvidencePointer: evidencePointer,
Error: null);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to capture bundle import evidence for job {JobId}", evidence.JobId);
return new BundleImportEvidenceResult(
Success: false,
Snapshot: null,
EvidencePointer: null,
Error: ex.Message);
}
}
/// <inheritdoc />
public async Task<PortableEvidenceBundleResult> ExportToPortableBundleAsync(
string jobId,
string outputPath,
CancellationToken cancellationToken = default)
{
try
{
// Get all snapshots for this job
var snapshots = await _store.GetByRunIdAsync(jobId, cancellationToken);
if (snapshots.Count == 0)
{
return new PortableEvidenceBundleResult(
Success: false,
OutputPath: null,
BundleSha256: null,
SizeBytes: 0,
Error: $"No evidence found for job {jobId}");
}
// Create portable bundle structure
var bundleManifest = new PortableEvidenceBundleManifest
{
Version = "1.0.0",
CreatedAt = DateTimeOffset.UtcNow,
JobId = jobId,
SnapshotCount = snapshots.Count,
Snapshots = snapshots.Select(s => new PortableSnapshotReference
{
SnapshotId = s.SnapshotId,
Kind = s.Kind.ToString(),
RootHash = s.RootHash,
CreatedAt = s.CreatedAt,
MaterialCount = s.Materials.Count
}).ToList()
};
// Serialize bundle
var bundleJson = System.Text.Json.JsonSerializer.Serialize(new
{
manifest = bundleManifest,
snapshots = snapshots
}, new System.Text.Json.JsonSerializerOptions
{
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
WriteIndented = true
});
// Write to file
await File.WriteAllTextAsync(outputPath, bundleJson, cancellationToken);
var fileInfo = new FileInfo(outputPath);
// Compute bundle hash
var bundleBytes = await File.ReadAllBytesAsync(outputPath, cancellationToken);
var hash = System.Security.Cryptography.SHA256.HashData(bundleBytes);
var bundleSha256 = $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
_logger.LogInformation(
"Exported portable evidence bundle for job {JobId} to {OutputPath}, size {SizeBytes} bytes",
jobId,
outputPath,
fileInfo.Length);
return new PortableEvidenceBundleResult(
Success: true,
OutputPath: outputPath,
BundleSha256: bundleSha256,
SizeBytes: fileInfo.Length,
Error: null);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to export portable evidence bundle for job {JobId}", jobId);
return new PortableEvidenceBundleResult(
Success: false,
OutputPath: null,
BundleSha256: null,
SizeBytes: 0,
Error: ex.Message);
}
}
/// <inheritdoc />
public async Task<BundleImportEvidence?> GetAsync(
string jobId,
CancellationToken cancellationToken = default)
{
var snapshots = await _store.GetByRunIdAsync(jobId, cancellationToken);
var importSnapshot = snapshots.FirstOrDefault(s => s.Kind == PackRunEvidenceSnapshotKind.BundleImport);
if (importSnapshot is null)
{
return null;
}
// Reconstruct evidence from snapshot
return ReconstructEvidence(importSnapshot);
}
private static BundleImportEvidence? ReconstructEvidence(PackRunEvidenceSnapshot snapshot)
{
// This would deserialize the stored materials back into the evidence structure
// For now, return a minimal reconstruction from metadata
var metadata = snapshot.Metadata ?? new Dictionary<string, string>();
return new BundleImportEvidence(
JobId: metadata.GetValueOrDefault("jobId", snapshot.RunId),
TenantId: snapshot.TenantId,
SourcePath: metadata.GetValueOrDefault("sourcePath", "unknown"),
StartedAt: DateTimeOffset.TryParse(metadata.GetValueOrDefault("startedAt"), out var started)
? started : snapshot.CreatedAt,
CompletedAt: DateTimeOffset.TryParse(metadata.GetValueOrDefault("completedAt"), out var completed)
? completed : null,
Status: Enum.TryParse<BundleImportStatus>(metadata.GetValueOrDefault("status"), out var status)
? status : BundleImportStatus.Completed,
ErrorMessage: null,
InitiatedBy: metadata.GetValueOrDefault("initiatedBy"),
InputManifest: null,
OutputFiles: [],
Transcript: [],
ValidationResult: null,
HashChain: new BundleImportHashChain(
InputsHash: "sha256:reconstructed",
OutputsHash: "sha256:reconstructed",
TranscriptHash: "sha256:reconstructed",
RootHash: metadata.GetValueOrDefault("rootHash", snapshot.RootHash),
Algorithm: "sha256"));
}
private sealed class PortableEvidenceBundleManifest
{
public required string Version { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public required string JobId { get; init; }
public required int SnapshotCount { get; init; }
public required IReadOnlyList<PortableSnapshotReference> Snapshots { get; init; }
}
private sealed class PortableSnapshotReference
{
public required Guid SnapshotId { get; init; }
public required string Kind { get; init; }
public required string RootHash { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public required int MaterialCount { get; init; }
}
}

View File

@@ -1,504 +0,0 @@
using Microsoft.Extensions.Logging;
using StellaOps.TaskRunner.Core.Events;
using StellaOps.TaskRunner.Core.Execution;
using System.Globalization;
namespace StellaOps.TaskRunner.Core.Evidence;
/// <summary>
/// Service for capturing pack run evidence snapshots.
/// Per TASKRUN-OBS-53-001.
/// </summary>
public interface IPackRunEvidenceSnapshotService
{
/// <summary>
/// Captures a run completion snapshot with all materials.
/// </summary>
Task<PackRunEvidenceSnapshotResult> CaptureRunCompletionAsync(
string tenantId,
string runId,
string planHash,
PackRunState state,
IReadOnlyList<PackRunStepTranscript>? transcripts = null,
IReadOnlyList<PackRunApprovalEvidence>? approvals = null,
IReadOnlyList<PackRunPolicyEvidence>? policyEvaluations = null,
PackRunEnvironmentDigest? environmentDigest = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Captures a step execution snapshot.
/// </summary>
Task<PackRunEvidenceSnapshotResult> CaptureStepExecutionAsync(
string tenantId,
string runId,
string planHash,
PackRunStepTranscript transcript,
CancellationToken cancellationToken = default);
/// <summary>
/// Captures an approval decision snapshot.
/// </summary>
Task<PackRunEvidenceSnapshotResult> CaptureApprovalDecisionAsync(
string tenantId,
string runId,
string planHash,
PackRunApprovalEvidence approval,
CancellationToken cancellationToken = default);
/// <summary>
/// Captures a policy evaluation snapshot.
/// </summary>
Task<PackRunEvidenceSnapshotResult> CapturePolicyEvaluationAsync(
string tenantId,
string runId,
string planHash,
PackRunPolicyEvidence evaluation,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of evidence snapshot capture.
/// </summary>
public sealed record PackRunEvidenceSnapshotResult(
/// <summary>Whether capture was successful.</summary>
bool Success,
/// <summary>The captured snapshot.</summary>
PackRunEvidenceSnapshot? Snapshot,
/// <summary>Evidence pointer for timeline events.</summary>
PackRunEvidencePointer? EvidencePointer,
/// <summary>Error message if capture failed.</summary>
string? Error);
/// <summary>
/// Default implementation of evidence snapshot service.
/// </summary>
public sealed class PackRunEvidenceSnapshotService : IPackRunEvidenceSnapshotService
{
private readonly IPackRunEvidenceStore _store;
private readonly IPackRunRedactionGuard _redactionGuard;
private readonly IPackRunTimelineEventEmitter? _timelineEmitter;
private readonly ILogger<PackRunEvidenceSnapshotService> _logger;
private readonly PackRunEvidenceSnapshotOptions _options;
public PackRunEvidenceSnapshotService(
IPackRunEvidenceStore store,
IPackRunRedactionGuard redactionGuard,
ILogger<PackRunEvidenceSnapshotService> logger,
IPackRunTimelineEventEmitter? timelineEmitter = null,
PackRunEvidenceSnapshotOptions? options = null)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_redactionGuard = redactionGuard ?? throw new ArgumentNullException(nameof(redactionGuard));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timelineEmitter = timelineEmitter;
_options = options ?? PackRunEvidenceSnapshotOptions.Default;
}
public async Task<PackRunEvidenceSnapshotResult> CaptureRunCompletionAsync(
string tenantId,
string runId,
string planHash,
PackRunState state,
IReadOnlyList<PackRunStepTranscript>? transcripts = null,
IReadOnlyList<PackRunApprovalEvidence>? approvals = null,
IReadOnlyList<PackRunPolicyEvidence>? policyEvaluations = null,
PackRunEnvironmentDigest? environmentDigest = null,
CancellationToken cancellationToken = default)
{
try
{
var materials = new List<PackRunEvidenceMaterial>();
// Add state summary
var stateSummary = CreateStateSummary(state);
materials.Add(PackRunEvidenceMaterial.FromJson(
"summary",
"run-state.json",
stateSummary));
// Add transcripts (redacted)
if (transcripts is not null)
{
foreach (var transcript in transcripts)
{
var redacted = _redactionGuard.RedactTranscript(transcript);
materials.Add(PackRunEvidenceMaterial.FromJson(
"transcript",
$"{redacted.StepId}.json",
redacted,
new Dictionary<string, string> { ["stepId"] = redacted.StepId }));
}
}
// Add approvals (redacted)
if (approvals is not null)
{
foreach (var approval in approvals)
{
var redacted = _redactionGuard.RedactApproval(approval);
materials.Add(PackRunEvidenceMaterial.FromJson(
"approval",
$"{redacted.ApprovalId}.json",
redacted,
new Dictionary<string, string> { ["approvalId"] = redacted.ApprovalId }));
}
}
// Add policy evaluations
if (policyEvaluations is not null)
{
foreach (var evaluation in policyEvaluations)
{
materials.Add(PackRunEvidenceMaterial.FromJson(
"policy",
$"{evaluation.PolicyName}.json",
evaluation,
new Dictionary<string, string> { ["policyName"] = evaluation.PolicyName }));
}
}
// Add environment digest (redacted)
if (environmentDigest is not null)
{
var redacted = _redactionGuard.RedactEnvironment(environmentDigest);
materials.Add(PackRunEvidenceMaterial.FromJson(
"environment",
"digest.json",
redacted));
}
// Create snapshot
var metadata = new Dictionary<string, string>
{
["runId"] = runId,
["planHash"] = planHash,
["stepCount"] = state.Steps.Count.ToString(),
["capturedAt"] = DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture)
};
var snapshot = PackRunEvidenceSnapshot.Create(
tenantId,
runId,
planHash,
PackRunEvidenceSnapshotKind.RunCompletion,
materials,
metadata);
// Store snapshot
await _store.StoreAsync(snapshot, cancellationToken);
var evidencePointer = PackRunEvidencePointer.Bundle(
snapshot.SnapshotId,
snapshot.RootHash);
// Emit timeline event if emitter available
if (_timelineEmitter is not null)
{
await _timelineEmitter.EmitAsync(
PackRunTimelineEvent.Create(
tenantId: tenantId,
eventType: "pack.evidence.captured",
source: "taskrunner-evidence",
occurredAt: DateTimeOffset.UtcNow,
runId: runId,
planHash: planHash,
attributes: new Dictionary<string, string>
{
["snapshotId"] = snapshot.SnapshotId.ToString(),
["rootHash"] = snapshot.RootHash,
["materialCount"] = materials.Count.ToString()
},
evidencePointer: evidencePointer),
cancellationToken);
}
_logger.LogInformation(
"Captured run completion evidence for run {RunId} with {MaterialCount} materials, root hash {RootHash}",
runId, materials.Count, snapshot.RootHash);
return new PackRunEvidenceSnapshotResult(
Success: true,
Snapshot: snapshot,
EvidencePointer: evidencePointer,
Error: null);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to capture run completion evidence for run {RunId}",
runId);
return new PackRunEvidenceSnapshotResult(
Success: false,
Snapshot: null,
EvidencePointer: null,
Error: ex.Message);
}
}
public async Task<PackRunEvidenceSnapshotResult> CaptureStepExecutionAsync(
string tenantId,
string runId,
string planHash,
PackRunStepTranscript transcript,
CancellationToken cancellationToken = default)
{
try
{
var redacted = _redactionGuard.RedactTranscript(transcript);
var materials = new List<PackRunEvidenceMaterial>
{
PackRunEvidenceMaterial.FromJson(
"transcript",
$"{redacted.StepId}.json",
redacted,
new Dictionary<string, string> { ["stepId"] = redacted.StepId })
};
// Add artifacts if present
if (redacted.Artifacts is not null)
{
foreach (var artifact in redacted.Artifacts)
{
materials.Add(new PackRunEvidenceMaterial(
Section: "artifact",
Path: artifact.Name,
Sha256: artifact.Sha256,
SizeBytes: artifact.SizeBytes,
MediaType: artifact.MediaType,
Attributes: new Dictionary<string, string> { ["stepId"] = redacted.StepId }));
}
}
var metadata = new Dictionary<string, string>
{
["runId"] = runId,
["planHash"] = planHash,
["stepId"] = transcript.StepId,
["status"] = transcript.Status,
["attempt"] = transcript.Attempt.ToString()
};
var snapshot = PackRunEvidenceSnapshot.Create(
tenantId,
runId,
planHash,
PackRunEvidenceSnapshotKind.StepExecution,
materials,
metadata);
await _store.StoreAsync(snapshot, cancellationToken);
var evidencePointer = PackRunEvidencePointer.Bundle(
snapshot.SnapshotId,
snapshot.RootHash);
_logger.LogDebug(
"Captured step execution evidence for run {RunId} step {StepId}",
runId, transcript.StepId);
return new PackRunEvidenceSnapshotResult(
Success: true,
Snapshot: snapshot,
EvidencePointer: evidencePointer,
Error: null);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to capture step execution evidence for run {RunId} step {StepId}",
runId, transcript.StepId);
return new PackRunEvidenceSnapshotResult(
Success: false,
Snapshot: null,
EvidencePointer: null,
Error: ex.Message);
}
}
public async Task<PackRunEvidenceSnapshotResult> CaptureApprovalDecisionAsync(
string tenantId,
string runId,
string planHash,
PackRunApprovalEvidence approval,
CancellationToken cancellationToken = default)
{
try
{
var redacted = _redactionGuard.RedactApproval(approval);
var materials = new List<PackRunEvidenceMaterial>
{
PackRunEvidenceMaterial.FromJson(
"approval",
$"{redacted.ApprovalId}.json",
redacted)
};
var metadata = new Dictionary<string, string>
{
["runId"] = runId,
["planHash"] = planHash,
["approvalId"] = approval.ApprovalId,
["decision"] = approval.Decision,
["approver"] = _redactionGuard.RedactIdentity(approval.Approver)
};
var snapshot = PackRunEvidenceSnapshot.Create(
tenantId,
runId,
planHash,
PackRunEvidenceSnapshotKind.ApprovalDecision,
materials,
metadata);
await _store.StoreAsync(snapshot, cancellationToken);
var evidencePointer = PackRunEvidencePointer.Bundle(
snapshot.SnapshotId,
snapshot.RootHash);
_logger.LogDebug(
"Captured approval decision evidence for run {RunId} approval {ApprovalId}",
runId, approval.ApprovalId);
return new PackRunEvidenceSnapshotResult(
Success: true,
Snapshot: snapshot,
EvidencePointer: evidencePointer,
Error: null);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to capture approval decision evidence for run {RunId}",
runId);
return new PackRunEvidenceSnapshotResult(
Success: false,
Snapshot: null,
EvidencePointer: null,
Error: ex.Message);
}
}
public async Task<PackRunEvidenceSnapshotResult> CapturePolicyEvaluationAsync(
string tenantId,
string runId,
string planHash,
PackRunPolicyEvidence evaluation,
CancellationToken cancellationToken = default)
{
try
{
var materials = new List<PackRunEvidenceMaterial>
{
PackRunEvidenceMaterial.FromJson(
"policy",
$"{evaluation.PolicyName}.json",
evaluation)
};
var metadata = new Dictionary<string, string>
{
["runId"] = runId,
["planHash"] = planHash,
["policyName"] = evaluation.PolicyName,
["result"] = evaluation.Result
};
if (evaluation.PolicyVersion is not null)
{
metadata["policyVersion"] = evaluation.PolicyVersion;
}
var snapshot = PackRunEvidenceSnapshot.Create(
tenantId,
runId,
planHash,
PackRunEvidenceSnapshotKind.PolicyEvaluation,
materials,
metadata);
await _store.StoreAsync(snapshot, cancellationToken);
var evidencePointer = PackRunEvidencePointer.Bundle(
snapshot.SnapshotId,
snapshot.RootHash);
_logger.LogDebug(
"Captured policy evaluation evidence for run {RunId} policy {PolicyName}",
runId, evaluation.PolicyName);
return new PackRunEvidenceSnapshotResult(
Success: true,
Snapshot: snapshot,
EvidencePointer: evidencePointer,
Error: null);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to capture policy evaluation evidence for run {RunId}",
runId);
return new PackRunEvidenceSnapshotResult(
Success: false,
Snapshot: null,
EvidencePointer: null,
Error: ex.Message);
}
}
private static object CreateStateSummary(PackRunState state)
{
var stepSummaries = state.Steps.Values.Select(s => new
{
s.StepId,
Kind = s.Kind.ToString(),
s.Enabled,
Status = s.Status.ToString(),
s.Attempts,
s.StatusReason
}).ToList();
return new
{
state.RunId,
state.PlanHash,
state.RequestedAt,
state.CreatedAt,
state.UpdatedAt,
StepCount = state.Steps.Count,
Steps = stepSummaries
};
}
}
/// <summary>
/// Options for evidence snapshot service.
/// </summary>
public sealed record PackRunEvidenceSnapshotOptions(
/// <summary>Maximum transcript output length before truncation.</summary>
int MaxTranscriptOutputLength,
/// <summary>Maximum comment length before truncation.</summary>
int MaxCommentLength,
/// <summary>Whether to include step outputs.</summary>
bool IncludeStepOutput,
/// <summary>Whether to emit timeline events.</summary>
bool EmitTimelineEvents)
{
/// <summary>Default options.</summary>
public static PackRunEvidenceSnapshotOptions Default => new(
MaxTranscriptOutputLength: 64 * 1024, // 64KB
MaxCommentLength: 4096,
IncludeStepOutput: true,
EmitTimelineEvents: true);
}

View File

@@ -1,203 +0,0 @@
namespace StellaOps.TaskRunner.Core.Evidence;
/// <summary>
/// Store for pack run evidence snapshots.
/// Per TASKRUN-OBS-53-001.
/// </summary>
public interface IPackRunEvidenceStore
{
/// <summary>
/// Stores an evidence snapshot.
/// </summary>
Task StoreAsync(
PackRunEvidenceSnapshot snapshot,
CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves an evidence snapshot by ID.
/// </summary>
Task<PackRunEvidenceSnapshot?> GetAsync(
Guid snapshotId,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists evidence snapshots for a run.
/// </summary>
Task<IReadOnlyList<PackRunEvidenceSnapshot>> ListByRunAsync(
string tenantId,
string runId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets evidence snapshots by run ID only (across all tenants).
/// For bundle import evidence lookups.
/// </summary>
Task<IReadOnlyList<PackRunEvidenceSnapshot>> GetByRunIdAsync(
string runId,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists evidence snapshots by kind for a run.
/// </summary>
Task<IReadOnlyList<PackRunEvidenceSnapshot>> ListByKindAsync(
string tenantId,
string runId,
PackRunEvidenceSnapshotKind kind,
CancellationToken cancellationToken = default);
/// <summary>
/// Verifies the integrity of a snapshot by recomputing its Merkle root.
/// </summary>
Task<PackRunEvidenceVerificationResult> VerifyAsync(
Guid snapshotId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of evidence verification.
/// </summary>
public sealed record PackRunEvidenceVerificationResult(
/// <summary>Whether verification passed.</summary>
bool Valid,
/// <summary>The snapshot that was verified.</summary>
Guid SnapshotId,
/// <summary>Expected root hash.</summary>
string ExpectedHash,
/// <summary>Computed root hash.</summary>
string ComputedHash,
/// <summary>Error message if verification failed.</summary>
string? Error);
/// <summary>
/// In-memory evidence store for testing.
/// </summary>
public sealed class InMemoryPackRunEvidenceStore : IPackRunEvidenceStore
{
private readonly Dictionary<Guid, PackRunEvidenceSnapshot> _snapshots = new();
private readonly object _lock = new();
public Task StoreAsync(
PackRunEvidenceSnapshot snapshot,
CancellationToken cancellationToken = default)
{
lock (_lock)
{
_snapshots[snapshot.SnapshotId] = snapshot;
}
return Task.CompletedTask;
}
public Task<PackRunEvidenceSnapshot?> GetAsync(
Guid snapshotId,
CancellationToken cancellationToken = default)
{
lock (_lock)
{
_snapshots.TryGetValue(snapshotId, out var snapshot);
return Task.FromResult(snapshot);
}
}
public Task<IReadOnlyList<PackRunEvidenceSnapshot>> ListByRunAsync(
string tenantId,
string runId,
CancellationToken cancellationToken = default)
{
lock (_lock)
{
var results = _snapshots.Values
.Where(s => s.TenantId == tenantId && s.RunId == runId)
.OrderBy(s => s.CreatedAt)
.ToList();
return Task.FromResult<IReadOnlyList<PackRunEvidenceSnapshot>>(results);
}
}
public Task<IReadOnlyList<PackRunEvidenceSnapshot>> GetByRunIdAsync(
string runId,
CancellationToken cancellationToken = default)
{
lock (_lock)
{
var results = _snapshots.Values
.Where(s => s.RunId == runId)
.OrderBy(s => s.CreatedAt)
.ToList();
return Task.FromResult<IReadOnlyList<PackRunEvidenceSnapshot>>(results);
}
}
public Task<IReadOnlyList<PackRunEvidenceSnapshot>> ListByKindAsync(
string tenantId,
string runId,
PackRunEvidenceSnapshotKind kind,
CancellationToken cancellationToken = default)
{
lock (_lock)
{
var results = _snapshots.Values
.Where(s => s.TenantId == tenantId && s.RunId == runId && s.Kind == kind)
.OrderBy(s => s.CreatedAt)
.ToList();
return Task.FromResult<IReadOnlyList<PackRunEvidenceSnapshot>>(results);
}
}
public Task<PackRunEvidenceVerificationResult> VerifyAsync(
Guid snapshotId,
CancellationToken cancellationToken = default)
{
lock (_lock)
{
if (!_snapshots.TryGetValue(snapshotId, out var snapshot))
{
return Task.FromResult(new PackRunEvidenceVerificationResult(
Valid: false,
SnapshotId: snapshotId,
ExpectedHash: string.Empty,
ComputedHash: string.Empty,
Error: "Snapshot not found"));
}
// Recompute by creating a new snapshot with same materials
var recomputed = PackRunEvidenceSnapshot.Create(
snapshot.TenantId,
snapshot.RunId,
snapshot.PlanHash,
snapshot.Kind,
snapshot.Materials,
snapshot.Metadata);
var valid = snapshot.RootHash == recomputed.RootHash;
return Task.FromResult(new PackRunEvidenceVerificationResult(
Valid: valid,
SnapshotId: snapshotId,
ExpectedHash: snapshot.RootHash,
ComputedHash: recomputed.RootHash,
Error: valid ? null : "Root hash mismatch"));
}
}
/// <summary>Gets all snapshots (for testing).</summary>
public IReadOnlyList<PackRunEvidenceSnapshot> GetAll()
{
lock (_lock) { return _snapshots.Values.ToList(); }
}
/// <summary>Clears all snapshots (for testing).</summary>
public void Clear()
{
lock (_lock) { _snapshots.Clear(); }
}
/// <summary>Gets snapshot count.</summary>
public int Count
{
get { lock (_lock) { return _snapshots.Count; } }
}
}

View File

@@ -1,270 +0,0 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
namespace StellaOps.TaskRunner.Core.Evidence;
/// <summary>
/// Redaction guard for sensitive data in evidence snapshots.
/// Per TASKRUN-OBS-53-001.
/// </summary>
public interface IPackRunRedactionGuard
{
/// <summary>
/// Redacts sensitive data from a step transcript.
/// </summary>
PackRunStepTranscript RedactTranscript(PackRunStepTranscript transcript);
/// <summary>
/// Redacts sensitive data from an approval evidence record.
/// </summary>
PackRunApprovalEvidence RedactApproval(PackRunApprovalEvidence approval);
/// <summary>
/// Redacts sensitive data from an environment digest.
/// </summary>
PackRunEnvironmentDigest RedactEnvironment(PackRunEnvironmentDigest digest);
/// <summary>
/// Redacts an identity string (e.g., email, username).
/// </summary>
string RedactIdentity(string identity);
/// <summary>
/// Redacts a string value that may contain secrets.
/// </summary>
string RedactValue(string value);
}
/// <summary>
/// Options for redaction guard.
/// </summary>
public sealed record PackRunRedactionGuardOptions(
/// <summary>Patterns that indicate sensitive variable names.</summary>
IReadOnlyList<string> SensitiveVariablePatterns,
/// <summary>Patterns that indicate sensitive content in output.</summary>
IReadOnlyList<string> SensitiveContentPatterns,
/// <summary>Whether to hash redacted values for correlation.</summary>
bool HashRedactedValues,
/// <summary>Maximum length of output before truncation.</summary>
int MaxOutputLength,
/// <summary>Whether to preserve email domain.</summary>
bool PreserveEmailDomain)
{
/// <summary>Default redaction options.</summary>
public static PackRunRedactionGuardOptions Default => new(
SensitiveVariablePatterns: new[]
{
"(?i)password",
"(?i)secret",
"(?i)token",
"(?i)api_key",
"(?i)apikey",
"(?i)auth",
"(?i)credential",
"(?i)private_key",
"(?i)privatekey",
"(?i)access_key",
"(?i)accesskey",
"(?i)connection_string",
"(?i)connectionstring"
},
SensitiveContentPatterns: new[]
{
@"(?i)bearer\s+[a-zA-Z0-9\-_.]+",
@"(?i)basic\s+[a-zA-Z0-9+/=]+",
@"-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----",
@"(?i)password\s*[=:]\s*\S+",
@"(?i)secret\s*[=:]\s*\S+",
@"(?i)token\s*[=:]\s*\S+"
},
HashRedactedValues: true,
MaxOutputLength: 64 * 1024,
PreserveEmailDomain: false);
}
/// <summary>
/// Default implementation of redaction guard.
/// </summary>
public sealed partial class PackRunRedactionGuard : IPackRunRedactionGuard
{
private const string RedactedPlaceholder = "[REDACTED]";
private const string TruncatedSuffix = "...[TRUNCATED]";
private readonly PackRunRedactionGuardOptions _options;
private readonly List<Regex> _sensitiveVarPatterns;
private readonly List<Regex> _sensitiveContentPatterns;
public PackRunRedactionGuard(PackRunRedactionGuardOptions? options = null)
{
_options = options ?? PackRunRedactionGuardOptions.Default;
_sensitiveVarPatterns = _options.SensitiveVariablePatterns
.Select(p => new Regex(p, RegexOptions.Compiled))
.ToList();
_sensitiveContentPatterns = _options.SensitiveContentPatterns
.Select(p => new Regex(p, RegexOptions.Compiled))
.ToList();
}
public PackRunStepTranscript RedactTranscript(PackRunStepTranscript transcript)
{
var redactedOutput = transcript.Output is not null
? RedactOutput(transcript.Output)
: null;
var redactedError = transcript.Error is not null
? RedactOutput(transcript.Error)
: null;
var redactedEnvDigest = transcript.EnvironmentDigest is not null
? RedactEnvDigestString(transcript.EnvironmentDigest)
: null;
return transcript with
{
Output = redactedOutput,
Error = redactedError,
EnvironmentDigest = redactedEnvDigest
};
}
public PackRunApprovalEvidence RedactApproval(PackRunApprovalEvidence approval)
{
var redactedApprover = RedactIdentity(approval.Approver);
var redactedComments = approval.Comments is not null
? RedactOutput(approval.Comments)
: null;
var redactedGrantedBy = approval.GrantedBy?.Select(RedactIdentity).ToList();
return approval with
{
Approver = redactedApprover,
Comments = redactedComments,
GrantedBy = redactedGrantedBy
};
}
public PackRunEnvironmentDigest RedactEnvironment(PackRunEnvironmentDigest digest)
{
// Seeds are already expected to be redacted or hashed
// Environment variable names are kept, values should not be present
// Tool images are public information
return digest;
}
public string RedactIdentity(string identity)
{
if (string.IsNullOrEmpty(identity))
return identity;
// Check if it's an email
if (identity.Contains('@'))
{
var parts = identity.Split('@');
if (parts.Length == 2)
{
var localPart = parts[0];
var domain = parts[1];
var redactedLocal = localPart.Length <= 2
? RedactedPlaceholder
: $"{localPart[0]}***{localPart[^1]}";
if (_options.PreserveEmailDomain)
{
return $"{redactedLocal}@{domain}";
}
return $"{redactedLocal}@[DOMAIN]";
}
}
// For non-email identities, hash if configured
if (_options.HashRedactedValues)
{
return $"[USER:{ComputeShortHash(identity)}]";
}
return RedactedPlaceholder;
}
public string RedactValue(string value)
{
if (string.IsNullOrEmpty(value))
return value;
if (_options.HashRedactedValues)
{
return $"[HASH:{ComputeShortHash(value)}]";
}
return RedactedPlaceholder;
}
private string RedactOutput(string output)
{
if (string.IsNullOrEmpty(output))
return output;
var result = output;
// Apply content pattern redaction
foreach (var pattern in _sensitiveContentPatterns)
{
result = pattern.Replace(result, match =>
{
if (_options.HashRedactedValues)
{
return $"[REDACTED:{ComputeShortHash(match.Value)}]";
}
return RedactedPlaceholder;
});
}
// Truncate if too long
if (result.Length > _options.MaxOutputLength)
{
result = result[..(_options.MaxOutputLength - TruncatedSuffix.Length)] + TruncatedSuffix;
}
return result;
}
private string RedactEnvDigestString(string digest)
{
// Environment digest is typically already a hash, preserve it
return digest;
}
private static string ComputeShortHash(string value)
{
var bytes = Encoding.UTF8.GetBytes(value);
var hash = SHA256.HashData(bytes);
// Return first 8 characters of hex hash
return Convert.ToHexString(hash)[..8].ToLowerInvariant();
}
}
/// <summary>
/// No-op redaction guard for testing (preserves all data).
/// </summary>
public sealed class NoOpPackRunRedactionGuard : IPackRunRedactionGuard
{
public static NoOpPackRunRedactionGuard Instance { get; } = new();
private NoOpPackRunRedactionGuard() { }
public PackRunStepTranscript RedactTranscript(PackRunStepTranscript transcript) => transcript;
public PackRunApprovalEvidence RedactApproval(PackRunApprovalEvidence approval) => approval;
public PackRunEnvironmentDigest RedactEnvironment(PackRunEnvironmentDigest digest) => digest;
public string RedactIdentity(string identity) => identity;
public string RedactValue(string value) => value;
}

View File

@@ -1,360 +0,0 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.TaskRunner.Core.Evidence;
/// <summary>
/// Evidence snapshot for pack run execution.
/// Per TASKRUN-OBS-53-001.
/// </summary>
public sealed record PackRunEvidenceSnapshot(
/// <summary>Unique snapshot identifier.</summary>
Guid SnapshotId,
/// <summary>Tenant scope.</summary>
string TenantId,
/// <summary>Run ID this snapshot belongs to.</summary>
string RunId,
/// <summary>Plan hash that was executed.</summary>
string PlanHash,
/// <summary>When the snapshot was created.</summary>
DateTimeOffset CreatedAt,
/// <summary>Snapshot kind.</summary>
PackRunEvidenceSnapshotKind Kind,
/// <summary>Materials included in this snapshot.</summary>
IReadOnlyList<PackRunEvidenceMaterial> Materials,
/// <summary>Computed Merkle root hash of all materials.</summary>
string RootHash,
/// <summary>Snapshot metadata.</summary>
IReadOnlyDictionary<string, string>? Metadata)
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
/// <summary>
/// Creates a new snapshot with computed root hash.
/// </summary>
public static PackRunEvidenceSnapshot Create(
string tenantId,
string runId,
string planHash,
PackRunEvidenceSnapshotKind kind,
IReadOnlyList<PackRunEvidenceMaterial> materials,
IReadOnlyDictionary<string, string>? metadata = null)
{
var rootHash = ComputeMerkleRoot(materials);
return new PackRunEvidenceSnapshot(
SnapshotId: Guid.NewGuid(),
TenantId: tenantId,
RunId: runId,
PlanHash: planHash,
CreatedAt: DateTimeOffset.UtcNow,
Kind: kind,
Materials: materials,
RootHash: rootHash,
Metadata: metadata);
}
/// <summary>
/// Computes Merkle root from materials.
/// </summary>
private static string ComputeMerkleRoot(IReadOnlyList<PackRunEvidenceMaterial> materials)
{
if (materials.Count == 0)
{
// Empty root: 64 zeros
return "sha256:" + new string('0', 64);
}
// Sort materials by canonical path for determinism
var sorted = materials
.OrderBy(m => m.Section, StringComparer.Ordinal)
.ThenBy(m => m.Path, StringComparer.Ordinal)
.ToList();
// Build leaves from material hashes
var leaves = sorted.Select(m => m.Sha256).ToList();
// Compute Merkle root
while (leaves.Count > 1)
{
var nextLevel = new List<string>();
for (var i = 0; i < leaves.Count; i += 2)
{
if (i + 1 < leaves.Count)
{
nextLevel.Add(HashPair(leaves[i], leaves[i + 1]));
}
else
{
nextLevel.Add(HashPair(leaves[i], leaves[i]));
}
}
leaves = nextLevel;
}
return leaves[0];
}
private static string HashPair(string left, string right)
{
var combined = left + right;
var bytes = Encoding.UTF8.GetBytes(combined);
var hash = SHA256.HashData(bytes);
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
/// <summary>
/// Serializes to JSON.
/// </summary>
public string ToJson() => JsonSerializer.Serialize(this, JsonOptions);
/// <summary>
/// Deserializes from JSON.
/// </summary>
public static PackRunEvidenceSnapshot? FromJson(string json)
=> JsonSerializer.Deserialize<PackRunEvidenceSnapshot>(json, JsonOptions);
}
/// <summary>
/// Kind of pack run evidence snapshot.
/// </summary>
public enum PackRunEvidenceSnapshotKind
{
/// <summary>Run completion snapshot.</summary>
RunCompletion,
/// <summary>Step execution snapshot.</summary>
StepExecution,
/// <summary>Approval decision snapshot.</summary>
ApprovalDecision,
/// <summary>Policy evaluation snapshot.</summary>
PolicyEvaluation,
/// <summary>Artifact manifest snapshot.</summary>
ArtifactManifest,
/// <summary>Environment digest snapshot.</summary>
EnvironmentDigest,
/// <summary>Bundle import snapshot (TASKRUN-AIRGAP-58-001).</summary>
BundleImport
}
/// <summary>
/// Material included in evidence snapshot.
/// </summary>
public sealed record PackRunEvidenceMaterial(
/// <summary>Section (e.g., "transcript", "artifact", "policy").</summary>
string Section,
/// <summary>Path within section.</summary>
string Path,
/// <summary>SHA-256 digest of content.</summary>
string Sha256,
/// <summary>Size in bytes.</summary>
long SizeBytes,
/// <summary>Media type.</summary>
string MediaType,
/// <summary>Custom attributes.</summary>
IReadOnlyDictionary<string, string>? Attributes)
{
/// <summary>
/// Creates material from content bytes.
/// </summary>
public static PackRunEvidenceMaterial FromContent(
string section,
string path,
byte[] content,
string mediaType = "application/octet-stream",
IReadOnlyDictionary<string, string>? attributes = null)
{
var hash = SHA256.HashData(content);
var sha256 = $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
return new PackRunEvidenceMaterial(
Section: section,
Path: path,
Sha256: sha256,
SizeBytes: content.Length,
MediaType: mediaType,
Attributes: attributes);
}
/// <summary>
/// Creates material from string content.
/// </summary>
public static PackRunEvidenceMaterial FromString(
string section,
string path,
string content,
string mediaType = "text/plain",
IReadOnlyDictionary<string, string>? attributes = null)
{
return FromContent(section, path, Encoding.UTF8.GetBytes(content), mediaType, attributes);
}
/// <summary>
/// Creates material from JSON object.
/// </summary>
public static PackRunEvidenceMaterial FromJson<T>(
string section,
string path,
T obj,
IReadOnlyDictionary<string, string>? attributes = null)
{
var json = JsonSerializer.Serialize(obj, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
});
return FromString(section, path, json, "application/json", attributes);
}
/// <summary>
/// Canonical path for ordering.
/// </summary>
public string CanonicalPath => $"{Section}/{Path}";
}
/// <summary>
/// Step transcript for evidence capture.
/// </summary>
public sealed record PackRunStepTranscript(
/// <summary>Step identifier.</summary>
string StepId,
/// <summary>Step kind.</summary>
string Kind,
/// <summary>Execution start time.</summary>
DateTimeOffset StartedAt,
/// <summary>Execution end time.</summary>
DateTimeOffset? EndedAt,
/// <summary>Final status.</summary>
string Status,
/// <summary>Attempt number.</summary>
int Attempt,
/// <summary>Duration in milliseconds.</summary>
double? DurationMs,
/// <summary>Output (redacted if needed).</summary>
string? Output,
/// <summary>Error message (redacted if needed).</summary>
string? Error,
/// <summary>Environment variables digest.</summary>
string? EnvironmentDigest,
/// <summary>Artifacts produced.</summary>
IReadOnlyList<PackRunArtifactReference>? Artifacts);
/// <summary>
/// Reference to artifact in evidence.
/// </summary>
public sealed record PackRunArtifactReference(
/// <summary>Artifact name.</summary>
string Name,
/// <summary>SHA-256 digest.</summary>
string Sha256,
/// <summary>Size in bytes.</summary>
long SizeBytes,
/// <summary>Media type.</summary>
string MediaType);
/// <summary>
/// Approval record for evidence.
/// </summary>
public sealed record PackRunApprovalEvidence(
/// <summary>Approval identifier.</summary>
string ApprovalId,
/// <summary>Approver identity.</summary>
string Approver,
/// <summary>When approved.</summary>
DateTimeOffset ApprovedAt,
/// <summary>Approval decision.</summary>
string Decision,
/// <summary>Required grants.</summary>
IReadOnlyList<string> RequiredGrants,
/// <summary>Granted by.</summary>
IReadOnlyList<string>? GrantedBy,
/// <summary>Comments (redacted if needed).</summary>
string? Comments);
/// <summary>
/// Policy evaluation record for evidence.
/// </summary>
public sealed record PackRunPolicyEvidence(
/// <summary>Policy name.</summary>
string PolicyName,
/// <summary>Policy version.</summary>
string? PolicyVersion,
/// <summary>Evaluation result.</summary>
string Result,
/// <summary>When evaluated.</summary>
DateTimeOffset EvaluatedAt,
/// <summary>Evaluation duration in milliseconds.</summary>
double DurationMs,
/// <summary>Matched rules.</summary>
IReadOnlyList<string>? MatchedRules,
/// <summary>Policy digest for reproducibility.</summary>
string? PolicyDigest);
/// <summary>
/// Environment digest for evidence.
/// </summary>
public sealed record PackRunEnvironmentDigest(
/// <summary>When digest was computed.</summary>
DateTimeOffset ComputedAt,
/// <summary>Tool image digests (name -> sha256).</summary>
IReadOnlyDictionary<string, string> ToolImages,
/// <summary>Seed values (redacted).</summary>
IReadOnlyDictionary<string, string>? Seeds,
/// <summary>Environment variables (redacted).</summary>
IReadOnlyList<string>? EnvironmentVariableNames,
/// <summary>Combined digest of all inputs.</summary>
string InputsDigest);

View File

@@ -1,10 +0,0 @@
namespace StellaOps.TaskRunner.Core.Execution;
public interface IPackRunApprovalStore
{
Task SaveAsync(string runId, IReadOnlyList<PackRunApprovalState> approvals, CancellationToken cancellationToken);
Task<IReadOnlyList<PackRunApprovalState>> GetAsync(string runId, CancellationToken cancellationToken);
Task UpdateAsync(string runId, PackRunApprovalState approval, CancellationToken cancellationToken);
}

View File

@@ -1,16 +0,0 @@
namespace StellaOps.TaskRunner.Core.Execution;
public interface IPackRunArtifactReader
{
Task<IReadOnlyList<PackRunArtifactRecord>> ListAsync(string runId, CancellationToken cancellationToken);
}
public sealed record PackRunArtifactRecord(
string Name,
string Type,
string? SourcePath,
string? StoredPath,
string Status,
string? Notes,
DateTimeOffset CapturedAt,
string? ExpressionJson = null);

View File

@@ -1,12 +0,0 @@
using StellaOps.TaskRunner.Core.Planning;
namespace StellaOps.TaskRunner.Core.Execution;
public interface IPackRunArtifactUploader
{
Task UploadAsync(
PackRunExecutionContext context,
PackRunState state,
IReadOnlyList<TaskPackPlanOutput> outputs,
CancellationToken cancellationToken);
}

View File

@@ -1,6 +0,0 @@
namespace StellaOps.TaskRunner.Core.Execution;
public interface IPackRunJobDispatcher
{
Task<PackRunExecutionContext?> TryDequeueAsync(CancellationToken cancellationToken);
}

View File

@@ -1,6 +0,0 @@
namespace StellaOps.TaskRunner.Core.Execution;
public interface IPackRunJobScheduler
{
Task ScheduleAsync(PackRunExecutionContext context, CancellationToken cancellationToken);
}

View File

@@ -1,33 +0,0 @@
namespace StellaOps.TaskRunner.Core.Execution;
/// <summary>
/// Persists pack run log entries in a deterministic append-only fashion.
/// </summary>
public interface IPackRunLogStore
{
/// <summary>
/// Appends a single log entry to the run log.
/// </summary>
Task AppendAsync(string runId, PackRunLogEntry entry, CancellationToken cancellationToken);
/// <summary>
/// Returns the log entries for the specified run in chronological order.
/// </summary>
IAsyncEnumerable<PackRunLogEntry> ReadAsync(string runId, CancellationToken cancellationToken);
/// <summary>
/// Determines whether any log entries exist for the specified run.
/// </summary>
Task<bool> ExistsAsync(string runId, CancellationToken cancellationToken);
}
/// <summary>
/// Represents a single structured log entry emitted during a pack run.
/// </summary>
public sealed record PackRunLogEntry(
DateTimeOffset Timestamp,
string Level,
string EventType,
string Message,
string? StepId,
IReadOnlyDictionary<string, string>? Metadata);

View File

@@ -1,8 +0,0 @@
namespace StellaOps.TaskRunner.Core.Execution;
public interface IPackRunNotificationPublisher
{
Task PublishApprovalRequestedAsync(string runId, ApprovalNotification notification, CancellationToken cancellationToken);
Task PublishPolicyGatePendingAsync(string runId, PolicyGateNotification notification, CancellationToken cancellationToken);
}

View File

@@ -1,6 +0,0 @@
namespace StellaOps.TaskRunner.Core.Execution;
public interface IPackRunProvenanceWriter
{
Task WriteAsync(PackRunExecutionContext context, PackRunState state, CancellationToken cancellationToken);
}

View File

@@ -1,19 +0,0 @@
using StellaOps.TaskRunner.Core.Planning;
namespace StellaOps.TaskRunner.Core.Execution;
public interface IPackRunStepExecutor
{
Task<PackRunStepExecutionResult> ExecuteAsync(
PackRunExecutionStep step,
IReadOnlyDictionary<string, TaskPackPlanParameterValue> parameters,
CancellationToken cancellationToken);
}
public sealed record PackRunStepExecutionResult(bool Succeeded, string? Error = null)
{
public static PackRunStepExecutionResult Success() => new(true, null);
public static PackRunStepExecutionResult Failure(string error)
=> new(false, string.IsNullOrWhiteSpace(error) ? "Unknown error" : error);
}

View File

@@ -1,178 +0,0 @@
using StellaOps.TaskRunner.Core.Planning;
using System.Collections.Concurrent;
using System.Collections.Immutable;
namespace StellaOps.TaskRunner.Core.Execution;
public sealed class PackRunApprovalCoordinator
{
private readonly ConcurrentDictionary<string, PackRunApprovalState> approvals;
private readonly IReadOnlyDictionary<string, PackRunApprovalRequirement> requirements;
private PackRunApprovalCoordinator(
IReadOnlyDictionary<string, PackRunApprovalState> approvals,
IReadOnlyDictionary<string, PackRunApprovalRequirement> requirements)
{
this.approvals = new ConcurrentDictionary<string, PackRunApprovalState>(approvals);
this.requirements = requirements;
}
public static PackRunApprovalCoordinator Create(TaskPackPlan plan, DateTimeOffset requestTimestamp)
{
ArgumentNullException.ThrowIfNull(plan);
var requirements = TaskPackPlanInsights
.CollectApprovalRequirements(plan)
.ToDictionary(
requirement => requirement.ApprovalId,
requirement => new PackRunApprovalRequirement(
requirement.ApprovalId,
requirement.Grants.ToImmutableArray(),
requirement.StepIds.ToImmutableArray(),
requirement.Messages.ToImmutableArray(),
requirement.ReasonTemplate),
StringComparer.Ordinal);
var states = requirements.Values
.ToDictionary(
requirement => requirement.ApprovalId,
requirement => new PackRunApprovalState(
requirement.ApprovalId,
requirement.RequiredGrants,
requirement.StepIds,
requirement.Messages,
requirement.ReasonTemplate,
requestTimestamp,
PackRunApprovalStatus.Pending),
StringComparer.Ordinal);
return new PackRunApprovalCoordinator(states, requirements);
}
public static PackRunApprovalCoordinator Restore(TaskPackPlan plan, IReadOnlyList<PackRunApprovalState> existingStates, DateTimeOffset requestedAt)
{
ArgumentNullException.ThrowIfNull(plan);
ArgumentNullException.ThrowIfNull(existingStates);
var coordinator = Create(plan, requestedAt);
foreach (var state in existingStates)
{
coordinator.approvals[state.ApprovalId] = state;
}
return coordinator;
}
public IReadOnlyList<PackRunApprovalState> GetApprovals()
=> approvals.Values
.OrderBy(state => state.ApprovalId, StringComparer.Ordinal)
.ToImmutableArray();
public bool HasPendingApprovals => approvals.Values.Any(state => state.Status == PackRunApprovalStatus.Pending);
public ApprovalActionResult Approve(string approvalId, string actorId, DateTimeOffset completedAt, string? summary = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(approvalId);
ArgumentException.ThrowIfNullOrWhiteSpace(actorId);
var updated = approvals.AddOrUpdate(
approvalId,
static _ => throw new KeyNotFoundException("Unknown approval."),
(_, current) => current.Approve(actorId, completedAt, summary));
var shouldResume = approvals.Values.All(state => state.Status == PackRunApprovalStatus.Approved);
return new ApprovalActionResult(updated, shouldResume);
}
public ApprovalActionResult Reject(string approvalId, string actorId, DateTimeOffset completedAt, string? summary = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(approvalId);
ArgumentException.ThrowIfNullOrWhiteSpace(actorId);
var updated = approvals.AddOrUpdate(
approvalId,
static _ => throw new KeyNotFoundException("Unknown approval."),
(_, current) => current.Reject(actorId, completedAt, summary));
return new ApprovalActionResult(updated, false);
}
public ApprovalActionResult Expire(string approvalId, DateTimeOffset expiredAt, string? summary = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(approvalId);
var updated = approvals.AddOrUpdate(
approvalId,
static _ => throw new KeyNotFoundException("Unknown approval."),
(_, current) => current.Expire(expiredAt, summary));
return new ApprovalActionResult(updated, false);
}
public IReadOnlyList<ApprovalNotification> BuildNotifications(TaskPackPlan plan)
{
ArgumentNullException.ThrowIfNull(plan);
var hints = TaskPackPlanInsights.CollectApprovalRequirements(plan);
var notifications = new List<ApprovalNotification>(hints.Count);
foreach (var hint in hints)
{
if (!requirements.TryGetValue(hint.ApprovalId, out var requirement))
{
continue;
}
notifications.Add(new ApprovalNotification(
requirement.ApprovalId,
requirement.RequiredGrants,
requirement.Messages,
requirement.StepIds,
requirement.ReasonTemplate));
}
return notifications;
}
public IReadOnlyList<PolicyGateNotification> BuildPolicyNotifications(TaskPackPlan plan)
{
ArgumentNullException.ThrowIfNull(plan);
var policyHints = TaskPackPlanInsights.CollectPolicyGateHints(plan);
return policyHints
.Select(hint => new PolicyGateNotification(
hint.StepId,
hint.Message,
hint.Parameters.Select(parameter => new PolicyGateNotificationParameter(
parameter.Name,
parameter.RequiresRuntimeValue,
parameter.Expression,
parameter.Error)).ToImmutableArray()))
.ToImmutableArray();
}
}
public sealed record PackRunApprovalRequirement(
string ApprovalId,
IReadOnlyList<string> RequiredGrants,
IReadOnlyList<string> StepIds,
IReadOnlyList<string> Messages,
string? ReasonTemplate);
public sealed record ApprovalActionResult(PackRunApprovalState State, bool ShouldResumeRun);
public sealed record ApprovalNotification(
string ApprovalId,
IReadOnlyList<string> RequiredGrants,
IReadOnlyList<string> Messages,
IReadOnlyList<string> StepIds,
string? ReasonTemplate);
public sealed record PolicyGateNotification(string StepId, string? Message, IReadOnlyList<PolicyGateNotificationParameter> Parameters);
public sealed record PolicyGateNotificationParameter(
string Name,
bool RequiresRuntimeValue,
string? Expression,
string? Error);

View File

@@ -1,84 +0,0 @@
using System.Collections.Immutable;
namespace StellaOps.TaskRunner.Core.Execution;
public sealed class PackRunApprovalState
{
public PackRunApprovalState(
string approvalId,
IReadOnlyList<string> requiredGrants,
IReadOnlyList<string> stepIds,
IReadOnlyList<string> messages,
string? reasonTemplate,
DateTimeOffset requestedAt,
PackRunApprovalStatus status,
string? actorId = null,
DateTimeOffset? completedAt = null,
string? summary = null)
{
if (string.IsNullOrWhiteSpace(approvalId))
{
throw new ArgumentException("Approval id must not be empty.", nameof(approvalId));
}
ApprovalId = approvalId;
RequiredGrants = requiredGrants.ToImmutableArray();
StepIds = stepIds.ToImmutableArray();
Messages = messages.ToImmutableArray();
ReasonTemplate = reasonTemplate;
RequestedAt = requestedAt;
Status = status;
ActorId = actorId;
CompletedAt = completedAt;
Summary = summary;
}
public string ApprovalId { get; }
public IReadOnlyList<string> RequiredGrants { get; }
public IReadOnlyList<string> StepIds { get; }
public IReadOnlyList<string> Messages { get; }
public string? ReasonTemplate { get; }
public DateTimeOffset RequestedAt { get; }
public PackRunApprovalStatus Status { get; }
public string? ActorId { get; }
public DateTimeOffset? CompletedAt { get; }
public string? Summary { get; }
public PackRunApprovalState Approve(string actorId, DateTimeOffset completedAt, string? summary = null)
=> Transition(PackRunApprovalStatus.Approved, actorId, completedAt, summary);
public PackRunApprovalState Reject(string actorId, DateTimeOffset completedAt, string? summary = null)
=> Transition(PackRunApprovalStatus.Rejected, actorId, completedAt, summary);
public PackRunApprovalState Expire(DateTimeOffset expiredAt, string? summary = null)
=> Transition(PackRunApprovalStatus.Expired, actorId: null, expiredAt, summary);
private PackRunApprovalState Transition(PackRunApprovalStatus status, string? actorId, DateTimeOffset completedAt, string? summary)
{
if (Status != PackRunApprovalStatus.Pending)
{
throw new InvalidOperationException($"Approval '{ApprovalId}' is already {Status}.");
}
return new PackRunApprovalState(
ApprovalId,
RequiredGrants,
StepIds,
Messages,
ReasonTemplate,
RequestedAt,
status,
actorId,
completedAt,
summary);
}
}

View File

@@ -1,9 +0,0 @@
namespace StellaOps.TaskRunner.Core.Execution;
public enum PackRunApprovalStatus
{
Pending = 0,
Approved = 1,
Rejected = 2,
Expired = 3
}

View File

@@ -1,25 +0,0 @@
using StellaOps.TaskRunner.Core.Planning;
namespace StellaOps.TaskRunner.Core.Execution;
public sealed class PackRunExecutionContext
{
public PackRunExecutionContext(string runId, TaskPackPlan plan, DateTimeOffset requestedAt, string? tenantId = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
ArgumentNullException.ThrowIfNull(plan);
RunId = runId;
Plan = plan;
RequestedAt = requestedAt;
TenantId = string.IsNullOrWhiteSpace(tenantId) ? null : tenantId.Trim();
}
public string RunId { get; }
public TaskPackPlan Plan { get; }
public DateTimeOffset RequestedAt { get; }
public string? TenantId { get; }
}

View File

@@ -1,241 +0,0 @@
using StellaOps.TaskRunner.Core.Planning;
using System.Collections.ObjectModel;
namespace StellaOps.TaskRunner.Core.Execution;
public sealed class PackRunExecutionGraph
{
public static readonly TaskPackPlanFailurePolicy DefaultFailurePolicy = new(1, 0, ContinueOnError: false);
public PackRunExecutionGraph(IReadOnlyList<PackRunExecutionStep> steps, TaskPackPlanFailurePolicy? failurePolicy)
{
Steps = steps ?? throw new ArgumentNullException(nameof(steps));
FailurePolicy = failurePolicy ?? DefaultFailurePolicy;
}
public IReadOnlyList<PackRunExecutionStep> Steps { get; }
public TaskPackPlanFailurePolicy FailurePolicy { get; }
}
public enum PackRunStepKind
{
Unknown = 0,
Run,
GateApproval,
GatePolicy,
Parallel,
Map,
Loop,
Conditional
}
public sealed class PackRunExecutionStep
{
public PackRunExecutionStep(
string id,
string templateId,
PackRunStepKind kind,
bool enabled,
string? uses,
IReadOnlyDictionary<string, TaskPackPlanParameterValue> parameters,
string? approvalId,
string? gateMessage,
int? maxParallel,
bool continueOnError,
IReadOnlyList<PackRunExecutionStep> children,
PackRunLoopConfig? loopConfig = null,
PackRunConditionalConfig? conditionalConfig = null,
PackRunPolicyGateConfig? policyGateConfig = null)
{
Id = string.IsNullOrWhiteSpace(id) ? throw new ArgumentException("Value cannot be null or whitespace.", nameof(id)) : id;
TemplateId = string.IsNullOrWhiteSpace(templateId) ? throw new ArgumentException("Value cannot be null or whitespace.", nameof(templateId)) : templateId;
Kind = kind;
Enabled = enabled;
Uses = uses;
Parameters = parameters ?? throw new ArgumentNullException(nameof(parameters));
ApprovalId = approvalId;
GateMessage = gateMessage;
MaxParallel = maxParallel;
ContinueOnError = continueOnError;
Children = children ?? throw new ArgumentNullException(nameof(children));
LoopConfig = loopConfig;
ConditionalConfig = conditionalConfig;
PolicyGateConfig = policyGateConfig;
}
public string Id { get; }
public string TemplateId { get; }
public PackRunStepKind Kind { get; }
public bool Enabled { get; }
public string? Uses { get; }
public IReadOnlyDictionary<string, TaskPackPlanParameterValue> Parameters { get; }
public string? ApprovalId { get; }
public string? GateMessage { get; }
public int? MaxParallel { get; }
public bool ContinueOnError { get; }
public IReadOnlyList<PackRunExecutionStep> Children { get; }
/// <summary>Loop step configuration (when Kind == Loop).</summary>
public PackRunLoopConfig? LoopConfig { get; }
/// <summary>Conditional step configuration (when Kind == Conditional).</summary>
public PackRunConditionalConfig? ConditionalConfig { get; }
/// <summary>Policy gate configuration (when Kind == GatePolicy).</summary>
public PackRunPolicyGateConfig? PolicyGateConfig { get; }
public static IReadOnlyDictionary<string, TaskPackPlanParameterValue> EmptyParameters { get; } =
new ReadOnlyDictionary<string, TaskPackPlanParameterValue>(new Dictionary<string, TaskPackPlanParameterValue>(StringComparer.Ordinal));
public static IReadOnlyList<PackRunExecutionStep> EmptyChildren { get; } =
Array.Empty<PackRunExecutionStep>();
}
/// <summary>
/// Configuration for loop steps per taskpack-control-flow.schema.json.
/// </summary>
public sealed record PackRunLoopConfig(
/// <summary>Expression yielding items to iterate over.</summary>
string? ItemsExpression,
/// <summary>Static items array (alternative to expression).</summary>
IReadOnlyList<object>? StaticItems,
/// <summary>Range specification (alternative to expression).</summary>
PackRunLoopRange? Range,
/// <summary>Variable name bound to current item (default: "item").</summary>
string Iterator,
/// <summary>Variable name bound to current index (default: "index").</summary>
string Index,
/// <summary>Maximum iterations (safety limit).</summary>
int MaxIterations,
/// <summary>Aggregation mode for loop outputs.</summary>
PackRunLoopAggregationMode AggregationMode,
/// <summary>JMESPath to extract from each iteration result.</summary>
string? OutputPath)
{
public static PackRunLoopConfig Default => new(
null, null, null, "item", "index", 1000, PackRunLoopAggregationMode.Collect, null);
}
/// <summary>Range specification for loop iteration.</summary>
public sealed record PackRunLoopRange(int Start, int End, int Step = 1);
/// <summary>Loop output aggregation modes.</summary>
public enum PackRunLoopAggregationMode
{
/// <summary>Collect outputs into array.</summary>
Collect = 0,
/// <summary>Deep merge objects.</summary>
Merge,
/// <summary>Keep only last output.</summary>
Last,
/// <summary>Keep only first output.</summary>
First,
/// <summary>Discard outputs.</summary>
None
}
/// <summary>
/// Configuration for conditional steps per taskpack-control-flow.schema.json.
/// </summary>
public sealed record PackRunConditionalConfig(
/// <summary>Ordered branches (first matching executes).</summary>
IReadOnlyList<PackRunConditionalBranch> Branches,
/// <summary>Steps to execute if no branch matches.</summary>
IReadOnlyList<PackRunExecutionStep>? ElseBranch,
/// <summary>Whether to union outputs from all branches.</summary>
bool OutputUnion);
/// <summary>A conditional branch with condition and body.</summary>
public sealed record PackRunConditionalBranch(
/// <summary>Condition expression (JMESPath or operator-based).</summary>
string ConditionExpression,
/// <summary>Steps to execute if condition matches.</summary>
IReadOnlyList<PackRunExecutionStep> Body);
/// <summary>
/// Configuration for policy gate steps per taskpack-control-flow.schema.json.
/// </summary>
public sealed record PackRunPolicyGateConfig(
/// <summary>Policy identifier in the registry.</summary>
string PolicyId,
/// <summary>Specific policy version (semver).</summary>
string? PolicyVersion,
/// <summary>Policy digest for reproducibility.</summary>
string? PolicyDigest,
/// <summary>JMESPath expression to construct policy input.</summary>
string? InputExpression,
/// <summary>Timeout for policy evaluation.</summary>
TimeSpan Timeout,
/// <summary>What to do on policy failure.</summary>
PackRunPolicyFailureAction FailureAction,
/// <summary>Retry count on failure.</summary>
int RetryCount,
/// <summary>Delay between retries.</summary>
TimeSpan RetryDelay,
/// <summary>Override approvers (if action is RequestOverride).</summary>
IReadOnlyList<string>? OverrideApprovers,
/// <summary>Step ID to branch to (if action is Branch).</summary>
string? BranchTo,
/// <summary>Whether to record decision in evidence locker.</summary>
bool RecordDecision,
/// <summary>Whether to record policy input.</summary>
bool RecordInput,
/// <summary>Whether to record rationale.</summary>
bool RecordRationale,
/// <summary>Whether to create DSSE attestation.</summary>
bool CreateAttestation)
{
public static PackRunPolicyGateConfig Default(string policyId) => new(
policyId, null, null, null,
TimeSpan.FromMinutes(5),
PackRunPolicyFailureAction.Abort, 0, TimeSpan.FromSeconds(10),
null, null, true, false, true, false);
}
/// <summary>Policy gate failure actions.</summary>
public enum PackRunPolicyFailureAction
{
/// <summary>Abort the run.</summary>
Abort = 0,
/// <summary>Log warning and continue.</summary>
Warn,
/// <summary>Request override approval.</summary>
RequestOverride,
/// <summary>Branch to specified step.</summary>
Branch
}

View File

@@ -1,244 +0,0 @@
using StellaOps.TaskRunner.Core.Planning;
using System.Collections.ObjectModel;
using System.Text.Json.Nodes;
namespace StellaOps.TaskRunner.Core.Execution;
public sealed class PackRunExecutionGraphBuilder
{
public PackRunExecutionGraph Build(TaskPackPlan plan)
{
ArgumentNullException.ThrowIfNull(plan);
var steps = plan.Steps.Select(ConvertStep).ToList();
var failurePolicy = plan.FailurePolicy;
return new PackRunExecutionGraph(steps, failurePolicy);
}
private static PackRunExecutionStep ConvertStep(TaskPackPlanStep step)
{
var kind = DetermineKind(step.Type);
var parameters = step.Parameters is null
? PackRunExecutionStep.EmptyParameters
: new ReadOnlyDictionary<string, TaskPackPlanParameterValue>(
new Dictionary<string, TaskPackPlanParameterValue>(step.Parameters, StringComparer.Ordinal));
var children = step.Children is null
? PackRunExecutionStep.EmptyChildren
: step.Children.Select(ConvertStep).ToList();
var maxParallel = TryGetInt(parameters, "maxParallel");
var continueOnError = TryGetBool(parameters, "continueOnError");
// Extract type-specific configurations
var loopConfig = kind == PackRunStepKind.Loop ? ExtractLoopConfig(parameters, children) : null;
var conditionalConfig = kind == PackRunStepKind.Conditional ? ExtractConditionalConfig(parameters, children) : null;
var policyGateConfig = kind == PackRunStepKind.GatePolicy ? ExtractPolicyGateConfig(parameters, step) : null;
return new PackRunExecutionStep(
step.Id,
step.TemplateId,
kind,
step.Enabled,
step.Uses,
parameters,
step.ApprovalId,
step.GateMessage,
maxParallel,
continueOnError,
children,
loopConfig,
conditionalConfig,
policyGateConfig);
}
private static PackRunStepKind DetermineKind(string? type)
=> type switch
{
"run" => PackRunStepKind.Run,
"gate.approval" => PackRunStepKind.GateApproval,
"gate.policy" => PackRunStepKind.GatePolicy,
"parallel" => PackRunStepKind.Parallel,
"map" => PackRunStepKind.Map,
"loop" => PackRunStepKind.Loop,
"conditional" => PackRunStepKind.Conditional,
_ => PackRunStepKind.Unknown
};
private static PackRunLoopConfig ExtractLoopConfig(
IReadOnlyDictionary<string, TaskPackPlanParameterValue> parameters,
IReadOnlyList<PackRunExecutionStep> children)
{
var itemsExpression = TryGetString(parameters, "items");
var iterator = TryGetString(parameters, "iterator") ?? "item";
var index = TryGetString(parameters, "index") ?? "index";
var maxIterations = TryGetInt(parameters, "maxIterations") ?? 1000;
var aggregationMode = ParseAggregationMode(TryGetString(parameters, "aggregation"));
var outputPath = TryGetString(parameters, "outputPath");
// Parse range if present
PackRunLoopRange? range = null;
if (parameters.TryGetValue("range", out var rangeValue) && rangeValue.Value is JsonObject rangeObj)
{
var start = rangeObj["start"]?.GetValue<int>() ?? 0;
var end = rangeObj["end"]?.GetValue<int>() ?? 0;
var step = rangeObj["step"]?.GetValue<int>() ?? 1;
range = new PackRunLoopRange(start, end, step);
}
// Parse static items if present
IReadOnlyList<object>? staticItems = null;
if (parameters.TryGetValue("staticItems", out var staticValue) && staticValue.Value is JsonArray arr)
{
staticItems = arr.Select(n => (object)(n?.ToString() ?? "")).ToList();
}
return new PackRunLoopConfig(
itemsExpression, staticItems, range, iterator, index,
maxIterations, aggregationMode, outputPath);
}
private static PackRunConditionalConfig ExtractConditionalConfig(
IReadOnlyDictionary<string, TaskPackPlanParameterValue> parameters,
IReadOnlyList<PackRunExecutionStep> children)
{
var branches = new List<PackRunConditionalBranch>();
IReadOnlyList<PackRunExecutionStep>? elseBranch = null;
var outputUnion = TryGetBool(parameters, "outputUnion");
// Parse branches from parameters
if (parameters.TryGetValue("branches", out var branchesValue) && branchesValue.Value is JsonArray branchArray)
{
foreach (var branchNode in branchArray)
{
if (branchNode is not JsonObject branchObj) continue;
var condition = branchObj["condition"]?.ToString() ?? "true";
var bodySteps = new List<PackRunExecutionStep>();
// Body would be parsed from the plan's children structure
// For now, use empty body - actual body comes from step children
branches.Add(new PackRunConditionalBranch(condition, bodySteps));
}
}
// If no explicit branches parsed, treat children as the primary branch body
if (branches.Count == 0 && children.Count > 0)
{
branches.Add(new PackRunConditionalBranch("true", children));
}
return new PackRunConditionalConfig(branches, elseBranch, outputUnion);
}
private static PackRunPolicyGateConfig? ExtractPolicyGateConfig(
IReadOnlyDictionary<string, TaskPackPlanParameterValue> parameters,
TaskPackPlanStep step)
{
var policyId = TryGetString(parameters, "policyId") ?? TryGetString(parameters, "policy");
if (string.IsNullOrEmpty(policyId)) return null;
var policyVersion = TryGetString(parameters, "policyVersion");
var policyDigest = TryGetString(parameters, "policyDigest");
var inputExpression = TryGetString(parameters, "inputExpression");
var timeout = ParseTimeSpan(TryGetString(parameters, "timeout"), TimeSpan.FromMinutes(5));
var failureAction = ParsePolicyFailureAction(TryGetString(parameters, "failureAction"));
var retryCount = TryGetInt(parameters, "retryCount") ?? 0;
var retryDelay = ParseTimeSpan(TryGetString(parameters, "retryDelay"), TimeSpan.FromSeconds(10));
var recordDecision = TryGetBool(parameters, "recordDecision") || !parameters.ContainsKey("recordDecision");
var recordInput = TryGetBool(parameters, "recordInput");
var recordRationale = TryGetBool(parameters, "recordRationale") || !parameters.ContainsKey("recordRationale");
var createAttestation = TryGetBool(parameters, "attestation");
// Parse override approvers
IReadOnlyList<string>? overrideApprovers = null;
if (parameters.TryGetValue("overrideApprovers", out var approversValue) && approversValue.Value is JsonArray arr)
{
overrideApprovers = arr.Select(n => n?.ToString() ?? "").Where(s => !string.IsNullOrEmpty(s)).ToList();
}
var branchTo = TryGetString(parameters, "branchTo");
return new PackRunPolicyGateConfig(
policyId, policyVersion, policyDigest, inputExpression,
timeout, failureAction, retryCount, retryDelay,
overrideApprovers, branchTo,
recordDecision, recordInput, recordRationale, createAttestation);
}
private static PackRunLoopAggregationMode ParseAggregationMode(string? mode)
=> mode?.ToLowerInvariant() switch
{
"collect" => PackRunLoopAggregationMode.Collect,
"merge" => PackRunLoopAggregationMode.Merge,
"last" => PackRunLoopAggregationMode.Last,
"first" => PackRunLoopAggregationMode.First,
"none" => PackRunLoopAggregationMode.None,
_ => PackRunLoopAggregationMode.Collect
};
private static PackRunPolicyFailureAction ParsePolicyFailureAction(string? action)
=> action?.ToLowerInvariant() switch
{
"abort" => PackRunPolicyFailureAction.Abort,
"warn" => PackRunPolicyFailureAction.Warn,
"requestoverride" => PackRunPolicyFailureAction.RequestOverride,
"branch" => PackRunPolicyFailureAction.Branch,
_ => PackRunPolicyFailureAction.Abort
};
private static TimeSpan ParseTimeSpan(string? value, TimeSpan defaultValue)
{
if (string.IsNullOrEmpty(value)) return defaultValue;
// Parse formats like "30s", "5m", "1h"
if (value.Length < 2) return defaultValue;
var unit = value[^1];
if (!int.TryParse(value[..^1], out var number)) return defaultValue;
return unit switch
{
's' => TimeSpan.FromSeconds(number),
'm' => TimeSpan.FromMinutes(number),
'h' => TimeSpan.FromHours(number),
'd' => TimeSpan.FromDays(number),
_ => defaultValue
};
}
private static int? TryGetInt(IReadOnlyDictionary<string, TaskPackPlanParameterValue> parameters, string key)
{
if (!parameters.TryGetValue(key, out var value) || value.Value is not JsonValue jsonValue)
{
return null;
}
return jsonValue.TryGetValue<int>(out var result) ? result : null;
}
private static bool TryGetBool(IReadOnlyDictionary<string, TaskPackPlanParameterValue> parameters, string key)
{
if (!parameters.TryGetValue(key, out var value) || value.Value is not JsonValue jsonValue)
{
return false;
}
return jsonValue.TryGetValue<bool>(out var result) && result;
}
private static string? TryGetString(IReadOnlyDictionary<string, TaskPackPlanParameterValue> parameters, string key)
{
if (!parameters.TryGetValue(key, out var value))
{
return null;
}
return value.Value switch
{
JsonValue jsonValue when jsonValue.TryGetValue<string>(out var str) => str,
_ => value.Value?.ToString()
};
}
}

View File

@@ -1,159 +0,0 @@
using System.Collections.ObjectModel;
using System.Linq;
namespace StellaOps.TaskRunner.Core.Execution;
public static class PackRunGateStateUpdater
{
public static PackRunGateStateUpdateResult Apply(
PackRunState state,
PackRunExecutionGraph graph,
PackRunApprovalCoordinator coordinator,
DateTimeOffset timestamp)
{
ArgumentNullException.ThrowIfNull(state);
ArgumentNullException.ThrowIfNull(graph);
ArgumentNullException.ThrowIfNull(coordinator);
var approvals = coordinator.GetApprovals()
.SelectMany(approval => approval.StepIds.Select(stepId => (stepId, approval)))
.GroupBy(tuple => tuple.stepId, StringComparer.Ordinal)
.ToDictionary(
group => group.Key,
group => group.First().approval,
StringComparer.Ordinal);
var mutable = new Dictionary<string, PackRunStepStateRecord>(state.Steps, StringComparer.Ordinal);
var changed = false;
var hasBlockingFailure = false;
foreach (var step in EnumerateSteps(graph.Steps))
{
if (!mutable.TryGetValue(step.Id, out var record))
{
continue;
}
switch (step.Kind)
{
case PackRunStepKind.GateApproval:
if (!approvals.TryGetValue(step.Id, out var approvalState))
{
continue;
}
switch (approvalState.Status)
{
case PackRunApprovalStatus.Pending:
break;
case PackRunApprovalStatus.Approved:
if (record.Status != PackRunStepExecutionStatus.Succeeded || record.StatusReason is not null)
{
mutable[step.Id] = record with
{
Status = PackRunStepExecutionStatus.Succeeded,
StatusReason = null,
LastTransitionAt = timestamp,
NextAttemptAt = null
};
changed = true;
}
break;
case PackRunApprovalStatus.Rejected:
case PackRunApprovalStatus.Expired:
var failureReason = BuildFailureReason(approvalState);
if (record.Status != PackRunStepExecutionStatus.Failed ||
!string.Equals(record.StatusReason, failureReason, StringComparison.Ordinal))
{
mutable[step.Id] = record with
{
Status = PackRunStepExecutionStatus.Failed,
StatusReason = failureReason,
LastTransitionAt = timestamp,
NextAttemptAt = null
};
changed = true;
}
hasBlockingFailure = true;
break;
}
break;
case PackRunStepKind.GatePolicy:
if (record.Status == PackRunStepExecutionStatus.Pending &&
string.Equals(record.StatusReason, "requires-policy", StringComparison.Ordinal))
{
mutable[step.Id] = record with
{
Status = PackRunStepExecutionStatus.Succeeded,
StatusReason = null,
LastTransitionAt = timestamp,
NextAttemptAt = null
};
changed = true;
}
break;
}
}
if (!changed)
{
return new PackRunGateStateUpdateResult(state, hasBlockingFailure);
}
var updatedState = state with
{
UpdatedAt = timestamp,
Steps = new ReadOnlyDictionary<string, PackRunStepStateRecord>(mutable)
};
return new PackRunGateStateUpdateResult(updatedState, hasBlockingFailure);
}
private static IEnumerable<PackRunExecutionStep> EnumerateSteps(IReadOnlyList<PackRunExecutionStep> steps)
{
if (steps.Count == 0)
{
yield break;
}
foreach (var step in steps)
{
yield return step;
if (step.Children.Count > 0)
{
foreach (var child in EnumerateSteps(step.Children))
{
yield return child;
}
}
}
}
private static string BuildFailureReason(PackRunApprovalState state)
{
var baseReason = state.Status switch
{
PackRunApprovalStatus.Rejected => "approval-rejected",
PackRunApprovalStatus.Expired => "approval-expired",
_ => "approval-invalid"
};
if (string.IsNullOrWhiteSpace(state.Summary))
{
return baseReason;
}
var summary = state.Summary.Trim();
return $"{baseReason}:{summary}";
}
}
public readonly record struct PackRunGateStateUpdateResult(PackRunState State, bool HasBlockingFailure);

View File

@@ -1,84 +0,0 @@
using Microsoft.Extensions.Logging;
namespace StellaOps.TaskRunner.Core.Execution;
public sealed class PackRunProcessor
{
private readonly IPackRunApprovalStore approvalStore;
private readonly IPackRunNotificationPublisher notificationPublisher;
private readonly ILogger<PackRunProcessor> logger;
public PackRunProcessor(
IPackRunApprovalStore approvalStore,
IPackRunNotificationPublisher notificationPublisher,
ILogger<PackRunProcessor> logger)
{
this.approvalStore = approvalStore ?? throw new ArgumentNullException(nameof(approvalStore));
this.notificationPublisher = notificationPublisher ?? throw new ArgumentNullException(nameof(notificationPublisher));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<PackRunProcessorResult> ProcessNewRunAsync(PackRunExecutionContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
var existing = await approvalStore.GetAsync(context.RunId, cancellationToken).ConfigureAwait(false);
PackRunApprovalCoordinator coordinator;
bool shouldResume;
if (existing.Count > 0)
{
coordinator = PackRunApprovalCoordinator.Restore(context.Plan, existing, context.RequestedAt);
shouldResume = !coordinator.HasPendingApprovals;
logger.LogInformation("Run {RunId} approvals restored (pending: {Pending}).", context.RunId, coordinator.HasPendingApprovals);
}
else
{
coordinator = PackRunApprovalCoordinator.Create(context.Plan, context.RequestedAt);
await approvalStore.SaveAsync(context.RunId, coordinator.GetApprovals(), cancellationToken).ConfigureAwait(false);
var approvalNotifications = coordinator.BuildNotifications(context.Plan);
foreach (var notification in approvalNotifications)
{
await notificationPublisher.PublishApprovalRequestedAsync(context.RunId, notification, cancellationToken).ConfigureAwait(false);
logger.LogInformation(
"Approval requested for run {RunId} gate {ApprovalId} requiring grants {Grants}.",
context.RunId,
notification.ApprovalId,
string.Join(",", notification.RequiredGrants));
}
var policyNotifications = coordinator.BuildPolicyNotifications(context.Plan);
foreach (var notification in policyNotifications)
{
await notificationPublisher.PublishPolicyGatePendingAsync(context.RunId, notification, cancellationToken).ConfigureAwait(false);
logger.LogDebug(
"Policy gate pending for run {RunId} step {StepId}.",
context.RunId,
notification.StepId);
}
shouldResume = !coordinator.HasPendingApprovals;
}
if (shouldResume)
{
logger.LogInformation("Run {RunId} has no approvals; proceeding immediately.", context.RunId);
}
return new PackRunProcessorResult(coordinator, shouldResume);
}
public async Task<PackRunApprovalCoordinator> RestoreAsync(PackRunExecutionContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
var states = await approvalStore.GetAsync(context.RunId, cancellationToken).ConfigureAwait(false);
if (states.Count == 0)
{
return PackRunApprovalCoordinator.Create(context.Plan, context.RequestedAt);
}
return PackRunApprovalCoordinator.Restore(context.Plan, states, context.RequestedAt);
}
}

View File

@@ -1,5 +0,0 @@
namespace StellaOps.TaskRunner.Core.Execution;
public sealed record PackRunProcessorResult(
PackRunApprovalCoordinator ApprovalCoordinator,
bool ShouldResumeImmediately);

View File

@@ -1,60 +0,0 @@
using StellaOps.TaskRunner.Core.Planning;
using System.Collections.ObjectModel;
namespace StellaOps.TaskRunner.Core.Execution;
public sealed record PackRunState(
string RunId,
string PlanHash,
TaskPackPlan Plan,
TaskPackPlanFailurePolicy FailurePolicy,
DateTimeOffset RequestedAt,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt,
IReadOnlyDictionary<string, PackRunStepStateRecord> Steps,
string? TenantId = null)
{
public static PackRunState Create(
string runId,
string planHash,
TaskPackPlan plan,
TaskPackPlanFailurePolicy failurePolicy,
DateTimeOffset requestedAt,
IReadOnlyDictionary<string, PackRunStepStateRecord> steps,
DateTimeOffset timestamp,
string? tenantId = null)
=> new(
runId,
planHash,
plan,
failurePolicy,
requestedAt,
timestamp,
timestamp,
new ReadOnlyDictionary<string, PackRunStepStateRecord>(new Dictionary<string, PackRunStepStateRecord>(steps, StringComparer.Ordinal)),
tenantId);
}
public sealed record PackRunStepStateRecord(
string StepId,
PackRunStepKind Kind,
bool Enabled,
bool ContinueOnError,
int? MaxParallel,
string? ApprovalId,
string? GateMessage,
PackRunStepExecutionStatus Status,
int Attempts,
DateTimeOffset? LastTransitionAt,
DateTimeOffset? NextAttemptAt,
string? StatusReason);
public interface IPackRunStateStore
{
Task<PackRunState?> GetAsync(string runId, CancellationToken cancellationToken);
Task SaveAsync(PackRunState state, CancellationToken cancellationToken);
Task<IReadOnlyList<PackRunState>> ListAsync(CancellationToken cancellationToken);
}

View File

@@ -1,117 +0,0 @@
using StellaOps.TaskRunner.Core.Execution.Simulation;
namespace StellaOps.TaskRunner.Core.Execution;
/// <summary>
/// Builds deterministic <see cref="PackRunState"/> snapshots for freshly scheduled runs.
/// </summary>
public static class PackRunStateFactory
{
public static PackRunState CreateInitialState(
PackRunExecutionContext context,
PackRunExecutionGraph graph,
PackRunSimulationEngine simulationEngine,
DateTimeOffset timestamp)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(graph);
ArgumentNullException.ThrowIfNull(simulationEngine);
var simulation = simulationEngine.Simulate(context.Plan);
var simulationIndex = IndexSimulation(simulation.Steps);
var stepRecords = new Dictionary<string, PackRunStepStateRecord>(StringComparer.Ordinal);
foreach (var step in EnumerateSteps(graph.Steps))
{
var simulationStatus = simulationIndex.TryGetValue(step.Id, out var node)
? node.Status
: PackRunSimulationStatus.Pending;
var status = step.Enabled ? PackRunStepExecutionStatus.Pending : PackRunStepExecutionStatus.Skipped;
string? statusReason = null;
if (!step.Enabled)
{
statusReason = "disabled";
}
else if (simulationStatus == PackRunSimulationStatus.RequiresApproval)
{
statusReason = "requires-approval";
}
else if (simulationStatus == PackRunSimulationStatus.RequiresPolicy)
{
statusReason = "requires-policy";
}
else if (simulationStatus == PackRunSimulationStatus.Skipped)
{
status = PackRunStepExecutionStatus.Skipped;
statusReason = "condition-false";
}
var record = new PackRunStepStateRecord(
step.Id,
step.Kind,
step.Enabled,
step.ContinueOnError,
step.MaxParallel,
step.ApprovalId,
step.GateMessage,
status,
Attempts: 0,
LastTransitionAt: null,
NextAttemptAt: null,
StatusReason: statusReason);
stepRecords[step.Id] = record;
}
var failurePolicy = graph.FailurePolicy ?? PackRunExecutionGraph.DefaultFailurePolicy;
return PackRunState.Create(
context.RunId,
context.Plan.Hash,
context.Plan,
failurePolicy,
context.RequestedAt,
stepRecords,
timestamp,
context.TenantId);
}
private static Dictionary<string, PackRunSimulationNode> IndexSimulation(IReadOnlyList<PackRunSimulationNode> nodes)
{
var result = new Dictionary<string, PackRunSimulationNode>(StringComparer.Ordinal);
foreach (var node in nodes)
{
IndexSimulationNode(node, result);
}
return result;
}
private static void IndexSimulationNode(PackRunSimulationNode node, Dictionary<string, PackRunSimulationNode> accumulator)
{
accumulator[node.Id] = node;
foreach (var child in node.Children)
{
IndexSimulationNode(child, accumulator);
}
}
private static IEnumerable<PackRunExecutionStep> EnumerateSteps(IReadOnlyList<PackRunExecutionStep> steps)
{
foreach (var step in steps)
{
yield return step;
if (step.Children.Count == 0)
{
continue;
}
foreach (var child in EnumerateSteps(step.Children))
{
yield return child;
}
}
}
}

View File

@@ -1,121 +0,0 @@
using StellaOps.TaskRunner.Core.Planning;
namespace StellaOps.TaskRunner.Core.Execution;
public static class PackRunStepStateMachine
{
public static PackRunStepState Create(DateTimeOffset? createdAt = null)
=> new(PackRunStepExecutionStatus.Pending, Attempts: 0, createdAt, NextAttemptAt: null);
public static PackRunStepState Start(PackRunStepState state, DateTimeOffset startedAt)
{
ArgumentNullException.ThrowIfNull(state);
if (state.Status is not PackRunStepExecutionStatus.Pending)
{
throw new InvalidOperationException($"Cannot start step from status {state.Status}.");
}
return state with
{
Status = PackRunStepExecutionStatus.Running,
LastTransitionAt = startedAt,
NextAttemptAt = null
};
}
public static PackRunStepState CompleteSuccess(PackRunStepState state, DateTimeOffset completedAt)
{
ArgumentNullException.ThrowIfNull(state);
if (state.Status is not PackRunStepExecutionStatus.Running)
{
throw new InvalidOperationException($"Cannot complete step from status {state.Status}.");
}
return state with
{
Status = PackRunStepExecutionStatus.Succeeded,
Attempts = state.Attempts + 1,
LastTransitionAt = completedAt,
NextAttemptAt = null
};
}
public static PackRunStepFailureResult RegisterFailure(
PackRunStepState state,
DateTimeOffset failedAt,
TaskPackPlanFailurePolicy failurePolicy)
{
ArgumentNullException.ThrowIfNull(state);
ArgumentNullException.ThrowIfNull(failurePolicy);
if (state.Status is not PackRunStepExecutionStatus.Running)
{
throw new InvalidOperationException($"Cannot register failure from status {state.Status}.");
}
var attempts = state.Attempts + 1;
if (attempts < failurePolicy.MaxAttempts)
{
var backoff = TimeSpan.FromSeconds(Math.Max(0, failurePolicy.BackoffSeconds));
var nextAttemptAt = failedAt + backoff;
var nextState = state with
{
Status = PackRunStepExecutionStatus.Pending,
Attempts = attempts,
LastTransitionAt = failedAt,
NextAttemptAt = nextAttemptAt
};
return new PackRunStepFailureResult(nextState, PackRunStepFailureOutcome.Retry);
}
var finalState = state with
{
Status = PackRunStepExecutionStatus.Failed,
Attempts = attempts,
LastTransitionAt = failedAt,
NextAttemptAt = null
};
return new PackRunStepFailureResult(finalState, PackRunStepFailureOutcome.Abort);
}
public static PackRunStepState Skip(PackRunStepState state, DateTimeOffset skippedAt)
{
ArgumentNullException.ThrowIfNull(state);
if (state.Status is not PackRunStepExecutionStatus.Pending)
{
throw new InvalidOperationException($"Cannot skip step from status {state.Status}.");
}
return state with
{
Status = PackRunStepExecutionStatus.Skipped,
LastTransitionAt = skippedAt,
NextAttemptAt = null
};
}
}
public sealed record PackRunStepState(
PackRunStepExecutionStatus Status,
int Attempts,
DateTimeOffset? LastTransitionAt,
DateTimeOffset? NextAttemptAt);
public enum PackRunStepExecutionStatus
{
Pending = 0,
Running,
Succeeded,
Failed,
Skipped
}
public readonly record struct PackRunStepFailureResult(PackRunStepState State, PackRunStepFailureOutcome Outcome);
public enum PackRunStepFailureOutcome
{
Retry = 0,
Abort
}

View File

@@ -1,65 +0,0 @@
using StellaOps.TaskRunner.Core.Planning;
namespace StellaOps.TaskRunner.Core.Execution;
public static class ProvenanceManifestFactory
{
public static ProvenanceManifest Create(PackRunExecutionContext context, PackRunState state, DateTimeOffset completedAt)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(state);
var steps = state.Steps.Values
.OrderBy(step => step.StepId, StringComparer.Ordinal)
.Select(step => new ProvenanceStep(
step.StepId,
step.Kind.ToString(),
step.Status.ToString(),
step.Attempts,
step.LastTransitionAt,
step.StatusReason))
.ToList();
var outputs = context.Plan.Outputs
.Select(output => new ProvenanceOutput(output.Name, output.Type))
.ToList();
return new ProvenanceManifest(
context.RunId,
context.TenantId,
context.Plan.Hash,
context.Plan.Metadata.Name,
context.Plan.Metadata.Version,
context.Plan.Metadata.Description,
context.Plan.Metadata.Tags,
context.RequestedAt,
state.CreatedAt,
completedAt,
steps,
outputs);
}
}
public sealed record ProvenanceManifest(
string RunId,
string? TenantId,
string PlanHash,
string PackName,
string PackVersion,
string? PackDescription,
IReadOnlyList<string> PackTags,
DateTimeOffset RequestedAt,
DateTimeOffset CreatedAt,
DateTimeOffset CompletedAt,
IReadOnlyList<ProvenanceStep> Steps,
IReadOnlyList<ProvenanceOutput> Outputs);
public sealed record ProvenanceStep(
string Id,
string Kind,
string Status,
int Attempts,
DateTimeOffset? LastTransitionAt,
string? StatusReason);
public sealed record ProvenanceOutput(string Name, string Type);

View File

@@ -1,110 +0,0 @@
using StellaOps.TaskRunner.Core.Planning;
using System.Collections.ObjectModel;
namespace StellaOps.TaskRunner.Core.Execution.Simulation;
public sealed class PackRunSimulationEngine
{
private readonly PackRunExecutionGraphBuilder graphBuilder;
public PackRunSimulationEngine()
{
graphBuilder = new PackRunExecutionGraphBuilder();
}
public PackRunSimulationResult Simulate(TaskPackPlan plan)
{
ArgumentNullException.ThrowIfNull(plan);
var graph = graphBuilder.Build(plan);
var steps = graph.Steps.Select(ConvertStep).ToList();
var outputs = BuildOutputs(plan.Outputs);
return new PackRunSimulationResult(steps, outputs, graph.FailurePolicy);
}
private static PackRunSimulationNode ConvertStep(PackRunExecutionStep step)
{
var status = DetermineStatus(step);
var children = step.Children.Count == 0
? PackRunSimulationNode.Empty
: new ReadOnlyCollection<PackRunSimulationNode>(step.Children.Select(ConvertStep).ToList());
// Extract loop/conditional specific details
var loopInfo = step.Kind == PackRunStepKind.Loop && step.LoopConfig is not null
? new PackRunSimulationLoopInfo(
step.LoopConfig.ItemsExpression,
step.LoopConfig.Iterator,
step.LoopConfig.Index,
step.LoopConfig.MaxIterations,
step.LoopConfig.AggregationMode.ToString().ToLowerInvariant())
: null;
var conditionalInfo = step.Kind == PackRunStepKind.Conditional && step.ConditionalConfig is not null
? new PackRunSimulationConditionalInfo(
step.ConditionalConfig.Branches.Select(b =>
new PackRunSimulationBranch(b.ConditionExpression, b.Body.Count)).ToList(),
step.ConditionalConfig.ElseBranch?.Count ?? 0,
step.ConditionalConfig.OutputUnion)
: null;
var policyInfo = step.Kind == PackRunStepKind.GatePolicy && step.PolicyGateConfig is not null
? new PackRunSimulationPolicyInfo(
step.PolicyGateConfig.PolicyId,
step.PolicyGateConfig.PolicyVersion,
step.PolicyGateConfig.FailureAction.ToString().ToLowerInvariant(),
step.PolicyGateConfig.RetryCount)
: null;
return new PackRunSimulationNode(
step.Id,
step.TemplateId,
step.Kind,
step.Enabled,
step.Uses,
step.ApprovalId,
step.GateMessage,
step.Parameters,
step.MaxParallel,
step.ContinueOnError,
status,
children,
loopInfo,
conditionalInfo,
policyInfo);
}
private static PackRunSimulationStatus DetermineStatus(PackRunExecutionStep step)
{
if (!step.Enabled)
{
return PackRunSimulationStatus.Skipped;
}
return step.Kind switch
{
PackRunStepKind.GateApproval => PackRunSimulationStatus.RequiresApproval,
PackRunStepKind.GatePolicy => PackRunSimulationStatus.RequiresPolicy,
PackRunStepKind.Loop => PackRunSimulationStatus.WillIterate,
PackRunStepKind.Conditional => PackRunSimulationStatus.WillBranch,
_ => PackRunSimulationStatus.Pending
};
}
private static IReadOnlyList<PackRunSimulationOutput> BuildOutputs(IReadOnlyList<TaskPackPlanOutput> outputs)
{
if (outputs.Count == 0)
{
return PackRunSimulationOutput.Empty;
}
var list = new List<PackRunSimulationOutput>(outputs.Count);
foreach (var output in outputs)
{
list.Add(new PackRunSimulationOutput(output.Name, output.Type, output.Path, output.Expression));
}
return new ReadOnlyCollection<PackRunSimulationOutput>(list);
}
}

View File

@@ -1,191 +0,0 @@
using StellaOps.TaskRunner.Core.Planning;
using System.Collections.ObjectModel;
namespace StellaOps.TaskRunner.Core.Execution.Simulation;
public sealed class PackRunSimulationResult
{
public PackRunSimulationResult(
IReadOnlyList<PackRunSimulationNode> steps,
IReadOnlyList<PackRunSimulationOutput> outputs,
TaskPackPlanFailurePolicy failurePolicy)
{
Steps = steps ?? throw new ArgumentNullException(nameof(steps));
Outputs = outputs ?? throw new ArgumentNullException(nameof(outputs));
FailurePolicy = failurePolicy ?? throw new ArgumentNullException(nameof(failurePolicy));
}
public IReadOnlyList<PackRunSimulationNode> Steps { get; }
public IReadOnlyList<PackRunSimulationOutput> Outputs { get; }
public TaskPackPlanFailurePolicy FailurePolicy { get; }
public bool HasPendingApprovals => Steps.Any(ContainsApprovalRequirement);
private static bool ContainsApprovalRequirement(PackRunSimulationNode node)
{
if (node.Status is PackRunSimulationStatus.RequiresApproval or PackRunSimulationStatus.RequiresPolicy)
{
return true;
}
return node.Children.Any(ContainsApprovalRequirement);
}
}
public sealed class PackRunSimulationNode
{
public PackRunSimulationNode(
string id,
string templateId,
PackRunStepKind kind,
bool enabled,
string? uses,
string? approvalId,
string? gateMessage,
IReadOnlyDictionary<string, TaskPackPlanParameterValue> parameters,
int? maxParallel,
bool continueOnError,
PackRunSimulationStatus status,
IReadOnlyList<PackRunSimulationNode> children,
PackRunSimulationLoopInfo? loopInfo = null,
PackRunSimulationConditionalInfo? conditionalInfo = null,
PackRunSimulationPolicyInfo? policyInfo = null)
{
Id = string.IsNullOrWhiteSpace(id) ? throw new ArgumentException("Value cannot be null or whitespace.", nameof(id)) : id;
TemplateId = string.IsNullOrWhiteSpace(templateId) ? throw new ArgumentException("Value cannot be null or whitespace.", nameof(templateId)) : templateId;
Kind = kind;
Enabled = enabled;
Uses = uses;
ApprovalId = approvalId;
GateMessage = gateMessage;
Parameters = parameters ?? throw new ArgumentNullException(nameof(parameters));
MaxParallel = maxParallel;
ContinueOnError = continueOnError;
Status = status;
Children = children ?? throw new ArgumentNullException(nameof(children));
LoopInfo = loopInfo;
ConditionalInfo = conditionalInfo;
PolicyInfo = policyInfo;
}
public string Id { get; }
public string TemplateId { get; }
public PackRunStepKind Kind { get; }
public bool Enabled { get; }
public string? Uses { get; }
public string? ApprovalId { get; }
public string? GateMessage { get; }
public IReadOnlyDictionary<string, TaskPackPlanParameterValue> Parameters { get; }
public int? MaxParallel { get; }
public bool ContinueOnError { get; }
public PackRunSimulationStatus Status { get; }
public IReadOnlyList<PackRunSimulationNode> Children { get; }
/// <summary>Loop step simulation info (when Kind == Loop).</summary>
public PackRunSimulationLoopInfo? LoopInfo { get; }
/// <summary>Conditional step simulation info (when Kind == Conditional).</summary>
public PackRunSimulationConditionalInfo? ConditionalInfo { get; }
/// <summary>Policy gate simulation info (when Kind == GatePolicy).</summary>
public PackRunSimulationPolicyInfo? PolicyInfo { get; }
public static IReadOnlyList<PackRunSimulationNode> Empty { get; } =
new ReadOnlyCollection<PackRunSimulationNode>(Array.Empty<PackRunSimulationNode>());
}
public enum PackRunSimulationStatus
{
Pending = 0,
Skipped,
RequiresApproval,
RequiresPolicy,
/// <summary>Loop step will iterate over items.</summary>
WillIterate,
/// <summary>Conditional step will branch based on conditions.</summary>
WillBranch
}
/// <summary>Loop step simulation details.</summary>
public sealed record PackRunSimulationLoopInfo(
/// <summary>Items expression to iterate over.</summary>
string? ItemsExpression,
/// <summary>Iterator variable name.</summary>
string Iterator,
/// <summary>Index variable name.</summary>
string Index,
/// <summary>Maximum iterations allowed.</summary>
int MaxIterations,
/// <summary>Aggregation mode for outputs.</summary>
string AggregationMode);
/// <summary>Conditional step simulation details.</summary>
public sealed record PackRunSimulationConditionalInfo(
/// <summary>Branch conditions and body step counts.</summary>
IReadOnlyList<PackRunSimulationBranch> Branches,
/// <summary>Number of steps in else branch.</summary>
int ElseStepCount,
/// <summary>Whether outputs are unioned.</summary>
bool OutputUnion);
/// <summary>A conditional branch summary.</summary>
public sealed record PackRunSimulationBranch(
/// <summary>Condition expression.</summary>
string Condition,
/// <summary>Number of steps in body.</summary>
int StepCount);
/// <summary>Policy gate simulation details.</summary>
public sealed record PackRunSimulationPolicyInfo(
/// <summary>Policy identifier.</summary>
string PolicyId,
/// <summary>Policy version (if specified).</summary>
string? PolicyVersion,
/// <summary>Failure action.</summary>
string FailureAction,
/// <summary>Retry count on failure.</summary>
int RetryCount);
public sealed class PackRunSimulationOutput
{
public PackRunSimulationOutput(
string name,
string type,
TaskPackPlanParameterValue? path,
TaskPackPlanParameterValue? expression)
{
Name = string.IsNullOrWhiteSpace(name) ? throw new ArgumentException("Value cannot be null or whitespace.", nameof(name)) : name;
Type = string.IsNullOrWhiteSpace(type) ? throw new ArgumentException("Value cannot be null or whitespace.", nameof(type)) : type;
Path = path;
Expression = expression;
}
public string Name { get; }
public string Type { get; }
public TaskPackPlanParameterValue? Path { get; }
public TaskPackPlanParameterValue? Expression { get; }
public bool RequiresRuntimeValue =>
(Path?.RequiresRuntimeValue ?? false) ||
(Expression?.RequiresRuntimeValue ?? false);
public static IReadOnlyList<PackRunSimulationOutput> Empty { get; } =
new ReadOnlyCollection<PackRunSimulationOutput>(Array.Empty<PackRunSimulationOutput>());
}

View File

@@ -1,16 +0,0 @@
using System.Diagnostics.Metrics;
namespace StellaOps.TaskRunner.Core.Execution;
public static class TaskRunnerTelemetry
{
public const string MeterName = "stellaops.taskrunner";
public static readonly Meter Meter = new(MeterName);
public static readonly Histogram<double> StepDurationMs =
Meter.CreateHistogram<double>("taskrunner.step.duration.ms", unit: "ms");
public static readonly Counter<long> StepRetryCount =
Meter.CreateCounter<long>("taskrunner.step.retry.count");
public static readonly UpDownCounter<long> RunningSteps =
Meter.CreateUpDownCounter<long>("taskrunner.steps.running");
}

View File

@@ -1,596 +0,0 @@
using System.Collections.Immutable;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
namespace StellaOps.TaskRunner.Core.Expressions;
internal static class TaskPackExpressions
{
private static readonly Regex ExpressionPattern = new("^\\s*\\{\\{(.+)\\}\\}\\s*$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex ComparisonPattern = new("^(?<left>.+?)\\s*(?<op>==|!=)\\s*(?<right>.+)$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex InPattern = new("^(?<left>.+?)\\s+in\\s+(?<right>.+)$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
public static bool TryEvaluateBoolean(string? candidate, TaskPackExpressionContext context, out bool value, out string? error)
{
value = false;
error = null;
if (string.IsNullOrWhiteSpace(candidate))
{
value = true;
return true;
}
if (!TryExtractExpression(candidate, out var expression))
{
return TryParseBooleanLiteral(candidate.Trim(), out value, out error);
}
expression = expression.Trim();
return TryEvaluateBooleanInternal(expression, context, out value, out error);
}
public static TaskPackValueResolution EvaluateValue(JsonNode? node, TaskPackExpressionContext context)
{
if (node is null)
{
return TaskPackValueResolution.FromValue(null);
}
if (node is JsonValue valueNode && valueNode.TryGetValue(out string? stringValue))
{
if (!TryExtractExpression(stringValue, out var expression))
{
return TaskPackValueResolution.FromValue(valueNode);
}
var trimmed = expression.Trim();
return EvaluateExpression(trimmed, context);
}
return TaskPackValueResolution.FromValue(node);
}
public static TaskPackValueResolution EvaluateString(string value, TaskPackExpressionContext context)
{
if (!TryExtractExpression(value, out var expression))
{
return TaskPackValueResolution.FromValue(JsonValue.Create(value));
}
return EvaluateExpression(expression.Trim(), context);
}
private static bool TryEvaluateBooleanInternal(string expression, TaskPackExpressionContext context, out bool result, out string? error)
{
result = false;
error = null;
if (TrySplitTopLevel(expression, "||", out var left, out var right) ||
TrySplitTopLevel(expression, " or ", out left, out right))
{
if (!TryEvaluateBooleanInternal(left, context, out var leftValue, out error))
{
return false;
}
if (leftValue)
{
result = true;
return true;
}
if (!TryEvaluateBooleanInternal(right, context, out var rightValue, out error))
{
return false;
}
result = rightValue;
return true;
}
if (TrySplitTopLevel(expression, "&&", out left, out right) ||
TrySplitTopLevel(expression, " and ", out left, out right))
{
if (!TryEvaluateBooleanInternal(left, context, out var leftValue, out error))
{
return false;
}
if (!leftValue)
{
result = false;
return true;
}
if (!TryEvaluateBooleanInternal(right, context, out var rightValue, out error))
{
return false;
}
result = rightValue;
return true;
}
if (expression.StartsWith("not ", StringComparison.Ordinal))
{
var inner = expression["not ".Length..].Trim();
if (!TryEvaluateBooleanInternal(inner, context, out var innerValue, out error))
{
return false;
}
result = !innerValue;
return true;
}
if (TryEvaluateComparison(expression, context, out result, out error))
{
return error is null;
}
var resolution = EvaluateExpression(expression, context);
if (!resolution.Resolved)
{
error = resolution.Error ?? $"Expression '{expression}' requires runtime evaluation.";
return false;
}
result = ToBoolean(resolution.Value);
return true;
}
private static bool TryEvaluateComparison(string expression, TaskPackExpressionContext context, out bool value, out string? error)
{
value = false;
error = null;
var comparisonMatch = ComparisonPattern.Match(expression);
if (comparisonMatch.Success)
{
var left = comparisonMatch.Groups["left"].Value.Trim();
var op = comparisonMatch.Groups["op"].Value;
var right = comparisonMatch.Groups["right"].Value.Trim();
var leftResolution = EvaluateOperand(left, context);
if (!leftResolution.IsValid(out error))
{
return false;
}
var rightResolution = EvaluateOperand(right, context);
if (!rightResolution.IsValid(out error))
{
return false;
}
if (!leftResolution.TryGetValue(out var leftValue, out error) ||
!rightResolution.TryGetValue(out var rightValue, out error))
{
return false;
}
value = CompareNodes(leftValue, rightValue, op == "==");
return true;
}
var inMatch = InPattern.Match(expression);
if (inMatch.Success)
{
var member = inMatch.Groups["left"].Value.Trim();
var collection = inMatch.Groups["right"].Value.Trim();
var memberResolution = EvaluateOperand(member, context);
if (!memberResolution.IsValid(out error))
{
return false;
}
var collectionResolution = EvaluateOperand(collection, context);
if (!collectionResolution.IsValid(out error))
{
return false;
}
if (!memberResolution.TryGetValue(out var memberValue, out error) ||
!collectionResolution.TryGetValue(out var collectionValue, out error))
{
return false;
}
value = EvaluateMembership(memberValue, collectionValue);
return true;
}
return false;
}
private static OperandResolution EvaluateOperand(string expression, TaskPackExpressionContext context)
{
if (TryParseStringLiteral(expression, out var literal))
{
return OperandResolution.FromValue(JsonValue.Create(literal));
}
if (bool.TryParse(expression, out var boolLiteral))
{
return OperandResolution.FromValue(JsonValue.Create(boolLiteral));
}
if (double.TryParse(expression, System.Globalization.NumberStyles.Float | System.Globalization.NumberStyles.AllowThousands, System.Globalization.CultureInfo.InvariantCulture, out var numberLiteral))
{
return OperandResolution.FromValue(JsonValue.Create(numberLiteral));
}
var resolution = EvaluateExpression(expression, context);
if (!resolution.Resolved)
{
if (resolution.RequiresRuntimeValue && resolution.Error is null)
{
return OperandResolution.FromRuntime(expression);
}
return OperandResolution.FromError(resolution.Error ?? $"Expression '{expression}' could not be resolved.");
}
return OperandResolution.FromValue(resolution.Value);
}
private static TaskPackValueResolution EvaluateExpression(string expression, TaskPackExpressionContext context)
{
if (!TryResolvePath(expression, context, out var resolved, out var requiresRuntime, out var error))
{
return TaskPackValueResolution.FromError(expression, error ?? $"Failed to resolve expression '{expression}'.");
}
if (requiresRuntime)
{
return TaskPackValueResolution.FromDeferred(expression);
}
return TaskPackValueResolution.FromValue(resolved);
}
private static bool TryResolvePath(string expression, TaskPackExpressionContext context, out JsonNode? value, out bool requiresRuntime, out string? error)
{
value = null;
error = null;
requiresRuntime = false;
if (string.IsNullOrWhiteSpace(expression))
{
error = "Expression cannot be empty.";
return false;
}
var segments = expression.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (segments.Length == 0)
{
error = $"Expression '{expression}' is invalid.";
return false;
}
var root = segments[0];
switch (root)
{
case "inputs":
if (segments.Length == 1)
{
error = "Expression must reference a specific input (e.g., inputs.example).";
return false;
}
if (!context.Inputs.TryGetValue(segments[1], out var current))
{
error = $"Input '{segments[1]}' was not supplied.";
return false;
}
value = Traverse(current, segments, startIndex: 2);
return true;
case "item":
if (context.CurrentItem is null)
{
error = "Expression references 'item' outside of a map iteration.";
return false;
}
value = Traverse(context.CurrentItem, segments, startIndex: 1);
return true;
case "steps":
if (segments.Length < 2)
{
error = "Step expressions must specify a step identifier (e.g., steps.plan.outputs.value).";
return false;
}
var stepId = segments[1];
if (!context.StepExists(stepId))
{
error = $"Step '{stepId}' referenced before it is defined.";
return false;
}
requiresRuntime = true;
value = null;
return true;
case "secrets":
if (segments.Length < 2)
{
error = "Secret expressions must specify a secret name (e.g., secrets.jiraToken).";
return false;
}
var secretName = segments[1];
if (!context.SecretExists(secretName))
{
error = $"Secret '{secretName}' is not declared in the manifest.";
return false;
}
requiresRuntime = true;
value = null;
return true;
default:
error = $"Expression '{expression}' references '{root}', supported roots are inputs, item, steps, and secrets.";
return false;
}
}
private static JsonNode? Traverse(JsonNode? current, IReadOnlyList<string> segments, int startIndex)
{
for (var i = startIndex; i < segments.Count && current is not null; i++)
{
var segment = segments[i];
if (current is JsonObject obj)
{
if (!obj.TryGetPropertyValue(segment, out current))
{
current = null;
}
}
else if (current is JsonArray array)
{
current = TryGetArrayElement(array, segment);
}
else
{
current = null;
}
}
return current;
}
private static JsonNode? TryGetArrayElement(JsonArray array, string segment)
{
if (int.TryParse(segment, out var index) && index >= 0 && index < array.Count)
{
return array[index];
}
return null;
}
private static bool TryExtractExpression(string candidate, out string expression)
{
var match = ExpressionPattern.Match(candidate);
if (!match.Success)
{
expression = candidate;
return false;
}
expression = match.Groups[1].Value;
return true;
}
private static bool TryParseBooleanLiteral(string value, out bool result, out string? error)
{
if (bool.TryParse(value, out result))
{
error = null;
return true;
}
error = $"Unable to parse boolean literal '{value}'.";
return false;
}
private static bool TrySplitTopLevel(string expression, string token, out string left, out string right)
{
var inSingle = false;
var inDouble = false;
for (var i = 0; i <= expression.Length - token.Length; i++)
{
var c = expression[i];
if (c == '\'' && !inDouble)
{
inSingle = !inSingle;
}
else if (c == '"' && !inSingle)
{
inDouble = !inDouble;
}
if (inSingle || inDouble)
{
continue;
}
if (expression.AsSpan(i, token.Length).SequenceEqual(token))
{
left = expression[..i].Trim();
right = expression[(i + token.Length)..].Trim();
return true;
}
}
left = string.Empty;
right = string.Empty;
return false;
}
private static bool TryParseStringLiteral(string candidate, out string? literal)
{
literal = null;
if (candidate.Length >= 2)
{
if ((candidate[0] == '"' && candidate[^1] == '"') ||
(candidate[0] == '\'' && candidate[^1] == '\''))
{
literal = candidate[1..^1];
return true;
}
}
return false;
}
private static bool CompareNodes(JsonNode? left, JsonNode? right, bool equality)
{
if (left is null && right is null)
{
return equality;
}
if (left is null || right is null)
{
return !equality;
}
var comparison = JsonNode.DeepEquals(left, right);
return equality ? comparison : !comparison;
}
private static bool EvaluateMembership(JsonNode? member, JsonNode? collection)
{
if (collection is JsonArray array)
{
foreach (var element in array)
{
if (JsonNode.DeepEquals(member, element))
{
return true;
}
}
return false;
}
if (collection is JsonValue value && value.TryGetValue(out string? text) && member is JsonValue memberValue && memberValue.TryGetValue(out string? memberText))
{
return text?.Contains(memberText, StringComparison.Ordinal) ?? false;
}
return false;
}
private static bool ToBoolean(JsonNode? node)
{
if (node is null)
{
return false;
}
if (node is JsonValue value)
{
if (value.TryGetValue<bool>(out var boolValue))
{
return boolValue;
}
if (value.TryGetValue<string>(out var stringValue))
{
return !string.IsNullOrWhiteSpace(stringValue);
}
if (value.TryGetValue<double>(out var number))
{
return Math.Abs(number) > double.Epsilon;
}
}
if (node is JsonArray array)
{
return array.Count > 0;
}
if (node is JsonObject obj)
{
return obj.Count > 0;
}
return true;
}
private readonly record struct OperandResolution(JsonNode? Value, string? Error, bool RequiresRuntime)
{
public bool IsValid(out string? error)
{
error = Error;
return string.IsNullOrEmpty(Error);
}
public bool TryGetValue(out JsonNode? value, out string? error)
{
if (RequiresRuntime)
{
error = "Expression requires runtime evaluation.";
value = null;
return false;
}
value = Value;
error = Error;
return error is null;
}
public static OperandResolution FromValue(JsonNode? value)
=> new(value, null, false);
public static OperandResolution FromRuntime(string expression)
=> new(null, $"Expression '{expression}' requires runtime evaluation.", true);
public static OperandResolution FromError(string error)
=> new(null, error, false);
}
}
internal readonly record struct TaskPackExpressionContext(
IReadOnlyDictionary<string, JsonNode?> Inputs,
ISet<string> KnownSteps,
ISet<string> KnownSecrets,
JsonNode? CurrentItem)
{
public static TaskPackExpressionContext Create(
IReadOnlyDictionary<string, JsonNode?> inputs,
ISet<string> knownSteps,
ISet<string> knownSecrets)
=> new(inputs, knownSteps, knownSecrets, null);
public bool StepExists(string stepId) => KnownSteps.Contains(stepId);
public void RegisterStep(string stepId) => KnownSteps.Add(stepId);
public bool SecretExists(string secretName) => KnownSecrets.Contains(secretName);
public TaskPackExpressionContext WithItem(JsonNode? item) => new(Inputs, KnownSteps, KnownSecrets, item);
}
internal readonly record struct TaskPackValueResolution(bool Resolved, JsonNode? Value, string? Expression, string? Error, bool RequiresRuntimeValue)
{
public static TaskPackValueResolution FromValue(JsonNode? value)
=> new(true, value, null, null, false);
public static TaskPackValueResolution FromDeferred(string expression)
=> new(false, null, expression, null, true);
public static TaskPackValueResolution FromError(string expression, string error)
=> new(false, null, expression, error, false);
}

View File

@@ -1,534 +0,0 @@
using Microsoft.Extensions.Logging;
using StellaOps.TaskRunner.Core.Events;
namespace StellaOps.TaskRunner.Core.IncidentMode;
/// <summary>
/// Service for managing pack run incident mode.
/// Per TASKRUN-OBS-55-001.
/// </summary>
public interface IPackRunIncidentModeService
{
/// <summary>
/// Activates incident mode for a run.
/// </summary>
Task<IncidentModeActivationResult> ActivateAsync(
IncidentModeActivationRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Deactivates incident mode for a run.
/// </summary>
Task<IncidentModeActivationResult> DeactivateAsync(
string runId,
string? reason = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the current incident mode status for a run.
/// </summary>
Task<PackRunIncidentModeStatus> GetStatusAsync(
string runId,
CancellationToken cancellationToken = default);
/// <summary>
/// Handles an SLO breach notification.
/// </summary>
Task<IncidentModeActivationResult> HandleSloBreachAsync(
SloBreachNotification notification,
CancellationToken cancellationToken = default);
/// <summary>
/// Escalates incident mode to a higher level.
/// </summary>
Task<IncidentModeActivationResult> EscalateAsync(
string runId,
IncidentEscalationLevel newLevel,
string? reason = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets settings for the current incident mode level.
/// </summary>
IncidentModeSettings GetSettingsForLevel(IncidentEscalationLevel level);
}
/// <summary>
/// Store for incident mode state.
/// </summary>
public interface IPackRunIncidentModeStore
{
/// <summary>
/// Stores incident mode status.
/// </summary>
Task StoreAsync(
string runId,
PackRunIncidentModeStatus status,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets incident mode status.
/// </summary>
Task<PackRunIncidentModeStatus?> GetAsync(
string runId,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists all runs in incident mode.
/// </summary>
Task<IReadOnlyList<string>> ListActiveRunsAsync(
CancellationToken cancellationToken = default);
/// <summary>
/// Removes incident mode status.
/// </summary>
Task RemoveAsync(
string runId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Settings for incident mode levels.
/// </summary>
public sealed record IncidentModeSettings(
/// <summary>Escalation level.</summary>
IncidentEscalationLevel Level,
/// <summary>Retention policy.</summary>
IncidentRetentionPolicy RetentionPolicy,
/// <summary>Telemetry settings.</summary>
IncidentTelemetrySettings TelemetrySettings,
/// <summary>Debug capture settings.</summary>
IncidentDebugCaptureSettings DebugCaptureSettings);
/// <summary>
/// Default implementation of pack run incident mode service.
/// </summary>
public sealed class PackRunIncidentModeService : IPackRunIncidentModeService
{
private readonly IPackRunIncidentModeStore _store;
private readonly IPackRunTimelineEventEmitter? _timelineEmitter;
private readonly ILogger<PackRunIncidentModeService> _logger;
private readonly TimeProvider _timeProvider;
public PackRunIncidentModeService(
IPackRunIncidentModeStore store,
ILogger<PackRunIncidentModeService> logger,
TimeProvider? timeProvider = null,
IPackRunTimelineEventEmitter? timelineEmitter = null)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
_timelineEmitter = timelineEmitter;
}
/// <inheritdoc />
public async Task<IncidentModeActivationResult> ActivateAsync(
IncidentModeActivationRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
try
{
var now = _timeProvider.GetUtcNow();
var settings = GetSettingsForLevel(request.Level);
var expiresAt = request.DurationMinutes.HasValue
? now.AddMinutes(request.DurationMinutes.Value)
: (DateTimeOffset?)null;
var status = new PackRunIncidentModeStatus(
Active: true,
Level: request.Level,
ActivatedAt: now,
ActivationReason: request.Reason,
Source: request.Source,
ExpiresAt: expiresAt,
RetentionPolicy: settings.RetentionPolicy,
TelemetrySettings: settings.TelemetrySettings,
DebugCaptureSettings: settings.DebugCaptureSettings);
await _store.StoreAsync(request.RunId, status, cancellationToken);
// Emit timeline event
await EmitTimelineEventAsync(
request.TenantId,
request.RunId,
PackRunIncidentEventTypes.IncidentModeActivated,
new Dictionary<string, string>
{
["level"] = request.Level.ToString(),
["source"] = request.Source.ToString(),
["reason"] = request.Reason,
["requestedBy"] = request.RequestedBy ?? "system"
},
cancellationToken);
_logger.LogWarning(
"Incident mode activated for run {RunId} at level {Level} due to: {Reason}",
request.RunId,
request.Level,
request.Reason);
return new IncidentModeActivationResult(
Success: true,
Status: status,
Error: null);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to activate incident mode for run {RunId}", request.RunId);
return new IncidentModeActivationResult(
Success: false,
Status: PackRunIncidentModeStatus.Inactive(),
Error: ex.Message);
}
}
/// <inheritdoc />
public async Task<IncidentModeActivationResult> DeactivateAsync(
string runId,
string? reason = null,
CancellationToken cancellationToken = default)
{
try
{
var current = await _store.GetAsync(runId, cancellationToken);
if (current is null || !current.Active)
{
return new IncidentModeActivationResult(
Success: true,
Status: PackRunIncidentModeStatus.Inactive(),
Error: null);
}
await _store.RemoveAsync(runId, cancellationToken);
var inactive = PackRunIncidentModeStatus.Inactive();
// Emit timeline event (using default tenant since we don't have it)
await EmitTimelineEventAsync(
"default",
runId,
PackRunIncidentEventTypes.IncidentModeDeactivated,
new Dictionary<string, string>
{
["previousLevel"] = current.Level.ToString(),
["reason"] = reason ?? "Manual deactivation",
["activeDuration"] = current.ActivatedAt.HasValue
? (_timeProvider.GetUtcNow() - current.ActivatedAt.Value).ToString()
: "unknown"
},
cancellationToken);
_logger.LogInformation(
"Incident mode deactivated for run {RunId}. Reason: {Reason}",
runId,
reason ?? "Manual deactivation");
return new IncidentModeActivationResult(
Success: true,
Status: inactive,
Error: null);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to deactivate incident mode for run {RunId}", runId);
return new IncidentModeActivationResult(
Success: false,
Status: PackRunIncidentModeStatus.Inactive(),
Error: ex.Message);
}
}
/// <inheritdoc />
public async Task<PackRunIncidentModeStatus> GetStatusAsync(
string runId,
CancellationToken cancellationToken = default)
{
var status = await _store.GetAsync(runId, cancellationToken);
if (status is null)
{
return PackRunIncidentModeStatus.Inactive();
}
// Check if expired
if (status.ExpiresAt.HasValue && status.ExpiresAt.Value <= _timeProvider.GetUtcNow())
{
await _store.RemoveAsync(runId, cancellationToken);
return PackRunIncidentModeStatus.Inactive();
}
return status;
}
/// <inheritdoc />
public async Task<IncidentModeActivationResult> HandleSloBreachAsync(
SloBreachNotification notification,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(notification);
if (string.IsNullOrWhiteSpace(notification.ResourceId))
{
_logger.LogWarning(
"Received SLO breach notification {BreachId} without resource ID, skipping incident activation",
notification.BreachId);
return new IncidentModeActivationResult(
Success: false,
Status: PackRunIncidentModeStatus.Inactive(),
Error: "No resource ID in SLO breach notification");
}
// Map severity to escalation level
var level = notification.Severity?.ToUpperInvariant() switch
{
"CRITICAL" => IncidentEscalationLevel.Critical,
"HIGH" => IncidentEscalationLevel.High,
"MEDIUM" => IncidentEscalationLevel.Medium,
"LOW" => IncidentEscalationLevel.Low,
_ => IncidentEscalationLevel.Medium
};
var request = new IncidentModeActivationRequest(
RunId: notification.ResourceId,
TenantId: notification.TenantId ?? "default",
Level: level,
Source: IncidentModeSource.SloBreach,
Reason: $"SLO breach: {notification.SloName} ({notification.CurrentValue:F2} vs threshold {notification.Threshold:F2})",
DurationMinutes: 60, // Auto-expire after 1 hour
RequestedBy: "slo-monitor");
_logger.LogWarning(
"Processing SLO breach {BreachId} for {SloName} on resource {ResourceId}",
notification.BreachId,
notification.SloName,
notification.ResourceId);
return await ActivateAsync(request, cancellationToken);
}
/// <inheritdoc />
public async Task<IncidentModeActivationResult> EscalateAsync(
string runId,
IncidentEscalationLevel newLevel,
string? reason = null,
CancellationToken cancellationToken = default)
{
var current = await _store.GetAsync(runId, cancellationToken);
if (current is null || !current.Active)
{
return new IncidentModeActivationResult(
Success: false,
Status: PackRunIncidentModeStatus.Inactive(),
Error: "Incident mode is not active for this run");
}
if (newLevel <= current.Level)
{
return new IncidentModeActivationResult(
Success: false,
Status: current,
Error: $"Cannot escalate to {newLevel} - current level is {current.Level}");
}
var settings = GetSettingsForLevel(newLevel);
var now = _timeProvider.GetUtcNow();
var escalated = current with
{
Level = newLevel,
ActivationReason = $"{current.ActivationReason} [Escalated: {reason ?? "Manual escalation"}]",
RetentionPolicy = settings.RetentionPolicy,
TelemetrySettings = settings.TelemetrySettings,
DebugCaptureSettings = settings.DebugCaptureSettings
};
await _store.StoreAsync(runId, escalated, cancellationToken);
// Emit timeline event
await EmitTimelineEventAsync(
"default",
runId,
PackRunIncidentEventTypes.IncidentModeEscalated,
new Dictionary<string, string>
{
["previousLevel"] = current.Level.ToString(),
["newLevel"] = newLevel.ToString(),
["reason"] = reason ?? "Manual escalation"
},
cancellationToken);
_logger.LogWarning(
"Incident mode escalated for run {RunId} from {OldLevel} to {NewLevel}. Reason: {Reason}",
runId,
current.Level,
newLevel,
reason ?? "Manual escalation");
return new IncidentModeActivationResult(
Success: true,
Status: escalated,
Error: null);
}
/// <inheritdoc />
public IncidentModeSettings GetSettingsForLevel(IncidentEscalationLevel level) => level switch
{
IncidentEscalationLevel.None => new IncidentModeSettings(
level,
IncidentRetentionPolicy.Default(),
IncidentTelemetrySettings.Default(),
IncidentDebugCaptureSettings.Default()),
IncidentEscalationLevel.Low => new IncidentModeSettings(
level,
IncidentRetentionPolicy.Default() with { LogRetentionDays = 30 },
IncidentTelemetrySettings.Default() with
{
EnhancedTelemetryActive = true,
LogVerbosity = IncidentLogVerbosity.Verbose,
TraceSamplingRate = 0.5
},
IncidentDebugCaptureSettings.Default()),
IncidentEscalationLevel.Medium => new IncidentModeSettings(
level,
IncidentRetentionPolicy.Extended(),
IncidentTelemetrySettings.Enhanced(),
IncidentDebugCaptureSettings.Basic()),
IncidentEscalationLevel.High => new IncidentModeSettings(
level,
IncidentRetentionPolicy.Extended() with { LogRetentionDays = 180, ArtifactRetentionDays = 365 },
IncidentTelemetrySettings.Enhanced() with { LogVerbosity = IncidentLogVerbosity.Debug },
IncidentDebugCaptureSettings.Full()),
IncidentEscalationLevel.Critical => new IncidentModeSettings(
level,
IncidentRetentionPolicy.Maximum(),
IncidentTelemetrySettings.Maximum(),
IncidentDebugCaptureSettings.Full() with { MaxCaptureSizeMb = 1000 }),
_ => throw new ArgumentOutOfRangeException(nameof(level))
};
private async Task EmitTimelineEventAsync(
string tenantId,
string runId,
string eventType,
IReadOnlyDictionary<string, string> attributes,
CancellationToken cancellationToken)
{
if (_timelineEmitter is null) return;
await _timelineEmitter.EmitAsync(
PackRunTimelineEvent.Create(
tenantId: tenantId,
eventType: eventType,
source: "taskrunner-incident-mode",
occurredAt: _timeProvider.GetUtcNow(),
runId: runId,
severity: PackRunEventSeverity.Warning,
attributes: attributes),
cancellationToken);
}
}
/// <summary>
/// Incident mode timeline event types.
/// </summary>
public static class PackRunIncidentEventTypes
{
/// <summary>Incident mode activated.</summary>
public const string IncidentModeActivated = "pack.incident.activated";
/// <summary>Incident mode deactivated.</summary>
public const string IncidentModeDeactivated = "pack.incident.deactivated";
/// <summary>Incident mode escalated.</summary>
public const string IncidentModeEscalated = "pack.incident.escalated";
/// <summary>SLO breach detected.</summary>
public const string SloBreachDetected = "pack.incident.slo_breach";
}
/// <summary>
/// In-memory incident mode store for testing.
/// </summary>
public sealed class InMemoryPackRunIncidentModeStore : IPackRunIncidentModeStore
{
private readonly Dictionary<string, PackRunIncidentModeStatus> _statuses = new();
private readonly object _lock = new();
/// <inheritdoc />
public Task StoreAsync(
string runId,
PackRunIncidentModeStatus status,
CancellationToken cancellationToken = default)
{
lock (_lock)
{
_statuses[runId] = status;
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<PackRunIncidentModeStatus?> GetAsync(
string runId,
CancellationToken cancellationToken = default)
{
lock (_lock)
{
_statuses.TryGetValue(runId, out var status);
return Task.FromResult(status);
}
}
/// <inheritdoc />
public Task<IReadOnlyList<string>> ListActiveRunsAsync(
CancellationToken cancellationToken = default)
{
lock (_lock)
{
var active = _statuses
.Where(kvp => kvp.Value.Active)
.Select(kvp => kvp.Key)
.ToList();
return Task.FromResult<IReadOnlyList<string>>(active);
}
}
/// <inheritdoc />
public Task RemoveAsync(
string runId,
CancellationToken cancellationToken = default)
{
lock (_lock)
{
_statuses.Remove(runId);
}
return Task.CompletedTask;
}
/// <summary>Gets count of stored statuses.</summary>
public int Count
{
get { lock (_lock) { return _statuses.Count; } }
}
/// <summary>Clears all statuses.</summary>
public void Clear()
{
lock (_lock) { _statuses.Clear(); }
}
}

View File

@@ -1,363 +0,0 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.TaskRunner.Core.IncidentMode;
/// <summary>
/// Incident mode status for a pack run.
/// Per TASKRUN-OBS-55-001.
/// </summary>
public sealed record PackRunIncidentModeStatus(
/// <summary>Whether incident mode is active.</summary>
bool Active,
/// <summary>Current escalation level.</summary>
IncidentEscalationLevel Level,
/// <summary>When incident mode was activated.</summary>
DateTimeOffset? ActivatedAt,
/// <summary>Reason for activation.</summary>
string? ActivationReason,
/// <summary>Source of activation (SLO breach, manual, etc.).</summary>
IncidentModeSource Source,
/// <summary>When incident mode will auto-deactivate (if set).</summary>
DateTimeOffset? ExpiresAt,
/// <summary>Current retention policy in effect.</summary>
IncidentRetentionPolicy RetentionPolicy,
/// <summary>Active telemetry escalation settings.</summary>
IncidentTelemetrySettings TelemetrySettings,
/// <summary>Debug artifact capture settings.</summary>
IncidentDebugCaptureSettings DebugCaptureSettings)
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
/// <summary>
/// Creates a default inactive status.
/// </summary>
public static PackRunIncidentModeStatus Inactive() => new(
Active: false,
Level: IncidentEscalationLevel.None,
ActivatedAt: null,
ActivationReason: null,
Source: IncidentModeSource.None,
ExpiresAt: null,
RetentionPolicy: IncidentRetentionPolicy.Default(),
TelemetrySettings: IncidentTelemetrySettings.Default(),
DebugCaptureSettings: IncidentDebugCaptureSettings.Default());
/// <summary>
/// Serializes to JSON.
/// </summary>
public string ToJson() => JsonSerializer.Serialize(this, JsonOptions);
}
/// <summary>
/// Incident escalation levels.
/// </summary>
public enum IncidentEscalationLevel
{
/// <summary>No incident mode.</summary>
None = 0,
/// <summary>Low severity - enhanced logging.</summary>
Low = 1,
/// <summary>Medium severity - debug capture enabled.</summary>
Medium = 2,
/// <summary>High severity - full debug + extended retention.</summary>
High = 3,
/// <summary>Critical - maximum telemetry + indefinite retention.</summary>
Critical = 4
}
/// <summary>
/// Source of incident mode activation.
/// </summary>
public enum IncidentModeSource
{
/// <summary>No incident mode.</summary>
None,
/// <summary>Activated manually by operator.</summary>
Manual,
/// <summary>Activated by SLO breach webhook.</summary>
SloBreach,
/// <summary>Activated by error rate threshold.</summary>
ErrorRate,
/// <summary>Activated by policy evaluation.</summary>
PolicyTrigger,
/// <summary>Activated by external system.</summary>
External
}
/// <summary>
/// Retention policy during incident mode.
/// </summary>
public sealed record IncidentRetentionPolicy(
/// <summary>Whether extended retention is active.</summary>
bool ExtendedRetentionActive,
/// <summary>Log retention in days.</summary>
int LogRetentionDays,
/// <summary>Artifact retention in days.</summary>
int ArtifactRetentionDays,
/// <summary>Debug capture retention in days.</summary>
int DebugCaptureRetentionDays,
/// <summary>Trace retention in days.</summary>
int TraceRetentionDays)
{
/// <summary>Default retention policy.</summary>
public static IncidentRetentionPolicy Default() => new(
ExtendedRetentionActive: false,
LogRetentionDays: 7,
ArtifactRetentionDays: 30,
DebugCaptureRetentionDays: 3,
TraceRetentionDays: 7);
/// <summary>Extended retention for incident mode.</summary>
public static IncidentRetentionPolicy Extended() => new(
ExtendedRetentionActive: true,
LogRetentionDays: 90,
ArtifactRetentionDays: 180,
DebugCaptureRetentionDays: 30,
TraceRetentionDays: 90);
/// <summary>Maximum retention for critical incidents.</summary>
public static IncidentRetentionPolicy Maximum() => new(
ExtendedRetentionActive: true,
LogRetentionDays: 365,
ArtifactRetentionDays: 365,
DebugCaptureRetentionDays: 90,
TraceRetentionDays: 365);
}
/// <summary>
/// Telemetry settings during incident mode.
/// </summary>
public sealed record IncidentTelemetrySettings(
/// <summary>Whether enhanced telemetry is active.</summary>
bool EnhancedTelemetryActive,
/// <summary>Log verbosity level.</summary>
IncidentLogVerbosity LogVerbosity,
/// <summary>Trace sampling rate (0.0 to 1.0).</summary>
double TraceSamplingRate,
/// <summary>Whether to capture environment variables.</summary>
bool CaptureEnvironment,
/// <summary>Whether to capture step inputs/outputs.</summary>
bool CaptureStepIo,
/// <summary>Whether to capture network calls.</summary>
bool CaptureNetworkCalls,
/// <summary>Maximum trace spans per step.</summary>
int MaxTraceSpansPerStep)
{
/// <summary>Default telemetry settings.</summary>
public static IncidentTelemetrySettings Default() => new(
EnhancedTelemetryActive: false,
LogVerbosity: IncidentLogVerbosity.Normal,
TraceSamplingRate: 0.1,
CaptureEnvironment: false,
CaptureStepIo: false,
CaptureNetworkCalls: false,
MaxTraceSpansPerStep: 100);
/// <summary>Enhanced telemetry for incident mode.</summary>
public static IncidentTelemetrySettings Enhanced() => new(
EnhancedTelemetryActive: true,
LogVerbosity: IncidentLogVerbosity.Verbose,
TraceSamplingRate: 1.0,
CaptureEnvironment: true,
CaptureStepIo: true,
CaptureNetworkCalls: true,
MaxTraceSpansPerStep: 1000);
/// <summary>Maximum telemetry for debugging.</summary>
public static IncidentTelemetrySettings Maximum() => new(
EnhancedTelemetryActive: true,
LogVerbosity: IncidentLogVerbosity.Debug,
TraceSamplingRate: 1.0,
CaptureEnvironment: true,
CaptureStepIo: true,
CaptureNetworkCalls: true,
MaxTraceSpansPerStep: 10000);
}
/// <summary>
/// Log verbosity levels for incident mode.
/// </summary>
public enum IncidentLogVerbosity
{
/// <summary>Minimal logging (errors only).</summary>
Minimal,
/// <summary>Normal logging.</summary>
Normal,
/// <summary>Verbose logging.</summary>
Verbose,
/// <summary>Debug logging (maximum detail).</summary>
Debug
}
/// <summary>
/// Debug artifact capture settings.
/// </summary>
public sealed record IncidentDebugCaptureSettings(
/// <summary>Whether debug capture is active.</summary>
bool CaptureActive,
/// <summary>Whether to capture heap dumps.</summary>
bool CaptureHeapDumps,
/// <summary>Whether to capture thread dumps.</summary>
bool CaptureThreadDumps,
/// <summary>Whether to capture profiling data.</summary>
bool CaptureProfilingData,
/// <summary>Whether to capture system metrics.</summary>
bool CaptureSystemMetrics,
/// <summary>Maximum capture size in MB.</summary>
int MaxCaptureSizeMb,
/// <summary>Capture interval in seconds.</summary>
int CaptureIntervalSeconds)
{
/// <summary>Default capture settings (disabled).</summary>
public static IncidentDebugCaptureSettings Default() => new(
CaptureActive: false,
CaptureHeapDumps: false,
CaptureThreadDumps: false,
CaptureProfilingData: false,
CaptureSystemMetrics: false,
MaxCaptureSizeMb: 0,
CaptureIntervalSeconds: 0);
/// <summary>Basic debug capture.</summary>
public static IncidentDebugCaptureSettings Basic() => new(
CaptureActive: true,
CaptureHeapDumps: false,
CaptureThreadDumps: true,
CaptureProfilingData: false,
CaptureSystemMetrics: true,
MaxCaptureSizeMb: 100,
CaptureIntervalSeconds: 60);
/// <summary>Full debug capture.</summary>
public static IncidentDebugCaptureSettings Full() => new(
CaptureActive: true,
CaptureHeapDumps: true,
CaptureThreadDumps: true,
CaptureProfilingData: true,
CaptureSystemMetrics: true,
MaxCaptureSizeMb: 500,
CaptureIntervalSeconds: 30);
}
/// <summary>
/// SLO breach notification payload.
/// </summary>
public sealed record SloBreachNotification(
/// <summary>Breach identifier.</summary>
[property: JsonPropertyName("breachId")]
string BreachId,
/// <summary>SLO that was breached.</summary>
[property: JsonPropertyName("sloName")]
string SloName,
/// <summary>Breach severity.</summary>
[property: JsonPropertyName("severity")]
string Severity,
/// <summary>When the breach occurred.</summary>
[property: JsonPropertyName("occurredAt")]
DateTimeOffset OccurredAt,
/// <summary>Current metric value.</summary>
[property: JsonPropertyName("currentValue")]
double CurrentValue,
/// <summary>Threshold that was breached.</summary>
[property: JsonPropertyName("threshold")]
double Threshold,
/// <summary>Target metric value.</summary>
[property: JsonPropertyName("target")]
double Target,
/// <summary>Affected resource (run ID, step ID, etc.).</summary>
[property: JsonPropertyName("resourceId")]
string? ResourceId,
/// <summary>Affected tenant.</summary>
[property: JsonPropertyName("tenantId")]
string? TenantId,
/// <summary>Additional context.</summary>
[property: JsonPropertyName("context")]
IReadOnlyDictionary<string, string>? Context);
/// <summary>
/// Request to activate incident mode.
/// </summary>
public sealed record IncidentModeActivationRequest(
/// <summary>Run ID to activate incident mode for.</summary>
string RunId,
/// <summary>Tenant ID.</summary>
string TenantId,
/// <summary>Escalation level to activate.</summary>
IncidentEscalationLevel Level,
/// <summary>Activation source.</summary>
IncidentModeSource Source,
/// <summary>Reason for activation.</summary>
string Reason,
/// <summary>Duration in minutes (null for indefinite).</summary>
int? DurationMinutes,
/// <summary>Operator or system that requested activation.</summary>
string? RequestedBy);
/// <summary>
/// Result of incident mode activation.
/// </summary>
public sealed record IncidentModeActivationResult(
/// <summary>Whether activation succeeded.</summary>
bool Success,
/// <summary>Current incident mode status.</summary>
PackRunIncidentModeStatus Status,
/// <summary>Error message if activation failed.</summary>
string? Error);

View File

@@ -1,105 +0,0 @@
using StellaOps.TaskRunner.Core.Expressions;
using System.Collections.Immutable;
using System.Text.Json.Nodes;
namespace StellaOps.TaskRunner.Core.Planning;
public sealed class TaskPackPlan
{
public TaskPackPlan(
TaskPackPlanMetadata metadata,
IReadOnlyDictionary<string, JsonNode?> inputs,
IReadOnlyList<TaskPackPlanStep> steps,
string hash,
IReadOnlyList<TaskPackPlanApproval> approvals,
IReadOnlyList<TaskPackPlanSecret> secrets,
IReadOnlyList<TaskPackPlanOutput> outputs,
TaskPackPlanFailurePolicy? failurePolicy)
{
Metadata = metadata;
Inputs = inputs;
Steps = steps;
Hash = hash;
Approvals = approvals;
Secrets = secrets;
Outputs = outputs;
FailurePolicy = failurePolicy;
}
public TaskPackPlanMetadata Metadata { get; }
public IReadOnlyDictionary<string, JsonNode?> Inputs { get; }
public IReadOnlyList<TaskPackPlanStep> Steps { get; }
public string Hash { get; }
public IReadOnlyList<TaskPackPlanApproval> Approvals { get; }
public IReadOnlyList<TaskPackPlanSecret> Secrets { get; }
public IReadOnlyList<TaskPackPlanOutput> Outputs { get; }
public TaskPackPlanFailurePolicy? FailurePolicy { get; }
}
public sealed record TaskPackPlanMetadata(string Name, string Version, string? Description, IReadOnlyList<string> Tags);
public sealed record TaskPackPlanStep(
string Id,
string TemplateId,
string? Name,
string Type,
bool Enabled,
string? Uses,
IReadOnlyDictionary<string, TaskPackPlanParameterValue>? Parameters,
string? ApprovalId,
string? GateMessage,
IReadOnlyList<TaskPackPlanStep>? Children);
public sealed record TaskPackPlanParameterValue(
JsonNode? Value,
string? Expression,
string? Error,
bool RequiresRuntimeValue)
{
internal static TaskPackPlanParameterValue FromResolution(TaskPackValueResolution resolution)
=> new(resolution.Value, resolution.Expression, resolution.Error, resolution.RequiresRuntimeValue);
}
public sealed record TaskPackPlanApproval(
string Id,
IReadOnlyList<string> Grants,
string? ExpiresAfter,
string? ReasonTemplate);
public sealed record TaskPackPlanSecret(string Name, string Scope, string? Description);
public sealed record TaskPackPlanOutput(
string Name,
string Type,
TaskPackPlanParameterValue? Path,
TaskPackPlanParameterValue? Expression);
public sealed record TaskPackPlanFailurePolicy(
int MaxAttempts,
int BackoffSeconds,
bool ContinueOnError);
public sealed class TaskPackPlanResult
{
public TaskPackPlanResult(TaskPackPlan? plan, ImmutableArray<TaskPackPlanError> errors)
{
Plan = plan;
Errors = errors;
}
public TaskPackPlan? Plan { get; }
public ImmutableArray<TaskPackPlanError> Errors { get; }
public bool Success => Plan is not null && Errors.IsDefaultOrEmpty;
}
public sealed record TaskPackPlanError(string Path, string Message);

View File

@@ -1,120 +0,0 @@
using StellaOps.TaskRunner.Core.Serialization;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Nodes;
namespace StellaOps.TaskRunner.Core.Planning;
internal static class TaskPackPlanHasher
{
public static string ComputeHash(
TaskPackPlanMetadata metadata,
IReadOnlyDictionary<string, JsonNode?> inputs,
IReadOnlyList<TaskPackPlanStep> steps,
IReadOnlyList<TaskPackPlanApproval> approvals,
IReadOnlyList<TaskPackPlanSecret> secrets,
IReadOnlyList<TaskPackPlanOutput> outputs,
TaskPackPlanFailurePolicy? failurePolicy)
{
var canonical = new CanonicalPlan(
new CanonicalMetadata(metadata.Name, metadata.Version, metadata.Description, metadata.Tags),
inputs.ToDictionary(kvp => kvp.Key, kvp => kvp.Value, StringComparer.Ordinal),
steps.Select(ToCanonicalStep).ToList(),
approvals
.OrderBy(a => a.Id, StringComparer.Ordinal)
.Select(a => new CanonicalApproval(a.Id, a.Grants.OrderBy(g => g, StringComparer.Ordinal).ToList(), a.ExpiresAfter, a.ReasonTemplate))
.ToList(),
secrets
.OrderBy(s => s.Name, StringComparer.Ordinal)
.Select(s => new CanonicalSecret(s.Name, s.Scope, s.Description))
.ToList(),
outputs
.OrderBy(o => o.Name, StringComparer.Ordinal)
.Select(ToCanonicalOutput)
.ToList(),
failurePolicy is null
? null
: new CanonicalFailurePolicy(failurePolicy.MaxAttempts, failurePolicy.BackoffSeconds, failurePolicy.ContinueOnError));
var json = CanonicalJson.Serialize(canonical);
using var sha256 = SHA256.Create();
var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(json));
return $"sha256:{ConvertToHex(hashBytes)}";
}
private static string ConvertToHex(byte[] hashBytes)
{
var builder = new StringBuilder(hashBytes.Length * 2);
foreach (var b in hashBytes)
{
builder.Append(b.ToString("x2", System.Globalization.CultureInfo.InvariantCulture));
}
return builder.ToString();
}
private static CanonicalPlanStep ToCanonicalStep(TaskPackPlanStep step)
=> new(
step.Id,
step.TemplateId,
step.Name,
step.Type,
step.Enabled,
step.Uses,
step.Parameters?.ToDictionary(
kvp => kvp.Key,
kvp => new CanonicalParameter(kvp.Value.Value, kvp.Value.Expression, kvp.Value.Error, kvp.Value.RequiresRuntimeValue),
StringComparer.Ordinal),
step.ApprovalId,
step.GateMessage,
step.Children?.Select(ToCanonicalStep).ToList());
private sealed record CanonicalPlan(
CanonicalMetadata Metadata,
IDictionary<string, JsonNode?> Inputs,
IReadOnlyList<CanonicalPlanStep> Steps,
IReadOnlyList<CanonicalApproval> Approvals,
IReadOnlyList<CanonicalSecret> Secrets,
IReadOnlyList<CanonicalOutput> Outputs,
CanonicalFailurePolicy? FailurePolicy);
private sealed record CanonicalMetadata(string Name, string Version, string? Description, IReadOnlyList<string> Tags);
private sealed record CanonicalPlanStep(
string Id,
string TemplateId,
string? Name,
string Type,
bool Enabled,
string? Uses,
IDictionary<string, CanonicalParameter>? Parameters,
string? ApprovalId,
string? GateMessage,
IReadOnlyList<CanonicalPlanStep>? Children);
private sealed record CanonicalApproval(string Id, IReadOnlyList<string> Grants, string? ExpiresAfter, string? ReasonTemplate);
private sealed record CanonicalSecret(string Name, string Scope, string? Description);
private sealed record CanonicalParameter(JsonNode? Value, string? Expression, string? Error, bool RequiresRuntimeValue);
private sealed record CanonicalOutput(
string Name,
string Type,
CanonicalParameter? Path,
CanonicalParameter? Expression);
private sealed record CanonicalFailurePolicy(int MaxAttempts, int BackoffSeconds, bool ContinueOnError);
private static CanonicalOutput ToCanonicalOutput(TaskPackPlanOutput output)
=> new(
output.Name,
output.Type,
ToCanonicalParameter(output.Path),
ToCanonicalParameter(output.Expression));
private static CanonicalParameter? ToCanonicalParameter(TaskPackPlanParameterValue? value)
=> value is null ? null : new CanonicalParameter(value.Value, value.Expression, value.Error, value.RequiresRuntimeValue);
}

View File

@@ -1,185 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace StellaOps.TaskRunner.Core.Planning;
public static class TaskPackPlanInsights
{
public static IReadOnlyList<TaskPackPlanApprovalRequirement> CollectApprovalRequirements(TaskPackPlan plan)
{
ArgumentNullException.ThrowIfNull(plan);
var approvals = plan.Approvals.ToDictionary(approval => approval.Id, StringComparer.Ordinal);
var builders = new Dictionary<string, ApprovalRequirementBuilder>(StringComparer.Ordinal);
void Visit(IReadOnlyList<TaskPackPlanStep>? steps)
{
if (steps is null)
{
return;
}
foreach (var step in steps)
{
if (string.Equals(step.Type, "gate.approval", StringComparison.Ordinal) && !string.IsNullOrEmpty(step.ApprovalId))
{
if (!builders.TryGetValue(step.ApprovalId, out var builder))
{
builder = new ApprovalRequirementBuilder(step.ApprovalId);
builders[step.ApprovalId] = builder;
}
builder.AddStep(step);
}
Visit(step.Children);
}
}
Visit(plan.Steps);
return builders.Values
.Select(builder => builder.Build(approvals))
.OrderBy(requirement => requirement.ApprovalId, StringComparer.Ordinal)
.ToList();
}
public static IReadOnlyList<TaskPackPlanNotificationHint> CollectNotificationHints(TaskPackPlan plan)
{
ArgumentNullException.ThrowIfNull(plan);
var notifications = new List<TaskPackPlanNotificationHint>();
void Visit(IReadOnlyList<TaskPackPlanStep>? steps)
{
if (steps is null)
{
return;
}
foreach (var step in steps)
{
if (string.Equals(step.Type, "gate.approval", StringComparison.Ordinal))
{
notifications.Add(new TaskPackPlanNotificationHint(step.Id, "approval-request", step.GateMessage, step.ApprovalId));
}
else if (string.Equals(step.Type, "gate.policy", StringComparison.Ordinal))
{
notifications.Add(new TaskPackPlanNotificationHint(step.Id, "policy-gate", step.GateMessage, null));
}
Visit(step.Children);
}
}
Visit(plan.Steps);
return notifications;
}
public static IReadOnlyList<TaskPackPlanPolicyGateHint> CollectPolicyGateHints(TaskPackPlan plan)
{
ArgumentNullException.ThrowIfNull(plan);
var hints = new List<TaskPackPlanPolicyGateHint>();
void Visit(IReadOnlyList<TaskPackPlanStep>? steps)
{
if (steps is null)
{
return;
}
foreach (var step in steps)
{
if (string.Equals(step.Type, "gate.policy", StringComparison.Ordinal))
{
var parameters = step.Parameters?
.OrderBy(kvp => kvp.Key, StringComparer.Ordinal)
.Select(kvp => new TaskPackPlanPolicyParameter(
kvp.Key,
kvp.Value.RequiresRuntimeValue,
kvp.Value.Expression,
kvp.Value.Error))
.ToList() ?? new List<TaskPackPlanPolicyParameter>();
hints.Add(new TaskPackPlanPolicyGateHint(step.Id, step.GateMessage, parameters));
}
Visit(step.Children);
}
}
Visit(plan.Steps);
return hints;
}
private sealed class ApprovalRequirementBuilder
{
private readonly HashSet<string> stepIds = new(StringComparer.Ordinal);
private readonly List<string> messages = new();
public ApprovalRequirementBuilder(string approvalId)
{
ApprovalId = approvalId;
}
public string ApprovalId { get; }
public void AddStep(TaskPackPlanStep step)
{
stepIds.Add(step.Id);
if (!string.IsNullOrWhiteSpace(step.GateMessage))
{
messages.Add(step.GateMessage!);
}
}
public TaskPackPlanApprovalRequirement Build(IReadOnlyDictionary<string, TaskPackPlanApproval> knownApprovals)
{
knownApprovals.TryGetValue(ApprovalId, out var approval);
var orderedSteps = stepIds
.OrderBy(id => id, StringComparer.Ordinal)
.ToList();
var orderedMessages = messages
.Where(message => !string.IsNullOrWhiteSpace(message))
.Distinct(StringComparer.Ordinal)
.ToList();
return new TaskPackPlanApprovalRequirement(
ApprovalId,
approval?.Grants ?? Array.Empty<string>(),
approval?.ExpiresAfter,
approval?.ReasonTemplate,
orderedSteps,
orderedMessages);
}
}
}
public sealed record TaskPackPlanApprovalRequirement(
string ApprovalId,
IReadOnlyList<string> Grants,
string? ExpiresAfter,
string? ReasonTemplate,
IReadOnlyList<string> StepIds,
IReadOnlyList<string> Messages);
public sealed record TaskPackPlanNotificationHint(
string StepId,
string Type,
string? Message,
string? ApprovalId);
public sealed record TaskPackPlanPolicyGateHint(
string StepId,
string? Message,
IReadOnlyList<TaskPackPlanPolicyParameter> Parameters);
public sealed record TaskPackPlanPolicyParameter(
string Name,
bool RequiresRuntimeValue,
string? Expression,
string? Error);

View File

@@ -1,878 +0,0 @@
using StellaOps.AirGap.Policy;
using StellaOps.TaskRunner.Core.Expressions;
using StellaOps.TaskRunner.Core.TaskPacks;
using System;
using System.Collections.Immutable;
using System.Globalization;
using System.Linq;
using System.Text.Json.Nodes;
namespace StellaOps.TaskRunner.Core.Planning;
public sealed class TaskPackPlanner
{
private static readonly string[] NetworkParameterHints = { "url", "uri", "endpoint", "host", "registry", "mirror", "address" };
private readonly TaskPackManifestValidator validator;
private readonly IEgressPolicy? egressPolicy;
public TaskPackPlanner(IEgressPolicy? egressPolicy = null)
{
validator = new TaskPackManifestValidator();
this.egressPolicy = egressPolicy;
}
public TaskPackPlanResult Plan(TaskPackManifest manifest, IDictionary<string, JsonNode?>? providedInputs = null)
{
ArgumentNullException.ThrowIfNull(manifest);
var errors = ImmutableArray.CreateBuilder<TaskPackPlanError>();
ValidateSandboxAndSlo(manifest, errors);
var validation = validator.Validate(manifest);
if (!validation.IsValid)
{
foreach (var error in validation.Errors)
{
errors.Add(new TaskPackPlanError(error.Path, error.Message));
}
return new TaskPackPlanResult(null, errors.ToImmutable());
}
var effectiveInputs = MaterializeInputs(manifest.Spec.Inputs, providedInputs, errors);
if (errors.Count > 0)
{
return new TaskPackPlanResult(null, errors.ToImmutable());
}
var stepTracker = new HashSet<string>(StringComparer.Ordinal);
var secretTracker = new HashSet<string>(StringComparer.Ordinal);
if (manifest.Spec.Secrets is not null)
{
foreach (var secret in manifest.Spec.Secrets)
{
secretTracker.Add(secret.Name);
}
}
var context = TaskPackExpressionContext.Create(effectiveInputs, stepTracker, secretTracker);
var packName = manifest.Metadata.Name;
var packVersion = manifest.Metadata.Version;
var planSteps = new List<TaskPackPlanStep>();
var steps = manifest.Spec.Steps;
for (var i = 0; i < steps.Count; i++)
{
var step = steps[i];
var planStep = BuildStep(packName, packVersion, step, context, $"spec.steps[{i}]", errors);
planSteps.Add(planStep);
}
if (errors.Count > 0)
{
return new TaskPackPlanResult(null, errors.ToImmutable());
}
var metadata = new TaskPackPlanMetadata(
manifest.Metadata.Name,
manifest.Metadata.Version,
manifest.Metadata.Description,
manifest.Metadata.Tags?.ToList() ?? new List<string>());
var planApprovals = manifest.Spec.Approvals?
.Select(approval => new TaskPackPlanApproval(
approval.Id,
NormalizeGrants(approval.Grants),
approval.ExpiresAfter,
approval.ReasonTemplate))
.ToList() ?? new List<TaskPackPlanApproval>();
var planSecrets = manifest.Spec.Secrets?
.Select(secret => new TaskPackPlanSecret(secret.Name, secret.Scope, secret.Description))
.ToList() ?? new List<TaskPackPlanSecret>();
var planOutputs = MaterializeOutputs(manifest.Spec.Outputs, context, errors);
if (errors.Count > 0)
{
return new TaskPackPlanResult(null, errors.ToImmutable());
}
var failurePolicy = MaterializeFailurePolicy(manifest.Spec.Failure);
var hash = TaskPackPlanHasher.ComputeHash(metadata, effectiveInputs, planSteps, planApprovals, planSecrets, planOutputs, failurePolicy);
var plan = new TaskPackPlan(metadata, effectiveInputs, planSteps, hash, planApprovals, planSecrets, planOutputs, failurePolicy);
return new TaskPackPlanResult(plan, ImmutableArray<TaskPackPlanError>.Empty);
}
private static void ValidateSandboxAndSlo(TaskPackManifest manifest, ImmutableArray<TaskPackPlanError>.Builder errors)
{
// TP6: sandbox quotas must be present.
var sandbox = manifest.Spec.Sandbox;
if (sandbox is null)
{
errors.Add(new TaskPackPlanError("spec.sandbox", "Sandbox settings are required (mode, egressAllowlist, CPU/memory, quotaSeconds)."));
}
else
{
if (string.IsNullOrWhiteSpace(sandbox.Mode))
{
errors.Add(new TaskPackPlanError("spec.sandbox.mode", "Sandbox mode is required (sealed or restricted)."));
}
if (sandbox.EgressAllowlist is null)
{
errors.Add(new TaskPackPlanError("spec.sandbox.egressAllowlist", "Egress allowlist must be declared (empty list allowed)."));
}
if (sandbox.CpuLimitMillicores <= 0)
{
errors.Add(new TaskPackPlanError("spec.sandbox.cpuLimitMillicores", "CPU limit must be > 0."));
}
if (sandbox.MemoryLimitMiB <= 0)
{
errors.Add(new TaskPackPlanError("spec.sandbox.memoryLimitMiB", "Memory limit must be > 0."));
}
if (sandbox.QuotaSeconds <= 0)
{
errors.Add(new TaskPackPlanError("spec.sandbox.quotaSeconds", "quotaSeconds must be > 0."));
}
}
// TP9: SLOs must be declared and positive.
var slo = manifest.Spec.Slo;
if (slo is null)
{
errors.Add(new TaskPackPlanError("spec.slo", "SLO section is required (runP95Seconds, approvalP95Seconds, maxQueueDepth)."));
return;
}
if (slo.RunP95Seconds <= 0)
{
errors.Add(new TaskPackPlanError("spec.slo.runP95Seconds", "runP95Seconds must be > 0."));
}
if (slo.ApprovalP95Seconds <= 0)
{
errors.Add(new TaskPackPlanError("spec.slo.approvalP95Seconds", "approvalP95Seconds must be > 0."));
}
if (slo.MaxQueueDepth <= 0)
{
errors.Add(new TaskPackPlanError("spec.slo.maxQueueDepth", "maxQueueDepth must be > 0."));
}
}
private Dictionary<string, JsonNode?> MaterializeInputs(
IReadOnlyList<TaskPackInput>? definitions,
IDictionary<string, JsonNode?>? providedInputs,
ImmutableArray<TaskPackPlanError>.Builder errors)
{
var effective = new Dictionary<string, JsonNode?>(StringComparer.Ordinal);
if (definitions is not null)
{
foreach (var input in definitions)
{
if ((providedInputs is not null && providedInputs.TryGetValue(input.Name, out var supplied)))
{
effective[input.Name] = supplied?.DeepClone();
}
else if (input.Default is not null)
{
effective[input.Name] = input.Default.DeepClone();
}
else if (input.Required)
{
errors.Add(new TaskPackPlanError($"inputs.{input.Name}", "Input is required but was not supplied."));
}
}
}
if (providedInputs is not null)
{
foreach (var kvp in providedInputs)
{
if (!effective.ContainsKey(kvp.Key))
{
effective[kvp.Key] = kvp.Value?.DeepClone();
}
}
}
return effective;
}
private static TaskPackPlanFailurePolicy? MaterializeFailurePolicy(TaskPackFailure? failure)
{
if (failure?.Retries is not TaskPackRetryPolicy retries)
{
return null;
}
var maxAttempts = retries.MaxAttempts <= 0 ? 1 : retries.MaxAttempts;
var backoffSeconds = retries.BackoffSeconds < 0 ? 0 : retries.BackoffSeconds;
return new TaskPackPlanFailurePolicy(maxAttempts, backoffSeconds, ContinueOnError: false);
}
private TaskPackPlanStep BuildStep(
string packName,
string packVersion,
TaskPackStep step,
TaskPackExpressionContext context,
string path,
ImmutableArray<TaskPackPlanError>.Builder errors)
{
if (!TaskPackExpressions.TryEvaluateBoolean(step.When, context, out var enabled, out var whenError))
{
errors.Add(new TaskPackPlanError($"{path}.when", whenError ?? "Failed to evaluate 'when' expression."));
enabled = false;
}
TaskPackPlanStep planStep;
if (step.Run is not null)
{
planStep = BuildRunStep(packName, packVersion, step, step.Run, context, path, enabled, errors);
}
else if (step.Gate is not null)
{
planStep = BuildGateStep(step, step.Gate, context, path, enabled, errors);
}
else if (step.Parallel is not null)
{
planStep = BuildParallelStep(packName, packVersion, step, step.Parallel, context, path, enabled, errors);
}
else if (step.Map is not null)
{
planStep = BuildMapStep(packName, packVersion, step, step.Map, context, path, enabled, errors);
}
else if (step.Loop is not null)
{
planStep = BuildLoopStep(packName, packVersion, step, step.Loop, context, path, enabled, errors);
}
else if (step.Conditional is not null)
{
planStep = BuildConditionalStep(packName, packVersion, step, step.Conditional, context, path, enabled, errors);
}
else
{
errors.Add(new TaskPackPlanError(path, "Step did not specify run, gate, parallel, map, loop, or conditional."));
planStep = new TaskPackPlanStep(step.Id, step.Id, step.Name, "invalid", enabled, null, null, ApprovalId: null, GateMessage: null, Children: null);
}
context.RegisterStep(step.Id);
return planStep;
}
private TaskPackPlanStep BuildRunStep(
string packName,
string packVersion,
TaskPackStep step,
TaskPackRunStep run,
TaskPackExpressionContext context,
string path,
bool enabled,
ImmutableArray<TaskPackPlanError>.Builder errors)
{
var parameters = ResolveParameters(run.With, context, $"{path}.run", errors);
if (egressPolicy?.IsSealed == true)
{
ValidateRunStepEgress(packName, packVersion, step, run, parameters, path, errors);
}
return new TaskPackPlanStep(
step.Id,
step.Id,
step.Name,
"run",
enabled,
run.Uses,
parameters,
ApprovalId: null,
GateMessage: null,
Children: null);
}
private void ValidateRunStepEgress(
string packName,
string packVersion,
TaskPackStep step,
TaskPackRunStep run,
IReadOnlyDictionary<string, TaskPackPlanParameterValue>? parameters,
string path,
ImmutableArray<TaskPackPlanError>.Builder errors)
{
if (egressPolicy is null || !egressPolicy.IsSealed)
{
return;
}
var destinations = new List<Uri>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
void AddDestination(Uri uri)
{
if (seen.Add(uri.ToString()))
{
destinations.Add(uri);
}
}
if (run.Egress is not null)
{
for (var i = 0; i < run.Egress.Count; i++)
{
var entry = run.Egress[i];
var entryPath = $"{path}.egress[{i}]";
if (entry is null)
{
continue;
}
if (TryParseNetworkUri(entry.Url, out var uri))
{
AddDestination(uri);
}
else
{
errors.Add(new TaskPackPlanError($"{entryPath}.url", "Egress URL must be an absolute HTTP or HTTPS address."));
}
}
}
var requiresRuntimeNetwork = false;
if (parameters is not null)
{
foreach (var parameter in parameters)
{
var value = parameter.Value;
if (value.Value is JsonValue jsonValue && jsonValue.TryGetValue<string>(out var literal) && TryParseNetworkUri(literal, out var uri))
{
AddDestination(uri);
}
else if (value.RequiresRuntimeValue && MightBeNetworkParameter(parameter.Key))
{
requiresRuntimeNetwork = true;
}
}
}
if (destinations.Count == 0)
{
if (requiresRuntimeNetwork && (run.Egress is null || run.Egress.Count == 0))
{
errors.Add(new TaskPackPlanError(path, $"Step '{step.Id}' references runtime network parameters while sealed mode is enabled. Declare explicit run.egress URLs or remove external calls."));
}
return;
}
foreach (var destination in destinations)
{
try
{
var request = new EgressRequest(
component: "TaskRunner",
destination: destination,
intent: $"taskpack:{packName}@{packVersion}:{step.Id}",
transport: DetermineTransport(destination),
operation: run.Uses);
egressPolicy.EnsureAllowed(request);
}
catch (AirGapEgressBlockedException blocked)
{
var remediation = blocked.Remediation;
errors.Add(new TaskPackPlanError(
path,
$"Step '{step.Id}' attempted to reach '{destination}' in sealed mode and was blocked. Reason: {blocked.Reason}. Remediation: {remediation}"));
}
}
}
private static bool TryParseNetworkUri(string? value, out Uri uri)
{
uri = default!;
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
if (!Uri.TryCreate(value, UriKind.Absolute, out var parsed))
{
return false;
}
if (!IsNetworkScheme(parsed))
{
return false;
}
uri = parsed;
return true;
}
private static bool IsNetworkScheme(Uri uri)
=> string.Equals(uri.Scheme, "http", StringComparison.OrdinalIgnoreCase)
|| string.Equals(uri.Scheme, "https", StringComparison.OrdinalIgnoreCase);
private static bool MightBeNetworkParameter(string name)
{
if (string.IsNullOrWhiteSpace(name))
{
return false;
}
foreach (var hint in NetworkParameterHints)
{
if (name.Contains(hint, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
private static EgressTransport DetermineTransport(Uri destination)
=> string.Equals(destination.Scheme, "https", StringComparison.OrdinalIgnoreCase)
? EgressTransport.Https
: string.Equals(destination.Scheme, "http", StringComparison.OrdinalIgnoreCase)
? EgressTransport.Http
: EgressTransport.Any;
private static IReadOnlyList<string> NormalizeGrants(IReadOnlyList<string>? grants)
{
if (grants is null || grants.Count == 0)
{
return Array.Empty<string>();
}
var normalized = new List<string>(grants.Count);
foreach (var grant in grants)
{
if (string.IsNullOrWhiteSpace(grant))
{
continue;
}
var segments = grant
.Split('.', StringSplitOptions.RemoveEmptyEntries)
.Select(segment =>
{
var trimmed = segment.Trim();
if (trimmed.Length == 0)
{
return string.Empty;
}
if (trimmed.Length == 1)
{
return trimmed.ToUpperInvariant();
}
var first = char.ToUpperInvariant(trimmed[0]);
var rest = trimmed[1..].ToLowerInvariant();
return string.Concat(first, rest);
})
.Where(segment => segment.Length > 0)
.ToArray();
if (segments.Length == 0)
{
continue;
}
normalized.Add(string.Join('.', segments));
}
return normalized.Count == 0
? Array.Empty<string>()
: normalized;
}
private TaskPackPlanStep BuildGateStep(
TaskPackStep step,
TaskPackGateStep gate,
TaskPackExpressionContext context,
string path,
bool enabled,
ImmutableArray<TaskPackPlanError>.Builder errors)
{
string type;
string? approvalId = null;
IReadOnlyDictionary<string, TaskPackPlanParameterValue>? parameters = null;
if (gate.Approval is not null)
{
type = "gate.approval";
approvalId = gate.Approval.Id;
}
else if (gate.Policy is not null)
{
type = "gate.policy";
var resolvedParams = ResolveParameters(gate.Policy.Parameters, context, $"{path}.gate.policy", errors);
var policyParams = new Dictionary<string, TaskPackPlanParameterValue>(
resolvedParams ?? new Dictionary<string, TaskPackPlanParameterValue>(),
StringComparer.Ordinal);
// Store the policy ID in parameters for downstream config extraction
policyParams["policyId"] = new TaskPackPlanParameterValue(JsonValue.Create(gate.Policy.Policy), null, null, false);
parameters = policyParams;
}
else
{
type = "gate";
errors.Add(new TaskPackPlanError($"{path}.gate", "Gate must specify approval or policy."));
}
return new TaskPackPlanStep(
step.Id,
step.Id,
step.Name,
type,
enabled,
Uses: null,
parameters,
ApprovalId: approvalId,
GateMessage: gate.Message,
Children: null);
}
private TaskPackPlanStep BuildParallelStep(
string packName,
string packVersion,
TaskPackStep step,
TaskPackParallelStep parallel,
TaskPackExpressionContext context,
string path,
bool enabled,
ImmutableArray<TaskPackPlanError>.Builder errors)
{
var children = new List<TaskPackPlanStep>();
for (var i = 0; i < parallel.Steps.Count; i++)
{
var child = BuildStep(packName, packVersion, parallel.Steps[i], context, $"{path}.parallel.steps[{i}]", errors);
children.Add(child);
}
var parameters = new Dictionary<string, TaskPackPlanParameterValue>(StringComparer.Ordinal);
if (parallel.MaxParallel.HasValue)
{
parameters["maxParallel"] = new TaskPackPlanParameterValue(JsonValue.Create(parallel.MaxParallel.Value), null, null, false);
}
parameters["continueOnError"] = new TaskPackPlanParameterValue(JsonValue.Create(parallel.ContinueOnError), null, null, false);
return new TaskPackPlanStep(
step.Id,
step.Id,
step.Name,
"parallel",
enabled,
Uses: null,
parameters,
ApprovalId: null,
GateMessage: null,
Children: children);
}
private TaskPackPlanStep BuildMapStep(
string packName,
string packVersion,
TaskPackStep step,
TaskPackMapStep map,
TaskPackExpressionContext context,
string path,
bool enabled,
ImmutableArray<TaskPackPlanError>.Builder errors)
{
var parameters = new Dictionary<string, TaskPackPlanParameterValue>(StringComparer.Ordinal);
var itemsResolution = TaskPackExpressions.EvaluateString(map.Items, context);
JsonArray? itemsArray = null;
if (!itemsResolution.Resolved)
{
if (itemsResolution.Error is not null)
{
errors.Add(new TaskPackPlanError($"{path}.map.items", itemsResolution.Error));
}
else
{
errors.Add(new TaskPackPlanError($"{path}.map.items", "Map items expression requires runtime evaluation. Packs must provide deterministic item lists at plan time."));
}
}
else if (itemsResolution.Value is JsonArray array)
{
itemsArray = (JsonArray?)array.DeepClone();
}
else
{
errors.Add(new TaskPackPlanError($"{path}.map.items", "Map items expression must resolve to an array."));
}
if (itemsArray is not null)
{
parameters["items"] = new TaskPackPlanParameterValue(itemsArray, null, null, false);
parameters["iterationCount"] = new TaskPackPlanParameterValue(JsonValue.Create(itemsArray.Count), null, null, false);
}
else
{
parameters["items"] = new TaskPackPlanParameterValue(null, map.Items, "Map items expression could not be resolved.", true);
}
var children = new List<TaskPackPlanStep>();
if (itemsArray is not null)
{
for (var i = 0; i < itemsArray.Count; i++)
{
var item = itemsArray[i];
var iterationContext = context.WithItem(item);
var iterationPath = $"{path}.map.step[{i}]";
var templateStep = BuildStep(packName, packVersion, map.Step, iterationContext, iterationPath, errors);
var childId = $"{step.Id}[{i}]::{map.Step.Id}";
var iterationParameters = templateStep.Parameters is null
? new Dictionary<string, TaskPackPlanParameterValue>(StringComparer.Ordinal)
: new Dictionary<string, TaskPackPlanParameterValue>(templateStep.Parameters);
iterationParameters["item"] = new TaskPackPlanParameterValue(item?.DeepClone(), null, null, false);
var iterationStep = templateStep with
{
Id = childId,
TemplateId = map.Step.Id,
Parameters = iterationParameters
};
children.Add(iterationStep);
}
}
return new TaskPackPlanStep(
step.Id,
step.Id,
step.Name,
"map",
enabled,
Uses: null,
parameters,
ApprovalId: null,
GateMessage: null,
Children: children);
}
private TaskPackPlanStep BuildLoopStep(
string packName,
string packVersion,
TaskPackStep step,
TaskPackLoopStep loop,
TaskPackExpressionContext context,
string path,
bool enabled,
ImmutableArray<TaskPackPlanError>.Builder errors)
{
var parameters = new Dictionary<string, TaskPackPlanParameterValue>(StringComparer.Ordinal);
// Store loop configuration parameters
if (!string.IsNullOrWhiteSpace(loop.Items))
{
parameters["items"] = new TaskPackPlanParameterValue(null, loop.Items, null, true);
}
if (loop.Range is not null)
{
var rangeObj = new JsonObject
{
["start"] = loop.Range.Start,
["end"] = loop.Range.End,
["step"] = loop.Range.Step
};
parameters["range"] = new TaskPackPlanParameterValue(rangeObj, null, null, false);
}
if (loop.StaticItems is not null)
{
var staticArray = new JsonArray();
foreach (var item in loop.StaticItems)
{
staticArray.Add(JsonValue.Create(item?.ToString()));
}
parameters["staticItems"] = new TaskPackPlanParameterValue(staticArray, null, null, false);
}
parameters["iterator"] = new TaskPackPlanParameterValue(JsonValue.Create(loop.Iterator), null, null, false);
parameters["index"] = new TaskPackPlanParameterValue(JsonValue.Create(loop.Index), null, null, false);
parameters["maxIterations"] = new TaskPackPlanParameterValue(JsonValue.Create(loop.MaxIterations), null, null, false);
if (!string.IsNullOrWhiteSpace(loop.Aggregation))
{
parameters["aggregation"] = new TaskPackPlanParameterValue(JsonValue.Create(loop.Aggregation), null, null, false);
}
if (!string.IsNullOrWhiteSpace(loop.OutputPath))
{
parameters["outputPath"] = new TaskPackPlanParameterValue(JsonValue.Create(loop.OutputPath), null, null, false);
}
// Build child steps (the loop body)
var children = new List<TaskPackPlanStep>();
for (var i = 0; i < loop.Steps.Count; i++)
{
var child = BuildStep(packName, packVersion, loop.Steps[i], context, $"{path}.loop.steps[{i}]", errors);
children.Add(child);
}
return new TaskPackPlanStep(
step.Id,
step.Id,
step.Name,
"loop",
enabled,
Uses: null,
parameters,
ApprovalId: null,
GateMessage: null,
Children: children);
}
private TaskPackPlanStep BuildConditionalStep(
string packName,
string packVersion,
TaskPackStep step,
TaskPackConditionalStep conditional,
TaskPackExpressionContext context,
string path,
bool enabled,
ImmutableArray<TaskPackPlanError>.Builder errors)
{
var parameters = new Dictionary<string, TaskPackPlanParameterValue>(StringComparer.Ordinal);
// Store branch conditions as metadata
var branchesArray = new JsonArray();
foreach (var branch in conditional.Branches)
{
branchesArray.Add(new JsonObject
{
["condition"] = branch.Condition,
["stepCount"] = branch.Steps.Count
});
}
parameters["branches"] = new TaskPackPlanParameterValue(branchesArray, null, null, false);
parameters["outputUnion"] = new TaskPackPlanParameterValue(JsonValue.Create(conditional.OutputUnion), null, null, false);
// Build all branch bodies and else branch as children
var children = new List<TaskPackPlanStep>();
for (var branchIdx = 0; branchIdx < conditional.Branches.Count; branchIdx++)
{
var branch = conditional.Branches[branchIdx];
for (var stepIdx = 0; stepIdx < branch.Steps.Count; stepIdx++)
{
var child = BuildStep(packName, packVersion, branch.Steps[stepIdx], context, $"{path}.conditional.branches[{branchIdx}].steps[{stepIdx}]", errors);
children.Add(child);
}
}
if (conditional.Else is not null)
{
for (var i = 0; i < conditional.Else.Count; i++)
{
var child = BuildStep(packName, packVersion, conditional.Else[i], context, $"{path}.conditional.else[{i}]", errors);
children.Add(child);
}
}
return new TaskPackPlanStep(
step.Id,
step.Id,
step.Name,
"conditional",
enabled,
Uses: null,
parameters,
ApprovalId: null,
GateMessage: null,
Children: children);
}
private IReadOnlyDictionary<string, TaskPackPlanParameterValue>? ResolveParameters(
IDictionary<string, JsonNode?>? rawParameters,
TaskPackExpressionContext context,
string path,
ImmutableArray<TaskPackPlanError>.Builder errors)
{
if (rawParameters is null || rawParameters.Count == 0)
{
return null;
}
var resolved = new Dictionary<string, TaskPackPlanParameterValue>(StringComparer.Ordinal);
foreach (var (key, value) in rawParameters)
{
var evaluation = TaskPackExpressions.EvaluateValue(value, context);
if (!evaluation.Resolved && evaluation.Error is not null)
{
errors.Add(new TaskPackPlanError($"{path}.with.{key}", evaluation.Error));
}
resolved[key] = TaskPackPlanParameterValue.FromResolution(evaluation);
}
return resolved;
}
private IReadOnlyList<TaskPackPlanOutput> MaterializeOutputs(
IReadOnlyList<TaskPackOutput>? outputs,
TaskPackExpressionContext context,
ImmutableArray<TaskPackPlanError>.Builder errors)
{
if (outputs is null || outputs.Count == 0)
{
return Array.Empty<TaskPackPlanOutput>();
}
var results = new List<TaskPackPlanOutput>(outputs.Count);
foreach (var (output, index) in outputs.Select((output, index) => (output, index)))
{
var pathValue = ConvertString(output.Path, context, $"spec.outputs[{index}].path", errors);
var expressionValue = ConvertString(output.Expression, context, $"spec.outputs[{index}].expression", errors);
results.Add(new TaskPackPlanOutput(
output.Name,
output.Type,
pathValue,
expressionValue));
}
return results;
}
private TaskPackPlanParameterValue? ConvertString(
string? value,
TaskPackExpressionContext context,
string path,
ImmutableArray<TaskPackPlanError>.Builder errors)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
var resolution = TaskPackExpressions.EvaluateString(value, context);
if (!resolution.Resolved && resolution.Error is not null)
{
errors.Add(new TaskPackPlanError(path, resolution.Error));
}
return TaskPackPlanParameterValue.FromResolution(resolution);
}
}

View File

@@ -1,68 +0,0 @@
using System.Linq;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace StellaOps.TaskRunner.Core.Serialization;
internal static class CanonicalJson
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
WriteIndented = false
};
public static string Serialize<T>(T value)
{
var node = JsonSerializer.SerializeToNode(value, SerializerOptions);
if (node is null)
{
throw new InvalidOperationException("Unable to serialize value to JSON node.");
}
var canonical = Canonicalize(node);
return canonical.ToJsonString(SerializerOptions);
}
public static JsonNode Canonicalize(JsonNode node)
{
return node switch
{
JsonObject obj => CanonicalizeObject(obj),
JsonArray array => CanonicalizeArray(array),
_ => node.DeepClone()
};
}
private static JsonObject CanonicalizeObject(JsonObject obj)
{
var canonical = new JsonObject();
foreach (var property in obj.OrderBy(static p => p.Key, StringComparer.Ordinal))
{
if (property.Value is null)
{
canonical[property.Key] = null;
}
else
{
canonical[property.Key] = Canonicalize(property.Value);
}
}
return canonical;
}
private static JsonArray CanonicalizeArray(JsonArray array)
{
var canonical = new JsonArray();
foreach (var element in array)
{
canonical.Add(element is null ? null : Canonicalize(element));
}
return canonical;
}
}

View File

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

View File

@@ -1,8 +0,0 @@
# StellaOps.TaskRunner.Core Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/StellaOps.TaskRunner.Core.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |

View File

@@ -1,384 +0,0 @@
using StellaOps.TaskRunner.Core.AirGap;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
namespace StellaOps.TaskRunner.Core.TaskPacks;
public sealed class TaskPackManifest
{
[JsonPropertyName("apiVersion")]
public required string ApiVersion { get; init; }
[JsonPropertyName("kind")]
public required string Kind { get; init; }
[JsonPropertyName("metadata")]
public required TaskPackMetadata Metadata { get; init; }
[JsonPropertyName("spec")]
public required TaskPackSpec Spec { get; init; }
}
public sealed class TaskPackMetadata
{
[JsonPropertyName("name")]
public required string Name { get; init; }
[JsonPropertyName("version")]
public required string Version { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
[JsonPropertyName("tags")]
public IReadOnlyList<string>? Tags { get; init; }
[JsonPropertyName("tenantVisibility")]
public IReadOnlyList<string>? TenantVisibility { get; init; }
[JsonPropertyName("maintainers")]
public IReadOnlyList<TaskPackMaintainer>? Maintainers { get; init; }
[JsonPropertyName("license")]
public string? License { get; init; }
[JsonPropertyName("annotations")]
public IReadOnlyDictionary<string, string>? Annotations { get; init; }
}
public sealed class TaskPackMaintainer
{
[JsonPropertyName("name")]
public required string Name { get; init; }
[JsonPropertyName("email")]
public string? Email { get; init; }
}
public sealed class TaskPackSpec
{
[JsonPropertyName("inputs")]
public IReadOnlyList<TaskPackInput>? Inputs { get; init; }
[JsonPropertyName("secrets")]
public IReadOnlyList<TaskPackSecret>? Secrets { get; init; }
[JsonPropertyName("approvals")]
public IReadOnlyList<TaskPackApproval>? Approvals { get; init; }
[JsonPropertyName("steps")]
public IReadOnlyList<TaskPackStep> Steps { get; init; } = Array.Empty<TaskPackStep>();
[JsonPropertyName("outputs")]
public IReadOnlyList<TaskPackOutput>? Outputs { get; init; }
[JsonPropertyName("success")]
public TaskPackSuccess? Success { get; init; }
[JsonPropertyName("failure")]
public TaskPackFailure? Failure { get; init; }
[JsonPropertyName("sandbox")]
public TaskPackSandbox? Sandbox { get; init; }
[JsonPropertyName("slo")]
public TaskPackSlo? Slo { get; init; }
/// <summary>
/// Whether this pack requires a sealed (air-gapped) environment.
/// </summary>
[JsonPropertyName("sealedInstall")]
public bool SealedInstall { get; init; }
/// <summary>
/// Specific requirements for sealed install mode.
/// </summary>
[JsonPropertyName("sealedRequirements")]
public SealedRequirements? SealedRequirements { get; init; }
}
public sealed class TaskPackInput
{
[JsonPropertyName("name")]
public required string Name { get; init; }
[JsonPropertyName("type")]
public required string Type { get; init; }
[JsonPropertyName("schema")]
public string? Schema { get; init; }
[JsonPropertyName("required")]
public bool Required { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
[JsonPropertyName("default")]
public JsonNode? Default { get; init; }
}
public sealed class TaskPackSecret
{
[JsonPropertyName("name")]
public required string Name { get; init; }
[JsonPropertyName("scope")]
public required string Scope { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
}
public sealed class TaskPackApproval
{
[JsonPropertyName("id")]
public required string Id { get; init; }
[JsonPropertyName("grants")]
public IReadOnlyList<string> Grants { get; init; } = Array.Empty<string>();
[JsonPropertyName("expiresAfter")]
public string? ExpiresAfter { get; init; }
[JsonPropertyName("reasonTemplate")]
public string? ReasonTemplate { get; init; }
}
public sealed class TaskPackStep
{
[JsonPropertyName("id")]
public required string Id { get; init; }
[JsonPropertyName("name")]
public string? Name { get; init; }
[JsonPropertyName("when")]
public string? When { get; init; }
[JsonPropertyName("run")]
public TaskPackRunStep? Run { get; init; }
[JsonPropertyName("gate")]
public TaskPackGateStep? Gate { get; init; }
[JsonPropertyName("parallel")]
public TaskPackParallelStep? Parallel { get; init; }
[JsonPropertyName("map")]
public TaskPackMapStep? Map { get; init; }
[JsonPropertyName("loop")]
public TaskPackLoopStep? Loop { get; init; }
[JsonPropertyName("conditional")]
public TaskPackConditionalStep? Conditional { get; init; }
}
public sealed class TaskPackRunStep
{
[JsonPropertyName("uses")]
public required string Uses { get; init; }
[JsonPropertyName("with")]
public IDictionary<string, JsonNode?>? With { get; init; }
[JsonPropertyName("egress")]
public IReadOnlyList<TaskPackRunEgress>? Egress { get; init; }
}
public sealed class TaskPackRunEgress
{
[JsonPropertyName("url")]
public required string Url { get; init; }
[JsonPropertyName("intent")]
public string? Intent { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
}
public sealed class TaskPackGateStep
{
[JsonPropertyName("approval")]
public TaskPackApprovalGate? Approval { get; init; }
[JsonPropertyName("policy")]
public TaskPackPolicyGate? Policy { get; init; }
[JsonPropertyName("message")]
public string? Message { get; init; }
}
public sealed class TaskPackApprovalGate
{
[JsonPropertyName("id")]
public required string Id { get; init; }
[JsonPropertyName("autoExpireAfter")]
public string? AutoExpireAfter { get; init; }
}
public sealed class TaskPackPolicyGate
{
[JsonPropertyName("policy")]
public required string Policy { get; init; }
[JsonPropertyName("parameters")]
public IDictionary<string, JsonNode?>? Parameters { get; init; }
}
public sealed class TaskPackParallelStep
{
[JsonPropertyName("steps")]
public IReadOnlyList<TaskPackStep> Steps { get; init; } = Array.Empty<TaskPackStep>();
[JsonPropertyName("maxParallel")]
public int? MaxParallel { get; init; }
[JsonPropertyName("continueOnError")]
public bool ContinueOnError { get; init; }
}
public sealed class TaskPackMapStep
{
[JsonPropertyName("items")]
public required string Items { get; init; }
[JsonPropertyName("step")]
public required TaskPackStep Step { get; init; }
}
public sealed class TaskPackLoopStep
{
[JsonPropertyName("items")]
public string? Items { get; init; }
[JsonPropertyName("range")]
public TaskPackLoopRange? Range { get; init; }
[JsonPropertyName("staticItems")]
public IReadOnlyList<object>? StaticItems { get; init; }
[JsonPropertyName("iterator")]
public string Iterator { get; init; } = "item";
[JsonPropertyName("index")]
public string Index { get; init; } = "index";
[JsonPropertyName("maxIterations")]
public int MaxIterations { get; init; } = 1000;
[JsonPropertyName("aggregation")]
public string? Aggregation { get; init; }
[JsonPropertyName("outputPath")]
public string? OutputPath { get; init; }
[JsonPropertyName("steps")]
public IReadOnlyList<TaskPackStep> Steps { get; init; } = Array.Empty<TaskPackStep>();
}
public sealed class TaskPackLoopRange
{
[JsonPropertyName("start")]
public int Start { get; init; }
[JsonPropertyName("end")]
public int End { get; init; }
[JsonPropertyName("step")]
public int Step { get; init; } = 1;
}
public sealed class TaskPackConditionalStep
{
[JsonPropertyName("branches")]
public IReadOnlyList<TaskPackConditionalBranch> Branches { get; init; } = Array.Empty<TaskPackConditionalBranch>();
[JsonPropertyName("else")]
public IReadOnlyList<TaskPackStep>? Else { get; init; }
[JsonPropertyName("outputUnion")]
public bool OutputUnion { get; init; }
}
public sealed class TaskPackConditionalBranch
{
[JsonPropertyName("condition")]
public required string Condition { get; init; }
[JsonPropertyName("steps")]
public IReadOnlyList<TaskPackStep> Steps { get; init; } = Array.Empty<TaskPackStep>();
}
public sealed class TaskPackOutput
{
[JsonPropertyName("name")]
public required string Name { get; init; }
[JsonPropertyName("type")]
public required string Type { get; init; }
[JsonPropertyName("path")]
public string? Path { get; init; }
[JsonPropertyName("expression")]
public string? Expression { get; init; }
}
public sealed class TaskPackSuccess
{
[JsonPropertyName("message")]
public string? Message { get; init; }
}
public sealed class TaskPackFailure
{
[JsonPropertyName("message")]
public string? Message { get; init; }
[JsonPropertyName("retries")]
public TaskPackRetryPolicy? Retries { get; init; }
}
public sealed class TaskPackRetryPolicy
{
[JsonPropertyName("maxAttempts")]
public int MaxAttempts { get; init; }
[JsonPropertyName("backoffSeconds")]
public int BackoffSeconds { get; init; }
}
public sealed class TaskPackSandbox
{
[JsonPropertyName("mode")]
public string? Mode { get; init; }
[JsonPropertyName("egressAllowlist")]
public IReadOnlyList<string>? EgressAllowlist { get; init; }
[JsonPropertyName("cpuLimitMillicores")]
public int CpuLimitMillicores { get; init; }
[JsonPropertyName("memoryLimitMiB")]
public int MemoryLimitMiB { get; init; }
[JsonPropertyName("quotaSeconds")]
public int QuotaSeconds { get; init; }
}
public sealed class TaskPackSlo
{
[JsonPropertyName("runP95Seconds")]
public int RunP95Seconds { get; init; }
[JsonPropertyName("approvalP95Seconds")]
public int ApprovalP95Seconds { get; init; }
[JsonPropertyName("maxQueueDepth")]
public int MaxQueueDepth { get; init; }
}

View File

@@ -1,169 +0,0 @@
using System.Collections;
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace StellaOps.TaskRunner.Core.TaskPacks;
public sealed class TaskPackManifestLoader
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public async Task<TaskPackManifest> LoadAsync(Stream stream, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(stream);
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 4096, leaveOpen: true);
var yaml = await reader.ReadToEndAsync().ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
return Deserialize(yaml);
}
public TaskPackManifest Load(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
throw new ArgumentException("Path must not be empty.", nameof(path));
}
using var stream = File.OpenRead(path);
return LoadAsync(stream).GetAwaiter().GetResult();
}
public TaskPackManifest Deserialize(string yaml)
{
if (string.IsNullOrWhiteSpace(yaml))
{
throw new TaskPackManifestLoadException("Manifest is empty.");
}
try
{
var deserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.IgnoreUnmatchedProperties()
.Build();
using var reader = new StringReader(yaml);
var yamlObject = deserializer.Deserialize(reader);
if (yamlObject is null)
{
throw new TaskPackManifestLoadException("Manifest is empty.");
}
var node = ConvertToJsonNode(yamlObject);
if (node is null)
{
throw new TaskPackManifestLoadException("Manifest is empty.");
}
var manifest = node.Deserialize<TaskPackManifest>(SerializerOptions);
if (manifest is null)
{
throw new TaskPackManifestLoadException("Unable to deserialize manifest.");
}
return manifest;
}
catch (TaskPackManifestLoadException)
{
throw;
}
catch (Exception ex)
{
throw new TaskPackManifestLoadException(string.Format(CultureInfo.InvariantCulture, "Failed to parse manifest: {0}", ex.Message), ex);
}
}
private static JsonNode? ConvertToJsonNode(object? value)
{
switch (value)
{
case null:
return null;
case string s:
if (bool.TryParse(s, out var boolValue))
{
return JsonValue.Create(boolValue);
}
if (long.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var longValue))
{
return JsonValue.Create(longValue);
}
if (double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var doubleValue))
{
return JsonValue.Create(doubleValue);
}
return JsonValue.Create(s);
case bool b:
return JsonValue.Create(b);
case int i:
return JsonValue.Create(i);
case long l:
return JsonValue.Create(l);
case double d:
return JsonValue.Create(d);
case float f:
return JsonValue.Create(f);
case decimal dec:
return JsonValue.Create(dec);
case IDictionary<object, object> dictionary:
{
var obj = new JsonObject();
foreach (var kvp in dictionary)
{
var key = Convert.ToString(kvp.Key, CultureInfo.InvariantCulture);
if (string.IsNullOrEmpty(key))
{
continue;
}
obj[key] = ConvertToJsonNode(kvp.Value);
}
return obj;
}
case IEnumerable enumerable:
{
var array = new JsonArray();
foreach (var item in enumerable)
{
array.Add(ConvertToJsonNode(item));
}
return array;
}
default:
return JsonValue.Create(value.ToString());
}
}
}
public sealed class TaskPackManifestLoadException : Exception
{
public TaskPackManifestLoadException(string message)
: base(message)
{
}
public TaskPackManifestLoadException(string message, Exception innerException)
: base(message, innerException)
{
}
}

View File

@@ -1,351 +0,0 @@
using System;
using System.Collections.Immutable;
using System.Linq;
using System.Text.RegularExpressions;
namespace StellaOps.TaskRunner.Core.TaskPacks;
public sealed class TaskPackManifestValidator
{
private static readonly Regex NameRegex = new("^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex VersionRegex = new("^[0-9]+\\.[0-9]+\\.[0-9]+(?:[-+][0-9A-Za-z-.]+)?$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
public TaskPackManifestValidationResult Validate(TaskPackManifest manifest)
{
ArgumentNullException.ThrowIfNull(manifest);
var errors = new List<TaskPackManifestValidationError>();
if (!string.Equals(manifest.ApiVersion, "stellaops.io/pack.v1", StringComparison.Ordinal))
{
errors.Add(new TaskPackManifestValidationError("apiVersion", "Only apiVersion 'stellaops.io/pack.v1' is supported."));
}
if (!string.Equals(manifest.Kind, "TaskPack", StringComparison.Ordinal))
{
errors.Add(new TaskPackManifestValidationError("kind", "Kind must be 'TaskPack'."));
}
ValidateMetadata(manifest.Metadata, errors);
ValidateSpec(manifest.Spec, errors);
return new TaskPackManifestValidationResult(errors.ToImmutableArray());
}
private static void ValidateMetadata(TaskPackMetadata metadata, ICollection<TaskPackManifestValidationError> errors)
{
if (string.IsNullOrWhiteSpace(metadata.Name))
{
errors.Add(new TaskPackManifestValidationError("metadata.name", "Name is required."));
}
else if (!NameRegex.IsMatch(metadata.Name))
{
errors.Add(new TaskPackManifestValidationError("metadata.name", "Name must follow DNS-1123 naming (lowercase alphanumeric plus '-')."));
}
if (string.IsNullOrWhiteSpace(metadata.Version))
{
errors.Add(new TaskPackManifestValidationError("metadata.version", "Version is required."));
}
else if (!VersionRegex.IsMatch(metadata.Version))
{
errors.Add(new TaskPackManifestValidationError("metadata.version", "Version must follow SemVer (major.minor.patch[+/-metadata])."));
}
}
private static void ValidateSpec(TaskPackSpec spec, ICollection<TaskPackManifestValidationError> errors)
{
if (spec.Steps is null || spec.Steps.Count == 0)
{
errors.Add(new TaskPackManifestValidationError("spec.steps", "At least one step is required."));
return;
}
var stepIds = new HashSet<string>(StringComparer.Ordinal);
var approvalIds = new HashSet<string>(StringComparer.Ordinal);
if (spec.Approvals is not null)
{
foreach (var approval in spec.Approvals)
{
if (!approvalIds.Add(approval.Id))
{
errors.Add(new TaskPackManifestValidationError($"spec.approvals[{approval.Id}]", "Duplicate approval id."));
}
}
}
ValidateInputs(spec, errors);
ValidateSteps(spec.Steps, "spec.steps", stepIds, approvalIds, errors);
}
private static void ValidateInputs(TaskPackSpec spec, ICollection<TaskPackManifestValidationError> errors)
{
if (spec.Inputs is null)
{
return;
}
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var (input, index) in spec.Inputs.Select((input, index) => (input, index)))
{
var prefix = $"spec.inputs[{index}]";
if (!seen.Add(input.Name))
{
errors.Add(new TaskPackManifestValidationError($"{prefix}.name", "Duplicate input name."));
}
if (string.IsNullOrWhiteSpace(input.Type))
{
errors.Add(new TaskPackManifestValidationError($"{prefix}.type", "Input type is required."));
}
}
}
private static void ValidateSteps(
IReadOnlyList<TaskPackStep> steps,
string pathPrefix,
HashSet<string> stepIds,
HashSet<string> approvalIds,
ICollection<TaskPackManifestValidationError> errors)
{
foreach (var (step, index) in steps.Select((step, index) => (step, index)))
{
var path = $"{pathPrefix}[{index}]";
if (!stepIds.Add(step.Id))
{
errors.Add(new TaskPackManifestValidationError($"{path}.id", "Duplicate step id."));
}
var typeCount = (step.Run is not null ? 1 : 0)
+ (step.Gate is not null ? 1 : 0)
+ (step.Parallel is not null ? 1 : 0)
+ (step.Map is not null ? 1 : 0)
+ (step.Loop is not null ? 1 : 0)
+ (step.Conditional is not null ? 1 : 0);
if (typeCount == 0)
{
errors.Add(new TaskPackManifestValidationError(path, "Step must define one of run, gate, parallel, map, loop, or conditional."));
}
else if (typeCount > 1)
{
errors.Add(new TaskPackManifestValidationError(path, "Step may define only one of run, gate, parallel, map, loop, or conditional."));
}
if (step.Run is not null)
{
ValidateRunStep(step.Run, $"{path}.run", errors);
}
if (step.Gate is not null)
{
ValidateGateStep(step.Gate, approvalIds, $"{path}.gate", errors);
}
if (step.Parallel is not null)
{
ValidateParallelStep(step.Parallel, $"{path}.parallel", stepIds, approvalIds, errors);
}
if (step.Map is not null)
{
ValidateMapStep(step.Map, $"{path}.map", stepIds, approvalIds, errors);
}
if (step.Loop is not null)
{
ValidateLoopStep(step.Loop, $"{path}.loop", stepIds, approvalIds, errors);
}
if (step.Conditional is not null)
{
ValidateConditionalStep(step.Conditional, $"{path}.conditional", stepIds, approvalIds, errors);
}
}
}
private static void ValidateRunStep(TaskPackRunStep run, string path, ICollection<TaskPackManifestValidationError> errors)
{
if (string.IsNullOrWhiteSpace(run.Uses))
{
errors.Add(new TaskPackManifestValidationError($"{path}.uses", "Run step requires 'uses'."));
}
if (run.Egress is not null)
{
for (var i = 0; i < run.Egress.Count; i++)
{
var entry = run.Egress[i];
var entryPath = $"{path}.egress[{i}]";
if (entry is null)
{
errors.Add(new TaskPackManifestValidationError(entryPath, "Egress entry must be specified."));
continue;
}
if (string.IsNullOrWhiteSpace(entry.Url))
{
errors.Add(new TaskPackManifestValidationError($"{entryPath}.url", "Egress entry requires an absolute URL."));
}
else if (!Uri.TryCreate(entry.Url, UriKind.Absolute, out var uri) ||
(!string.Equals(uri.Scheme, "http", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(uri.Scheme, "https", StringComparison.OrdinalIgnoreCase)))
{
errors.Add(new TaskPackManifestValidationError($"{entryPath}.url", "Egress URL must be an absolute HTTP or HTTPS address."));
}
if (entry.Intent is not null && string.IsNullOrWhiteSpace(entry.Intent))
{
errors.Add(new TaskPackManifestValidationError($"{entryPath}.intent", "Intent must be omitted or non-empty."));
}
}
}
}
private static void ValidateGateStep(TaskPackGateStep gate, HashSet<string> approvalIds, string path, ICollection<TaskPackManifestValidationError> errors)
{
if (gate.Approval is null && gate.Policy is null)
{
errors.Add(new TaskPackManifestValidationError(path, "Gate step requires 'approval' or 'policy'."));
return;
}
if (gate.Approval is not null)
{
if (!approvalIds.Contains(gate.Approval.Id))
{
errors.Add(new TaskPackManifestValidationError($"{path}.approval.id", $"Approval '{gate.Approval.Id}' is not declared under spec.approvals."));
}
}
}
private static void ValidateParallelStep(
TaskPackParallelStep parallel,
string path,
HashSet<string> stepIds,
HashSet<string> approvalIds,
ICollection<TaskPackManifestValidationError> errors)
{
if (parallel.Steps.Count == 0)
{
errors.Add(new TaskPackManifestValidationError($"{path}.steps", "Parallel step requires nested steps."));
return;
}
ValidateSteps(parallel.Steps, $"{path}.steps", stepIds, approvalIds, errors);
}
private static void ValidateMapStep(
TaskPackMapStep map,
string path,
HashSet<string> stepIds,
HashSet<string> approvalIds,
ICollection<TaskPackManifestValidationError> errors)
{
if (string.IsNullOrWhiteSpace(map.Items))
{
errors.Add(new TaskPackManifestValidationError($"{path}.items", "Map step requires 'items' expression."));
}
if (map.Step is null)
{
errors.Add(new TaskPackManifestValidationError($"{path}.step", "Map step requires nested step definition."));
}
else
{
ValidateSteps(new[] { map.Step }, $"{path}.step", stepIds, approvalIds, errors);
}
}
private static void ValidateLoopStep(
TaskPackLoopStep loop,
string path,
HashSet<string> stepIds,
HashSet<string> approvalIds,
ICollection<TaskPackManifestValidationError> errors)
{
// Loop must have one of: items expression, range, or staticItems
var sourceCount = (string.IsNullOrWhiteSpace(loop.Items) ? 0 : 1)
+ (loop.Range is not null ? 1 : 0)
+ (loop.StaticItems is not null ? 1 : 0);
if (sourceCount == 0)
{
errors.Add(new TaskPackManifestValidationError(path, "Loop step requires 'items', 'range', or 'staticItems'."));
}
if (loop.MaxIterations <= 0)
{
errors.Add(new TaskPackManifestValidationError($"{path}.maxIterations", "maxIterations must be greater than 0."));
}
if (loop.Steps.Count == 0)
{
errors.Add(new TaskPackManifestValidationError($"{path}.steps", "Loop step requires nested steps."));
}
else
{
ValidateSteps(loop.Steps, $"{path}.steps", stepIds, approvalIds, errors);
}
}
private static void ValidateConditionalStep(
TaskPackConditionalStep conditional,
string path,
HashSet<string> stepIds,
HashSet<string> approvalIds,
ICollection<TaskPackManifestValidationError> errors)
{
if (conditional.Branches.Count == 0)
{
errors.Add(new TaskPackManifestValidationError($"{path}.branches", "Conditional step requires at least one branch."));
return;
}
for (var i = 0; i < conditional.Branches.Count; i++)
{
var branch = conditional.Branches[i];
var branchPath = $"{path}.branches[{i}]";
if (string.IsNullOrWhiteSpace(branch.Condition))
{
errors.Add(new TaskPackManifestValidationError($"{branchPath}.condition", "Branch requires a condition expression."));
}
if (branch.Steps.Count == 0)
{
errors.Add(new TaskPackManifestValidationError($"{branchPath}.steps", "Branch requires nested steps."));
}
else
{
ValidateSteps(branch.Steps, $"{branchPath}.steps", stepIds, approvalIds, errors);
}
}
if (conditional.Else is not null && conditional.Else.Count > 0)
{
ValidateSteps(conditional.Else, $"{path}.else", stepIds, approvalIds, errors);
}
}
}
public sealed record TaskPackManifestValidationError(string Path, string Message);
public sealed class TaskPackManifestValidationResult
{
public TaskPackManifestValidationResult(ImmutableArray<TaskPackManifestValidationError> errors)
{
Errors = errors;
}
public ImmutableArray<TaskPackManifestValidationError> Errors { get; }
public bool IsValid => Errors.IsDefaultOrEmpty;
}

View File

@@ -1,402 +0,0 @@
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Net;
using System.Text.RegularExpressions;
namespace StellaOps.TaskRunner.Core.Tenancy;
/// <summary>
/// Interface for tenant egress policy enforcement per TASKRUN-TEN-48-001.
/// Controls outbound network access based on tenant restrictions.
/// </summary>
public interface ITenantEgressPolicy
{
/// <summary>
/// Checks whether egress to a given URI is allowed for the tenant.
/// </summary>
ValueTask<EgressPolicyResult> CheckEgressAsync(
TenantContext tenant,
Uri targetUri,
CancellationToken cancellationToken = default);
/// <summary>
/// Checks whether egress to a given host and port is allowed for the tenant.
/// </summary>
ValueTask<EgressPolicyResult> CheckEgressAsync(
TenantContext tenant,
string host,
int port,
CancellationToken cancellationToken = default);
/// <summary>
/// Records an egress attempt for auditing.
/// </summary>
ValueTask RecordEgressAttemptAsync(
TenantContext tenant,
string runId,
Uri targetUri,
EgressPolicyResult result,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of an egress policy check.
/// </summary>
public sealed record EgressPolicyResult
{
public static EgressPolicyResult Allowed { get; } = new() { IsAllowed = true };
public static EgressPolicyResult BlockedByTenant(string reason) => new()
{
IsAllowed = false,
BlockReason = EgressBlockReason.TenantRestriction,
Message = reason
};
public static EgressPolicyResult BlockedByGlobalPolicy(string reason) => new()
{
IsAllowed = false,
BlockReason = EgressBlockReason.GlobalPolicy,
Message = reason
};
public static EgressPolicyResult BlockedBySuspension(string reason) => new()
{
IsAllowed = false,
BlockReason = EgressBlockReason.TenantSuspended,
Message = reason
};
public bool IsAllowed { get; init; }
public EgressBlockReason? BlockReason { get; init; }
public string? Message { get; init; }
public DateTimeOffset? CheckedAt { get; init; } = DateTimeOffset.UtcNow;
}
/// <summary>
/// Reason for egress being blocked.
/// </summary>
public enum EgressBlockReason
{
/// <summary>
/// Blocked by tenant-specific restrictions.
/// </summary>
TenantRestriction,
/// <summary>
/// Blocked by global policy (blocklist).
/// </summary>
GlobalPolicy,
/// <summary>
/// Blocked because tenant is suspended.
/// </summary>
TenantSuspended,
/// <summary>
/// Blocked because egress is disabled for this environment.
/// </summary>
EgressDisabled
}
/// <summary>
/// Record of an egress attempt for auditing.
/// </summary>
public sealed record EgressAttemptRecord(
string TenantId,
string ProjectId,
string RunId,
Uri TargetUri,
bool WasAllowed,
EgressBlockReason? BlockReason,
string? BlockMessage,
DateTimeOffset Timestamp);
/// <summary>
/// Default implementation of tenant egress policy.
/// </summary>
public sealed partial class TenantEgressPolicy : ITenantEgressPolicy
{
private readonly TenantEgressPolicyOptions _options;
private readonly IEgressAuditLog _auditLog;
private readonly ILogger<TenantEgressPolicy> _logger;
private readonly HashSet<string> _globalAllowlist;
private readonly HashSet<string> _globalBlocklist;
public TenantEgressPolicy(
TenantEgressPolicyOptions options,
IEgressAuditLog auditLog,
ILogger<TenantEgressPolicy> logger)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_auditLog = auditLog ?? throw new ArgumentNullException(nameof(auditLog));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_globalAllowlist = new HashSet<string>(
options.GlobalAllowlist.Select(NormalizeHost),
StringComparer.OrdinalIgnoreCase);
_globalBlocklist = new HashSet<string>(
options.GlobalBlocklist.Select(NormalizeHost),
StringComparer.OrdinalIgnoreCase);
}
public ValueTask<EgressPolicyResult> CheckEgressAsync(
TenantContext tenant,
Uri targetUri,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(tenant);
ArgumentNullException.ThrowIfNull(targetUri);
return CheckEgressAsync(tenant, targetUri.Host, targetUri.Port, cancellationToken);
}
public ValueTask<EgressPolicyResult> CheckEgressAsync(
TenantContext tenant,
string host,
int port,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(tenant);
ArgumentException.ThrowIfNullOrWhiteSpace(host);
var normalizedHost = NormalizeHost(host);
// Check if tenant is suspended
if (tenant.Restrictions.Suspended)
{
_logger.LogWarning(
"Egress blocked for suspended tenant {TenantId} to {Host}:{Port}.",
tenant.TenantId,
host,
port);
return ValueTask.FromResult(
EgressPolicyResult.BlockedBySuspension("Tenant is suspended."));
}
// Check global blocklist first
if (IsInList(_globalBlocklist, normalizedHost))
{
_logger.LogWarning(
"Egress blocked by global blocklist for tenant {TenantId} to {Host}:{Port}.",
tenant.TenantId,
host,
port);
return ValueTask.FromResult(
EgressPolicyResult.BlockedByGlobalPolicy($"Host {host} is in global blocklist."));
}
// Check if tenant egress is completely blocked
if (tenant.Restrictions.EgressBlocked)
{
// Check tenant-specific allowlist
if (!tenant.Restrictions.AllowedEgressDomains.IsDefaultOrEmpty)
{
var tenantAllowlist = new HashSet<string>(
tenant.Restrictions.AllowedEgressDomains.Select(NormalizeHost),
StringComparer.OrdinalIgnoreCase);
if (IsInList(tenantAllowlist, normalizedHost))
{
_logger.LogDebug(
"Egress allowed via tenant allowlist for {TenantId} to {Host}:{Port}.",
tenant.TenantId,
host,
port);
return ValueTask.FromResult(EgressPolicyResult.Allowed);
}
}
_logger.LogWarning(
"Egress blocked by tenant restriction for {TenantId} to {Host}:{Port}.",
tenant.TenantId,
host,
port);
return ValueTask.FromResult(
EgressPolicyResult.BlockedByTenant($"Egress blocked for tenant {tenant.TenantId}."));
}
// Check global allowlist (if not allowing by default)
if (!_options.AllowByDefault)
{
if (!IsInList(_globalAllowlist, normalizedHost))
{
_logger.LogWarning(
"Egress blocked (not in allowlist) for tenant {TenantId} to {Host}:{Port}.",
tenant.TenantId,
host,
port);
return ValueTask.FromResult(
EgressPolicyResult.BlockedByGlobalPolicy($"Host {host} is not in allowlist."));
}
}
_logger.LogDebug(
"Egress allowed for tenant {TenantId} to {Host}:{Port}.",
tenant.TenantId,
host,
port);
return ValueTask.FromResult(EgressPolicyResult.Allowed);
}
public async ValueTask RecordEgressAttemptAsync(
TenantContext tenant,
string runId,
Uri targetUri,
EgressPolicyResult result,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(tenant);
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
ArgumentNullException.ThrowIfNull(targetUri);
ArgumentNullException.ThrowIfNull(result);
var record = new EgressAttemptRecord(
TenantId: tenant.TenantId,
ProjectId: tenant.ProjectId,
RunId: runId,
TargetUri: targetUri,
WasAllowed: result.IsAllowed,
BlockReason: result.BlockReason,
BlockMessage: result.Message,
Timestamp: DateTimeOffset.UtcNow);
await _auditLog.RecordAsync(record, cancellationToken).ConfigureAwait(false);
if (!result.IsAllowed && _options.LogBlockedAttempts)
{
_logger.LogWarning(
"Egress attempt blocked: Tenant={TenantId}, Run={RunId}, Target={TargetUri}, Reason={Reason}",
tenant.TenantId,
runId,
targetUri,
result.Message);
}
}
private static string NormalizeHost(string host)
{
var normalized = host.Trim().ToLowerInvariant();
if (normalized.StartsWith("*."))
{
return normalized; // Keep wildcard prefix
}
return normalized;
}
private static bool IsInList(HashSet<string> list, string host)
{
// Exact match
if (list.Contains(host))
{
return true;
}
// Wildcard match (*.example.com matches sub.example.com)
var parts = host.Split('.');
for (var i = 1; i < parts.Length; i++)
{
var wildcard = "*." + string.Join('.', parts[i..]);
if (list.Contains(wildcard))
{
return true;
}
}
return false;
}
}
/// <summary>
/// Interface for egress audit logging.
/// </summary>
public interface IEgressAuditLog
{
ValueTask RecordAsync(EgressAttemptRecord record, CancellationToken cancellationToken = default);
IAsyncEnumerable<EgressAttemptRecord> GetRecordsAsync(
string tenantId,
string? runId = null,
DateTimeOffset? since = null,
CancellationToken cancellationToken = default);
}
/// <summary>
/// In-memory implementation of egress audit log for testing.
/// </summary>
public sealed class InMemoryEgressAuditLog : IEgressAuditLog
{
private readonly ConcurrentBag<EgressAttemptRecord> _records = [];
public ValueTask RecordAsync(EgressAttemptRecord record, CancellationToken cancellationToken = default)
{
_records.Add(record);
return ValueTask.CompletedTask;
}
public async IAsyncEnumerable<EgressAttemptRecord> GetRecordsAsync(
string tenantId,
string? runId = null,
DateTimeOffset? since = null,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.Yield();
var query = _records
.Where(r => r.TenantId.Equals(tenantId, StringComparison.Ordinal));
if (runId is not null)
{
query = query.Where(r => r.RunId.Equals(runId, StringComparison.Ordinal));
}
if (since.HasValue)
{
query = query.Where(r => r.Timestamp >= since.Value);
}
foreach (var record in query.OrderBy(r => r.Timestamp))
{
cancellationToken.ThrowIfCancellationRequested();
yield return record;
}
}
/// <summary>
/// Gets all records (for testing).
/// </summary>
public IReadOnlyList<EgressAttemptRecord> GetAllRecords() => [.. _records];
}
/// <summary>
/// Null implementation of egress audit log.
/// </summary>
public sealed class NullEgressAuditLog : IEgressAuditLog
{
public static NullEgressAuditLog Instance { get; } = new();
public ValueTask RecordAsync(EgressAttemptRecord record, CancellationToken cancellationToken = default)
=> ValueTask.CompletedTask;
public async IAsyncEnumerable<EgressAttemptRecord> GetRecordsAsync(
string tenantId,
string? runId = null,
DateTimeOffset? since = null,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.Yield();
yield break;
}
}

View File

@@ -1,261 +0,0 @@
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.TaskRunner.Core.Tenancy;
/// <summary>
/// Interface for resolving tenant-scoped storage paths per TASKRUN-TEN-48-001.
/// Ensures all pack run storage (state, logs, artifacts) uses tenant-prefixed paths.
/// </summary>
public interface ITenantScopedStoragePathResolver
{
/// <summary>
/// Gets the tenant-prefixed path for run state storage.
/// </summary>
string GetStatePath(TenantContext tenant, string runId);
/// <summary>
/// Gets the tenant-prefixed path for run logs storage.
/// </summary>
string GetLogsPath(TenantContext tenant, string runId);
/// <summary>
/// Gets the tenant-prefixed path for run artifacts storage.
/// </summary>
string GetArtifactsPath(TenantContext tenant, string runId);
/// <summary>
/// Gets the tenant-prefixed path for approval records storage.
/// </summary>
string GetApprovalsPath(TenantContext tenant, string runId);
/// <summary>
/// Gets the tenant-prefixed path for provenance records storage.
/// </summary>
string GetProvenancePath(TenantContext tenant, string runId);
/// <summary>
/// Gets the tenant prefix for database collection/table queries.
/// </summary>
string GetDatabasePrefix(TenantContext tenant);
/// <summary>
/// Gets the base directory for a tenant's storage.
/// </summary>
string GetTenantBasePath(TenantContext tenant);
/// <summary>
/// Validates that a given path belongs to the specified tenant.
/// </summary>
bool ValidatePathBelongsToTenant(TenantContext tenant, string path);
}
/// <summary>
/// Default implementation of tenant-scoped storage path resolver.
/// </summary>
public sealed class TenantScopedStoragePathResolver : ITenantScopedStoragePathResolver
{
private readonly TenantStoragePathOptions _options;
private readonly string _rootPath;
public TenantScopedStoragePathResolver(
TenantStoragePathOptions options,
string rootPath)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
ArgumentException.ThrowIfNullOrWhiteSpace(rootPath);
_rootPath = Path.GetFullPath(rootPath);
}
public string GetStatePath(TenantContext tenant, string runId)
{
ArgumentNullException.ThrowIfNull(tenant);
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
return BuildPath(_options.StateBasePath, tenant, runId);
}
public string GetLogsPath(TenantContext tenant, string runId)
{
ArgumentNullException.ThrowIfNull(tenant);
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
return BuildPath(_options.LogsBasePath, tenant, runId);
}
public string GetArtifactsPath(TenantContext tenant, string runId)
{
ArgumentNullException.ThrowIfNull(tenant);
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
return BuildPath(_options.ArtifactsBasePath, tenant, runId);
}
public string GetApprovalsPath(TenantContext tenant, string runId)
{
ArgumentNullException.ThrowIfNull(tenant);
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
return BuildPath(_options.ApprovalsBasePath, tenant, runId);
}
public string GetProvenancePath(TenantContext tenant, string runId)
{
ArgumentNullException.ThrowIfNull(tenant);
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
return BuildPath(_options.ProvenanceBasePath, tenant, runId);
}
public string GetDatabasePrefix(TenantContext tenant)
{
ArgumentNullException.ThrowIfNull(tenant);
return _options.PathStrategy switch
{
TenantPathStrategy.Flat => tenant.FlatPrefix,
TenantPathStrategy.Hashed => ComputeHash(tenant.TenantId),
_ => $"{Sanitize(tenant.TenantId)}:{Sanitize(tenant.ProjectId)}"
};
}
public string GetTenantBasePath(TenantContext tenant)
{
ArgumentNullException.ThrowIfNull(tenant);
return _options.PathStrategy switch
{
TenantPathStrategy.Hierarchical => Path.Combine(
_rootPath,
Sanitize(tenant.TenantId),
Sanitize(tenant.ProjectId)),
TenantPathStrategy.Flat => Path.Combine(
_rootPath,
tenant.FlatPrefix),
TenantPathStrategy.Hashed => Path.Combine(
_rootPath,
ComputeHash(tenant.TenantId),
Sanitize(tenant.ProjectId)),
_ => Path.Combine(_rootPath, tenant.StoragePrefix)
};
}
public bool ValidatePathBelongsToTenant(TenantContext tenant, string path)
{
ArgumentNullException.ThrowIfNull(tenant);
ArgumentException.ThrowIfNullOrWhiteSpace(path);
var normalizedPath = Path.GetFullPath(path);
// For hierarchical paths, check that the tenant segment is in the path
return _options.PathStrategy switch
{
TenantPathStrategy.Hierarchical => ContainsTenantSegments(normalizedPath, tenant),
TenantPathStrategy.Flat => normalizedPath.Contains(tenant.FlatPrefix, StringComparison.OrdinalIgnoreCase),
TenantPathStrategy.Hashed => normalizedPath.Contains(ComputeHash(tenant.TenantId), StringComparison.OrdinalIgnoreCase)
&& normalizedPath.Contains(Sanitize(tenant.ProjectId), StringComparison.OrdinalIgnoreCase),
_ => ContainsTenantSegments(normalizedPath, tenant)
};
}
private bool ContainsTenantSegments(string path, TenantContext tenant)
{
// Check that path contains the tenant and project segments in order
var tenantSegment = Path.DirectorySeparatorChar + Sanitize(tenant.TenantId) + Path.DirectorySeparatorChar;
var projectSegment = Path.DirectorySeparatorChar + Sanitize(tenant.ProjectId) + Path.DirectorySeparatorChar;
var tenantIndex = path.IndexOf(tenantSegment, StringComparison.OrdinalIgnoreCase);
if (tenantIndex < 0)
{
return false;
}
var projectIndex = path.IndexOf(projectSegment, tenantIndex + tenantSegment.Length - 1, StringComparison.OrdinalIgnoreCase);
return projectIndex > tenantIndex;
}
private string BuildPath(string basePath, TenantContext tenant, string runId)
{
var safeRunId = Sanitize(runId);
return _options.PathStrategy switch
{
TenantPathStrategy.Hierarchical => Path.Combine(
_rootPath,
basePath,
Sanitize(tenant.TenantId),
Sanitize(tenant.ProjectId),
safeRunId),
TenantPathStrategy.Flat => Path.Combine(
_rootPath,
basePath,
$"{tenant.FlatPrefix}_{safeRunId}"),
TenantPathStrategy.Hashed => Path.Combine(
_rootPath,
basePath,
ComputeHash(tenant.TenantId),
Sanitize(tenant.ProjectId),
safeRunId),
_ => Path.Combine(_rootPath, basePath, tenant.StoragePrefix, safeRunId)
};
}
private static string Sanitize(string value)
{
var result = value.Trim().ToLowerInvariant();
foreach (var invalid in Path.GetInvalidFileNameChars())
{
result = result.Replace(invalid, '_');
}
result = result.Replace('/', '_').Replace('\\', '_');
return string.IsNullOrWhiteSpace(result) ? "unknown" : result;
}
private static string ComputeHash(string value)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(value));
return Convert.ToHexStringLower(bytes)[..16]; // First 16 chars of hex hash
}
}
/// <summary>
/// Storage path context for a specific pack run with tenant scoping.
/// </summary>
public sealed record TenantScopedStoragePaths(
string StatePath,
string LogsPath,
string ArtifactsPath,
string ApprovalsPath,
string ProvenancePath,
string DatabasePrefix,
string TenantBasePath)
{
/// <summary>
/// Creates storage paths from resolver and tenant context.
/// </summary>
public static TenantScopedStoragePaths Create(
ITenantScopedStoragePathResolver resolver,
TenantContext tenant,
string runId)
{
ArgumentNullException.ThrowIfNull(resolver);
ArgumentNullException.ThrowIfNull(tenant);
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
return new TenantScopedStoragePaths(
StatePath: resolver.GetStatePath(tenant, runId),
LogsPath: resolver.GetLogsPath(tenant, runId),
ArtifactsPath: resolver.GetArtifactsPath(tenant, runId),
ApprovalsPath: resolver.GetApprovalsPath(tenant, runId),
ProvenancePath: resolver.GetProvenancePath(tenant, runId),
DatabasePrefix: resolver.GetDatabasePrefix(tenant),
TenantBasePath: resolver.GetTenantBasePath(tenant));
}
}

View File

@@ -1,426 +0,0 @@
using Microsoft.Extensions.Logging;
using StellaOps.TaskRunner.Core.Execution;
namespace StellaOps.TaskRunner.Core.Tenancy;
/// <summary>
/// Enforces tenant context requirements for pack runs per TASKRUN-TEN-48-001.
/// Validates tenant context, enforces concurrent run limits, and propagates context.
/// </summary>
public interface IPackRunTenantEnforcer
{
/// <summary>
/// Validates that a pack run request has valid tenant context.
/// </summary>
ValueTask<TenantEnforcementResult> ValidateRequestAsync(
PackRunTenantRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Creates tenant-scoped execution context for a pack run.
/// </summary>
ValueTask<TenantScopedExecutionContext> CreateExecutionContextAsync(
PackRunTenantRequest request,
string runId,
CancellationToken cancellationToken = default);
/// <summary>
/// Records the start of a pack run for concurrent run tracking.
/// </summary>
ValueTask RecordRunStartAsync(
TenantContext tenant,
string runId,
CancellationToken cancellationToken = default);
/// <summary>
/// Records the completion of a pack run for concurrent run tracking.
/// </summary>
ValueTask RecordRunCompletionAsync(
TenantContext tenant,
string runId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the current concurrent run count for a tenant.
/// </summary>
ValueTask<int> GetConcurrentRunCountAsync(
TenantContext tenant,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Request for a tenant-scoped pack run.
/// </summary>
public sealed record PackRunTenantRequest(
string TenantId,
string ProjectId,
IReadOnlyDictionary<string, string>? Labels = null);
/// <summary>
/// Result of tenant enforcement validation.
/// </summary>
public sealed record TenantEnforcementResult
{
public static TenantEnforcementResult Success(TenantContext tenant) => new()
{
IsValid = true,
Tenant = tenant
};
public static TenantEnforcementResult Failure(string reason, TenantEnforcementFailureKind kind) => new()
{
IsValid = false,
FailureReason = reason,
FailureKind = kind
};
public bool IsValid { get; init; }
public TenantContext? Tenant { get; init; }
public string? FailureReason { get; init; }
public TenantEnforcementFailureKind? FailureKind { get; init; }
}
/// <summary>
/// Kind of tenant enforcement failure.
/// </summary>
public enum TenantEnforcementFailureKind
{
/// <summary>
/// Tenant ID is missing or invalid.
/// </summary>
MissingTenantId,
/// <summary>
/// Project ID is missing or invalid.
/// </summary>
MissingProjectId,
/// <summary>
/// Tenant does not exist or is not found.
/// </summary>
TenantNotFound,
/// <summary>
/// Tenant is suspended.
/// </summary>
TenantSuspended,
/// <summary>
/// Tenant is in read-only mode.
/// </summary>
TenantReadOnly,
/// <summary>
/// Tenant has reached maximum concurrent runs.
/// </summary>
MaxConcurrentRunsReached,
/// <summary>
/// Tenant validation failed for another reason.
/// </summary>
ValidationFailed
}
/// <summary>
/// Tenant-scoped execution context for a pack run.
/// </summary>
public sealed record TenantScopedExecutionContext(
TenantContext Tenant,
TenantScopedStoragePaths StoragePaths,
IReadOnlyDictionary<string, object> LoggingScope);
/// <summary>
/// Default implementation of pack run tenant enforcer.
/// </summary>
public sealed class PackRunTenantEnforcer : IPackRunTenantEnforcer
{
private readonly ITenantContextProvider _tenantProvider;
private readonly ITenantScopedStoragePathResolver _pathResolver;
private readonly TenancyEnforcementOptions _options;
private readonly IConcurrentRunTracker _runTracker;
private readonly ILogger<PackRunTenantEnforcer> _logger;
public PackRunTenantEnforcer(
ITenantContextProvider tenantProvider,
ITenantScopedStoragePathResolver pathResolver,
TenancyEnforcementOptions options,
IConcurrentRunTracker runTracker,
ILogger<PackRunTenantEnforcer> logger)
{
_tenantProvider = tenantProvider ?? throw new ArgumentNullException(nameof(tenantProvider));
_pathResolver = pathResolver ?? throw new ArgumentNullException(nameof(pathResolver));
_options = options ?? throw new ArgumentNullException(nameof(options));
_runTracker = runTracker ?? throw new ArgumentNullException(nameof(runTracker));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask<TenantEnforcementResult> ValidateRequestAsync(
PackRunTenantRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
// Validate tenant ID
if (string.IsNullOrWhiteSpace(request.TenantId))
{
_logger.LogWarning("Pack run request rejected: missing tenant ID.");
return TenantEnforcementResult.Failure(
"Tenant ID is required for pack runs.",
TenantEnforcementFailureKind.MissingTenantId);
}
// Validate project ID (if required)
if (_options.RequireProjectId && string.IsNullOrWhiteSpace(request.ProjectId))
{
_logger.LogWarning(
"Pack run request rejected for tenant {TenantId}: missing project ID.",
request.TenantId);
return TenantEnforcementResult.Failure(
"Project ID is required for pack runs.",
TenantEnforcementFailureKind.MissingProjectId);
}
// Get tenant context
var tenant = await _tenantProvider.GetContextAsync(
request.TenantId,
request.ProjectId,
cancellationToken).ConfigureAwait(false);
if (tenant is null && _options.ValidateTenantExists)
{
_logger.LogWarning(
"Pack run request rejected: tenant {TenantId}/{ProjectId} not found.",
request.TenantId,
request.ProjectId);
return TenantEnforcementResult.Failure(
$"Tenant {request.TenantId}/{request.ProjectId} not found.",
TenantEnforcementFailureKind.TenantNotFound);
}
// Create tenant context if provider didn't return one
tenant ??= new TenantContext(request.TenantId, request.ProjectId, request.Labels);
// Validate tenant status
if (_options.BlockSuspendedTenants)
{
var validation = await _tenantProvider.ValidateAsync(tenant, cancellationToken)
.ConfigureAwait(false);
if (!validation.IsValid)
{
_logger.LogWarning(
"Pack run request rejected for tenant {TenantId}: {Reason}",
request.TenantId,
validation.Reason);
var kind = validation.IsSuspended
? TenantEnforcementFailureKind.TenantSuspended
: TenantEnforcementFailureKind.ValidationFailed;
return TenantEnforcementResult.Failure(
validation.Reason ?? "Tenant validation failed.",
kind);
}
}
// Check read-only mode
if (tenant.Restrictions.ReadOnly)
{
_logger.LogWarning(
"Pack run request rejected: tenant {TenantId} is in read-only mode.",
request.TenantId);
return TenantEnforcementResult.Failure(
"Tenant is in read-only mode.",
TenantEnforcementFailureKind.TenantReadOnly);
}
// Check concurrent run limit
var maxConcurrent = tenant.Restrictions.MaxConcurrentRuns ?? _options.DefaultMaxConcurrentRuns;
var currentCount = await _runTracker.GetCountAsync(tenant.TenantId, cancellationToken)
.ConfigureAwait(false);
if (currentCount >= maxConcurrent)
{
_logger.LogWarning(
"Pack run request rejected: tenant {TenantId} has reached max concurrent runs ({Count}/{Max}).",
request.TenantId,
currentCount,
maxConcurrent);
return TenantEnforcementResult.Failure(
$"Maximum concurrent runs ({maxConcurrent}) reached for tenant.",
TenantEnforcementFailureKind.MaxConcurrentRunsReached);
}
_logger.LogInformation(
"Pack run request validated for tenant {TenantId}/{ProjectId}.",
request.TenantId,
request.ProjectId);
return TenantEnforcementResult.Success(tenant);
}
public async ValueTask<TenantScopedExecutionContext> CreateExecutionContextAsync(
PackRunTenantRequest request,
string runId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
var validationResult = await ValidateRequestAsync(request, cancellationToken)
.ConfigureAwait(false);
if (!validationResult.IsValid)
{
throw new TenantEnforcementException(
validationResult.FailureReason ?? "Tenant validation failed.",
validationResult.FailureKind ?? TenantEnforcementFailureKind.ValidationFailed);
}
var tenant = validationResult.Tenant!;
var storagePaths = TenantScopedStoragePaths.Create(_pathResolver, tenant, runId);
var loggingScope = new Dictionary<string, object>(tenant.ToLoggingScope())
{
["RunId"] = runId
};
return new TenantScopedExecutionContext(tenant, storagePaths, loggingScope);
}
public async ValueTask RecordRunStartAsync(
TenantContext tenant,
string runId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(tenant);
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
await _runTracker.IncrementAsync(tenant.TenantId, runId, cancellationToken)
.ConfigureAwait(false);
_logger.LogDebug(
"Recorded run start for tenant {TenantId}, run {RunId}.",
tenant.TenantId,
runId);
}
public async ValueTask RecordRunCompletionAsync(
TenantContext tenant,
string runId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(tenant);
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
await _runTracker.DecrementAsync(tenant.TenantId, runId, cancellationToken)
.ConfigureAwait(false);
_logger.LogDebug(
"Recorded run completion for tenant {TenantId}, run {RunId}.",
tenant.TenantId,
runId);
}
public async ValueTask<int> GetConcurrentRunCountAsync(
TenantContext tenant,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(tenant);
return await _runTracker.GetCountAsync(tenant.TenantId, cancellationToken)
.ConfigureAwait(false);
}
}
/// <summary>
/// Exception thrown when tenant enforcement fails.
/// </summary>
public sealed class TenantEnforcementException : Exception
{
public TenantEnforcementException(string message, TenantEnforcementFailureKind kind)
: base(message)
{
Kind = kind;
}
public TenantEnforcementFailureKind Kind { get; }
}
/// <summary>
/// Interface for tracking concurrent pack runs per tenant.
/// </summary>
public interface IConcurrentRunTracker
{
ValueTask<int> GetCountAsync(string tenantId, CancellationToken cancellationToken = default);
ValueTask IncrementAsync(string tenantId, string runId, CancellationToken cancellationToken = default);
ValueTask DecrementAsync(string tenantId, string runId, CancellationToken cancellationToken = default);
}
/// <summary>
/// In-memory implementation of concurrent run tracker for testing.
/// </summary>
public sealed class InMemoryConcurrentRunTracker : IConcurrentRunTracker
{
private readonly Dictionary<string, HashSet<string>> _runsByTenant = new(StringComparer.Ordinal);
private readonly object _lock = new();
public ValueTask<int> GetCountAsync(string tenantId, CancellationToken cancellationToken = default)
{
lock (_lock)
{
return ValueTask.FromResult(
_runsByTenant.TryGetValue(tenantId, out var runs) ? runs.Count : 0);
}
}
public ValueTask IncrementAsync(string tenantId, string runId, CancellationToken cancellationToken = default)
{
lock (_lock)
{
if (!_runsByTenant.TryGetValue(tenantId, out var runs))
{
runs = new HashSet<string>(StringComparer.Ordinal);
_runsByTenant[tenantId] = runs;
}
runs.Add(runId);
}
return ValueTask.CompletedTask;
}
public ValueTask DecrementAsync(string tenantId, string runId, CancellationToken cancellationToken = default)
{
lock (_lock)
{
if (_runsByTenant.TryGetValue(tenantId, out var runs))
{
runs.Remove(runId);
if (runs.Count == 0)
{
_runsByTenant.Remove(tenantId);
}
}
}
return ValueTask.CompletedTask;
}
/// <summary>
/// Gets all active runs for a tenant (for testing).
/// </summary>
public IReadOnlySet<string> GetActiveRuns(string tenantId)
{
lock (_lock)
{
return _runsByTenant.TryGetValue(tenantId, out var runs)
? new HashSet<string>(runs)
: new HashSet<string>();
}
}
}

View File

@@ -1,153 +0,0 @@
namespace StellaOps.TaskRunner.Core.Tenancy;
/// <summary>
/// Configuration options for tenancy enforcement per TASKRUN-TEN-48-001.
/// </summary>
public sealed class TenancyEnforcementOptions
{
/// <summary>
/// Whether tenancy enforcement is enabled. When true, all pack runs
/// must have valid tenant context.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Whether to require project ID in addition to tenant ID.
/// </summary>
public bool RequireProjectId { get; set; } = true;
/// <summary>
/// Whether to enforce tenant-prefixed storage paths.
/// </summary>
public bool EnforceStoragePrefixes { get; set; } = true;
/// <summary>
/// Whether to enforce egress policies for restricted tenants.
/// </summary>
public bool EnforceEgressPolicies { get; set; } = true;
/// <summary>
/// Whether to propagate tenant context to step logs.
/// </summary>
public bool PropagateToLogs { get; set; } = true;
/// <summary>
/// Whether to block runs for suspended tenants.
/// </summary>
public bool BlockSuspendedTenants { get; set; } = true;
/// <summary>
/// Whether to validate tenant exists before starting run.
/// </summary>
public bool ValidateTenantExists { get; set; } = true;
/// <summary>
/// Default maximum concurrent runs per tenant when not specified
/// in tenant restrictions.
/// </summary>
public int DefaultMaxConcurrentRuns { get; set; } = 10;
/// <summary>
/// Default retention period in days for run artifacts when not specified
/// in tenant restrictions.
/// </summary>
public int DefaultRetentionDays { get; set; } = 30;
/// <summary>
/// Storage path configuration for tenant scoping.
/// </summary>
public TenantStoragePathOptions Storage { get; set; } = new();
/// <summary>
/// Egress policy configuration.
/// </summary>
public TenantEgressPolicyOptions Egress { get; set; } = new();
}
/// <summary>
/// Storage path options for tenant scoping.
/// </summary>
public sealed class TenantStoragePathOptions
{
/// <summary>
/// Path segment strategy for tenant prefixes.
/// </summary>
public TenantPathStrategy PathStrategy { get; set; } = TenantPathStrategy.Hierarchical;
/// <summary>
/// Base path for run state storage.
/// </summary>
public string StateBasePath { get; set; } = "runs";
/// <summary>
/// Base path for run logs storage.
/// </summary>
public string LogsBasePath { get; set; } = "logs";
/// <summary>
/// Base path for run artifacts storage.
/// </summary>
public string ArtifactsBasePath { get; set; } = "artifacts";
/// <summary>
/// Base path for approval records storage.
/// </summary>
public string ApprovalsBasePath { get; set; } = "approvals";
/// <summary>
/// Base path for provenance records storage.
/// </summary>
public string ProvenanceBasePath { get; set; } = "provenance";
}
/// <summary>
/// Tenant path strategy for storage prefixes.
/// </summary>
public enum TenantPathStrategy
{
/// <summary>
/// Hierarchical paths: {base}/{tenantId}/{projectId}/{runId}
/// </summary>
Hierarchical,
/// <summary>
/// Flat paths with prefix: {base}/{tenantId}_{projectId}_{runId}
/// </summary>
Flat,
/// <summary>
/// Hashed tenant prefixes for privacy: {base}/{hash(tenantId)}/{projectId}/{runId}
/// </summary>
Hashed
}
/// <summary>
/// Egress policy options for tenant scoping.
/// </summary>
public sealed class TenantEgressPolicyOptions
{
/// <summary>
/// Whether to allow egress by default when not restricted.
/// </summary>
public bool AllowByDefault { get; set; } = true;
/// <summary>
/// Global egress allowlist applied to all tenants.
/// </summary>
public List<string> GlobalAllowlist { get; set; } = [];
/// <summary>
/// Global egress blocklist applied to all tenants.
/// </summary>
public List<string> GlobalBlocklist { get; set; } = [];
/// <summary>
/// Whether to log blocked egress attempts.
/// </summary>
public bool LogBlockedAttempts { get; set; } = true;
/// <summary>
/// Whether to fail the run on blocked egress attempts.
/// </summary>
public bool FailOnBlockedAttempts { get; set; } = false;
}

View File

@@ -1,228 +0,0 @@
using System.Collections.Immutable;
namespace StellaOps.TaskRunner.Core.Tenancy;
/// <summary>
/// Tenant context for pack runs per TASKRUN-TEN-48-001.
/// Provides required tenant/project context for every pack run, enabling
/// tenant-scoped storage prefixes and egress policy enforcement.
/// </summary>
public sealed record TenantContext
{
/// <summary>
/// Creates a new tenant context. Both tenant and project IDs are required.
/// </summary>
public TenantContext(
string tenantId,
string projectId,
IReadOnlyDictionary<string, string>? labels = null,
TenantRestrictions? restrictions = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(projectId);
TenantId = tenantId.Trim();
ProjectId = projectId.Trim();
Labels = labels?.ToImmutableDictionary(StringComparer.Ordinal) ?? ImmutableDictionary<string, string>.Empty;
Restrictions = restrictions ?? TenantRestrictions.None;
}
/// <summary>
/// Unique identifier for the tenant (organization/account).
/// </summary>
public string TenantId { get; }
/// <summary>
/// Unique identifier for the project within the tenant.
/// </summary>
public string ProjectId { get; }
/// <summary>
/// Optional labels for filtering and grouping.
/// </summary>
public ImmutableDictionary<string, string> Labels { get; }
/// <summary>
/// Restrictions applied to this tenant context.
/// </summary>
public TenantRestrictions Restrictions { get; }
/// <summary>
/// Gets a storage-safe path prefix for this tenant context.
/// Format: {tenantId}/{projectId}
/// </summary>
public string StoragePrefix => $"{SanitizePathSegment(TenantId)}/{SanitizePathSegment(ProjectId)}";
/// <summary>
/// Gets a flat storage key prefix for this tenant context.
/// Format: {tenantId}_{projectId}
/// </summary>
public string FlatPrefix => $"{SanitizePathSegment(TenantId)}_{SanitizePathSegment(ProjectId)}";
/// <summary>
/// Creates a logging scope dictionary with tenant context.
/// </summary>
public IReadOnlyDictionary<string, object> ToLoggingScope() =>
new Dictionary<string, object>
{
["TenantId"] = TenantId,
["ProjectId"] = ProjectId
};
private static string SanitizePathSegment(string value)
{
var result = value.Trim().ToLowerInvariant();
foreach (var invalid in Path.GetInvalidFileNameChars())
{
result = result.Replace(invalid, '_');
}
// Also replace path separators for flat prefixes
result = result.Replace('/', '_').Replace('\\', '_');
return string.IsNullOrWhiteSpace(result) ? "unknown" : result;
}
}
/// <summary>
/// Restrictions that can be applied to a tenant context.
/// </summary>
public sealed record TenantRestrictions
{
public static TenantRestrictions None { get; } = new();
/// <summary>
/// Whether egress (outbound network) is blocked for this tenant.
/// </summary>
public bool EgressBlocked { get; init; }
/// <summary>
/// Allowed egress domains when egress is restricted (not fully blocked).
/// Empty means all domains blocked when EgressBlocked is true.
/// </summary>
public ImmutableArray<string> AllowedEgressDomains { get; init; } = [];
/// <summary>
/// Whether the tenant is in read-only mode (no writes allowed).
/// </summary>
public bool ReadOnly { get; init; }
/// <summary>
/// Whether the tenant is suspended (no operations allowed).
/// </summary>
public bool Suspended { get; init; }
/// <summary>
/// Maximum concurrent pack runs allowed for this tenant.
/// Null means unlimited.
/// </summary>
public int? MaxConcurrentRuns { get; init; }
/// <summary>
/// Maximum retention period for run artifacts in days.
/// Null means default retention applies.
/// </summary>
public int? MaxRetentionDays { get; init; }
}
/// <summary>
/// Provider interface for tenant context resolution.
/// </summary>
public interface ITenantContextProvider
{
/// <summary>
/// Gets the tenant context for a given tenant and project ID.
/// </summary>
ValueTask<TenantContext?> GetContextAsync(
string tenantId,
string projectId,
CancellationToken cancellationToken = default);
/// <summary>
/// Validates that the tenant context is active and not suspended.
/// </summary>
ValueTask<TenantValidationResult> ValidateAsync(
TenantContext context,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of tenant validation.
/// </summary>
public sealed record TenantValidationResult
{
public static TenantValidationResult Valid { get; } = new() { IsValid = true };
public static TenantValidationResult Invalid(string reason) => new()
{
IsValid = false,
Reason = reason
};
public static TenantValidationResult Suspended(string reason) => new()
{
IsValid = false,
IsSuspended = true,
Reason = reason
};
public bool IsValid { get; init; }
public bool IsSuspended { get; init; }
public string? Reason { get; init; }
}
/// <summary>
/// In-memory implementation of tenant context provider for testing.
/// </summary>
public sealed class InMemoryTenantContextProvider : ITenantContextProvider
{
private readonly Dictionary<string, TenantContext> _contexts = new(StringComparer.Ordinal);
private readonly HashSet<string> _suspendedTenants = new(StringComparer.Ordinal);
public ValueTask<TenantContext?> GetContextAsync(
string tenantId,
string projectId,
CancellationToken cancellationToken = default)
{
var key = $"{tenantId}:{projectId}";
return ValueTask.FromResult(_contexts.TryGetValue(key, out var context) ? context : null);
}
public ValueTask<TenantValidationResult> ValidateAsync(
TenantContext context,
CancellationToken cancellationToken = default)
{
if (context.Restrictions.Suspended || _suspendedTenants.Contains(context.TenantId))
{
return ValueTask.FromResult(TenantValidationResult.Suspended("Tenant is suspended."));
}
return ValueTask.FromResult(TenantValidationResult.Valid);
}
/// <summary>
/// Registers a tenant context (for testing).
/// </summary>
public void Register(TenantContext context)
{
var key = $"{context.TenantId}:{context.ProjectId}";
_contexts[key] = context;
}
/// <summary>
/// Suspends a tenant (for testing).
/// </summary>
public void Suspend(string tenantId)
{
_suspendedTenants.Add(tenantId);
}
/// <summary>
/// Unsuspends a tenant (for testing).
/// </summary>
public void Unsuspend(string tenantId)
{
_suspendedTenants.Remove(tenantId);
}
}

Some files were not shown because too many files have changed in this diff Show More