feat: Implement approvals workflow and notifications integration
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Added approvals orchestration with persistence and workflow scaffolding. - Integrated notifications insights and staged resume hooks. - Introduced approval coordinator and policy notification bridge with unit tests. - Added approval decision API with resume requeue and persisted plan snapshots. - Documented the Excitor consensus API beta and provided JSON sample payload. - Created analyzers to flag usage of deprecated merge service APIs. - Implemented logging for artifact uploads and approval decision service. - Added tests for PackRunApprovalDecisionService and related components.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
using StellaOps.TaskRunner.Core.Execution;
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.TaskRunner.Core.Execution;
|
||||
using StellaOps.TaskRunner.Core.Planning;
|
||||
using StellaOps.TaskRunner.Infrastructure.Execution;
|
||||
|
||||
@@ -60,12 +61,34 @@ public sealed class FilePackRunStateStoreTests
|
||||
|
||||
private static PackRunState CreateState(string runId)
|
||||
{
|
||||
var failurePolicy = new TaskPackPlanFailurePolicy(MaxAttempts: 3, BackoffSeconds: 30, ContinueOnError: false);
|
||||
var steps = new Dictionary<string, PackRunStepStateRecord>(StringComparer.Ordinal)
|
||||
{
|
||||
["step-a"] = new PackRunStepStateRecord(
|
||||
StepId: "step-a",
|
||||
Kind: PackRunStepKind.Run,
|
||||
var failurePolicy = new TaskPackPlanFailurePolicy(MaxAttempts: 3, BackoffSeconds: 30, ContinueOnError: false);
|
||||
var metadata = new TaskPackPlanMetadata("sample", "1.0.0", null, Array.Empty<string>());
|
||||
var parameters = new Dictionary<string, TaskPackPlanParameterValue>(StringComparer.Ordinal);
|
||||
var stepPlan = new TaskPackPlanStep(
|
||||
Id: "step-a",
|
||||
TemplateId: "run/image",
|
||||
Name: "Run step",
|
||||
Type: "run",
|
||||
Enabled: true,
|
||||
Uses: "builtin/run",
|
||||
Parameters: parameters,
|
||||
ApprovalId: null,
|
||||
GateMessage: null,
|
||||
Children: Array.Empty<TaskPackPlanStep>());
|
||||
var plan = new TaskPackPlan(
|
||||
metadata,
|
||||
new Dictionary<string, JsonNode?>(StringComparer.Ordinal),
|
||||
new[] { stepPlan },
|
||||
"hash-123",
|
||||
Array.Empty<TaskPackPlanApproval>(),
|
||||
Array.Empty<TaskPackPlanSecret>(),
|
||||
Array.Empty<TaskPackPlanOutput>(),
|
||||
failurePolicy);
|
||||
var steps = new Dictionary<string, PackRunStepStateRecord>(StringComparer.Ordinal)
|
||||
{
|
||||
["step-a"] = new PackRunStepStateRecord(
|
||||
StepId: "step-a",
|
||||
Kind: PackRunStepKind.Run,
|
||||
Enabled: true,
|
||||
ContinueOnError: false,
|
||||
MaxParallel: null,
|
||||
@@ -75,10 +98,11 @@ public sealed class FilePackRunStateStoreTests
|
||||
Attempts: 1,
|
||||
LastTransitionAt: DateTimeOffset.UtcNow,
|
||||
NextAttemptAt: null,
|
||||
StatusReason: null)
|
||||
};
|
||||
|
||||
return PackRunState.Create(runId, "hash-123", failurePolicy, steps, DateTimeOffset.UtcNow);
|
||||
StatusReason: null)
|
||||
};
|
||||
|
||||
var timestamp = DateTimeOffset.UtcNow;
|
||||
return PackRunState.Create(runId, "hash-123", plan, failurePolicy, timestamp, steps, timestamp);
|
||||
}
|
||||
|
||||
private static string CreateTempDirectory()
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.TaskRunner.Core.Execution;
|
||||
using StellaOps.TaskRunner.Core.Planning;
|
||||
using StellaOps.TaskRunner.Infrastructure.Execution;
|
||||
|
||||
namespace StellaOps.TaskRunner.Tests;
|
||||
|
||||
public sealed class PackRunApprovalDecisionServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ApplyAsync_ApprovingLastGateSchedulesResume()
|
||||
{
|
||||
var plan = TestPlanFactory.CreatePlan();
|
||||
var state = TestPlanFactory.CreateState("run-1", plan);
|
||||
var approval = new PackRunApprovalState(
|
||||
"security-review",
|
||||
new[] { "Packs.Approve" },
|
||||
new[] { "step-a" },
|
||||
Array.Empty<string>(),
|
||||
null,
|
||||
DateTimeOffset.UtcNow.AddMinutes(-5),
|
||||
PackRunApprovalStatus.Pending);
|
||||
|
||||
var approvalStore = new InMemoryApprovalStore(new Dictionary<string, IReadOnlyList<PackRunApprovalState>>
|
||||
{
|
||||
["run-1"] = new List<PackRunApprovalState> { approval }
|
||||
});
|
||||
var stateStore = new InMemoryStateStore(new Dictionary<string, PackRunState>
|
||||
{
|
||||
["run-1"] = state
|
||||
});
|
||||
var scheduler = new RecordingScheduler();
|
||||
|
||||
var service = new PackRunApprovalDecisionService(
|
||||
approvalStore,
|
||||
stateStore,
|
||||
scheduler,
|
||||
NullLogger<PackRunApprovalDecisionService>.Instance);
|
||||
|
||||
var result = await service.ApplyAsync(
|
||||
new PackRunApprovalDecisionRequest("run-1", "security-review", PackRunApprovalDecisionType.Approved, "approver@example.com", "LGTM"),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal("resumed", result.Status);
|
||||
Assert.True(scheduler.ScheduledContexts.TryGetValue("run-1", out var context));
|
||||
Assert.Equal(plan.Hash, context!.Plan.Hash);
|
||||
Assert.Equal(PackRunApprovalStatus.Approved, approvalStore.LastUpdated?.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyAsync_ReturnsNotFoundWhenStateMissing()
|
||||
{
|
||||
var approvalStore = new InMemoryApprovalStore(new Dictionary<string, IReadOnlyList<PackRunApprovalState>>());
|
||||
var stateStore = new InMemoryStateStore(new Dictionary<string, PackRunState>());
|
||||
var scheduler = new RecordingScheduler();
|
||||
|
||||
var service = new PackRunApprovalDecisionService(
|
||||
approvalStore,
|
||||
stateStore,
|
||||
scheduler,
|
||||
NullLogger<PackRunApprovalDecisionService>.Instance);
|
||||
|
||||
var result = await service.ApplyAsync(
|
||||
new PackRunApprovalDecisionRequest("missing", "approval", PackRunApprovalDecisionType.Approved, "actor", null),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal("not_found", result.Status);
|
||||
Assert.False(scheduler.ScheduledContexts.Any());
|
||||
}
|
||||
|
||||
private sealed class InMemoryApprovalStore : IPackRunApprovalStore
|
||||
{
|
||||
private readonly Dictionary<string, List<PackRunApprovalState>> _approvals;
|
||||
public PackRunApprovalState? LastUpdated { get; private set; }
|
||||
|
||||
public InMemoryApprovalStore(IDictionary<string, IReadOnlyList<PackRunApprovalState>> seed)
|
||||
{
|
||||
_approvals = seed.ToDictionary(
|
||||
pair => pair.Key,
|
||||
pair => pair.Value.ToList(),
|
||||
StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
public Task SaveAsync(string runId, IReadOnlyList<PackRunApprovalState> approvals, CancellationToken cancellationToken)
|
||||
{
|
||||
_approvals[runId] = approvals.ToList();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<PackRunApprovalState>> GetAsync(string runId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_approvals.TryGetValue(runId, out var existing))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<PackRunApprovalState>>(existing);
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<PackRunApprovalState>>(Array.Empty<PackRunApprovalState>());
|
||||
}
|
||||
|
||||
public Task UpdateAsync(string runId, PackRunApprovalState approval, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_approvals.TryGetValue(runId, out var list))
|
||||
{
|
||||
for (var i = 0; i < list.Count; i++)
|
||||
{
|
||||
if (string.Equals(list[i].ApprovalId, approval.ApprovalId, StringComparison.Ordinal))
|
||||
{
|
||||
list[i] = approval;
|
||||
LastUpdated = approval;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryStateStore : IPackRunStateStore
|
||||
{
|
||||
private readonly Dictionary<string, PackRunState> _states;
|
||||
|
||||
public InMemoryStateStore(IDictionary<string, PackRunState> states)
|
||||
{
|
||||
_states = new Dictionary<string, PackRunState>(states, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
public Task<PackRunState?> GetAsync(string runId, CancellationToken cancellationToken)
|
||||
{
|
||||
_states.TryGetValue(runId, out var state);
|
||||
return Task.FromResult(state);
|
||||
}
|
||||
|
||||
public Task SaveAsync(PackRunState state, CancellationToken cancellationToken)
|
||||
{
|
||||
_states[state.RunId] = state;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<PackRunState>> ListAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult<IReadOnlyList<PackRunState>>(_states.Values.ToList());
|
||||
}
|
||||
|
||||
private sealed class RecordingScheduler : IPackRunJobScheduler
|
||||
{
|
||||
public Dictionary<string, PackRunExecutionContext> ScheduledContexts { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
public Task ScheduleAsync(PackRunExecutionContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
ScheduledContexts[context.RunId] = context;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static class TestPlanFactory
|
||||
{
|
||||
public static TaskPackPlan CreatePlan()
|
||||
{
|
||||
var metadata = new TaskPackPlanMetadata("sample", "1.0.0", null, Array.Empty<string>());
|
||||
var parameters = new Dictionary<string, TaskPackPlanParameterValue>(StringComparer.Ordinal);
|
||||
var step = new TaskPackPlanStep(
|
||||
Id: "step-a",
|
||||
TemplateId: "run/image",
|
||||
Name: "Run step",
|
||||
Type: "run",
|
||||
Enabled: true,
|
||||
Uses: "builtin/run",
|
||||
Parameters: parameters,
|
||||
ApprovalId: "security-review",
|
||||
GateMessage: null,
|
||||
Children: Array.Empty<TaskPackPlanStep>());
|
||||
|
||||
return new TaskPackPlan(
|
||||
metadata,
|
||||
new Dictionary<string, JsonNode?>(StringComparer.Ordinal),
|
||||
new[] { step },
|
||||
"hash-123",
|
||||
new[]
|
||||
{
|
||||
new TaskPackPlanApproval("security-review", new[] { "Packs.Approve" }, null, null)
|
||||
},
|
||||
Array.Empty<TaskPackPlanSecret>(),
|
||||
Array.Empty<TaskPackPlanOutput>(),
|
||||
new TaskPackPlanFailurePolicy(3, 30, false));
|
||||
}
|
||||
|
||||
public static PackRunState CreateState(string runId, TaskPackPlan plan)
|
||||
{
|
||||
var timestamp = DateTimeOffset.UtcNow;
|
||||
var steps = new Dictionary<string, PackRunStepStateRecord>(StringComparer.Ordinal)
|
||||
{
|
||||
["step-a"] = new PackRunStepStateRecord(
|
||||
"step-a",
|
||||
PackRunStepKind.GateApproval,
|
||||
true,
|
||||
false,
|
||||
null,
|
||||
"security-review",
|
||||
null,
|
||||
PackRunStepExecutionStatus.Pending,
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
null)
|
||||
};
|
||||
|
||||
return PackRunState.Create(runId, plan.Hash, plan, plan.FailurePolicy ?? PackRunExecutionGraph.DefaultFailurePolicy, timestamp, steps, timestamp);
|
||||
}
|
||||
}
|
||||
@@ -129,7 +129,7 @@ public sealed class PackRunGateStateUpdaterTests
|
||||
StatusReason: reason);
|
||||
}
|
||||
|
||||
return PackRunState.Create("run-1", plan.Hash, graph.FailurePolicy, steps, RequestedAt);
|
||||
return PackRunState.Create("run-1", plan.Hash, plan, graph.FailurePolicy, RequestedAt, steps, RequestedAt);
|
||||
}
|
||||
|
||||
private static IEnumerable<PackRunExecutionStep> EnumerateSteps(IReadOnlyList<PackRunExecutionStep> steps)
|
||||
|
||||
Reference in New Issue
Block a user