feat: Implement approvals workflow and notifications integration
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:
master
2025-11-06 08:48:13 +02:00
parent 21a2759412
commit dd217b4546
98 changed files with 3883 additions and 2381 deletions

View File

@@ -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()

View File

@@ -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);
}
}

View File

@@ -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)