save checkpoint: save features

This commit is contained in:
master
2026-02-12 10:27:23 +02:00
parent dca86e1248
commit 5bca406787
8837 changed files with 1796879 additions and 5294 deletions

View File

@@ -272,14 +272,118 @@ internal sealed class ActionExecutor : IActionExecutor
executionId, proposal, context, ActionAuditOutcome.ApprovalRequested,
decision, null, cancellationToken, approvalRequest.RequestId);
if (!_options.AwaitApprovalCompletion)
{
return new ActionExecutionResult
{
ExecutionId = executionId,
Outcome = ActionExecutionOutcome.PendingApproval,
Message = $"Approval required from: {string.Join(", ", decision.RequiredApprovers.Select(a => a.Identifier))}",
StartedAt = startedAt,
CompletedAt = null, // Not completed yet
CanRollback = false,
OutputData = new Dictionary<string, string>
{
["approvalRequestId"] = approvalRequest.RequestId,
["approvalWorkflowId"] = approvalRequest.WorkflowId
}.ToImmutableDictionary()
};
}
var approvalResult = await _approvalAdapter.WaitForApprovalAsync(
approvalRequest.RequestId,
_options.ApprovalWaitTimeout,
cancellationToken);
if (approvalResult.Approved)
{
await RecordAuditEntryAsync(
executionId,
proposal,
context,
ActionAuditOutcome.Approved,
decision,
null,
cancellationToken,
approvalRequest.RequestId,
approvalResult.ApproverId);
var executedResult = await ExecuteImmediatelyAsync(
executionId,
proposal,
context,
decision,
startedAt,
cancellationToken);
return executedResult with
{
OutputData = executedResult.OutputData
.SetItem("approvalRequestId", approvalRequest.RequestId)
.SetItem("approvalWorkflowId", approvalRequest.WorkflowId)
.SetItem("approvedBy", approvalResult.ApproverId ?? string.Empty)
};
}
if (approvalResult.TimedOut)
{
await RecordAuditEntryAsync(
executionId,
proposal,
context,
ActionAuditOutcome.ApprovalTimedOut,
decision,
approvalResult.DenialReason,
cancellationToken,
approvalRequest.RequestId);
return new ActionExecutionResult
{
ExecutionId = executionId,
Outcome = ActionExecutionOutcome.Timeout,
Message = $"Approval timed out: {approvalResult.DenialReason}",
StartedAt = startedAt,
CompletedAt = _timeProvider.GetUtcNow(),
CanRollback = false,
Error = new ActionError
{
Code = "APPROVAL_TIMED_OUT",
Message = approvalResult.DenialReason ?? "Timed out waiting for approval",
IsRetryable = true
},
OutputData = new Dictionary<string, string>
{
["approvalRequestId"] = approvalRequest.RequestId,
["approvalWorkflowId"] = approvalRequest.WorkflowId
}.ToImmutableDictionary()
};
}
await RecordAuditEntryAsync(
executionId,
proposal,
context,
ActionAuditOutcome.ApprovalDenied,
decision,
approvalResult.DenialReason,
cancellationToken,
approvalRequest.RequestId,
approvalResult.ApproverId);
return new ActionExecutionResult
{
ExecutionId = executionId,
Outcome = ActionExecutionOutcome.PendingApproval,
Message = $"Approval required from: {string.Join(", ", decision.RequiredApprovers.Select(a => a.Identifier))}",
Outcome = ActionExecutionOutcome.Failed,
Message = $"Approval denied: {approvalResult.DenialReason}",
StartedAt = startedAt,
CompletedAt = null, // Not completed yet
CompletedAt = _timeProvider.GetUtcNow(),
CanRollback = false,
Error = new ActionError
{
Code = "APPROVAL_DENIED",
Message = approvalResult.DenialReason ?? "Action approval denied",
IsRetryable = false
},
OutputData = new Dictionary<string, string>
{
["approvalRequestId"] = approvalRequest.RequestId,
@@ -382,7 +486,8 @@ internal sealed class ActionExecutor : IActionExecutor
ActionPolicyDecision? decision,
string? errorMessage,
CancellationToken cancellationToken,
string? approvalRequestId = null)
string? approvalRequestId = null,
string? approverId = null)
{
var entry = new ActionAuditEntry
{
@@ -399,6 +504,7 @@ internal sealed class ActionExecutor : IActionExecutor
PolicyId = decision?.PolicyId,
PolicyResult = decision?.Decision,
ApprovalRequestId = approvalRequestId,
ApproverId = approverId,
Parameters = proposal.Parameters,
ExecutionId = executionId,
ErrorMessage = errorMessage
@@ -454,4 +560,14 @@ public sealed class ActionExecutorOptions
/// Default timeout for action execution.
/// </summary>
public TimeSpan DefaultTimeout { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Whether ExecuteAsync should wait for approval and continue execution when approved.
/// </summary>
public bool AwaitApprovalCompletion { get; set; }
/// <summary>
/// Maximum wait time when awaiting approval completion.
/// </summary>
public TimeSpan ApprovalWaitTimeout { get; set; } = TimeSpan.FromMinutes(5);
}

View File

@@ -0,0 +1,234 @@
// <copyright file="ActionWorkflowIntegrationTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.AdvisoryAI.Actions;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.AdvisoryAI.Tests.Actions;
/// <summary>
/// Integration tests for the full action policy gate workflow.
/// </summary>
[Trait("Category", TestCategories.Integration)]
public sealed class ActionWorkflowIntegrationTests
{
[Fact]
public async Task ExecuteAsync_AwaitApprovalEnabled_ApprovesExecutesAndAudits()
{
var harness = CreateHarness(awaitApprovalCompletion: true);
var proposal = CreateApproveProposal(harness.TimeProvider.GetUtcNow());
var context = CreateActionContext();
var decision = await harness.PolicyGate.EvaluateAsync(proposal, context, CancellationToken.None);
decision.Decision.Should().Be(PolicyDecisionKind.AllowWithApproval);
var executeTask = harness.Executor.ExecuteAsync(proposal, decision, context, CancellationToken.None);
await WaitForApprovalRequestAsync(
harness.ApprovalAdapter,
harness.ExpectedApprovalRequestId,
CancellationToken.None);
harness.ApprovalAdapter.RecordApproval(
harness.ExpectedApprovalRequestId,
approverId: "security-lead-1",
approved: true,
comments: "approved for release");
var result = await executeTask;
result.Outcome.Should().Be(ActionExecutionOutcome.Success);
result.OutputData.Should().ContainKey("approvalRequestId");
result.OutputData["approvalRequestId"].Should().Be(harness.ExpectedApprovalRequestId);
result.OutputData["approvedBy"].Should().Be("security-lead-1");
var entries = await harness.AuditLedger.GetByRunAsync(context.RunId!, CancellationToken.None);
var outcomes = entries.Select(e => e.Outcome).ToArray();
outcomes.Should().Contain(ActionAuditOutcome.ApprovalRequested);
outcomes.Should().Contain(ActionAuditOutcome.Approved);
outcomes.Should().Contain(ActionAuditOutcome.Executed);
}
[Fact]
public async Task ExecuteAsync_RepeatAfterApprovedExecution_ReturnsIdempotentResultAndAuditsSkip()
{
var harness = CreateHarness(awaitApprovalCompletion: true);
var proposal = CreateApproveProposal(harness.TimeProvider.GetUtcNow());
var context = CreateActionContext();
var decision = await harness.PolicyGate.EvaluateAsync(proposal, context, CancellationToken.None);
var firstExecutionTask = harness.Executor.ExecuteAsync(proposal, decision, context, CancellationToken.None);
await WaitForApprovalRequestAsync(
harness.ApprovalAdapter,
harness.ExpectedApprovalRequestId,
CancellationToken.None);
harness.ApprovalAdapter.RecordApproval(
harness.ExpectedApprovalRequestId,
approverId: "security-lead-1",
approved: true,
comments: "approved");
var firstResult = await firstExecutionTask;
var secondResult = await harness.Executor.ExecuteAsync(proposal, decision, context, CancellationToken.None);
secondResult.ExecutionId.Should().Be(firstResult.ExecutionId);
secondResult.Outcome.Should().Be(ActionExecutionOutcome.Success);
var entries = await harness.AuditLedger.GetByRunAsync(context.RunId!, CancellationToken.None);
entries.Select(e => e.Outcome).Should().Contain(ActionAuditOutcome.IdempotentSkipped);
}
[Fact]
public async Task ExecuteAsync_PolicyDenied_FailsAndAuditsDenial()
{
var harness = CreateHarness(awaitApprovalCompletion: false);
var proposal = CreateApproveProposal(harness.TimeProvider.GetUtcNow());
var context = CreateActionContext(userRoles: ["viewer"]);
var decision = await harness.PolicyGate.EvaluateAsync(proposal, context, CancellationToken.None);
decision.Decision.Should().Be(PolicyDecisionKind.Deny);
var result = await harness.Executor.ExecuteAsync(proposal, decision, context, CancellationToken.None);
result.Outcome.Should().Be(ActionExecutionOutcome.Failed);
result.Error.Should().NotBeNull();
result.Error!.Code.Should().Be("POLICY_DENIED");
var entries = await harness.AuditLedger.GetByRunAsync(context.RunId!, CancellationToken.None);
entries.Select(e => e.Outcome).Should().Contain(ActionAuditOutcome.DeniedByPolicy);
}
private static ActionProposal CreateApproveProposal(DateTimeOffset createdAt)
{
return new ActionProposal
{
ProposalId = "proposal-approve-cve",
ActionType = "approve",
Label = "Approve CVE risk",
Parameters = new Dictionary<string, string>
{
["cve_id"] = "CVE-2024-1234",
["justification"] = "Reachability confirms non-exploitable path"
}.ToImmutableDictionary(),
CreatedAt = createdAt,
IdempotencyKey = "approve-cve-2024-1234"
};
}
private static ActionContext CreateActionContext(ImmutableArray<string>? userRoles = null)
{
return new ActionContext
{
TenantId = "tenant-qa",
UserId = "analyst-1",
UserRoles = userRoles ?? ["security-analyst"],
Environment = "development",
RunId = "run-action-policy",
CveId = "CVE-2024-1234"
};
}
private static async Task WaitForApprovalRequestAsync(
ApprovalWorkflowAdapter approvalAdapter,
string requestId,
CancellationToken cancellationToken)
{
for (var attempt = 0; attempt < 50; attempt++)
{
var status = await approvalAdapter.GetApprovalStatusAsync(requestId, cancellationToken);
if (status is not null)
{
return;
}
await Task.Delay(20, cancellationToken);
}
throw new TimeoutException($"Approval request {requestId} was not created in time.");
}
private static ActionHarness CreateHarness(bool awaitApprovalCompletion)
{
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 2, 11, 12, 0, 0, TimeSpan.Zero));
var guidGenerator = new SequenceGuidGenerator(
"00000000-0000-0000-0000-000000000001",
"00000000-0000-0000-0000-000000000002");
var registry = new ActionRegistry();
var policyGate = new ActionPolicyGate(
registry,
timeProvider,
Options.Create(new ActionPolicyOptions()),
NullLogger<ActionPolicyGate>.Instance);
var idempotencyHandler = new IdempotencyHandler(
timeProvider,
Options.Create(new IdempotencyOptions { TtlDays = 30 }),
NullLogger<IdempotencyHandler>.Instance);
var approvalAdapter = new ApprovalWorkflowAdapter(
timeProvider,
guidGenerator,
NullLogger<ApprovalWorkflowAdapter>.Instance);
var auditLedger = new ActionAuditLedger(
Options.Create(new AuditLedgerOptions()),
NullLogger<ActionAuditLedger>.Instance);
var executor = new ActionExecutor(
policyGate,
registry,
idempotencyHandler,
approvalAdapter,
auditLedger,
timeProvider,
guidGenerator,
Options.Create(new ActionExecutorOptions
{
EnableIdempotency = true,
EnableAuditLogging = true,
AwaitApprovalCompletion = awaitApprovalCompletion,
ApprovalWaitTimeout = TimeSpan.FromSeconds(2)
}),
NullLogger<ActionExecutor>.Instance);
return new ActionHarness(
Executor: executor,
PolicyGate: policyGate,
ApprovalAdapter: approvalAdapter,
AuditLedger: auditLedger,
TimeProvider: timeProvider,
ExpectedApprovalRequestId: "00000000-0000-0000-0000-000000000002");
}
private sealed record ActionHarness(
ActionExecutor Executor,
ActionPolicyGate PolicyGate,
ApprovalWorkflowAdapter ApprovalAdapter,
ActionAuditLedger AuditLedger,
FakeTimeProvider TimeProvider,
string ExpectedApprovalRequestId);
private sealed class SequenceGuidGenerator : IGuidGenerator
{
private readonly Queue<Guid> _seededGuids;
private int _counter;
public SequenceGuidGenerator(params string[] seededGuids)
{
_seededGuids = new Queue<Guid>(seededGuids.Select(Guid.Parse));
}
public Guid NewGuid()
{
if (_seededGuids.Count > 0)
{
return _seededGuids.Dequeue();
}
return new Guid($"00000000-0000-0000-0000-{_counter++:D12}");
}
}
}