Restructure solution layout by module
	
		
			
	
		
	
	
		
	
		
			Some checks failed
		
		
	
	
		
			
				
	
				Docs CI / lint-and-preview (push) Has been cancelled
				
			
		
		
	
	
				
					
				
			
		
			Some checks failed
		
		
	
	Docs CI / lint-and-preview (push) Has been cancelled
				
			This commit is contained in:
		
							
								
								
									
										99
									
								
								src/TaskRunner/StellaOps.TaskRunner.sln
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								src/TaskRunner/StellaOps.TaskRunner.sln
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,99 @@ | ||||
|  | ||||
| Microsoft Visual Studio Solution File, Format Version 12.00 | ||||
| # Visual Studio Version 17 | ||||
| VisualStudioVersion = 17.0.31903.59 | ||||
| MinimumVisualStudioVersion = 10.0.40219.1 | ||||
| Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.TaskRunner", "StellaOps.TaskRunner", "{ACACD739-950B-C891-6A12-926A82053571}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.TaskRunner.Core", "StellaOps.TaskRunner\StellaOps.TaskRunner.Core\StellaOps.TaskRunner.Core.csproj", "{C2A829A6-4563-4E00-A4FA-A42AD564D5D5}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.TaskRunner.Infrastructure", "StellaOps.TaskRunner\StellaOps.TaskRunner.Infrastructure\StellaOps.TaskRunner.Infrastructure.csproj", "{4952F6C0-33B4-41A7-8E9D-3235227C8C57}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.TaskRunner.Tests", "StellaOps.TaskRunner\StellaOps.TaskRunner.Tests\StellaOps.TaskRunner.Tests.csproj", "{F12428B3-E106-4021-AE80-BD058C72254B}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.TaskRunner.WebService", "StellaOps.TaskRunner\StellaOps.TaskRunner.WebService\StellaOps.TaskRunner.WebService.csproj", "{4F5327F5-FDDE-41BB-91C8-A3426DF012CC}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.TaskRunner.Worker", "StellaOps.TaskRunner\StellaOps.TaskRunner.Worker\StellaOps.TaskRunner.Worker.csproj", "{2A68B840-7D42-4F0F-839C-96BEB46417D6}" | ||||
| EndProject | ||||
| Global | ||||
| 	GlobalSection(SolutionConfigurationPlatforms) = preSolution | ||||
| 		Debug|Any CPU = Debug|Any CPU | ||||
| 		Debug|x64 = Debug|x64 | ||||
| 		Debug|x86 = Debug|x86 | ||||
| 		Release|Any CPU = Release|Any CPU | ||||
| 		Release|x64 = Release|x64 | ||||
| 		Release|x86 = Release|x86 | ||||
| 	EndGlobalSection | ||||
| 	GlobalSection(ProjectConfigurationPlatforms) = postSolution | ||||
| 		{C2A829A6-4563-4E00-A4FA-A42AD564D5D5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | ||||
| 		{C2A829A6-4563-4E00-A4FA-A42AD564D5D5}.Debug|Any CPU.Build.0 = Debug|Any CPU | ||||
| 		{C2A829A6-4563-4E00-A4FA-A42AD564D5D5}.Debug|x64.ActiveCfg = Debug|Any CPU | ||||
| 		{C2A829A6-4563-4E00-A4FA-A42AD564D5D5}.Debug|x64.Build.0 = Debug|Any CPU | ||||
| 		{C2A829A6-4563-4E00-A4FA-A42AD564D5D5}.Debug|x86.ActiveCfg = Debug|Any CPU | ||||
| 		{C2A829A6-4563-4E00-A4FA-A42AD564D5D5}.Debug|x86.Build.0 = Debug|Any CPU | ||||
| 		{C2A829A6-4563-4E00-A4FA-A42AD564D5D5}.Release|Any CPU.ActiveCfg = Release|Any CPU | ||||
| 		{C2A829A6-4563-4E00-A4FA-A42AD564D5D5}.Release|Any CPU.Build.0 = Release|Any CPU | ||||
| 		{C2A829A6-4563-4E00-A4FA-A42AD564D5D5}.Release|x64.ActiveCfg = Release|Any CPU | ||||
| 		{C2A829A6-4563-4E00-A4FA-A42AD564D5D5}.Release|x64.Build.0 = Release|Any CPU | ||||
| 		{C2A829A6-4563-4E00-A4FA-A42AD564D5D5}.Release|x86.ActiveCfg = Release|Any CPU | ||||
| 		{C2A829A6-4563-4E00-A4FA-A42AD564D5D5}.Release|x86.Build.0 = Release|Any CPU | ||||
| 		{4952F6C0-33B4-41A7-8E9D-3235227C8C57}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | ||||
| 		{4952F6C0-33B4-41A7-8E9D-3235227C8C57}.Debug|Any CPU.Build.0 = Debug|Any CPU | ||||
| 		{4952F6C0-33B4-41A7-8E9D-3235227C8C57}.Debug|x64.ActiveCfg = Debug|Any CPU | ||||
| 		{4952F6C0-33B4-41A7-8E9D-3235227C8C57}.Debug|x64.Build.0 = Debug|Any CPU | ||||
| 		{4952F6C0-33B4-41A7-8E9D-3235227C8C57}.Debug|x86.ActiveCfg = Debug|Any CPU | ||||
| 		{4952F6C0-33B4-41A7-8E9D-3235227C8C57}.Debug|x86.Build.0 = Debug|Any CPU | ||||
| 		{4952F6C0-33B4-41A7-8E9D-3235227C8C57}.Release|Any CPU.ActiveCfg = Release|Any CPU | ||||
| 		{4952F6C0-33B4-41A7-8E9D-3235227C8C57}.Release|Any CPU.Build.0 = Release|Any CPU | ||||
| 		{4952F6C0-33B4-41A7-8E9D-3235227C8C57}.Release|x64.ActiveCfg = Release|Any CPU | ||||
| 		{4952F6C0-33B4-41A7-8E9D-3235227C8C57}.Release|x64.Build.0 = Release|Any CPU | ||||
| 		{4952F6C0-33B4-41A7-8E9D-3235227C8C57}.Release|x86.ActiveCfg = Release|Any CPU | ||||
| 		{4952F6C0-33B4-41A7-8E9D-3235227C8C57}.Release|x86.Build.0 = Release|Any CPU | ||||
| 		{F12428B3-E106-4021-AE80-BD058C72254B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | ||||
| 		{F12428B3-E106-4021-AE80-BD058C72254B}.Debug|Any CPU.Build.0 = Debug|Any CPU | ||||
| 		{F12428B3-E106-4021-AE80-BD058C72254B}.Debug|x64.ActiveCfg = Debug|Any CPU | ||||
| 		{F12428B3-E106-4021-AE80-BD058C72254B}.Debug|x64.Build.0 = Debug|Any CPU | ||||
| 		{F12428B3-E106-4021-AE80-BD058C72254B}.Debug|x86.ActiveCfg = Debug|Any CPU | ||||
| 		{F12428B3-E106-4021-AE80-BD058C72254B}.Debug|x86.Build.0 = Debug|Any CPU | ||||
| 		{F12428B3-E106-4021-AE80-BD058C72254B}.Release|Any CPU.ActiveCfg = Release|Any CPU | ||||
| 		{F12428B3-E106-4021-AE80-BD058C72254B}.Release|Any CPU.Build.0 = Release|Any CPU | ||||
| 		{F12428B3-E106-4021-AE80-BD058C72254B}.Release|x64.ActiveCfg = Release|Any CPU | ||||
| 		{F12428B3-E106-4021-AE80-BD058C72254B}.Release|x64.Build.0 = Release|Any CPU | ||||
| 		{F12428B3-E106-4021-AE80-BD058C72254B}.Release|x86.ActiveCfg = Release|Any CPU | ||||
| 		{F12428B3-E106-4021-AE80-BD058C72254B}.Release|x86.Build.0 = Release|Any CPU | ||||
| 		{4F5327F5-FDDE-41BB-91C8-A3426DF012CC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | ||||
| 		{4F5327F5-FDDE-41BB-91C8-A3426DF012CC}.Debug|Any CPU.Build.0 = Debug|Any CPU | ||||
| 		{4F5327F5-FDDE-41BB-91C8-A3426DF012CC}.Debug|x64.ActiveCfg = Debug|Any CPU | ||||
| 		{4F5327F5-FDDE-41BB-91C8-A3426DF012CC}.Debug|x64.Build.0 = Debug|Any CPU | ||||
| 		{4F5327F5-FDDE-41BB-91C8-A3426DF012CC}.Debug|x86.ActiveCfg = Debug|Any CPU | ||||
| 		{4F5327F5-FDDE-41BB-91C8-A3426DF012CC}.Debug|x86.Build.0 = Debug|Any CPU | ||||
| 		{4F5327F5-FDDE-41BB-91C8-A3426DF012CC}.Release|Any CPU.ActiveCfg = Release|Any CPU | ||||
| 		{4F5327F5-FDDE-41BB-91C8-A3426DF012CC}.Release|Any CPU.Build.0 = Release|Any CPU | ||||
| 		{4F5327F5-FDDE-41BB-91C8-A3426DF012CC}.Release|x64.ActiveCfg = Release|Any CPU | ||||
| 		{4F5327F5-FDDE-41BB-91C8-A3426DF012CC}.Release|x64.Build.0 = Release|Any CPU | ||||
| 		{4F5327F5-FDDE-41BB-91C8-A3426DF012CC}.Release|x86.ActiveCfg = Release|Any CPU | ||||
| 		{4F5327F5-FDDE-41BB-91C8-A3426DF012CC}.Release|x86.Build.0 = Release|Any CPU | ||||
| 		{2A68B840-7D42-4F0F-839C-96BEB46417D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | ||||
| 		{2A68B840-7D42-4F0F-839C-96BEB46417D6}.Debug|Any CPU.Build.0 = Debug|Any CPU | ||||
| 		{2A68B840-7D42-4F0F-839C-96BEB46417D6}.Debug|x64.ActiveCfg = Debug|Any CPU | ||||
| 		{2A68B840-7D42-4F0F-839C-96BEB46417D6}.Debug|x64.Build.0 = Debug|Any CPU | ||||
| 		{2A68B840-7D42-4F0F-839C-96BEB46417D6}.Debug|x86.ActiveCfg = Debug|Any CPU | ||||
| 		{2A68B840-7D42-4F0F-839C-96BEB46417D6}.Debug|x86.Build.0 = Debug|Any CPU | ||||
| 		{2A68B840-7D42-4F0F-839C-96BEB46417D6}.Release|Any CPU.ActiveCfg = Release|Any CPU | ||||
| 		{2A68B840-7D42-4F0F-839C-96BEB46417D6}.Release|Any CPU.Build.0 = Release|Any CPU | ||||
| 		{2A68B840-7D42-4F0F-839C-96BEB46417D6}.Release|x64.ActiveCfg = Release|Any CPU | ||||
| 		{2A68B840-7D42-4F0F-839C-96BEB46417D6}.Release|x64.Build.0 = Release|Any CPU | ||||
| 		{2A68B840-7D42-4F0F-839C-96BEB46417D6}.Release|x86.ActiveCfg = Release|Any CPU | ||||
| 		{2A68B840-7D42-4F0F-839C-96BEB46417D6}.Release|x86.Build.0 = Release|Any CPU | ||||
| 	EndGlobalSection | ||||
| 	GlobalSection(SolutionProperties) = preSolution | ||||
| 		HideSolutionNode = FALSE | ||||
| 	EndGlobalSection | ||||
| 	GlobalSection(NestedProjects) = preSolution | ||||
| 		{C2A829A6-4563-4E00-A4FA-A42AD564D5D5} = {ACACD739-950B-C891-6A12-926A82053571} | ||||
| 		{4952F6C0-33B4-41A7-8E9D-3235227C8C57} = {ACACD739-950B-C891-6A12-926A82053571} | ||||
| 		{F12428B3-E106-4021-AE80-BD058C72254B} = {ACACD739-950B-C891-6A12-926A82053571} | ||||
| 		{4F5327F5-FDDE-41BB-91C8-A3426DF012CC} = {ACACD739-950B-C891-6A12-926A82053571} | ||||
| 		{2A68B840-7D42-4F0F-839C-96BEB46417D6} = {ACACD739-950B-C891-6A12-926A82053571} | ||||
| 	EndGlobalSection | ||||
| EndGlobal | ||||
							
								
								
									
										17
									
								
								src/TaskRunner/StellaOps.TaskRunner/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								src/TaskRunner/StellaOps.TaskRunner/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | ||||
| # 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 Orchestrator, 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. | ||||
| @@ -0,0 +1,10 @@ | ||||
| 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); | ||||
| } | ||||
| @@ -0,0 +1,6 @@ | ||||
| namespace StellaOps.TaskRunner.Core.Execution; | ||||
|  | ||||
| public interface IPackRunJobDispatcher | ||||
| { | ||||
|     Task<PackRunExecutionContext?> TryDequeueAsync(CancellationToken cancellationToken); | ||||
| } | ||||
| @@ -0,0 +1,8 @@ | ||||
| namespace StellaOps.TaskRunner.Core.Execution; | ||||
|  | ||||
| public interface IPackRunNotificationPublisher | ||||
| { | ||||
|     Task PublishApprovalRequestedAsync(string runId, ApprovalNotification notification, CancellationToken cancellationToken); | ||||
|  | ||||
|     Task PublishPolicyGatePendingAsync(string runId, PolicyGateNotification notification, CancellationToken cancellationToken); | ||||
| } | ||||
| @@ -0,0 +1,177 @@ | ||||
| using System.Collections.Concurrent; | ||||
| using System.Collections.Immutable; | ||||
| using StellaOps.TaskRunner.Core.Planning; | ||||
|  | ||||
| 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); | ||||
| @@ -0,0 +1,84 @@ | ||||
| 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); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,9 @@ | ||||
| namespace StellaOps.TaskRunner.Core.Execution; | ||||
|  | ||||
| public enum PackRunApprovalStatus | ||||
| { | ||||
|     Pending = 0, | ||||
|     Approved = 1, | ||||
|     Rejected = 2, | ||||
|     Expired = 3 | ||||
| } | ||||
| @@ -0,0 +1,22 @@ | ||||
| using StellaOps.TaskRunner.Core.Planning; | ||||
|  | ||||
| namespace StellaOps.TaskRunner.Core.Execution; | ||||
|  | ||||
| public sealed class PackRunExecutionContext | ||||
| { | ||||
|     public PackRunExecutionContext(string runId, TaskPackPlan plan, DateTimeOffset requestedAt) | ||||
|     { | ||||
|         ArgumentException.ThrowIfNullOrWhiteSpace(runId); | ||||
|         ArgumentNullException.ThrowIfNull(plan); | ||||
|  | ||||
|         RunId = runId; | ||||
|         Plan = plan; | ||||
|         RequestedAt = requestedAt; | ||||
|     } | ||||
|  | ||||
|     public string RunId { get; } | ||||
|  | ||||
|     public TaskPackPlan Plan { get; } | ||||
|  | ||||
|     public DateTimeOffset RequestedAt { get; } | ||||
| } | ||||
| @@ -0,0 +1,84 @@ | ||||
| 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); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,5 @@ | ||||
| namespace StellaOps.TaskRunner.Core.Execution; | ||||
|  | ||||
| public sealed record PackRunProcessorResult( | ||||
|     PackRunApprovalCoordinator ApprovalCoordinator, | ||||
|     bool ShouldResumeImmediately); | ||||
| @@ -0,0 +1,596 @@ | ||||
| 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); | ||||
| } | ||||
| @@ -0,0 +1,95 @@ | ||||
| using System.Collections.Immutable; | ||||
| using System.Text.Json.Nodes; | ||||
| using StellaOps.TaskRunner.Core.Expressions; | ||||
|  | ||||
| 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) | ||||
|     { | ||||
|         Metadata = metadata; | ||||
|         Inputs = inputs; | ||||
|         Steps = steps; | ||||
|         Hash = hash; | ||||
|         Approvals = approvals; | ||||
|         Secrets = secrets; | ||||
|         Outputs = outputs; | ||||
|     } | ||||
|  | ||||
|     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 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 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); | ||||
| @@ -0,0 +1,112 @@ | ||||
| using System.Linq; | ||||
| using System.Security.Cryptography; | ||||
| using System.Text; | ||||
| using System.Text.Json.Nodes; | ||||
| using StellaOps.TaskRunner.Core.Serialization; | ||||
|  | ||||
| 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) | ||||
|     { | ||||
|         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()); | ||||
|  | ||||
|         var json = CanonicalJson.Serialize(canonical); | ||||
|         using var sha256 = SHA256.Create(); | ||||
|         var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(json)); | ||||
|         return 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); | ||||
|  | ||||
|     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 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); | ||||
| } | ||||
| @@ -0,0 +1,185 @@ | ||||
| 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); | ||||
| @@ -0,0 +1,431 @@ | ||||
| using System.Collections.Immutable; | ||||
| using System.Linq; | ||||
| using System.Text.Json.Nodes; | ||||
| using StellaOps.TaskRunner.Core.Expressions; | ||||
| using StellaOps.TaskRunner.Core.TaskPacks; | ||||
|  | ||||
| namespace StellaOps.TaskRunner.Core.Planning; | ||||
|  | ||||
| public sealed class TaskPackPlanner | ||||
| { | ||||
|     private readonly TaskPackManifestValidator validator; | ||||
|  | ||||
|     public TaskPackPlanner() | ||||
|     { | ||||
|         validator = new TaskPackManifestValidator(); | ||||
|     } | ||||
|  | ||||
|     public TaskPackPlanResult Plan(TaskPackManifest manifest, IDictionary<string, JsonNode?>? providedInputs = null) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(manifest); | ||||
|  | ||||
|         var errors = ImmutableArray.CreateBuilder<TaskPackPlanError>(); | ||||
|  | ||||
|         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 planSteps = new List<TaskPackPlanStep>(); | ||||
|         var steps = manifest.Spec.Steps; | ||||
|         for (var i = 0; i < steps.Count; i++) | ||||
|         { | ||||
|             var step = steps[i]; | ||||
|             var planStep = BuildStep(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, | ||||
|                 approval.Grants?.ToList() ?? new List<string>(), | ||||
|                 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 hash = TaskPackPlanHasher.ComputeHash(metadata, effectiveInputs, planSteps, planApprovals, planSecrets, planOutputs); | ||||
|  | ||||
|         var plan = new TaskPackPlan(metadata, effectiveInputs, planSteps, hash, planApprovals, planSecrets, planOutputs); | ||||
|         return new TaskPackPlanResult(plan, ImmutableArray<TaskPackPlanError>.Empty); | ||||
|     } | ||||
|  | ||||
|     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 TaskPackPlanStep BuildStep( | ||||
|         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(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(step, step.Parallel, context, path, enabled, errors); | ||||
|         } | ||||
|         else if (step.Map is not null) | ||||
|         { | ||||
|             planStep = BuildMapStep(step, step.Map, context, path, enabled, errors); | ||||
|         } | ||||
|         else | ||||
|         { | ||||
|             errors.Add(new TaskPackPlanError(path, "Step did not specify run, gate, parallel, or map.")); | ||||
|             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( | ||||
|         TaskPackStep step, | ||||
|         TaskPackRunStep run, | ||||
|         TaskPackExpressionContext context, | ||||
|         string path, | ||||
|         bool enabled, | ||||
|         ImmutableArray<TaskPackPlanError>.Builder errors) | ||||
|     { | ||||
|         var parameters = ResolveParameters(run.With, context, $"{path}.run", errors); | ||||
|  | ||||
|         return new TaskPackPlanStep( | ||||
|             step.Id, | ||||
|             step.Id, | ||||
|             step.Name, | ||||
|             "run", | ||||
|             enabled, | ||||
|             run.Uses, | ||||
|             parameters, | ||||
|             ApprovalId: null, | ||||
|             GateMessage: null, | ||||
|             Children: null); | ||||
|     } | ||||
|  | ||||
|     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"; | ||||
|             parameters = ResolveParameters(gate.Policy.Parameters, context, $"{path}.gate.policy", errors); | ||||
|         } | ||||
|         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( | ||||
|         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(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( | ||||
|         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(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 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); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,68 @@ | ||||
| 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; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,22 @@ | ||||
| <?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" Version="9.0.0" /> | ||||
|     <PackageReference Include="YamlDotNet" Version="13.7.1" /> | ||||
|   </ItemGroup> | ||||
| </Project> | ||||
| @@ -0,0 +1,250 @@ | ||||
| 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; } | ||||
| } | ||||
|  | ||||
| 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; } | ||||
| } | ||||
|  | ||||
| public sealed class TaskPackRunStep | ||||
| { | ||||
|     [JsonPropertyName("uses")] | ||||
|     public required string Uses { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("with")] | ||||
|     public IDictionary<string, JsonNode?>? With { 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 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; } | ||||
| } | ||||
| @@ -0,0 +1,168 @@ | ||||
| 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) | ||||
|     { | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,235 @@ | ||||
| using System.Collections.Immutable; | ||||
| using System.Text.RegularExpressions; | ||||
| using System.Linq; | ||||
|  | ||||
| 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); | ||||
|  | ||||
|             if (typeCount == 0) | ||||
|             { | ||||
|                 errors.Add(new TaskPackManifestValidationError(path, "Step must define one of run, gate, parallel, or map.")); | ||||
|             } | ||||
|             else if (typeCount > 1) | ||||
|             { | ||||
|                 errors.Add(new TaskPackManifestValidationError(path, "Step may define only one of run, gate, parallel, or map.")); | ||||
|             } | ||||
|  | ||||
|             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); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     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'.")); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     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); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| 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; | ||||
| } | ||||
| @@ -0,0 +1,118 @@ | ||||
| using System.Text.Json; | ||||
| using System.Text.Json.Nodes; | ||||
| using StellaOps.TaskRunner.Core.Execution; | ||||
|  | ||||
| namespace StellaOps.TaskRunner.Infrastructure.Execution; | ||||
|  | ||||
| public sealed class FilePackRunApprovalStore : IPackRunApprovalStore | ||||
| { | ||||
|     private readonly string rootPath; | ||||
|     private readonly JsonSerializerOptions serializerOptions = new(JsonSerializerDefaults.Web) | ||||
|     { | ||||
|         WriteIndented = true | ||||
|     }; | ||||
|  | ||||
|     public FilePackRunApprovalStore(string rootPath) | ||||
|     { | ||||
|         ArgumentException.ThrowIfNullOrWhiteSpace(rootPath); | ||||
|         this.rootPath = rootPath; | ||||
|         Directory.CreateDirectory(rootPath); | ||||
|     } | ||||
|  | ||||
|     public Task SaveAsync(string runId, IReadOnlyList<PackRunApprovalState> approvals, CancellationToken cancellationToken) | ||||
|     { | ||||
|         var path = GetFilePath(runId); | ||||
|         var json = SerializeApprovals(approvals); | ||||
|         File.WriteAllText(path, json); | ||||
|         return Task.CompletedTask; | ||||
|     } | ||||
|  | ||||
|     public Task<IReadOnlyList<PackRunApprovalState>> GetAsync(string runId, CancellationToken cancellationToken) | ||||
|     { | ||||
|         var path = GetFilePath(runId); | ||||
|         if (!File.Exists(path)) | ||||
|         { | ||||
|             return Task.FromResult((IReadOnlyList<PackRunApprovalState>)Array.Empty<PackRunApprovalState>()); | ||||
|         } | ||||
|  | ||||
|         var json = File.ReadAllText(path); | ||||
|         var approvals = DeserializeApprovals(json); | ||||
|         return Task.FromResult((IReadOnlyList<PackRunApprovalState>)approvals); | ||||
|     } | ||||
|  | ||||
|     public async Task UpdateAsync(string runId, PackRunApprovalState approval, CancellationToken cancellationToken) | ||||
|     { | ||||
|         var approvals = (await GetAsync(runId, cancellationToken).ConfigureAwait(false)).ToList(); | ||||
|         var index = approvals.FindIndex(existing => string.Equals(existing.ApprovalId, approval.ApprovalId, StringComparison.Ordinal)); | ||||
|         if (index < 0) | ||||
|         { | ||||
|             throw new InvalidOperationException($"Approval '{approval.ApprovalId}' not found for run '{runId}'."); | ||||
|         } | ||||
|  | ||||
|         approvals[index] = approval; | ||||
|         await SaveAsync(runId, approvals, cancellationToken).ConfigureAwait(false); | ||||
|     } | ||||
|  | ||||
|     private string GetFilePath(string runId) | ||||
|     { | ||||
|         var safeFile = $"{runId}.json"; | ||||
|         return Path.Combine(rootPath, safeFile); | ||||
|     } | ||||
|  | ||||
|     private string SerializeApprovals(IReadOnlyList<PackRunApprovalState> approvals) | ||||
|     { | ||||
|         var array = new JsonArray(); | ||||
|         foreach (var approval in approvals) | ||||
|         { | ||||
|             var node = new JsonObject | ||||
|             { | ||||
|                 ["approvalId"] = approval.ApprovalId, | ||||
|                 ["status"] = approval.Status.ToString(), | ||||
|                 ["requestedAt"] = approval.RequestedAt, | ||||
|                 ["actorId"] = approval.ActorId, | ||||
|                 ["completedAt"] = approval.CompletedAt, | ||||
|                 ["summary"] = approval.Summary, | ||||
|                 ["requiredGrants"] = new JsonArray(approval.RequiredGrants.Select(grant => (JsonNode)grant).ToArray()), | ||||
|                 ["stepIds"] = new JsonArray(approval.StepIds.Select(step => (JsonNode)step).ToArray()), | ||||
|                 ["messages"] = new JsonArray(approval.Messages.Select(message => (JsonNode)message).ToArray()), | ||||
|                 ["reasonTemplate"] = approval.ReasonTemplate | ||||
|             }; | ||||
|  | ||||
|             array.Add(node); | ||||
|         } | ||||
|  | ||||
|         return array.ToJsonString(serializerOptions); | ||||
|     } | ||||
|  | ||||
|     private static IReadOnlyList<PackRunApprovalState> DeserializeApprovals(string json) | ||||
|     { | ||||
|         var array = JsonNode.Parse(json)?.AsArray() ?? new JsonArray(); | ||||
|         var list = new List<PackRunApprovalState>(array.Count); | ||||
|         foreach (var entry in array) | ||||
|         { | ||||
|             if (entry is not JsonObject obj) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             var requiredGrants = obj["requiredGrants"]?.AsArray()?.Select(node => node!.GetValue<string>()).ToList() ?? new List<string>(); | ||||
|             var stepIds = obj["stepIds"]?.AsArray()?.Select(node => node!.GetValue<string>()).ToList() ?? new List<string>(); | ||||
|             var messages = obj["messages"]?.AsArray()?.Select(node => node!.GetValue<string>()).ToList() ?? new List<string>(); | ||||
|             Enum.TryParse(obj["status"]?.GetValue<string>(), ignoreCase: true, out PackRunApprovalStatus status); | ||||
|  | ||||
|             list.Add(new PackRunApprovalState( | ||||
|                 obj["approvalId"]?.GetValue<string>() ?? string.Empty, | ||||
|                 requiredGrants, | ||||
|                 stepIds, | ||||
|                 messages, | ||||
|                 obj["reasonTemplate"]?.GetValue<string>(), | ||||
|                 obj["requestedAt"]?.GetValue<DateTimeOffset>() ?? DateTimeOffset.UtcNow, | ||||
|                 status, | ||||
|                 obj["actorId"]?.GetValue<string>(), | ||||
|                 obj["completedAt"]?.GetValue<DateTimeOffset?>(), | ||||
|                 obj["summary"]?.GetValue<string>())); | ||||
|         } | ||||
|  | ||||
|         return list; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,92 @@ | ||||
| using System.Text.Json; | ||||
| using System.Text.Json.Nodes; | ||||
| using StellaOps.TaskRunner.Core.Execution; | ||||
| using StellaOps.TaskRunner.Core.Planning; | ||||
| using StellaOps.TaskRunner.Core.TaskPacks; | ||||
|  | ||||
| namespace StellaOps.TaskRunner.Infrastructure.Execution; | ||||
|  | ||||
| public sealed class FilesystemPackRunDispatcher : IPackRunJobDispatcher | ||||
| { | ||||
|     private readonly string queuePath; | ||||
|     private readonly string archivePath; | ||||
|     private readonly TaskPackManifestLoader manifestLoader = new(); | ||||
|     private readonly TaskPackPlanner planner = new(); | ||||
|     private readonly JsonSerializerOptions serializerOptions = new(JsonSerializerDefaults.Web); | ||||
|  | ||||
|     public FilesystemPackRunDispatcher(string queuePath, string archivePath) | ||||
|     { | ||||
|         this.queuePath = queuePath ?? throw new ArgumentNullException(nameof(queuePath)); | ||||
|         this.archivePath = archivePath ?? throw new ArgumentNullException(nameof(archivePath)); | ||||
|         Directory.CreateDirectory(queuePath); | ||||
|         Directory.CreateDirectory(archivePath); | ||||
|     } | ||||
|  | ||||
|     public async Task<PackRunExecutionContext?> TryDequeueAsync(CancellationToken cancellationToken) | ||||
|     { | ||||
|         var files = Directory.GetFiles(queuePath, "*.json", SearchOption.TopDirectoryOnly) | ||||
|             .OrderBy(path => path, StringComparer.Ordinal) | ||||
|             .ToArray(); | ||||
|  | ||||
|         foreach (var file in files) | ||||
|         { | ||||
|             cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|             try | ||||
|             { | ||||
|                 var jobJson = await File.ReadAllTextAsync(file, cancellationToken).ConfigureAwait(false); | ||||
|                 var job = JsonSerializer.Deserialize<JobEnvelope>(jobJson, serializerOptions) ?? continue; | ||||
|  | ||||
|                 var manifestPath = ResolvePath(queuePath, job.ManifestPath); | ||||
|                 var inputsPath = job.InputsPath is null ? null : ResolvePath(queuePath, job.InputsPath); | ||||
|  | ||||
|                 var manifest = manifestLoader.Load(manifestPath); | ||||
|                 var inputs = await LoadInputsAsync(inputsPath, cancellationToken).ConfigureAwait(false); | ||||
|                 var planResult = planner.Plan(manifest, inputs); | ||||
|                 if (!planResult.Success || planResult.Plan is null) | ||||
|                 { | ||||
|                     throw new InvalidOperationException($"Failed to plan pack for run {job.RunId}: {string.Join(';', planResult.Errors.Select(e => e.Message))}"); | ||||
|                 } | ||||
|  | ||||
|                 var archiveFile = Path.Combine(archivePath, Path.GetFileName(file)); | ||||
|                 File.Move(file, archiveFile, overwrite: true); | ||||
|  | ||||
|                 var requestedAt = job.RequestedAt ?? DateTimeOffset.UtcNow; | ||||
|                 return new PackRunExecutionContext(job.RunId ?? Guid.NewGuid().ToString("n"), planResult.Plan, requestedAt); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 var failedPath = file + ".failed"; | ||||
|                 File.Move(file, failedPath, overwrite: true); | ||||
|                 Console.Error.WriteLine($"Failed to dequeue job '{file}': {ex.Message}"); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return null; | ||||
|     } | ||||
|  | ||||
|     private static string ResolvePath(string root, string relative) | ||||
|         => Path.IsPathRooted(relative) ? relative : Path.Combine(root, relative); | ||||
|  | ||||
|     private static async Task<IDictionary<string, JsonNode?>> LoadInputsAsync(string? path, CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) | ||||
|         { | ||||
|             return new Dictionary<string, JsonNode?>(StringComparer.Ordinal); | ||||
|         } | ||||
|  | ||||
|         var json = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false); | ||||
|         var node = JsonNode.Parse(json) as JsonObject; | ||||
|         if (node is null) | ||||
|         { | ||||
|             return new Dictionary<string, JsonNode?>(StringComparer.Ordinal); | ||||
|         } | ||||
|  | ||||
|         return node.ToDictionary( | ||||
|             pair => pair.Key, | ||||
|             pair => pair.Value, | ||||
|             StringComparer.Ordinal); | ||||
|     } | ||||
|  | ||||
|     private sealed record JobEnvelope(string? RunId, string ManifestPath, string? InputsPath, DateTimeOffset? RequestedAt); | ||||
| } | ||||
| @@ -0,0 +1,73 @@ | ||||
| using System.Net.Http.Json; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using Microsoft.Extensions.Options; | ||||
| using StellaOps.TaskRunner.Core.Execution; | ||||
|  | ||||
| namespace StellaOps.TaskRunner.Infrastructure.Execution; | ||||
|  | ||||
| public sealed class HttpPackRunNotificationPublisher : IPackRunNotificationPublisher | ||||
| { | ||||
|     private readonly IHttpClientFactory httpClientFactory; | ||||
|     private readonly NotificationOptions options; | ||||
|     private readonly ILogger<HttpPackRunNotificationPublisher> logger; | ||||
|  | ||||
|     public HttpPackRunNotificationPublisher( | ||||
|         IHttpClientFactory httpClientFactory, | ||||
|         IOptions<NotificationOptions> options, | ||||
|         ILogger<HttpPackRunNotificationPublisher> logger) | ||||
|     { | ||||
|         this.httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); | ||||
|         this.options = options?.Value ?? throw new ArgumentNullException(nameof(options)); | ||||
|         this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|     } | ||||
|  | ||||
|     public async Task PublishApprovalRequestedAsync(string runId, ApprovalNotification notification, CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (options.ApprovalEndpoint is null) | ||||
|         { | ||||
|             logger.LogWarning("Approval endpoint not configured; skipping approval notification for run {RunId}.", runId); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         var client = httpClientFactory.CreateClient("taskrunner-notifications"); | ||||
|         var payload = new | ||||
|         { | ||||
|             runId, | ||||
|             notification.ApprovalId, | ||||
|             notification.RequiredGrants, | ||||
|             notification.Messages, | ||||
|             notification.StepIds, | ||||
|             notification.ReasonTemplate | ||||
|         }; | ||||
|  | ||||
|         var response = await client.PostAsJsonAsync(options.ApprovalEndpoint, payload, cancellationToken).ConfigureAwait(false); | ||||
|         response.EnsureSuccessStatusCode(); | ||||
|     } | ||||
|  | ||||
|     public async Task PublishPolicyGatePendingAsync(string runId, PolicyGateNotification notification, CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (options.PolicyEndpoint is null) | ||||
|         { | ||||
|             logger.LogDebug("Policy endpoint not configured; skipping policy notification for run {RunId} step {StepId}.", runId, notification.StepId); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         var client = httpClientFactory.CreateClient("taskrunner-notifications"); | ||||
|         var payload = new | ||||
|         { | ||||
|             runId, | ||||
|             notification.StepId, | ||||
|             notification.Message, | ||||
|             Parameters = notification.Parameters.Select(parameter => new | ||||
|             { | ||||
|                 parameter.Name, | ||||
|                 parameter.RequiresRuntimeValue, | ||||
|                 parameter.Expression, | ||||
|                 parameter.Error | ||||
|             }) | ||||
|         }; | ||||
|  | ||||
|         var response = await client.PostAsJsonAsync(options.PolicyEndpoint, payload, cancellationToken).ConfigureAwait(false); | ||||
|         response.EnsureSuccessStatusCode(); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,34 @@ | ||||
| using Microsoft.Extensions.Logging; | ||||
| using StellaOps.TaskRunner.Core.Execution; | ||||
|  | ||||
| namespace StellaOps.TaskRunner.Infrastructure.Execution; | ||||
|  | ||||
| public sealed class LoggingPackRunNotificationPublisher : IPackRunNotificationPublisher | ||||
| { | ||||
|     private readonly ILogger<LoggingPackRunNotificationPublisher> logger; | ||||
|  | ||||
|     public LoggingPackRunNotificationPublisher(ILogger<LoggingPackRunNotificationPublisher> logger) | ||||
|     { | ||||
|         this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|     } | ||||
|  | ||||
|     public Task PublishApprovalRequestedAsync(string runId, ApprovalNotification notification, CancellationToken cancellationToken) | ||||
|     { | ||||
|         logger.LogInformation( | ||||
|             "Run {RunId}: approval {ApprovalId} requires grants {Grants}.", | ||||
|             runId, | ||||
|             notification.ApprovalId, | ||||
|             string.Join(",", notification.RequiredGrants)); | ||||
|         return Task.CompletedTask; | ||||
|     } | ||||
|  | ||||
|     public Task PublishPolicyGatePendingAsync(string runId, PolicyGateNotification notification, CancellationToken cancellationToken) | ||||
|     { | ||||
|         logger.LogDebug( | ||||
|             "Run {RunId}: policy gate {StepId} pending (parameters: {Parameters}).", | ||||
|             runId, | ||||
|             notification.StepId, | ||||
|             string.Join(",", notification.Parameters.Select(p => p.Name))); | ||||
|         return Task.CompletedTask; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,9 @@ | ||||
| using StellaOps.TaskRunner.Core.Execution; | ||||
|  | ||||
| namespace StellaOps.TaskRunner.Infrastructure.Execution; | ||||
|  | ||||
| public sealed class NoopPackRunJobDispatcher : IPackRunJobDispatcher | ||||
| { | ||||
|     public Task<PackRunExecutionContext?> TryDequeueAsync(CancellationToken cancellationToken) | ||||
|         => Task.FromResult<PackRunExecutionContext?>(null); | ||||
| } | ||||
| @@ -0,0 +1,8 @@ | ||||
| namespace StellaOps.TaskRunner.Infrastructure.Execution; | ||||
|  | ||||
| public sealed class NotificationOptions | ||||
| { | ||||
|     public Uri? ApprovalEndpoint { get; set; } | ||||
|  | ||||
|     public Uri? PolicyEndpoint { get; set; } | ||||
| } | ||||
| @@ -0,0 +1,25 @@ | ||||
| <?xml version="1.0" ?> | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|    | ||||
|  | ||||
|    | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" /> | ||||
|     <ProjectReference Include="..\StellaOps.TaskRunner.Core\StellaOps.TaskRunner.Core.csproj" /> | ||||
|   </ItemGroup> | ||||
|    | ||||
|  | ||||
|    | ||||
|   <PropertyGroup> | ||||
|      | ||||
|      | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <Nullable>enable</Nullable> | ||||
|     <LangVersion>preview</LangVersion> | ||||
|     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> | ||||
|   </PropertyGroup> | ||||
|    | ||||
|  | ||||
|  | ||||
| </Project> | ||||
| @@ -0,0 +1,95 @@ | ||||
| using System.Text.Json.Nodes; | ||||
| using StellaOps.TaskRunner.Core.Execution; | ||||
| using StellaOps.TaskRunner.Core.Planning; | ||||
|  | ||||
| namespace StellaOps.TaskRunner.Tests; | ||||
|  | ||||
| public sealed class PackRunApprovalCoordinatorTests | ||||
| { | ||||
|     [Fact] | ||||
|     public void Create_FromPlan_PopulatesApprovals() | ||||
|     { | ||||
|         var plan = BuildPlan(); | ||||
|         var coordinator = PackRunApprovalCoordinator.Create(plan, DateTimeOffset.UtcNow); | ||||
|  | ||||
|         var approvals = coordinator.GetApprovals(); | ||||
|         Assert.Single(approvals); | ||||
|         Assert.Equal("security-review", approvals[0].ApprovalId); | ||||
|         Assert.Equal(PackRunApprovalStatus.Pending, approvals[0].Status); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void Approve_AllowsResumeWhenLastApprovalCompletes() | ||||
|     { | ||||
|         var plan = BuildPlan(); | ||||
|         var coordinator = PackRunApprovalCoordinator.Create(plan, DateTimeOffset.UtcNow); | ||||
|  | ||||
|         var result = coordinator.Approve("security-review", "approver-1", DateTimeOffset.UtcNow); | ||||
|  | ||||
|         Assert.True(result.ShouldResumeRun); | ||||
|         Assert.Equal(PackRunApprovalStatus.Approved, result.State.Status); | ||||
|         Assert.Equal("approver-1", result.State.ActorId); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void Reject_DoesNotResumeAndMarksState() | ||||
|     { | ||||
|         var plan = BuildPlan(); | ||||
|         var coordinator = PackRunApprovalCoordinator.Create(plan, DateTimeOffset.UtcNow); | ||||
|  | ||||
|         var result = coordinator.Reject("security-review", "approver-1", DateTimeOffset.UtcNow, "Not safe"); | ||||
|  | ||||
|         Assert.False(result.ShouldResumeRun); | ||||
|         Assert.Equal(PackRunApprovalStatus.Rejected, result.State.Status); | ||||
|         Assert.Equal("Not safe", result.State.Summary); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void BuildNotifications_UsesRequirements() | ||||
|     { | ||||
|         var plan = BuildPlan(); | ||||
|         var coordinator = PackRunApprovalCoordinator.Create(plan, DateTimeOffset.UtcNow); | ||||
|  | ||||
|         var notifications = coordinator.BuildNotifications(plan); | ||||
|         Assert.Single(notifications); | ||||
|         var notification = notifications[0]; | ||||
|         Assert.Equal("security-review", notification.ApprovalId); | ||||
|         Assert.Contains("Packs.Approve", notification.RequiredGrants); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void BuildPolicyNotifications_ProducesGateMetadata() | ||||
|     { | ||||
|         var plan = BuildPolicyPlan(); | ||||
|         var coordinator = PackRunApprovalCoordinator.Create(plan, DateTimeOffset.UtcNow); | ||||
|  | ||||
|         var notifications = coordinator.BuildPolicyNotifications(plan); | ||||
|         Assert.Single(notifications); | ||||
|         var hint = notifications[0]; | ||||
|         Assert.Equal("policy-check", hint.StepId); | ||||
|         var parameter = hint.Parameters.Single(p => p.Name == "threshold"); | ||||
|         Assert.False(parameter.RequiresRuntimeValue); | ||||
|         var runtimeParam = hint.Parameters.Single(p => p.Name == "evidenceRef"); | ||||
|         Assert.True(runtimeParam.RequiresRuntimeValue); | ||||
|         Assert.Equal("steps.prepare.outputs.evidence", runtimeParam.Expression); | ||||
|     } | ||||
|  | ||||
|     private static TaskPackPlan BuildPlan() | ||||
|     { | ||||
|         var manifest = TestManifests.Load(TestManifests.Sample); | ||||
|         var planner = new TaskPackPlanner(); | ||||
|         var inputs = new Dictionary<string, JsonNode?> | ||||
|         { | ||||
|             ["dryRun"] = JsonValue.Create(false) | ||||
|         }; | ||||
|  | ||||
|         return planner.Plan(manifest, inputs).Plan!; | ||||
|     } | ||||
|  | ||||
|     private static TaskPackPlan BuildPolicyPlan() | ||||
|     { | ||||
|         var manifest = TestManifests.Load(TestManifests.PolicyGate); | ||||
|         var planner = new TaskPackPlanner(); | ||||
|         return planner.Plan(manifest).Plan!; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,85 @@ | ||||
| using Microsoft.Extensions.Logging.Abstractions; | ||||
| using StellaOps.TaskRunner.Core.Execution; | ||||
| using StellaOps.TaskRunner.Core.Planning; | ||||
| using System.Text.Json.Nodes; | ||||
|  | ||||
| namespace StellaOps.TaskRunner.Tests; | ||||
|  | ||||
| public sealed class PackRunProcessorTests | ||||
| { | ||||
|     [Fact] | ||||
|     public async Task ProcessNewRunAsync_PersistsApprovalsAndPublishesNotifications() | ||||
|     { | ||||
|         var manifest = TestManifests.Load(TestManifests.Sample); | ||||
|         var planner = new TaskPackPlanner(); | ||||
|         var plan = planner.Plan(manifest, new Dictionary<string, JsonNode?> { ["dryRun"] = JsonValue.Create(false) }).Plan!; | ||||
|         var context = new PackRunExecutionContext("run-123", plan, DateTimeOffset.UtcNow); | ||||
|  | ||||
|         var store = new TestApprovalStore(); | ||||
|         var publisher = new TestNotificationPublisher(); | ||||
|         var processor = new PackRunProcessor(store, publisher, NullLogger<PackRunProcessor>.Instance); | ||||
|  | ||||
|         var result = await processor.ProcessNewRunAsync(context, CancellationToken.None); | ||||
|  | ||||
|         Assert.False(result.ShouldResumeImmediately); | ||||
|         var saved = Assert.Single(store.Saved); | ||||
|         Assert.Equal("security-review", saved.ApprovalId); | ||||
|         Assert.Single(publisher.Approvals); | ||||
|         Assert.Empty(publisher.Policies); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task ProcessNewRunAsync_NoApprovals_ResumesImmediately() | ||||
|     { | ||||
|         var manifest = TestManifests.Load(TestManifests.Output); | ||||
|         var planner = new TaskPackPlanner(); | ||||
|         var plan = planner.Plan(manifest).Plan!; | ||||
|         var context = new PackRunExecutionContext("run-456", plan, DateTimeOffset.UtcNow); | ||||
|  | ||||
|         var store = new TestApprovalStore(); | ||||
|         var publisher = new TestNotificationPublisher(); | ||||
|         var processor = new PackRunProcessor(store, publisher, NullLogger<PackRunProcessor>.Instance); | ||||
|  | ||||
|         var result = await processor.ProcessNewRunAsync(context, CancellationToken.None); | ||||
|  | ||||
|         Assert.True(result.ShouldResumeImmediately); | ||||
|         Assert.Empty(store.Saved); | ||||
|         Assert.Empty(publisher.Approvals); | ||||
|     } | ||||
|  | ||||
|     private sealed class TestApprovalStore : IPackRunApprovalStore | ||||
|     { | ||||
|         public List<PackRunApprovalState> Saved { get; } = new(); | ||||
|  | ||||
|         public Task<IReadOnlyList<PackRunApprovalState>> GetAsync(string runId, CancellationToken cancellationToken) | ||||
|             => Task.FromResult((IReadOnlyList<PackRunApprovalState>)Saved); | ||||
|  | ||||
|         public Task SaveAsync(string runId, IReadOnlyList<PackRunApprovalState> approvals, CancellationToken cancellationToken) | ||||
|         { | ||||
|             Saved.Clear(); | ||||
|             Saved.AddRange(approvals); | ||||
|             return Task.CompletedTask; | ||||
|         } | ||||
|  | ||||
|         public Task UpdateAsync(string runId, PackRunApprovalState approval, CancellationToken cancellationToken) | ||||
|             => Task.CompletedTask; | ||||
|     } | ||||
|  | ||||
|     private sealed class TestNotificationPublisher : IPackRunNotificationPublisher | ||||
|     { | ||||
|         public List<ApprovalNotification> Approvals { get; } = new(); | ||||
|         public List<PolicyGateNotification> Policies { get; } = new(); | ||||
|  | ||||
|         public Task PublishApprovalRequestedAsync(string runId, ApprovalNotification notification, CancellationToken cancellationToken) | ||||
|         { | ||||
|             Approvals.Add(notification); | ||||
|             return Task.CompletedTask; | ||||
|         } | ||||
|  | ||||
|         public Task PublishPolicyGatePendingAsync(string runId, PolicyGateNotification notification, CancellationToken cancellationToken) | ||||
|         { | ||||
|             Policies.Add(notification); | ||||
|             return Task.CompletedTask; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,135 @@ | ||||
| <?xml version="1.0" ?> | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|    | ||||
|    | ||||
|  | ||||
|    | ||||
|    | ||||
|   <PropertyGroup> | ||||
|      | ||||
|      | ||||
|      | ||||
|      | ||||
|     <OutputType>Exe</OutputType> | ||||
|      | ||||
|      | ||||
|      | ||||
|      | ||||
|     <IsPackable>false</IsPackable> | ||||
|      | ||||
|      | ||||
|  | ||||
|      | ||||
|      | ||||
|      | ||||
|      | ||||
|      | ||||
|      | ||||
|      | ||||
|      | ||||
|    | ||||
|      | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|      | ||||
|      | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|      | ||||
|      | ||||
|     <Nullable>enable</Nullable> | ||||
|      | ||||
|      | ||||
|     <UseConcelierTestInfra>false</UseConcelierTestInfra> | ||||
|      | ||||
|      | ||||
|     <LangVersion>preview</LangVersion> | ||||
|      | ||||
|      | ||||
|     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> | ||||
|      | ||||
|    | ||||
|   </PropertyGroup> | ||||
|    | ||||
|    | ||||
|  | ||||
|    | ||||
|    | ||||
|   <ItemGroup> | ||||
|      | ||||
|      | ||||
|      | ||||
|      | ||||
|     <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/> | ||||
|      | ||||
|      | ||||
|      | ||||
|      | ||||
|     <PackageReference Include="xunit.v3" Version="3.0.0"/> | ||||
|      | ||||
|      | ||||
|      | ||||
|      | ||||
|     <PackageReference Include="xunit.runner.visualstudio" Version="3.1.3"/> | ||||
|      | ||||
|      | ||||
|    | ||||
|    | ||||
|   </ItemGroup> | ||||
|    | ||||
|    | ||||
|  | ||||
|    | ||||
|    | ||||
|   <ItemGroup> | ||||
|      | ||||
|      | ||||
|      | ||||
|      | ||||
|     <Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest"/> | ||||
|      | ||||
|      | ||||
|    | ||||
|    | ||||
|   </ItemGroup> | ||||
|    | ||||
|    | ||||
|  | ||||
|    | ||||
|    | ||||
|   <ItemGroup> | ||||
|      | ||||
|      | ||||
|      | ||||
|      | ||||
|     <Using Include="Xunit"/> | ||||
|      | ||||
|      | ||||
|    | ||||
|    | ||||
|   </ItemGroup> | ||||
|    | ||||
|    | ||||
|  | ||||
|    | ||||
|    | ||||
|   <ItemGroup> | ||||
|      | ||||
|      | ||||
|      | ||||
|      | ||||
|     <ProjectReference Include="..\StellaOps.TaskRunner.Core\StellaOps.TaskRunner.Core.csproj"/> | ||||
|      | ||||
|      | ||||
|      | ||||
|      | ||||
|     <ProjectReference Include="..\StellaOps.TaskRunner.Infrastructure\StellaOps.TaskRunner.Infrastructure.csproj"/> | ||||
|      | ||||
|      | ||||
|    | ||||
|    | ||||
|   </ItemGroup> | ||||
|    | ||||
|    | ||||
|  | ||||
|  | ||||
|  | ||||
| </Project> | ||||
| @@ -0,0 +1,177 @@ | ||||
| using System.Linq; | ||||
| using System.Text.Json.Nodes; | ||||
| using StellaOps.TaskRunner.Core.Planning; | ||||
|  | ||||
| namespace StellaOps.TaskRunner.Tests; | ||||
|  | ||||
| public sealed class TaskPackPlannerTests | ||||
| { | ||||
|     [Fact] | ||||
|     public void Plan_WithSequentialSteps_ComputesDeterministicHash() | ||||
|     { | ||||
|         var manifest = TestManifests.Load(TestManifests.Sample); | ||||
|         var planner = new TaskPackPlanner(); | ||||
|  | ||||
|         var inputs = new Dictionary<string, JsonNode?> | ||||
|         { | ||||
|             ["dryRun"] = JsonValue.Create(false) | ||||
|         }; | ||||
|  | ||||
|         var resultA = planner.Plan(manifest, inputs); | ||||
|         Assert.True(resultA.Success); | ||||
|         var plan = resultA.Plan!; | ||||
|         Assert.Equal(3, plan.Steps.Count); | ||||
|         Assert.Equal("plan-step", plan.Steps[0].Id); | ||||
|         Assert.Equal("plan-step", plan.Steps[0].TemplateId); | ||||
|         Assert.Equal("run", plan.Steps[0].Type); | ||||
|         Assert.Equal("gate.approval", plan.Steps[1].Type); | ||||
|         Assert.Equal("security-review", plan.Steps[1].ApprovalId); | ||||
|         Assert.Equal("run", plan.Steps[2].Type); | ||||
|         Assert.True(plan.Steps[2].Enabled); | ||||
|         Assert.Single(plan.Approvals); | ||||
|         Assert.Equal("security-review", plan.Approvals[0].Id); | ||||
|         Assert.False(string.IsNullOrWhiteSpace(plan.Hash)); | ||||
|  | ||||
|         var resultB = planner.Plan(manifest, inputs); | ||||
|         Assert.True(resultB.Success); | ||||
|         Assert.Equal(plan.Hash, resultB.Plan!.Hash); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void Plan_WhenConditionEvaluatesFalse_DisablesStep() | ||||
|     { | ||||
|         var manifest = TestManifests.Load(TestManifests.Sample); | ||||
|         var planner = new TaskPackPlanner(); | ||||
|  | ||||
|         var inputs = new Dictionary<string, JsonNode?> | ||||
|         { | ||||
|             ["dryRun"] = JsonValue.Create(true) | ||||
|         }; | ||||
|  | ||||
|         var result = planner.Plan(manifest, inputs); | ||||
|         Assert.True(result.Success); | ||||
|         Assert.False(result.Plan!.Steps[2].Enabled); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void Plan_WithStepReferences_MarksParametersAsRuntime() | ||||
|     { | ||||
|         var manifest = TestManifests.Load(TestManifests.StepReference); | ||||
|         var planner = new TaskPackPlanner(); | ||||
|  | ||||
|         var result = planner.Plan(manifest); | ||||
|         Assert.True(result.Success); | ||||
|         var plan = result.Plan!; | ||||
|         Assert.Equal(2, plan.Steps.Count); | ||||
|         var referenceParameters = plan.Steps[1].Parameters!; | ||||
|         Assert.True(referenceParameters["sourceSummary"].RequiresRuntimeValue); | ||||
|         Assert.Equal("steps.prepare.outputs.summary", referenceParameters["sourceSummary"].Expression); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void Plan_WithMapStep_ExpandsIterations() | ||||
|     { | ||||
|         var manifest = TestManifests.Load(TestManifests.Map); | ||||
|         var planner = new TaskPackPlanner(); | ||||
|  | ||||
|         var inputs = new Dictionary<string, JsonNode?> | ||||
|         { | ||||
|             ["targets"] = new JsonArray("alpha", "beta", "gamma") | ||||
|         }; | ||||
|  | ||||
|         var result = planner.Plan(manifest, inputs); | ||||
|         Assert.True(result.Success); | ||||
|         var plan = result.Plan!; | ||||
|         var mapStep = plan.Steps.Single(s => s.Type == "map"); | ||||
|         Assert.Equal(3, mapStep.Children!.Count); | ||||
|         Assert.All(mapStep.Children!, child => Assert.Equal("echo-step", child.TemplateId)); | ||||
|         Assert.Equal(3, mapStep.Parameters!["iterationCount"].Value!.GetValue<int>()); | ||||
|         Assert.Equal("alpha", mapStep.Children![0].Parameters!["item"].Value!.GetValue<string>()); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void CollectApprovalRequirements_GroupsGates() | ||||
|     { | ||||
|         var manifest = TestManifests.Load(TestManifests.Sample); | ||||
|         var planner = new TaskPackPlanner(); | ||||
|  | ||||
|         var plan = planner.Plan(manifest).Plan!; | ||||
|         var requirements = TaskPackPlanInsights.CollectApprovalRequirements(plan); | ||||
|         Assert.Single(requirements); | ||||
|         var requirement = requirements[0]; | ||||
|         Assert.Equal("security-review", requirement.ApprovalId); | ||||
|         Assert.Contains("Packs.Approve", requirement.Grants); | ||||
|         Assert.Equal(plan.Steps[1].Id, requirement.StepIds.Single()); | ||||
|  | ||||
|         var notifications = TaskPackPlanInsights.CollectNotificationHints(plan); | ||||
|         Assert.Contains(notifications, hint => hint.Type == "approval-request" && hint.StepId == plan.Steps[1].Id); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void Plan_WithSecretReference_RecordsSecretMetadata() | ||||
|     { | ||||
|         var manifest = TestManifests.Load(TestManifests.Secret); | ||||
|         var planner = new TaskPackPlanner(); | ||||
|  | ||||
|         var result = planner.Plan(manifest); | ||||
|         Assert.True(result.Success); | ||||
|         var plan = result.Plan!; | ||||
|         Assert.Single(plan.Secrets); | ||||
|         Assert.Equal("apiKey", plan.Secrets[0].Name); | ||||
|         var param = plan.Steps[0].Parameters!["token"]; | ||||
|         Assert.True(param.RequiresRuntimeValue); | ||||
|         Assert.Equal("secrets.apiKey", param.Expression); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void Plan_WithOutputs_ProjectsResolvedValues() | ||||
|     { | ||||
|         var manifest = TestManifests.Load(TestManifests.Output); | ||||
|         var planner = new TaskPackPlanner(); | ||||
|  | ||||
|         var result = planner.Plan(manifest); | ||||
|         Assert.True(result.Success); | ||||
|         var plan = result.Plan!; | ||||
|         Assert.Equal(2, plan.Outputs.Count); | ||||
|  | ||||
|         var bundle = plan.Outputs.First(o => o.Name == "bundlePath"); | ||||
|         Assert.NotNull(bundle.Path); | ||||
|         Assert.False(bundle.Path!.RequiresRuntimeValue); | ||||
|         Assert.Equal("artifacts/report.txt", bundle.Path.Value!.GetValue<string>()); | ||||
|  | ||||
|         var evidence = plan.Outputs.First(o => o.Name == "evidenceModel"); | ||||
|         Assert.NotNull(evidence.Expression); | ||||
|         Assert.True(evidence.Expression!.RequiresRuntimeValue); | ||||
|         Assert.Equal("steps.generate.outputs.evidence", evidence.Expression.Expression); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void PolicyGateHints_IncludeRuntimeMetadata() | ||||
|     { | ||||
|         var manifest = TestManifests.Load(TestManifests.PolicyGate); | ||||
|         var planner = new TaskPackPlanner(); | ||||
|  | ||||
|         var plan = planner.Plan(manifest).Plan!; | ||||
|         var hints = TaskPackPlanInsights.CollectPolicyGateHints(plan); | ||||
|         Assert.Single(hints); | ||||
|         var hint = hints[0]; | ||||
|         Assert.Equal("policy-check", hint.StepId); | ||||
|         var threshold = hint.Parameters.Single(p => p.Name == "threshold"); | ||||
|         Assert.False(threshold.RequiresRuntimeValue); | ||||
|         Assert.Null(threshold.Expression); | ||||
|         var evidence = hint.Parameters.Single(p => p.Name == "evidenceRef"); | ||||
|         Assert.True(evidence.RequiresRuntimeValue); | ||||
|         Assert.Equal("steps.prepare.outputs.evidence", evidence.Expression); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void Plan_WhenRequiredInputMissing_ReturnsError() | ||||
|     { | ||||
|         var manifest = TestManifests.Load(TestManifests.RequiredInput); | ||||
|         var planner = new TaskPackPlanner(); | ||||
|  | ||||
|         var result = planner.Plan(manifest); | ||||
|         Assert.False(result.Success); | ||||
|         Assert.Contains(result.Errors, error => error.Path == "inputs.sbomBundle"); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,165 @@ | ||||
| using System.Text.Json.Nodes; | ||||
| using StellaOps.TaskRunner.Core.TaskPacks; | ||||
|  | ||||
| namespace StellaOps.TaskRunner.Tests; | ||||
|  | ||||
| internal static class TestManifests | ||||
| { | ||||
|     public static TaskPackManifest Load(string yaml) | ||||
|     { | ||||
|         var loader = new TaskPackManifestLoader(); | ||||
|         return loader.Deserialize(yaml); | ||||
|     } | ||||
|  | ||||
|     public const string Sample = """ | ||||
| apiVersion: stellaops.io/pack.v1 | ||||
| kind: TaskPack | ||||
| metadata: | ||||
|   name: sample-pack | ||||
|   version: 1.0.0 | ||||
|   description: Sample pack for planner tests | ||||
|   tags: [tests] | ||||
| spec: | ||||
|   inputs: | ||||
|     - name: dryRun | ||||
|       type: boolean | ||||
|       required: false | ||||
|       default: false | ||||
|   approvals: | ||||
|     - id: security-review | ||||
|       grants: ["Packs.Approve"] | ||||
|   steps: | ||||
|     - id: plan-step | ||||
|       name: Plan | ||||
|       run: | ||||
|         uses: builtin:plan | ||||
|         with: | ||||
|           dryRun: "{{ inputs.dryRun }}" | ||||
|     - id: approval | ||||
|       gate: | ||||
|         approval: | ||||
|           id: security-review | ||||
|         message: "Security approval required." | ||||
|     - id: apply-step | ||||
|       when: "{{ not inputs.dryRun }}" | ||||
|       run: | ||||
|         uses: builtin:apply | ||||
| """; | ||||
|  | ||||
|     public const string RequiredInput = """ | ||||
| apiVersion: stellaops.io/pack.v1 | ||||
| kind: TaskPack | ||||
| metadata: | ||||
|   name: required-input-pack | ||||
|   version: 1.2.3 | ||||
| spec: | ||||
|   inputs: | ||||
|     - name: sbomBundle | ||||
|       type: object | ||||
|       required: true | ||||
|   steps: | ||||
|     - id: noop | ||||
|       run: | ||||
|         uses: builtin:noop | ||||
| """; | ||||
|  | ||||
|     public const string StepReference = """ | ||||
| apiVersion: stellaops.io/pack.v1 | ||||
| kind: TaskPack | ||||
| metadata: | ||||
|   name: step-ref-pack | ||||
|   version: 1.0.0 | ||||
| spec: | ||||
|   steps: | ||||
|     - id: prepare | ||||
|       run: | ||||
|         uses: builtin:prepare | ||||
|     - id: consume | ||||
|       run: | ||||
|         uses: builtin:consume | ||||
|         with: | ||||
|           sourceSummary: "{{ steps.prepare.outputs.summary }}" | ||||
| """; | ||||
|  | ||||
|     public const string Map = """ | ||||
| apiVersion: stellaops.io/pack.v1 | ||||
| kind: TaskPack | ||||
| metadata: | ||||
|   name: map-pack | ||||
|   version: 1.0.0 | ||||
| spec: | ||||
|   inputs: | ||||
|     - name: targets | ||||
|       type: array | ||||
|       required: true | ||||
|   steps: | ||||
|     - id: maintenance-loop | ||||
|       map: | ||||
|         items: "{{ inputs.targets }}" | ||||
|         step: | ||||
|           id: echo-step | ||||
|           run: | ||||
|             uses: builtin:echo | ||||
|             with: | ||||
|               target: "{{ item }}" | ||||
| """; | ||||
|  | ||||
|     public const string Secret = """ | ||||
| apiVersion: stellaops.io/pack.v1 | ||||
| kind: TaskPack | ||||
| metadata: | ||||
|   name: secret-pack | ||||
|   version: 1.0.0 | ||||
| spec: | ||||
|   secrets: | ||||
|     - name: apiKey | ||||
|       scope: Packs.Run | ||||
|       description: API authentication token | ||||
|   steps: | ||||
|     - id: use-secret | ||||
|       run: | ||||
|         uses: builtin:http | ||||
|         with: | ||||
|           token: "{{ secrets.apiKey }}" | ||||
| """; | ||||
|  | ||||
|     public const string Output = """ | ||||
| apiVersion: stellaops.io/pack.v1 | ||||
| kind: TaskPack | ||||
| metadata: | ||||
|   name: output-pack | ||||
|   version: 1.0.0 | ||||
| spec: | ||||
|   steps: | ||||
|     - id: generate | ||||
|       run: | ||||
|         uses: builtin:generate | ||||
|   outputs: | ||||
|     - name: bundlePath | ||||
|       type: file | ||||
|       path: artifacts/report.txt | ||||
|     - name: evidenceModel | ||||
|       type: object | ||||
|       expression: "{{ steps.generate.outputs.evidence }}" | ||||
| """; | ||||
|  | ||||
|     public const string PolicyGate = """ | ||||
| apiVersion: stellaops.io/pack.v1 | ||||
| kind: TaskPack | ||||
| metadata: | ||||
|   name: policy-gate-pack | ||||
|   version: 1.0.0 | ||||
| spec: | ||||
|   steps: | ||||
|     - id: prepare | ||||
|       run: | ||||
|         uses: builtin:prepare | ||||
|     - id: policy-check | ||||
|       gate: | ||||
|         policy: | ||||
|           policy: security-hold | ||||
|           parameters: | ||||
|             threshold: high | ||||
|             evidenceRef: "{{ steps.prepare.outputs.evidence }}" | ||||
| """; | ||||
| } | ||||
| @@ -0,0 +1,3 @@ | ||||
| { | ||||
|     "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json" | ||||
| } | ||||
| @@ -0,0 +1,41 @@ | ||||
| var builder = WebApplication.CreateBuilder(args); | ||||
|  | ||||
| // Add services to the container. | ||||
| // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi | ||||
| builder.Services.AddOpenApi(); | ||||
|  | ||||
| var app = builder.Build(); | ||||
|  | ||||
| // Configure the HTTP request pipeline. | ||||
| if (app.Environment.IsDevelopment()) | ||||
| { | ||||
|     app.MapOpenApi(); | ||||
| } | ||||
|  | ||||
| app.UseHttpsRedirection(); | ||||
|  | ||||
| var summaries = new[] | ||||
| { | ||||
|     "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" | ||||
| }; | ||||
|  | ||||
| app.MapGet("/weatherforecast", () => | ||||
| { | ||||
|     var forecast =  Enumerable.Range(1, 5).Select(index => | ||||
|         new WeatherForecast | ||||
|         ( | ||||
|             DateOnly.FromDateTime(DateTime.Now.AddDays(index)), | ||||
|             Random.Shared.Next(-20, 55), | ||||
|             summaries[Random.Shared.Next(summaries.Length)] | ||||
|         )) | ||||
|         .ToArray(); | ||||
|     return forecast; | ||||
| }) | ||||
| .WithName("GetWeatherForecast"); | ||||
|  | ||||
| app.Run(); | ||||
|  | ||||
| record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) | ||||
| { | ||||
|     public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); | ||||
| } | ||||
| @@ -0,0 +1,23 @@ | ||||
| { | ||||
|   "$schema": "https://json.schemastore.org/launchsettings.json", | ||||
|   "profiles": { | ||||
|     "http": { | ||||
|       "commandName": "Project", | ||||
|       "dotnetRunMessages": true, | ||||
|       "launchBrowser": false, | ||||
|       "applicationUrl": "http://localhost:5157", | ||||
|       "environmentVariables": { | ||||
|         "ASPNETCORE_ENVIRONMENT": "Development" | ||||
|       } | ||||
|     }, | ||||
|     "https": { | ||||
|       "commandName": "Project", | ||||
|       "dotnetRunMessages": true, | ||||
|       "launchBrowser": false, | ||||
|       "applicationUrl": "https://localhost:7035;http://localhost:5157", | ||||
|       "environmentVariables": { | ||||
|         "ASPNETCORE_ENVIRONMENT": "Development" | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @@ -0,0 +1,41 @@ | ||||
| <?xml version="1.0" ?> | ||||
| <Project Sdk="Microsoft.NET.Sdk.Web"> | ||||
|    | ||||
|  | ||||
|    | ||||
|   <PropertyGroup> | ||||
|      | ||||
|      | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <Nullable>enable</Nullable> | ||||
|     <LangVersion>preview</LangVersion> | ||||
|     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> | ||||
|   </PropertyGroup> | ||||
|    | ||||
|  | ||||
|    | ||||
|   <ItemGroup> | ||||
|      | ||||
|      | ||||
|     <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0-rc.2.25502.107"/> | ||||
|      | ||||
|    | ||||
|   </ItemGroup> | ||||
|    | ||||
|  | ||||
|    | ||||
|   <ItemGroup> | ||||
|      | ||||
|      | ||||
|     <ProjectReference Include="..\StellaOps.TaskRunner.Core\StellaOps.TaskRunner.Core.csproj"/> | ||||
|      | ||||
|      | ||||
|     <ProjectReference Include="..\StellaOps.TaskRunner.Infrastructure\StellaOps.TaskRunner.Infrastructure.csproj"/> | ||||
|      | ||||
|    | ||||
|   </ItemGroup> | ||||
|    | ||||
|  | ||||
|  | ||||
| </Project> | ||||
| @@ -0,0 +1,6 @@ | ||||
| @StellaOps.TaskRunner.WebService_HostAddress = http://localhost:5157 | ||||
|  | ||||
| GET {{StellaOps.TaskRunner.WebService_HostAddress}}/weatherforecast/ | ||||
| Accept: application/json | ||||
|  | ||||
| ### | ||||
| @@ -0,0 +1,8 @@ | ||||
| { | ||||
|   "Logging": { | ||||
|     "LogLevel": { | ||||
|       "Default": "Information", | ||||
|       "Microsoft.AspNetCore": "Warning" | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @@ -0,0 +1,9 @@ | ||||
| { | ||||
|   "Logging": { | ||||
|     "LogLevel": { | ||||
|       "Default": "Information", | ||||
|       "Microsoft.AspNetCore": "Warning" | ||||
|     } | ||||
|   }, | ||||
|   "AllowedHosts": "*" | ||||
| } | ||||
| @@ -0,0 +1,42 @@ | ||||
| using Microsoft.Extensions.Options; | ||||
| using StellaOps.TaskRunner.Core.Execution; | ||||
| using StellaOps.TaskRunner.Infrastructure.Execution; | ||||
| using StellaOps.TaskRunner.Worker.Services; | ||||
|  | ||||
| var builder = Host.CreateApplicationBuilder(args); | ||||
|  | ||||
| builder.Services.Configure<PackRunWorkerOptions>(builder.Configuration.GetSection("Worker")); | ||||
| builder.Services.Configure<NotificationOptions>(builder.Configuration.GetSection("Notifications")); | ||||
| builder.Services.AddHttpClient("taskrunner-notifications"); | ||||
|  | ||||
| builder.Services.AddSingleton<IPackRunApprovalStore>(sp => | ||||
| { | ||||
|     var options = sp.GetRequiredService<IOptions<PackRunWorkerOptions>>(); | ||||
|     return new FilePackRunApprovalStore(options.Value.ApprovalStorePath); | ||||
| }); | ||||
|  | ||||
| builder.Services.AddSingleton<IPackRunJobDispatcher>(sp => | ||||
| { | ||||
|     var options = sp.GetRequiredService<IOptions<PackRunWorkerOptions>>(); | ||||
|     return new FilesystemPackRunDispatcher(options.Value.QueuePath, options.Value.ArchivePath); | ||||
| }); | ||||
|  | ||||
| builder.Services.AddSingleton<IPackRunNotificationPublisher>(sp => | ||||
| { | ||||
|     var options = sp.GetRequiredService<IOptions<NotificationOptions>>().Value; | ||||
|     if (options.ApprovalEndpoint is not null || options.PolicyEndpoint is not null) | ||||
|     { | ||||
|         return new HttpPackRunNotificationPublisher( | ||||
|             sp.GetRequiredService<IHttpClientFactory>(), | ||||
|             sp.GetRequiredService<IOptions<NotificationOptions>>(), | ||||
|             sp.GetRequiredService<ILogger<HttpPackRunNotificationPublisher>>()); | ||||
|     } | ||||
|  | ||||
|     return new LoggingPackRunNotificationPublisher(sp.GetRequiredService<ILogger<LoggingPackRunNotificationPublisher>>()); | ||||
| }); | ||||
|  | ||||
| builder.Services.AddSingleton<PackRunProcessor>(); | ||||
| builder.Services.AddHostedService<PackRunWorkerService>(); | ||||
|  | ||||
| var host = builder.Build(); | ||||
| host.Run(); | ||||
| @@ -0,0 +1,12 @@ | ||||
| { | ||||
|   "$schema": "https://json.schemastore.org/launchsettings.json", | ||||
|   "profiles": { | ||||
|     "StellaOps.TaskRunner.Worker": { | ||||
|       "commandName": "Project", | ||||
|       "dotnetRunMessages": true, | ||||
|       "environmentVariables": { | ||||
|         "DOTNET_ENVIRONMENT": "Development" | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @@ -0,0 +1,12 @@ | ||||
| namespace StellaOps.TaskRunner.Worker.Services; | ||||
|  | ||||
| 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"); | ||||
| } | ||||
| @@ -0,0 +1,49 @@ | ||||
| using StellaOps.TaskRunner.Core.Execution; | ||||
| using Microsoft.Extensions.Options; | ||||
|  | ||||
| namespace StellaOps.TaskRunner.Worker.Services; | ||||
|  | ||||
| public sealed class PackRunWorkerService : BackgroundService | ||||
| { | ||||
|     private readonly IPackRunJobDispatcher dispatcher; | ||||
|     private readonly PackRunProcessor processor; | ||||
|     private readonly PackRunWorkerOptions options; | ||||
|     private readonly ILogger<PackRunWorkerService> logger; | ||||
|  | ||||
|     public PackRunWorkerService( | ||||
|         IPackRunJobDispatcher dispatcher, | ||||
|         PackRunProcessor processor, | ||||
|         IOptions<PackRunWorkerOptions> options, | ||||
|         ILogger<PackRunWorkerService> logger) | ||||
|     { | ||||
|         this.dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); | ||||
|         this.processor = processor ?? throw new ArgumentNullException(nameof(processor)); | ||||
|         this.options = options?.Value ?? throw new ArgumentNullException(nameof(options)); | ||||
|         this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|     } | ||||
|  | ||||
|     protected override async Task ExecuteAsync(CancellationToken stoppingToken) | ||||
|     { | ||||
|         while (!stoppingToken.IsCancellationRequested) | ||||
|         { | ||||
|             var context = await dispatcher.TryDequeueAsync(stoppingToken).ConfigureAwait(false); | ||||
|             if (context is null) | ||||
|             { | ||||
|                 await Task.Delay(options.IdleDelay, stoppingToken).ConfigureAwait(false); | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             logger.LogInformation("Processing pack run {RunId}.", context.RunId); | ||||
|             var result = await processor.ProcessNewRunAsync(context, stoppingToken).ConfigureAwait(false); | ||||
|  | ||||
|             if (result.ShouldResumeImmediately) | ||||
|             { | ||||
|                 logger.LogInformation("Run {RunId} is ready to resume immediately.", context.RunId); | ||||
|             } | ||||
|             else | ||||
|             { | ||||
|                 logger.LogInformation("Run {RunId} is awaiting approvals.", context.RunId); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,43 @@ | ||||
| <?xml version="1.0" ?> | ||||
| <Project Sdk="Microsoft.NET.Sdk.Worker"> | ||||
|    | ||||
|  | ||||
|    | ||||
|   <PropertyGroup> | ||||
|      | ||||
|      | ||||
|     <UserSecretsId>dotnet-StellaOps.TaskRunner.Worker-ce7b902e-94f1-41c2-861b-daa533850dc5</UserSecretsId> | ||||
|      | ||||
|    | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <Nullable>enable</Nullable> | ||||
|     <LangVersion>preview</LangVersion> | ||||
|     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> | ||||
|   </PropertyGroup> | ||||
|    | ||||
|  | ||||
|    | ||||
|   <ItemGroup> | ||||
|      | ||||
|      | ||||
|     <PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0-rc.2.25502.107"/> | ||||
|      | ||||
|    | ||||
|   </ItemGroup> | ||||
|    | ||||
|  | ||||
|    | ||||
|   <ItemGroup> | ||||
|      | ||||
|      | ||||
|     <ProjectReference Include="..\StellaOps.TaskRunner.Core\StellaOps.TaskRunner.Core.csproj"/> | ||||
|      | ||||
|      | ||||
|     <ProjectReference Include="..\StellaOps.TaskRunner.Infrastructure\StellaOps.TaskRunner.Infrastructure.csproj"/> | ||||
|      | ||||
|    | ||||
|   </ItemGroup> | ||||
|    | ||||
|  | ||||
| </Project> | ||||
| @@ -0,0 +1,8 @@ | ||||
| { | ||||
|   "Logging": { | ||||
|     "LogLevel": { | ||||
|       "Default": "Information", | ||||
|       "Microsoft.Hosting.Lifetime": "Information" | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @@ -0,0 +1,18 @@ | ||||
| { | ||||
|   "Logging": { | ||||
|     "LogLevel": { | ||||
|       "Default": "Information", | ||||
|       "Microsoft.Hosting.Lifetime": "Information" | ||||
|     } | ||||
|   }, | ||||
|   "Worker": { | ||||
|     "IdleDelay": "00:00:01", | ||||
|     "QueuePath": "queue", | ||||
|     "ArchivePath": "queue/archive", | ||||
|     "ApprovalStorePath": "state/approvals" | ||||
|   }, | ||||
|   "Notifications": { | ||||
|     "ApprovalEndpoint": null, | ||||
|     "PolicyEndpoint": null | ||||
|   } | ||||
| } | ||||
							
								
								
									
										90
									
								
								src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.sln
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										90
									
								
								src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.sln
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,90 @@ | ||||
|  | ||||
| Microsoft Visual Studio Solution File, Format Version 12.00 | ||||
| # Visual Studio Version 17 | ||||
| VisualStudioVersion = 17.0.31903.59 | ||||
| MinimumVisualStudioVersion = 10.0.40219.1 | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.TaskRunner.Core", "StellaOps.TaskRunner.Core\StellaOps.TaskRunner.Core.csproj", "{105A0C4D-1ECD-4581-8EBF-8DB29D6EE857}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.TaskRunner.Infrastructure", "StellaOps.TaskRunner.Infrastructure\StellaOps.TaskRunner.Infrastructure.csproj", "{1B4F4A2B-9C38-4E7A-BFBE-158BF7C1F61B}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.TaskRunner.WebService", "StellaOps.TaskRunner.WebService\StellaOps.TaskRunner.WebService.csproj", "{D8A63A97-9C56-448B-A4BB-056130224750}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.TaskRunner.Worker", "StellaOps.TaskRunner.Worker\StellaOps.TaskRunner.Worker.csproj", "{C0AC4DD1-6DD7-4FCF-A6DD-5DE9B86D6753}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.TaskRunner.Tests", "StellaOps.TaskRunner.Tests\StellaOps.TaskRunner.Tests.csproj", "{552E7C8A-74F6-4E33-B956-46DF96E2BE11}" | ||||
| EndProject | ||||
| Global | ||||
| 	GlobalSection(SolutionConfigurationPlatforms) = preSolution | ||||
| 		Debug|Any CPU = Debug|Any CPU | ||||
| 		Debug|x64 = Debug|x64 | ||||
| 		Debug|x86 = Debug|x86 | ||||
| 		Release|Any CPU = Release|Any CPU | ||||
| 		Release|x64 = Release|x64 | ||||
| 		Release|x86 = Release|x86 | ||||
| 	EndGlobalSection | ||||
| 	GlobalSection(ProjectConfigurationPlatforms) = postSolution | ||||
| 		{105A0C4D-1ECD-4581-8EBF-8DB29D6EE857}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | ||||
| 		{105A0C4D-1ECD-4581-8EBF-8DB29D6EE857}.Debug|Any CPU.Build.0 = Debug|Any CPU | ||||
| 		{105A0C4D-1ECD-4581-8EBF-8DB29D6EE857}.Debug|x64.ActiveCfg = Debug|Any CPU | ||||
| 		{105A0C4D-1ECD-4581-8EBF-8DB29D6EE857}.Debug|x64.Build.0 = Debug|Any CPU | ||||
| 		{105A0C4D-1ECD-4581-8EBF-8DB29D6EE857}.Debug|x86.ActiveCfg = Debug|Any CPU | ||||
| 		{105A0C4D-1ECD-4581-8EBF-8DB29D6EE857}.Debug|x86.Build.0 = Debug|Any CPU | ||||
| 		{105A0C4D-1ECD-4581-8EBF-8DB29D6EE857}.Release|Any CPU.ActiveCfg = Release|Any CPU | ||||
| 		{105A0C4D-1ECD-4581-8EBF-8DB29D6EE857}.Release|Any CPU.Build.0 = Release|Any CPU | ||||
| 		{105A0C4D-1ECD-4581-8EBF-8DB29D6EE857}.Release|x64.ActiveCfg = Release|Any CPU | ||||
| 		{105A0C4D-1ECD-4581-8EBF-8DB29D6EE857}.Release|x64.Build.0 = Release|Any CPU | ||||
| 		{105A0C4D-1ECD-4581-8EBF-8DB29D6EE857}.Release|x86.ActiveCfg = Release|Any CPU | ||||
| 		{105A0C4D-1ECD-4581-8EBF-8DB29D6EE857}.Release|x86.Build.0 = Release|Any CPU | ||||
| 		{1B4F4A2B-9C38-4E7A-BFBE-158BF7C1F61B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | ||||
| 		{1B4F4A2B-9C38-4E7A-BFBE-158BF7C1F61B}.Debug|Any CPU.Build.0 = Debug|Any CPU | ||||
| 		{1B4F4A2B-9C38-4E7A-BFBE-158BF7C1F61B}.Debug|x64.ActiveCfg = Debug|Any CPU | ||||
| 		{1B4F4A2B-9C38-4E7A-BFBE-158BF7C1F61B}.Debug|x64.Build.0 = Debug|Any CPU | ||||
| 		{1B4F4A2B-9C38-4E7A-BFBE-158BF7C1F61B}.Debug|x86.ActiveCfg = Debug|Any CPU | ||||
| 		{1B4F4A2B-9C38-4E7A-BFBE-158BF7C1F61B}.Debug|x86.Build.0 = Debug|Any CPU | ||||
| 		{1B4F4A2B-9C38-4E7A-BFBE-158BF7C1F61B}.Release|Any CPU.ActiveCfg = Release|Any CPU | ||||
| 		{1B4F4A2B-9C38-4E7A-BFBE-158BF7C1F61B}.Release|Any CPU.Build.0 = Release|Any CPU | ||||
| 		{1B4F4A2B-9C38-4E7A-BFBE-158BF7C1F61B}.Release|x64.ActiveCfg = Release|Any CPU | ||||
| 		{1B4F4A2B-9C38-4E7A-BFBE-158BF7C1F61B}.Release|x64.Build.0 = Release|Any CPU | ||||
| 		{1B4F4A2B-9C38-4E7A-BFBE-158BF7C1F61B}.Release|x86.ActiveCfg = Release|Any CPU | ||||
| 		{1B4F4A2B-9C38-4E7A-BFBE-158BF7C1F61B}.Release|x86.Build.0 = Release|Any CPU | ||||
| 		{D8A63A97-9C56-448B-A4BB-056130224750}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | ||||
| 		{D8A63A97-9C56-448B-A4BB-056130224750}.Debug|Any CPU.Build.0 = Debug|Any CPU | ||||
| 		{D8A63A97-9C56-448B-A4BB-056130224750}.Debug|x64.ActiveCfg = Debug|Any CPU | ||||
| 		{D8A63A97-9C56-448B-A4BB-056130224750}.Debug|x64.Build.0 = Debug|Any CPU | ||||
| 		{D8A63A97-9C56-448B-A4BB-056130224750}.Debug|x86.ActiveCfg = Debug|Any CPU | ||||
| 		{D8A63A97-9C56-448B-A4BB-056130224750}.Debug|x86.Build.0 = Debug|Any CPU | ||||
| 		{D8A63A97-9C56-448B-A4BB-056130224750}.Release|Any CPU.ActiveCfg = Release|Any CPU | ||||
| 		{D8A63A97-9C56-448B-A4BB-056130224750}.Release|Any CPU.Build.0 = Release|Any CPU | ||||
| 		{D8A63A97-9C56-448B-A4BB-056130224750}.Release|x64.ActiveCfg = Release|Any CPU | ||||
| 		{D8A63A97-9C56-448B-A4BB-056130224750}.Release|x64.Build.0 = Release|Any CPU | ||||
| 		{D8A63A97-9C56-448B-A4BB-056130224750}.Release|x86.ActiveCfg = Release|Any CPU | ||||
| 		{D8A63A97-9C56-448B-A4BB-056130224750}.Release|x86.Build.0 = Release|Any CPU | ||||
| 		{C0AC4DD1-6DD7-4FCF-A6DD-5DE9B86D6753}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | ||||
| 		{C0AC4DD1-6DD7-4FCF-A6DD-5DE9B86D6753}.Debug|Any CPU.Build.0 = Debug|Any CPU | ||||
| 		{C0AC4DD1-6DD7-4FCF-A6DD-5DE9B86D6753}.Debug|x64.ActiveCfg = Debug|Any CPU | ||||
| 		{C0AC4DD1-6DD7-4FCF-A6DD-5DE9B86D6753}.Debug|x64.Build.0 = Debug|Any CPU | ||||
| 		{C0AC4DD1-6DD7-4FCF-A6DD-5DE9B86D6753}.Debug|x86.ActiveCfg = Debug|Any CPU | ||||
| 		{C0AC4DD1-6DD7-4FCF-A6DD-5DE9B86D6753}.Debug|x86.Build.0 = Debug|Any CPU | ||||
| 		{C0AC4DD1-6DD7-4FCF-A6DD-5DE9B86D6753}.Release|Any CPU.ActiveCfg = Release|Any CPU | ||||
| 		{C0AC4DD1-6DD7-4FCF-A6DD-5DE9B86D6753}.Release|Any CPU.Build.0 = Release|Any CPU | ||||
| 		{C0AC4DD1-6DD7-4FCF-A6DD-5DE9B86D6753}.Release|x64.ActiveCfg = Release|Any CPU | ||||
| 		{C0AC4DD1-6DD7-4FCF-A6DD-5DE9B86D6753}.Release|x64.Build.0 = Release|Any CPU | ||||
| 		{C0AC4DD1-6DD7-4FCF-A6DD-5DE9B86D6753}.Release|x86.ActiveCfg = Release|Any CPU | ||||
| 		{C0AC4DD1-6DD7-4FCF-A6DD-5DE9B86D6753}.Release|x86.Build.0 = Release|Any CPU | ||||
| 		{552E7C8A-74F6-4E33-B956-46DF96E2BE11}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | ||||
| 		{552E7C8A-74F6-4E33-B956-46DF96E2BE11}.Debug|Any CPU.Build.0 = Debug|Any CPU | ||||
| 		{552E7C8A-74F6-4E33-B956-46DF96E2BE11}.Debug|x64.ActiveCfg = Debug|Any CPU | ||||
| 		{552E7C8A-74F6-4E33-B956-46DF96E2BE11}.Debug|x64.Build.0 = Debug|Any CPU | ||||
| 		{552E7C8A-74F6-4E33-B956-46DF96E2BE11}.Debug|x86.ActiveCfg = Debug|Any CPU | ||||
| 		{552E7C8A-74F6-4E33-B956-46DF96E2BE11}.Debug|x86.Build.0 = Debug|Any CPU | ||||
| 		{552E7C8A-74F6-4E33-B956-46DF96E2BE11}.Release|Any CPU.ActiveCfg = Release|Any CPU | ||||
| 		{552E7C8A-74F6-4E33-B956-46DF96E2BE11}.Release|Any CPU.Build.0 = Release|Any CPU | ||||
| 		{552E7C8A-74F6-4E33-B956-46DF96E2BE11}.Release|x64.ActiveCfg = Release|Any CPU | ||||
| 		{552E7C8A-74F6-4E33-B956-46DF96E2BE11}.Release|x64.Build.0 = Release|Any CPU | ||||
| 		{552E7C8A-74F6-4E33-B956-46DF96E2BE11}.Release|x86.ActiveCfg = Release|Any CPU | ||||
| 		{552E7C8A-74F6-4E33-B956-46DF96E2BE11}.Release|x86.Build.0 = Release|Any CPU | ||||
| 	EndGlobalSection | ||||
| 	GlobalSection(SolutionProperties) = preSolution | ||||
| 		HideSolutionNode = FALSE | ||||
| 	EndGlobalSection | ||||
| EndGlobal | ||||
							
								
								
									
										51
									
								
								src/TaskRunner/StellaOps.TaskRunner/TASKS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								src/TaskRunner/StellaOps.TaskRunner/TASKS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,51 @@ | ||||
| # Task Runner Service Task Board — Epic 12: CLI Parity & Task Packs | ||||
|  | ||||
| ## Sprint 41 – Foundations | ||||
| | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | | ||||
| |----|--------|----------|------------|-------------|---------------| | ||||
| | TASKRUN-41-001 | TODO | Task Runner Guild | ORCH-SVC-41-101, AUTH-PACKS-41-001 | Bootstrap service, define migrations for `pack_runs`, `pack_run_logs`, `pack_artifacts`, implement run API (create/get/log stream), local executor, approvals pause, artifact capture, and provenance manifest generation. | Service builds/tests; migrations scripted; run API functional with sample pack; logs/artefacts stored; manifest signed; compliance checklist recorded. | | ||||
|  | ||||
| ## Sprint 42 – Advanced Execution | ||||
| | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | | ||||
| |----|--------|----------|------------|-------------|---------------| | ||||
| | TASKRUN-42-001 | DOING (2025-10-29) | Task Runner Guild | TASKRUN-41-001 | Add loops, conditionals, `maxParallel`, outputs, simulation mode, policy gate integration, and failure recovery (retry/abort) with deterministic state. | Executor handles control flow; simulation returns plan; policy gates pause for approvals; tests cover restart/resume. | | ||||
| > 2025-10-29: Initiated manifest parsing + deterministic planning core to unblock approvals pipeline; building expression engine + plan hashing to support CLI parity. | ||||
| > 2025-10-29: Landed manifest loader, planner, deterministic hash, outputs + approval/policy insights with unit tests; awaiting upstream APIs for execution-side wiring. | ||||
|  | ||||
| ## Sprint 43 – Approvals, Notifications, Hardening | ||||
| | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | | ||||
| |----|--------|----------|------------|-------------|---------------| | ||||
| | TASKRUN-43-001 | DOING (2025-10-29) | Task Runner Guild | TASKRUN-42-001, NOTIFY-SVC-40-001 | Implement approvals workflow (resume after approval), notifications integration, remote artifact uploads, chaos resilience, secret injection, and audit logs. | Approvals/resume flow validated; notifications emitted; chaos tests documented; secrets redacted in logs; audit logs complete. | | ||||
| > 2025-10-29: Starting approvals orchestration — defining persistence/workflow scaffolding, integrating plan insights for notifications, and staging resume hooks. | ||||
| > 2025-10-29: Added approval coordinator + policy notification bridge with unit tests; ready to wire into worker execution/resume path. | ||||
|  | ||||
| ## Authority-Backed Scopes & Tenancy (Epic 14) | ||||
| | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | | ||||
| |----|--------|----------|------------|-------------|---------------| | ||||
| | TASKRUN-TEN-48-001 | TODO | Task Runner Guild | ORCH-TEN-48-001 | Require tenant/project context for every pack run, set DB/object-store prefixes, block egress when tenant restricted, and propagate context to steps/logs. | Pack runs fail without tenant context; artifacts stored under tenant prefix; tests verify enforcement. | | ||||
|  | ||||
| ## Observability & Forensics (Epic 15) | ||||
| | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | | ||||
| |----|--------|----------|------------|-------------|---------------| | ||||
| | TASKRUN-OBS-50-001 | TODO | Task Runner Guild | TELEMETRY-OBS-50-001, TELEMETRY-OBS-50-002 | Adopt telemetry core in Task Runner host + worker executors, ensuring step execution spans/logs include `trace_id`, `tenant_id`, `run_id`, and scrubbed command transcripts. | Telemetry emitted for sample runs; integration test verifies context propagation across async steps; log schema validated. | | ||||
| | TASKRUN-OBS-51-001 | TODO | Task Runner Guild, DevOps Guild | TASKRUN-OBS-50-001, TELEMETRY-OBS-51-001 | Emit metrics for step latency, retries, queue depth, sandbox resource usage; define SLOs for pack run completion and failure rate; surface burn-rate alerts to collector/Notifier. | Metrics appear in dashboards; burn-rate alert tested; docs capture thresholds and response playbook. | | ||||
| | TASKRUN-OBS-52-001 | TODO | Task Runner Guild | TASKRUN-OBS-50-001, TIMELINE-OBS-52-002 | Produce timeline events for pack runs (`pack.started`, `pack.step.completed`, `pack.failed`) containing evidence pointers and policy gate context. Provide dedupe + retry logic. | Timeline events recorded for sample runs; duplicates suppressed; tests cover error/retry; docs updated. | | ||||
| | TASKRUN-OBS-53-001 | TODO | Task Runner Guild, Evidence Locker Guild | TASKRUN-OBS-52-001, EVID-OBS-53-002 | Capture step transcripts, artifact manifests, environment digests, and policy approvals into evidence locker snapshots; ensure redaction + hash chain coverage. | Evidence bundle created for sample pack; redaction tests pass; manifest linked in timeline. | | ||||
| | TASKRUN-OBS-54-001 | TODO | Task Runner Guild, Provenance Guild | TASKRUN-OBS-53-001, PROV-OBS-53-002 | Generate DSSE attestations for pack runs (subjects = produced artifacts) and expose verification API/CLI integration. Store references in timeline events. | Attestation generated + verified; timeline includes attestation ref; docs updated. | | ||||
| | TASKRUN-OBS-55-001 | TODO | Task Runner Guild, DevOps Guild | TASKRUN-OBS-51-001, TELEMETRY-OBS-55-001, DEVOPS-OBS-55-001 | Implement incident mode escalations (extra telemetry, debug artifact capture, retention bump) and align on automatic activation via SLO breach webhooks. | Incident mode toggles validated; extra artefacts captured; notifier integration tested. | | ||||
|  | ||||
| ## Air-Gapped Mode (Epic 16) | ||||
| | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | | ||||
| |----|--------|----------|------------|-------------|---------------| | ||||
| | TASKRUN-AIRGAP-56-001 | TODO | Task Runner Guild, AirGap Policy Guild | AIRGAP-POL-56-001, TASKRUN-OBS-50-001 | Enforce plan-time validation rejecting steps with non-allowlisted network calls in sealed mode and surface remediation errors. | Planner blocks disallowed steps; error contains remediation; tests cover sealed/unsealed behavior. | | ||||
| | TASKRUN-AIRGAP-56-002 | TODO | Task Runner Guild, AirGap Importer Guild | TASKRUN-AIRGAP-56-001, AIRGAP-IMP-57-002 | Add helper steps for bundle ingestion (checksum verification, staging to object store) with deterministic outputs. | Helper steps succeed deterministically; integration tests import sample bundle. | | ||||
| | TASKRUN-AIRGAP-57-001 | TODO | Task Runner Guild, AirGap Controller Guild | TASKRUN-AIRGAP-56-001, AIRGAP-CTL-56-002 | Refuse to execute plans when environment sealed=false but declared sealed install; emit advisory timeline events. | Mismatch detection works; timeline + telemetry record violation; docs updated. | | ||||
| | TASKRUN-AIRGAP-58-001 | TODO | Task Runner Guild, Evidence Locker Guild | TASKRUN-OBS-53-001, EVID-OBS-55-001 | Capture bundle import job transcripts, hashed inputs, and outputs into portable evidence bundles. | Evidence recorded; manifests deterministic; timeline references created. | | ||||
|  | ||||
| ## SDKs & OpenAPI (Epic 17) | ||||
| | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | | ||||
| |----|--------|----------|------------|-------------|---------------| | ||||
| | TASKRUN-OAS-61-001 | TODO | Task Runner Guild, API Contracts Guild | OAS-61-001 | Document Task Runner APIs (pack runs, logs, approvals) in service OAS, including streaming response schemas and examples. | OAS covers all Task Runner endpoints with examples; lint passes. | | ||||
| | TASKRUN-OAS-61-002 | TODO | Task Runner Guild | TASKRUN-OAS-61-001 | Expose `GET /.well-known/openapi` returning signed spec metadata, build version, and ETag. | Discovery endpoint deployed; contract tests call endpoint; telemetry includes `x-stella-service`. | | ||||
| | TASKRUN-OAS-62-001 | TODO | Task Runner Guild, SDK Generator Guild | TASKRUN-OAS-61-001, SDKGEN-63-001 | Provide SDK examples for pack run lifecycle; ensure SDKs offer streaming log helpers and paginator wrappers. | SDK smoke tests cover pack run flows; docs auto-embed snippets. | | ||||
| | TASKRUN-OAS-63-001 | TODO | Task Runner Guild, API Governance Guild | APIGOV-63-001 | Implement deprecation header support and Sunset handling for legacy pack APIs; emit notifications metadata. | Deprecated endpoints emit headers; notifications pipeline validated; documentation updated. | | ||||
		Reference in New Issue
	
	Block a user