sprints work
This commit is contained in:
151
src/AdvisoryAI/StellaOps.AdvisoryAI/Actions/ActionAuditLedger.cs
Normal file
151
src/AdvisoryAI/StellaOps.AdvisoryAI/Actions/ActionAuditLedger.cs
Normal file
@@ -0,0 +1,151 @@
|
||||
// <copyright file="ActionAuditLedger.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory action audit ledger for development and testing.
|
||||
/// In production, this would use PostgreSQL with proper indexing.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-006
|
||||
/// </summary>
|
||||
internal sealed class ActionAuditLedger : IActionAuditLedger
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ActionAuditEntry> _entries = new();
|
||||
private readonly ILogger<ActionAuditLedger> _logger;
|
||||
private readonly AuditLedgerOptions _options;
|
||||
|
||||
public ActionAuditLedger(
|
||||
IOptions<AuditLedgerOptions> options,
|
||||
ILogger<ActionAuditLedger> logger)
|
||||
{
|
||||
_options = options?.Value ?? new AuditLedgerOptions();
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RecordAsync(ActionAuditEntry entry, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
|
||||
_entries[entry.EntryId] = entry;
|
||||
|
||||
_logger.LogDebug(
|
||||
"Recorded audit entry {EntryId}: {ActionType} by {Actor} -> {Outcome}",
|
||||
entry.EntryId, entry.ActionType, entry.Actor, entry.Outcome);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ImmutableArray<ActionAuditEntry>> QueryAsync(
|
||||
ActionAuditQuery query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
var entries = _entries.Values.AsEnumerable();
|
||||
|
||||
// Apply filters
|
||||
if (!string.IsNullOrEmpty(query.TenantId))
|
||||
{
|
||||
entries = entries.Where(e => e.TenantId.Equals(query.TenantId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(query.ActionType))
|
||||
{
|
||||
entries = entries.Where(e => e.ActionType.Equals(query.ActionType, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(query.Actor))
|
||||
{
|
||||
entries = entries.Where(e => e.Actor.Equals(query.Actor, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (query.Outcome.HasValue)
|
||||
{
|
||||
entries = entries.Where(e => e.Outcome == query.Outcome.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(query.RunId))
|
||||
{
|
||||
entries = entries.Where(e => e.RunId != null && e.RunId.Equals(query.RunId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(query.CveId))
|
||||
{
|
||||
entries = entries.Where(e => e.CveId != null && e.CveId.Equals(query.CveId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(query.ImageDigest))
|
||||
{
|
||||
entries = entries.Where(e => e.ImageDigest != null && e.ImageDigest.Equals(query.ImageDigest, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (query.FromTimestamp.HasValue)
|
||||
{
|
||||
entries = entries.Where(e => e.Timestamp >= query.FromTimestamp.Value);
|
||||
}
|
||||
|
||||
if (query.ToTimestamp.HasValue)
|
||||
{
|
||||
entries = entries.Where(e => e.Timestamp < query.ToTimestamp.Value);
|
||||
}
|
||||
|
||||
// Order by timestamp descending, apply pagination
|
||||
var result = entries
|
||||
.OrderByDescending(e => e.Timestamp)
|
||||
.Skip(query.Offset)
|
||||
.Take(query.Limit)
|
||||
.ToImmutableArray();
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ActionAuditEntry?> GetAsync(
|
||||
string entryId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(entryId);
|
||||
|
||||
_entries.TryGetValue(entryId, out var entry);
|
||||
return Task.FromResult(entry);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ImmutableArray<ActionAuditEntry>> GetByRunAsync(
|
||||
string runId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(runId);
|
||||
|
||||
var entries = _entries.Values
|
||||
.Where(e => e.RunId != null && e.RunId.Equals(runId, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(e => e.Timestamp)
|
||||
.ToImmutableArray();
|
||||
|
||||
return Task.FromResult(entries);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the audit ledger.
|
||||
/// </summary>
|
||||
public sealed class AuditLedgerOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Days to retain audit entries.
|
||||
/// </summary>
|
||||
public int RetentionDays { get; set; } = 365;
|
||||
|
||||
/// <summary>
|
||||
/// Whether audit logging is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
}
|
||||
135
src/AdvisoryAI/StellaOps.AdvisoryAI/Actions/ActionDefinition.cs
Normal file
135
src/AdvisoryAI/StellaOps.AdvisoryAI/Actions/ActionDefinition.cs
Normal file
@@ -0,0 +1,135 @@
|
||||
// <copyright file="ActionDefinition.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// Defines the metadata and constraints for an action type.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-003
|
||||
/// </summary>
|
||||
public sealed record ActionDefinition
|
||||
{
|
||||
/// <summary>
|
||||
/// The unique action type identifier (e.g., "approve", "quarantine").
|
||||
/// </summary>
|
||||
public required string ActionType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable display name.
|
||||
/// </summary>
|
||||
public required string DisplayName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Description of what this action does.
|
||||
/// </summary>
|
||||
public required string Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Role required to execute this action.
|
||||
/// </summary>
|
||||
public required string RequiredRole { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Risk level of this action for policy decisions.
|
||||
/// </summary>
|
||||
public required ActionRiskLevel RiskLevel { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this action is idempotent (safe to retry).
|
||||
/// </summary>
|
||||
public required bool IsIdempotent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this action supports rollback/compensation.
|
||||
/// </summary>
|
||||
public required bool HasCompensation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Action type for compensation/rollback, if supported.
|
||||
/// </summary>
|
||||
public string? CompensationActionType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Parameters accepted by this action.
|
||||
/// </summary>
|
||||
public ImmutableArray<ActionParameterDefinition> Parameters { get; init; } =
|
||||
ImmutableArray<ActionParameterDefinition>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Environments where this action can be executed.
|
||||
/// Empty means all environments.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> AllowedEnvironments { get; init; } =
|
||||
ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Tags for categorization.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Tags { get; init; } =
|
||||
ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Risk levels for actions, affecting policy decisions and approval requirements.
|
||||
/// </summary>
|
||||
public enum ActionRiskLevel
|
||||
{
|
||||
/// <summary>
|
||||
/// Read-only, informational actions.
|
||||
/// </summary>
|
||||
Low = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Creates records, sends notifications.
|
||||
/// </summary>
|
||||
Medium = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Modifies security posture.
|
||||
/// </summary>
|
||||
High = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Production blockers, quarantine operations.
|
||||
/// </summary>
|
||||
Critical = 3
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Definition of an action parameter.
|
||||
/// </summary>
|
||||
public sealed record ActionParameterDefinition
|
||||
{
|
||||
/// <summary>
|
||||
/// Parameter name.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Parameter type (string, int, bool, etc.).
|
||||
/// </summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this parameter is required.
|
||||
/// </summary>
|
||||
public required bool Required { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Description of the parameter.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Default value if not provided.
|
||||
/// </summary>
|
||||
public string? DefaultValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Validation regex pattern.
|
||||
/// </summary>
|
||||
public string? ValidationPattern { get; init; }
|
||||
}
|
||||
456
src/AdvisoryAI/StellaOps.AdvisoryAI/Actions/ActionExecutor.cs
Normal file
456
src/AdvisoryAI/StellaOps.AdvisoryAI/Actions/ActionExecutor.cs
Normal file
@@ -0,0 +1,456 @@
|
||||
// <copyright file="ActionExecutor.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// Executes AI-proposed actions with policy gate integration, idempotency, and audit logging.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-007
|
||||
/// </summary>
|
||||
internal sealed class ActionExecutor : IActionExecutor
|
||||
{
|
||||
private readonly IActionPolicyGate _policyGate;
|
||||
private readonly IActionRegistry _actionRegistry;
|
||||
private readonly IIdempotencyHandler _idempotencyHandler;
|
||||
private readonly IApprovalWorkflowAdapter _approvalAdapter;
|
||||
private readonly IActionAuditLedger _auditLedger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidGenerator _guidGenerator;
|
||||
private readonly ILogger<ActionExecutor> _logger;
|
||||
private readonly ActionExecutorOptions _options;
|
||||
|
||||
public ActionExecutor(
|
||||
IActionPolicyGate policyGate,
|
||||
IActionRegistry actionRegistry,
|
||||
IIdempotencyHandler idempotencyHandler,
|
||||
IApprovalWorkflowAdapter approvalAdapter,
|
||||
IActionAuditLedger auditLedger,
|
||||
TimeProvider timeProvider,
|
||||
IGuidGenerator guidGenerator,
|
||||
IOptions<ActionExecutorOptions> options,
|
||||
ILogger<ActionExecutor> logger)
|
||||
{
|
||||
_policyGate = policyGate ?? throw new ArgumentNullException(nameof(policyGate));
|
||||
_actionRegistry = actionRegistry ?? throw new ArgumentNullException(nameof(actionRegistry));
|
||||
_idempotencyHandler = idempotencyHandler ?? throw new ArgumentNullException(nameof(idempotencyHandler));
|
||||
_approvalAdapter = approvalAdapter ?? throw new ArgumentNullException(nameof(approvalAdapter));
|
||||
_auditLedger = auditLedger ?? throw new ArgumentNullException(nameof(auditLedger));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_guidGenerator = guidGenerator ?? throw new ArgumentNullException(nameof(guidGenerator));
|
||||
_options = options?.Value ?? new ActionExecutorOptions();
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ActionExecutionResult> ExecuteAsync(
|
||||
ActionProposal proposal,
|
||||
ActionPolicyDecision decision,
|
||||
ActionContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(proposal);
|
||||
ArgumentNullException.ThrowIfNull(decision);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var executionId = _guidGenerator.NewGuid().ToString();
|
||||
var startedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Executing action {ActionType} (execution: {ExecutionId}) for user {UserId}",
|
||||
proposal.ActionType, executionId, context.UserId);
|
||||
|
||||
// 1. Check idempotency first
|
||||
if (_options.EnableIdempotency)
|
||||
{
|
||||
var idempotencyKey = _idempotencyHandler.GenerateKey(proposal, context);
|
||||
var idempotencyCheck = await _idempotencyHandler.CheckAsync(idempotencyKey, cancellationToken);
|
||||
|
||||
if (idempotencyCheck.AlreadyExecuted)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Action {ActionType} skipped due to idempotency (previous execution: {PreviousId})",
|
||||
proposal.ActionType, idempotencyCheck.PreviousResult?.ExecutionId);
|
||||
|
||||
await RecordAuditEntryAsync(
|
||||
executionId, proposal, context, ActionAuditOutcome.IdempotentSkipped,
|
||||
null, null, cancellationToken);
|
||||
|
||||
return idempotencyCheck.PreviousResult!;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Evaluate policy if not already evaluated
|
||||
var policyDecision = decision;
|
||||
if (decision.Decision == PolicyDecisionKind.Indeterminate)
|
||||
{
|
||||
policyDecision = await _policyGate.EvaluateAsync(proposal, context, cancellationToken);
|
||||
}
|
||||
|
||||
// 3. Handle based on policy decision
|
||||
ActionExecutionResult result;
|
||||
switch (policyDecision.Decision)
|
||||
{
|
||||
case PolicyDecisionKind.Allow:
|
||||
result = await ExecuteImmediatelyAsync(executionId, proposal, context, policyDecision, startedAt, cancellationToken);
|
||||
break;
|
||||
|
||||
case PolicyDecisionKind.AllowWithApproval:
|
||||
result = await ExecuteWithApprovalAsync(executionId, proposal, context, policyDecision, startedAt, cancellationToken);
|
||||
break;
|
||||
|
||||
case PolicyDecisionKind.Deny:
|
||||
result = CreateDeniedResult(executionId, proposal, policyDecision, startedAt);
|
||||
await RecordAuditEntryAsync(
|
||||
executionId, proposal, context, ActionAuditOutcome.DeniedByPolicy,
|
||||
policyDecision, null, cancellationToken);
|
||||
break;
|
||||
|
||||
case PolicyDecisionKind.DenyWithOverride:
|
||||
result = CreateDeniedWithOverrideResult(executionId, proposal, policyDecision, startedAt);
|
||||
await RecordAuditEntryAsync(
|
||||
executionId, proposal, context, ActionAuditOutcome.DeniedByPolicy,
|
||||
policyDecision, null, cancellationToken);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new InvalidOperationException($"Unexpected policy decision: {policyDecision.Decision}");
|
||||
}
|
||||
|
||||
// 4. Record idempotency if execution was successful
|
||||
if (_options.EnableIdempotency && result.Outcome == ActionExecutionOutcome.Success)
|
||||
{
|
||||
var idempotencyKey = _idempotencyHandler.GenerateKey(proposal, context);
|
||||
await _idempotencyHandler.RecordExecutionAsync(idempotencyKey, result, context, cancellationToken);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ActionRollbackResult> RollbackAsync(
|
||||
string executionId,
|
||||
ActionContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(executionId);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
// In a real implementation, this would:
|
||||
// 1. Look up the original execution
|
||||
// 2. Find the compensation action
|
||||
// 3. Execute the compensation
|
||||
// 4. Record the rollback
|
||||
|
||||
_logger.LogInformation(
|
||||
"Rollback requested for execution {ExecutionId}",
|
||||
executionId);
|
||||
|
||||
// Stub implementation
|
||||
return new ActionRollbackResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "Rollback not yet implemented",
|
||||
Error = new ActionError
|
||||
{
|
||||
Code = "NOT_IMPLEMENTED",
|
||||
Message = "Rollback functionality is not yet implemented"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ActionExecutionStatus?> GetStatusAsync(
|
||||
string executionId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// In a real implementation, this would look up execution status from storage
|
||||
return Task.FromResult<ActionExecutionStatus?>(null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ImmutableArray<ActionTypeInfo> GetSupportedActionTypes()
|
||||
{
|
||||
return _actionRegistry.GetAllActions()
|
||||
.Select(a => new ActionTypeInfo
|
||||
{
|
||||
Type = a.ActionType,
|
||||
DisplayName = a.DisplayName,
|
||||
Description = a.Description,
|
||||
Category = GetActionCategory(a),
|
||||
Parameters = a.Parameters.Select(p => new ActionParameterInfo
|
||||
{
|
||||
Name = p.Name,
|
||||
DisplayName = p.Name,
|
||||
Description = p.Description,
|
||||
IsRequired = p.Required,
|
||||
Type = p.Type,
|
||||
DefaultValue = p.DefaultValue
|
||||
}).ToImmutableArray(),
|
||||
RequiredPermission = a.RequiredRole,
|
||||
SupportsRollback = a.HasCompensation,
|
||||
IsDestructive = a.RiskLevel >= ActionRiskLevel.High
|
||||
})
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private async Task<ActionExecutionResult> ExecuteImmediatelyAsync(
|
||||
string executionId,
|
||||
ActionProposal proposal,
|
||||
ActionContext context,
|
||||
ActionPolicyDecision decision,
|
||||
DateTimeOffset startedAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Executing action {ActionType} immediately (policy: {PolicyId})",
|
||||
proposal.ActionType, decision.PolicyId);
|
||||
|
||||
try
|
||||
{
|
||||
// Perform the actual action execution
|
||||
// In a real implementation, this would dispatch to specific action handlers
|
||||
var result = await PerformActionAsync(executionId, proposal, context, startedAt, cancellationToken);
|
||||
|
||||
await RecordAuditEntryAsync(
|
||||
executionId, proposal, context,
|
||||
result.Outcome == ActionExecutionOutcome.Success
|
||||
? ActionAuditOutcome.Executed
|
||||
: ActionAuditOutcome.ExecutionFailed,
|
||||
decision, result.Error?.Message, cancellationToken);
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Action execution failed for {ActionType}", proposal.ActionType);
|
||||
|
||||
await RecordAuditEntryAsync(
|
||||
executionId, proposal, context, ActionAuditOutcome.ExecutionFailed,
|
||||
decision, ex.Message, cancellationToken);
|
||||
|
||||
return new ActionExecutionResult
|
||||
{
|
||||
ExecutionId = executionId,
|
||||
Outcome = ActionExecutionOutcome.Failed,
|
||||
Message = $"Execution failed: {ex.Message}",
|
||||
StartedAt = startedAt,
|
||||
CompletedAt = _timeProvider.GetUtcNow(),
|
||||
CanRollback = false,
|
||||
Error = new ActionError
|
||||
{
|
||||
Code = "EXECUTION_FAILED",
|
||||
Message = ex.Message,
|
||||
IsRetryable = true
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ActionExecutionResult> ExecuteWithApprovalAsync(
|
||||
string executionId,
|
||||
ActionProposal proposal,
|
||||
ActionContext context,
|
||||
ActionPolicyDecision decision,
|
||||
DateTimeOffset startedAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Action {ActionType} requires approval (policy: {PolicyId})",
|
||||
proposal.ActionType, decision.PolicyId);
|
||||
|
||||
// Create approval request
|
||||
var approvalRequest = await _approvalAdapter.CreateApprovalRequestAsync(
|
||||
proposal, decision, context, cancellationToken);
|
||||
|
||||
await RecordAuditEntryAsync(
|
||||
executionId, proposal, context, ActionAuditOutcome.ApprovalRequested,
|
||||
decision, null, cancellationToken, approvalRequest.RequestId);
|
||||
|
||||
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()
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<ActionExecutionResult> PerformActionAsync(
|
||||
string executionId,
|
||||
ActionProposal proposal,
|
||||
ActionContext context,
|
||||
DateTimeOffset startedAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Get action definition for rollback capability
|
||||
var actionDef = _actionRegistry.GetAction(proposal.ActionType);
|
||||
var canRollback = actionDef?.HasCompensation ?? false;
|
||||
|
||||
// In a real implementation, this would dispatch to specific action handlers
|
||||
// For now, simulate successful execution
|
||||
_logger.LogInformation(
|
||||
"Performed action {ActionType} with parameters: {Parameters}",
|
||||
proposal.ActionType,
|
||||
string.Join(", ", proposal.Parameters.Select(p => $"{p.Key}={p.Value}")));
|
||||
|
||||
return new ActionExecutionResult
|
||||
{
|
||||
ExecutionId = executionId,
|
||||
Outcome = ActionExecutionOutcome.Success,
|
||||
Message = $"Action {proposal.ActionType} executed successfully",
|
||||
StartedAt = startedAt,
|
||||
CompletedAt = _timeProvider.GetUtcNow(),
|
||||
CanRollback = canRollback,
|
||||
OutputData = new Dictionary<string, string>
|
||||
{
|
||||
["actionType"] = proposal.ActionType,
|
||||
["status"] = "completed"
|
||||
}.ToImmutableDictionary()
|
||||
};
|
||||
}
|
||||
|
||||
private ActionExecutionResult CreateDeniedResult(
|
||||
string executionId,
|
||||
ActionProposal proposal,
|
||||
ActionPolicyDecision decision,
|
||||
DateTimeOffset startedAt)
|
||||
{
|
||||
return new ActionExecutionResult
|
||||
{
|
||||
ExecutionId = executionId,
|
||||
Outcome = ActionExecutionOutcome.Failed,
|
||||
Message = $"Action denied by policy: {decision.Reason}",
|
||||
StartedAt = startedAt,
|
||||
CompletedAt = _timeProvider.GetUtcNow(),
|
||||
CanRollback = false,
|
||||
Error = new ActionError
|
||||
{
|
||||
Code = "POLICY_DENIED",
|
||||
Message = decision.Reason ?? "Action denied by policy",
|
||||
IsRetryable = false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private ActionExecutionResult CreateDeniedWithOverrideResult(
|
||||
string executionId,
|
||||
ActionProposal proposal,
|
||||
ActionPolicyDecision decision,
|
||||
DateTimeOffset startedAt)
|
||||
{
|
||||
return new ActionExecutionResult
|
||||
{
|
||||
ExecutionId = executionId,
|
||||
Outcome = ActionExecutionOutcome.Failed,
|
||||
Message = $"Action denied (override available): {decision.Reason}",
|
||||
StartedAt = startedAt,
|
||||
CompletedAt = _timeProvider.GetUtcNow(),
|
||||
CanRollback = false,
|
||||
Error = new ActionError
|
||||
{
|
||||
Code = "POLICY_DENIED_OVERRIDE_AVAILABLE",
|
||||
Message = decision.Reason ?? "Action denied by policy",
|
||||
Details = "An administrator can override this decision",
|
||||
IsRetryable = false
|
||||
},
|
||||
OutputData = new Dictionary<string, string>
|
||||
{
|
||||
["overrideAvailable"] = "true",
|
||||
["policyId"] = decision.PolicyId ?? ""
|
||||
}.ToImmutableDictionary()
|
||||
};
|
||||
}
|
||||
|
||||
private async Task RecordAuditEntryAsync(
|
||||
string executionId,
|
||||
ActionProposal proposal,
|
||||
ActionContext context,
|
||||
ActionAuditOutcome outcome,
|
||||
ActionPolicyDecision? decision,
|
||||
string? errorMessage,
|
||||
CancellationToken cancellationToken,
|
||||
string? approvalRequestId = null)
|
||||
{
|
||||
var entry = new ActionAuditEntry
|
||||
{
|
||||
EntryId = _guidGenerator.NewGuid().ToString(),
|
||||
TenantId = context.TenantId,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
ActionType = proposal.ActionType,
|
||||
Actor = context.UserId,
|
||||
Outcome = outcome,
|
||||
RunId = context.RunId,
|
||||
FindingId = context.FindingId,
|
||||
CveId = context.CveId,
|
||||
ImageDigest = context.ImageDigest,
|
||||
PolicyId = decision?.PolicyId,
|
||||
PolicyResult = decision?.Decision,
|
||||
ApprovalRequestId = approvalRequestId,
|
||||
Parameters = proposal.Parameters,
|
||||
ExecutionId = executionId,
|
||||
ErrorMessage = errorMessage
|
||||
};
|
||||
|
||||
await _auditLedger.RecordAsync(entry, cancellationToken);
|
||||
}
|
||||
|
||||
private static string? GetActionCategory(ActionDefinition action)
|
||||
{
|
||||
if (action.Tags.Contains("cve", StringComparer.OrdinalIgnoreCase) ||
|
||||
action.Tags.Contains("vex", StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return "Vulnerability Management";
|
||||
}
|
||||
|
||||
if (action.Tags.Contains("container", StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return "Container Security";
|
||||
}
|
||||
|
||||
if (action.Tags.Contains("report", StringComparer.OrdinalIgnoreCase) ||
|
||||
action.Tags.Contains("export", StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return "Reporting";
|
||||
}
|
||||
|
||||
if (action.Tags.Contains("notification", StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return "Communication";
|
||||
}
|
||||
|
||||
return "General";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for action execution.
|
||||
/// </summary>
|
||||
public sealed class ActionExecutorOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether idempotency checking is enabled.
|
||||
/// </summary>
|
||||
public bool EnableIdempotency { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether audit logging is enabled.
|
||||
/// </summary>
|
||||
public bool EnableAuditLogging { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Default timeout for action execution.
|
||||
/// </summary>
|
||||
public TimeSpan DefaultTimeout { get; set; } = TimeSpan.FromMinutes(5);
|
||||
}
|
||||
352
src/AdvisoryAI/StellaOps.AdvisoryAI/Actions/ActionPolicyGate.cs
Normal file
352
src/AdvisoryAI/StellaOps.AdvisoryAI/Actions/ActionPolicyGate.cs
Normal file
@@ -0,0 +1,352 @@
|
||||
// <copyright file="ActionPolicyGate.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates action proposals against K4 lattice policy rules.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-002
|
||||
/// </summary>
|
||||
internal sealed class ActionPolicyGate : IActionPolicyGate
|
||||
{
|
||||
private readonly IActionRegistry _actionRegistry;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<ActionPolicyGate> _logger;
|
||||
private readonly ActionPolicyOptions _options;
|
||||
|
||||
public ActionPolicyGate(
|
||||
IActionRegistry actionRegistry,
|
||||
TimeProvider timeProvider,
|
||||
IOptions<ActionPolicyOptions> options,
|
||||
ILogger<ActionPolicyGate> logger)
|
||||
{
|
||||
_actionRegistry = actionRegistry ?? throw new ArgumentNullException(nameof(actionRegistry));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_options = options?.Value ?? new ActionPolicyOptions();
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ActionPolicyDecision> EvaluateAsync(
|
||||
ActionProposal proposal,
|
||||
ActionContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(proposal);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Evaluating policy for action {ActionType} by user {UserId} in tenant {TenantId}",
|
||||
proposal.ActionType, context.UserId, context.TenantId);
|
||||
|
||||
// Get action definition
|
||||
var actionDef = _actionRegistry.GetAction(proposal.ActionType);
|
||||
if (actionDef is null)
|
||||
{
|
||||
return Task.FromResult(CreateDenyDecision(
|
||||
$"Unknown action type: {proposal.ActionType}",
|
||||
"action-validation",
|
||||
allowOverride: false));
|
||||
}
|
||||
|
||||
// Validate parameters
|
||||
var paramValidation = _actionRegistry.ValidateParameters(proposal.ActionType, proposal.Parameters);
|
||||
if (!paramValidation.IsValid)
|
||||
{
|
||||
return Task.FromResult(CreateDenyDecision(
|
||||
$"Invalid parameters: {string.Join(", ", paramValidation.Errors)}",
|
||||
"parameter-validation",
|
||||
allowOverride: false));
|
||||
}
|
||||
|
||||
// Check required role
|
||||
if (!HasRequiredRole(context.UserRoles, actionDef.RequiredRole))
|
||||
{
|
||||
return Task.FromResult(CreateDenyDecision(
|
||||
$"Missing required role: {actionDef.RequiredRole}",
|
||||
"role-check",
|
||||
allowOverride: false));
|
||||
}
|
||||
|
||||
// Check environment restrictions
|
||||
if (actionDef.AllowedEnvironments.Length > 0 &&
|
||||
!actionDef.AllowedEnvironments.Contains(context.Environment, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return Task.FromResult(CreateDenyDecision(
|
||||
$"Action not allowed in environment: {context.Environment}",
|
||||
"environment-check",
|
||||
allowOverride: true));
|
||||
}
|
||||
|
||||
// Evaluate based on risk level and K4 context
|
||||
var decision = EvaluateRiskLevel(actionDef, context);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Policy decision for {ActionType}: {Decision} (policy: {PolicyId})",
|
||||
proposal.ActionType, decision.Decision, decision.PolicyId);
|
||||
|
||||
return Task.FromResult(decision);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<PolicyExplanation> ExplainAsync(
|
||||
ActionPolicyDecision decision,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var details = new List<string>();
|
||||
|
||||
if (!string.IsNullOrEmpty(decision.Reason))
|
||||
{
|
||||
details.Add(decision.Reason);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(decision.K4Position))
|
||||
{
|
||||
details.Add($"K4 lattice position: {decision.K4Position}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(decision.VexStatus))
|
||||
{
|
||||
details.Add($"VEX status: {decision.VexStatus}");
|
||||
}
|
||||
|
||||
var suggestedActions = new List<string>();
|
||||
switch (decision.Decision)
|
||||
{
|
||||
case PolicyDecisionKind.Deny:
|
||||
suggestedActions.Add("Contact your security administrator to request elevated permissions");
|
||||
break;
|
||||
case PolicyDecisionKind.DenyWithOverride:
|
||||
suggestedActions.Add("Request an admin override if you have business justification");
|
||||
break;
|
||||
case PolicyDecisionKind.AllowWithApproval:
|
||||
suggestedActions.Add($"Request approval from: {string.Join(", ", decision.RequiredApprovers.Select(a => a.Identifier))}");
|
||||
break;
|
||||
}
|
||||
|
||||
var explanation = new PolicyExplanation
|
||||
{
|
||||
Summary = GenerateSummary(decision),
|
||||
Details = details.ToImmutableArray(),
|
||||
PolicyReferences = decision.PolicyId is not null
|
||||
? ImmutableArray.Create(new PolicyReference
|
||||
{
|
||||
PolicyId = decision.PolicyId,
|
||||
Name = GetPolicyName(decision.PolicyId)
|
||||
})
|
||||
: ImmutableArray<PolicyReference>.Empty,
|
||||
SuggestedActions = suggestedActions.ToImmutableArray()
|
||||
};
|
||||
|
||||
return Task.FromResult(explanation);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IdempotencyCheckResult> CheckIdempotencyAsync(
|
||||
ActionProposal proposal,
|
||||
ActionContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Delegate to IdempotencyHandler - this is a stub for interface compliance
|
||||
// The actual check is done in ActionExecutor
|
||||
return Task.FromResult(new IdempotencyCheckResult
|
||||
{
|
||||
WasExecuted = false
|
||||
});
|
||||
}
|
||||
|
||||
private ActionPolicyDecision EvaluateRiskLevel(ActionDefinition actionDef, ActionContext context)
|
||||
{
|
||||
// Map risk level to policy decision
|
||||
return actionDef.RiskLevel switch
|
||||
{
|
||||
ActionRiskLevel.Low => CreateAllowDecision(actionDef, context),
|
||||
ActionRiskLevel.Medium => EvaluateMediumRisk(actionDef, context),
|
||||
ActionRiskLevel.High => EvaluateHighRisk(actionDef, context),
|
||||
ActionRiskLevel.Critical => EvaluateCriticalRisk(actionDef, context),
|
||||
_ => CreateDenyDecision("Unknown risk level", "risk-evaluation", allowOverride: true)
|
||||
};
|
||||
}
|
||||
|
||||
private ActionPolicyDecision CreateAllowDecision(ActionDefinition actionDef, ActionContext context)
|
||||
{
|
||||
return new ActionPolicyDecision
|
||||
{
|
||||
Decision = PolicyDecisionKind.Allow,
|
||||
PolicyId = "low-risk-auto-allow",
|
||||
Reason = "Low-risk action allowed automatically"
|
||||
};
|
||||
}
|
||||
|
||||
private ActionPolicyDecision EvaluateMediumRisk(ActionDefinition actionDef, ActionContext context)
|
||||
{
|
||||
// Check if user has elevated role
|
||||
if (HasRequiredRole(context.UserRoles, "security-lead") ||
|
||||
HasRequiredRole(context.UserRoles, "admin"))
|
||||
{
|
||||
return new ActionPolicyDecision
|
||||
{
|
||||
Decision = PolicyDecisionKind.Allow,
|
||||
PolicyId = "medium-risk-elevated-role",
|
||||
Reason = "Medium-risk action allowed for elevated role"
|
||||
};
|
||||
}
|
||||
|
||||
// Require team lead approval
|
||||
return CreateApprovalDecision(
|
||||
actionDef,
|
||||
context,
|
||||
"medium-risk-approval",
|
||||
"Medium-risk action requires team lead approval",
|
||||
[new RequiredApprover { Type = ApproverType.Role, Identifier = "team-lead" }]);
|
||||
}
|
||||
|
||||
private ActionPolicyDecision EvaluateHighRisk(ActionDefinition actionDef, ActionContext context)
|
||||
{
|
||||
// Check if admin
|
||||
if (HasRequiredRole(context.UserRoles, "admin"))
|
||||
{
|
||||
return new ActionPolicyDecision
|
||||
{
|
||||
Decision = PolicyDecisionKind.Allow,
|
||||
PolicyId = "high-risk-admin",
|
||||
Reason = "High-risk action allowed for admin"
|
||||
};
|
||||
}
|
||||
|
||||
// Require security lead approval
|
||||
return CreateApprovalDecision(
|
||||
actionDef,
|
||||
context,
|
||||
"high-risk-approval",
|
||||
"High-risk action requires security lead approval",
|
||||
[new RequiredApprover { Type = ApproverType.Role, Identifier = "security-lead" }]);
|
||||
}
|
||||
|
||||
private ActionPolicyDecision EvaluateCriticalRisk(ActionDefinition actionDef, ActionContext context)
|
||||
{
|
||||
// Critical actions always require multi-party approval
|
||||
// Even admins need CISO sign-off for critical actions in production
|
||||
if (context.Environment.Equals("production", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return CreateApprovalDecision(
|
||||
actionDef,
|
||||
context,
|
||||
"critical-risk-production",
|
||||
"Critical action in production requires CISO and security lead approval",
|
||||
[
|
||||
new RequiredApprover { Type = ApproverType.Role, Identifier = "ciso" },
|
||||
new RequiredApprover { Type = ApproverType.Role, Identifier = "security-lead" }
|
||||
]);
|
||||
}
|
||||
|
||||
// Non-production: just security lead
|
||||
return CreateApprovalDecision(
|
||||
actionDef,
|
||||
context,
|
||||
"critical-risk-non-prod",
|
||||
"Critical action requires security lead approval",
|
||||
[new RequiredApprover { Type = ApproverType.Role, Identifier = "security-lead" }]);
|
||||
}
|
||||
|
||||
private ActionPolicyDecision CreateApprovalDecision(
|
||||
ActionDefinition actionDef,
|
||||
ActionContext context,
|
||||
string policyId,
|
||||
string reason,
|
||||
ImmutableArray<RequiredApprover> approvers)
|
||||
{
|
||||
var timeout = actionDef.RiskLevel == ActionRiskLevel.Critical
|
||||
? TimeSpan.FromHours(_options.CriticalTimeoutHours)
|
||||
: TimeSpan.FromHours(_options.DefaultTimeoutHours);
|
||||
|
||||
return new ActionPolicyDecision
|
||||
{
|
||||
Decision = PolicyDecisionKind.AllowWithApproval,
|
||||
PolicyId = policyId,
|
||||
Reason = reason,
|
||||
RequiredApprovers = approvers,
|
||||
ApprovalWorkflowId = $"action-approval-{actionDef.RiskLevel.ToString().ToLowerInvariant()}",
|
||||
ExpiresAt = _timeProvider.GetUtcNow().Add(timeout)
|
||||
};
|
||||
}
|
||||
|
||||
private static ActionPolicyDecision CreateDenyDecision(string reason, string policyId, bool allowOverride)
|
||||
{
|
||||
return new ActionPolicyDecision
|
||||
{
|
||||
Decision = allowOverride ? PolicyDecisionKind.DenyWithOverride : PolicyDecisionKind.Deny,
|
||||
PolicyId = policyId,
|
||||
Reason = reason
|
||||
};
|
||||
}
|
||||
|
||||
private static bool HasRequiredRole(ImmutableArray<string> userRoles, string requiredRole)
|
||||
{
|
||||
// Admin role can do everything
|
||||
if (userRoles.Contains("admin", StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return userRoles.Contains(requiredRole, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string GenerateSummary(ActionPolicyDecision decision)
|
||||
{
|
||||
return decision.Decision switch
|
||||
{
|
||||
PolicyDecisionKind.Allow => "Action is allowed and can proceed immediately.",
|
||||
PolicyDecisionKind.AllowWithApproval => "Action requires approval before execution.",
|
||||
PolicyDecisionKind.Deny => "Action is denied by policy.",
|
||||
PolicyDecisionKind.DenyWithOverride => "Action is denied but can be overridden by an administrator.",
|
||||
PolicyDecisionKind.Indeterminate => "Unable to determine policy decision.",
|
||||
_ => "Unknown policy decision."
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetPolicyName(string policyId)
|
||||
{
|
||||
return policyId switch
|
||||
{
|
||||
"low-risk-auto-allow" => "Low Risk Auto-Allow Policy",
|
||||
"medium-risk-elevated-role" => "Medium Risk Elevated Role Policy",
|
||||
"medium-risk-approval" => "Medium Risk Approval Policy",
|
||||
"high-risk-admin" => "High Risk Admin Policy",
|
||||
"high-risk-approval" => "High Risk Approval Policy",
|
||||
"critical-risk-production" => "Critical Risk Production Policy",
|
||||
"critical-risk-non-prod" => "Critical Risk Non-Production Policy",
|
||||
"role-check" => "Role Authorization Policy",
|
||||
"environment-check" => "Environment Restriction Policy",
|
||||
"action-validation" => "Action Validation Policy",
|
||||
"parameter-validation" => "Parameter Validation Policy",
|
||||
_ => policyId
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for action policy evaluation.
|
||||
/// </summary>
|
||||
public sealed class ActionPolicyOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Default timeout in hours for approval requests.
|
||||
/// </summary>
|
||||
public int DefaultTimeoutHours { get; set; } = 4;
|
||||
|
||||
/// <summary>
|
||||
/// Timeout in hours for critical risk approval requests.
|
||||
/// </summary>
|
||||
public int CriticalTimeoutHours { get; set; } = 24;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to enable K4 lattice integration.
|
||||
/// </summary>
|
||||
public bool EnableK4Integration { get; set; } = true;
|
||||
}
|
||||
433
src/AdvisoryAI/StellaOps.AdvisoryAI/Actions/ActionRegistry.cs
Normal file
433
src/AdvisoryAI/StellaOps.AdvisoryAI/Actions/ActionRegistry.cs
Normal file
@@ -0,0 +1,433 @@
|
||||
// <copyright file="ActionRegistry.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Frozen;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of action registry with built-in action definitions.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-003
|
||||
/// </summary>
|
||||
internal sealed partial class ActionRegistry : IActionRegistry
|
||||
{
|
||||
private readonly FrozenDictionary<string, ActionDefinition> _actions;
|
||||
|
||||
public ActionRegistry()
|
||||
{
|
||||
_actions = CreateBuiltInActions().ToFrozenDictionary(a => a.ActionType, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ActionDefinition? GetAction(string actionType)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(actionType);
|
||||
return _actions.GetValueOrDefault(actionType);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ImmutableArray<ActionDefinition> GetAllActions() =>
|
||||
_actions.Values.ToImmutableArray();
|
||||
|
||||
/// <inheritdoc />
|
||||
public ImmutableArray<ActionDefinition> GetActionsByRiskLevel(ActionRiskLevel riskLevel) =>
|
||||
_actions.Values.Where(a => a.RiskLevel == riskLevel).ToImmutableArray();
|
||||
|
||||
/// <inheritdoc />
|
||||
public ImmutableArray<ActionDefinition> GetActionsByTag(string tag) =>
|
||||
_actions.Values.Where(a => a.Tags.Contains(tag, StringComparer.OrdinalIgnoreCase)).ToImmutableArray();
|
||||
|
||||
/// <inheritdoc />
|
||||
public ActionParameterValidationResult ValidateParameters(
|
||||
string actionType,
|
||||
ImmutableDictionary<string, string> parameters)
|
||||
{
|
||||
var definition = GetAction(actionType);
|
||||
if (definition is null)
|
||||
{
|
||||
return ActionParameterValidationResult.Failure($"Unknown action type: {actionType}");
|
||||
}
|
||||
|
||||
var errors = new List<string>();
|
||||
|
||||
// Check required parameters
|
||||
foreach (var param in definition.Parameters.Where(p => p.Required))
|
||||
{
|
||||
if (!parameters.ContainsKey(param.Name) || string.IsNullOrWhiteSpace(parameters[param.Name]))
|
||||
{
|
||||
errors.Add($"Missing required parameter: {param.Name}");
|
||||
}
|
||||
}
|
||||
|
||||
// Validate parameter patterns
|
||||
foreach (var param in definition.Parameters.Where(p => p.ValidationPattern is not null))
|
||||
{
|
||||
if (parameters.TryGetValue(param.Name, out var value) && !string.IsNullOrEmpty(value))
|
||||
{
|
||||
if (!Regex.IsMatch(value, param.ValidationPattern!))
|
||||
{
|
||||
errors.Add($"Parameter '{param.Name}' does not match pattern: {param.ValidationPattern}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors.Count == 0
|
||||
? ActionParameterValidationResult.Success
|
||||
: ActionParameterValidationResult.Failure(errors.ToArray());
|
||||
}
|
||||
|
||||
private static IEnumerable<ActionDefinition> CreateBuiltInActions()
|
||||
{
|
||||
// CVE/Finding Actions
|
||||
yield return new ActionDefinition
|
||||
{
|
||||
ActionType = "approve",
|
||||
DisplayName = "Approve Risk",
|
||||
Description = "Accept the risk for a CVE finding with documented justification",
|
||||
RequiredRole = "security-analyst",
|
||||
RiskLevel = ActionRiskLevel.High,
|
||||
IsIdempotent = true,
|
||||
HasCompensation = true,
|
||||
CompensationActionType = "revoke_approval",
|
||||
Parameters =
|
||||
[
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "cve_id",
|
||||
Type = "string",
|
||||
Required = true,
|
||||
Description = "CVE identifier",
|
||||
ValidationPattern = CveIdPattern().ToString()
|
||||
},
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "justification",
|
||||
Type = "string",
|
||||
Required = true,
|
||||
Description = "Risk acceptance justification"
|
||||
},
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "expires_days",
|
||||
Type = "int",
|
||||
Required = false,
|
||||
Description = "Days until approval expires",
|
||||
DefaultValue = "90"
|
||||
}
|
||||
],
|
||||
Tags = ["cve", "risk", "vex"]
|
||||
};
|
||||
|
||||
yield return new ActionDefinition
|
||||
{
|
||||
ActionType = "revoke_approval",
|
||||
DisplayName = "Revoke Risk Approval",
|
||||
Description = "Revoke a previously approved risk acceptance",
|
||||
RequiredRole = "security-analyst",
|
||||
RiskLevel = ActionRiskLevel.Medium,
|
||||
IsIdempotent = true,
|
||||
HasCompensation = false,
|
||||
Parameters =
|
||||
[
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "cve_id",
|
||||
Type = "string",
|
||||
Required = true,
|
||||
Description = "CVE identifier",
|
||||
ValidationPattern = CveIdPattern().ToString()
|
||||
},
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "reason",
|
||||
Type = "string",
|
||||
Required = true,
|
||||
Description = "Reason for revocation"
|
||||
}
|
||||
],
|
||||
Tags = ["cve", "risk", "vex"]
|
||||
};
|
||||
|
||||
yield return new ActionDefinition
|
||||
{
|
||||
ActionType = "quarantine",
|
||||
DisplayName = "Quarantine Image",
|
||||
Description = "Block an image from deployment due to critical vulnerability",
|
||||
RequiredRole = "security-lead",
|
||||
RiskLevel = ActionRiskLevel.Critical,
|
||||
IsIdempotent = true,
|
||||
HasCompensation = true,
|
||||
CompensationActionType = "release_quarantine",
|
||||
Parameters =
|
||||
[
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "image_digest",
|
||||
Type = "string",
|
||||
Required = true,
|
||||
Description = "Image digest to quarantine",
|
||||
ValidationPattern = ImageDigestPattern().ToString()
|
||||
},
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "reason",
|
||||
Type = "string",
|
||||
Required = true,
|
||||
Description = "Reason for quarantine"
|
||||
},
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "cve_ids",
|
||||
Type = "string",
|
||||
Required = false,
|
||||
Description = "Comma-separated CVE IDs"
|
||||
}
|
||||
],
|
||||
Tags = ["container", "security", "deployment"]
|
||||
};
|
||||
|
||||
yield return new ActionDefinition
|
||||
{
|
||||
ActionType = "release_quarantine",
|
||||
DisplayName = "Release from Quarantine",
|
||||
Description = "Release a previously quarantined image",
|
||||
RequiredRole = "security-lead",
|
||||
RiskLevel = ActionRiskLevel.High,
|
||||
IsIdempotent = true,
|
||||
HasCompensation = false,
|
||||
Parameters =
|
||||
[
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "image_digest",
|
||||
Type = "string",
|
||||
Required = true,
|
||||
Description = "Image digest to release",
|
||||
ValidationPattern = ImageDigestPattern().ToString()
|
||||
},
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "justification",
|
||||
Type = "string",
|
||||
Required = true,
|
||||
Description = "Justification for release"
|
||||
}
|
||||
],
|
||||
Tags = ["container", "security", "deployment"]
|
||||
};
|
||||
|
||||
yield return new ActionDefinition
|
||||
{
|
||||
ActionType = "defer",
|
||||
DisplayName = "Defer Finding",
|
||||
Description = "Defer remediation of a finding to a later date",
|
||||
RequiredRole = "security-analyst",
|
||||
RiskLevel = ActionRiskLevel.Low,
|
||||
IsIdempotent = true,
|
||||
HasCompensation = true,
|
||||
CompensationActionType = "undefer",
|
||||
Parameters =
|
||||
[
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "finding_id",
|
||||
Type = "string",
|
||||
Required = true,
|
||||
Description = "Finding identifier"
|
||||
},
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "defer_days",
|
||||
Type = "int",
|
||||
Required = true,
|
||||
Description = "Days to defer"
|
||||
},
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "reason",
|
||||
Type = "string",
|
||||
Required = true,
|
||||
Description = "Reason for deferral"
|
||||
}
|
||||
],
|
||||
Tags = ["finding", "triage"]
|
||||
};
|
||||
|
||||
yield return new ActionDefinition
|
||||
{
|
||||
ActionType = "undefer",
|
||||
DisplayName = "Undefer Finding",
|
||||
Description = "Remove deferral from a finding",
|
||||
RequiredRole = "security-analyst",
|
||||
RiskLevel = ActionRiskLevel.Low,
|
||||
IsIdempotent = true,
|
||||
HasCompensation = false,
|
||||
Parameters =
|
||||
[
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "finding_id",
|
||||
Type = "string",
|
||||
Required = true,
|
||||
Description = "Finding identifier"
|
||||
}
|
||||
],
|
||||
Tags = ["finding", "triage"]
|
||||
};
|
||||
|
||||
// VEX Actions
|
||||
yield return new ActionDefinition
|
||||
{
|
||||
ActionType = "create_vex",
|
||||
DisplayName = "Create VEX Statement",
|
||||
Description = "Create a VEX statement for a CVE",
|
||||
RequiredRole = "security-analyst",
|
||||
RiskLevel = ActionRiskLevel.Medium,
|
||||
IsIdempotent = false,
|
||||
HasCompensation = false,
|
||||
Parameters =
|
||||
[
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "cve_id",
|
||||
Type = "string",
|
||||
Required = true,
|
||||
Description = "CVE identifier",
|
||||
ValidationPattern = CveIdPattern().ToString()
|
||||
},
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "status",
|
||||
Type = "string",
|
||||
Required = true,
|
||||
Description = "VEX status (not_affected, affected, under_investigation, fixed)"
|
||||
},
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "justification",
|
||||
Type = "string",
|
||||
Required = false,
|
||||
Description = "Justification for not_affected status"
|
||||
},
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "impact_statement",
|
||||
Type = "string",
|
||||
Required = false,
|
||||
Description = "Impact statement for affected status"
|
||||
}
|
||||
],
|
||||
Tags = ["vex", "compliance"]
|
||||
};
|
||||
|
||||
// Report Actions
|
||||
yield return new ActionDefinition
|
||||
{
|
||||
ActionType = "generate_manifest",
|
||||
DisplayName = "Generate Security Manifest",
|
||||
Description = "Generate a security manifest for an image",
|
||||
RequiredRole = "viewer",
|
||||
RiskLevel = ActionRiskLevel.Low,
|
||||
IsIdempotent = true,
|
||||
HasCompensation = false,
|
||||
Parameters =
|
||||
[
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "image_digest",
|
||||
Type = "string",
|
||||
Required = true,
|
||||
Description = "Image digest",
|
||||
ValidationPattern = ImageDigestPattern().ToString()
|
||||
},
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "format",
|
||||
Type = "string",
|
||||
Required = false,
|
||||
Description = "Output format (json, pdf)",
|
||||
DefaultValue = "json"
|
||||
}
|
||||
],
|
||||
Tags = ["report", "compliance"]
|
||||
};
|
||||
|
||||
yield return new ActionDefinition
|
||||
{
|
||||
ActionType = "export_sbom",
|
||||
DisplayName = "Export SBOM",
|
||||
Description = "Export SBOM in specified format",
|
||||
RequiredRole = "viewer",
|
||||
RiskLevel = ActionRiskLevel.Low,
|
||||
IsIdempotent = true,
|
||||
HasCompensation = false,
|
||||
Parameters =
|
||||
[
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "image_digest",
|
||||
Type = "string",
|
||||
Required = true,
|
||||
Description = "Image digest",
|
||||
ValidationPattern = ImageDigestPattern().ToString()
|
||||
},
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "format",
|
||||
Type = "string",
|
||||
Required = false,
|
||||
Description = "SBOM format (spdx-json, cyclonedx-json)",
|
||||
DefaultValue = "spdx-json"
|
||||
}
|
||||
],
|
||||
Tags = ["sbom", "export", "compliance"]
|
||||
};
|
||||
|
||||
// Notification Actions
|
||||
yield return new ActionDefinition
|
||||
{
|
||||
ActionType = "notify_team",
|
||||
DisplayName = "Notify Team",
|
||||
Description = "Send notification to a team channel",
|
||||
RequiredRole = "security-analyst",
|
||||
RiskLevel = ActionRiskLevel.Medium,
|
||||
IsIdempotent = false,
|
||||
HasCompensation = false,
|
||||
Parameters =
|
||||
[
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "channel",
|
||||
Type = "string",
|
||||
Required = true,
|
||||
Description = "Notification channel"
|
||||
},
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "message",
|
||||
Type = "string",
|
||||
Required = true,
|
||||
Description = "Message content"
|
||||
},
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "priority",
|
||||
Type = "string",
|
||||
Required = false,
|
||||
Description = "Priority (low, medium, high, critical)",
|
||||
DefaultValue = "medium"
|
||||
}
|
||||
],
|
||||
Tags = ["notification", "communication"]
|
||||
};
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^CVE-\d{4}-\d{4,}$", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex CveIdPattern();
|
||||
|
||||
[GeneratedRegex(@"^sha256:[a-fA-F0-9]{64}$")]
|
||||
private static partial Regex ImageDigestPattern();
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
// <copyright file="ApprovalWorkflowAdapter.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory approval workflow adapter for development and testing.
|
||||
/// In production, this would integrate with ReviewWorkflowService.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-004
|
||||
/// </summary>
|
||||
internal sealed class ApprovalWorkflowAdapter : IApprovalWorkflowAdapter
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ApprovalRequestState> _requests = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidGenerator _guidGenerator;
|
||||
private readonly ILogger<ApprovalWorkflowAdapter> _logger;
|
||||
|
||||
public ApprovalWorkflowAdapter(
|
||||
TimeProvider timeProvider,
|
||||
IGuidGenerator guidGenerator,
|
||||
ILogger<ApprovalWorkflowAdapter> logger)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_guidGenerator = guidGenerator ?? throw new ArgumentNullException(nameof(guidGenerator));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ApprovalRequest> CreateApprovalRequestAsync(
|
||||
ActionProposal proposal,
|
||||
ActionPolicyDecision decision,
|
||||
ActionContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(proposal);
|
||||
ArgumentNullException.ThrowIfNull(decision);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var requestId = _guidGenerator.NewGuid().ToString();
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
var timeout = decision.ExpiresAt.HasValue
|
||||
? decision.ExpiresAt.Value - now
|
||||
: TimeSpan.FromHours(4);
|
||||
|
||||
var request = new ApprovalRequest
|
||||
{
|
||||
RequestId = requestId,
|
||||
WorkflowId = decision.ApprovalWorkflowId ?? "default",
|
||||
TenantId = context.TenantId,
|
||||
RequesterId = context.UserId,
|
||||
RequiredApprovers = decision.RequiredApprovers,
|
||||
Timeout = timeout,
|
||||
Payload = new ApprovalPayload
|
||||
{
|
||||
ActionType = proposal.ActionType,
|
||||
ActionLabel = proposal.Label,
|
||||
Parameters = proposal.Parameters,
|
||||
RunId = context.RunId,
|
||||
FindingId = context.FindingId,
|
||||
PolicyReason = decision.Reason
|
||||
},
|
||||
CreatedAt = now
|
||||
};
|
||||
|
||||
var state = new ApprovalRequestState
|
||||
{
|
||||
Request = request,
|
||||
State = ApprovalState.Pending,
|
||||
Approvals = ImmutableArray<ApprovalEntry>.Empty
|
||||
};
|
||||
|
||||
_requests[requestId] = state;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created approval request {RequestId} for action {ActionType} by user {UserId}",
|
||||
requestId, proposal.ActionType, context.UserId);
|
||||
|
||||
return Task.FromResult(request);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ApprovalStatus?> GetApprovalStatusAsync(
|
||||
string requestId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_requests.TryGetValue(requestId, out var state))
|
||||
{
|
||||
return Task.FromResult<ApprovalStatus?>(null);
|
||||
}
|
||||
|
||||
// Check for expiration
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
if (state.State == ApprovalState.Pending && now >= state.Request.ExpiresAt)
|
||||
{
|
||||
state.State = ApprovalState.Expired;
|
||||
state.UpdatedAt = now;
|
||||
}
|
||||
|
||||
var status = new ApprovalStatus
|
||||
{
|
||||
RequestId = requestId,
|
||||
State = state.State,
|
||||
Approvals = state.Approvals,
|
||||
CreatedAt = state.Request.CreatedAt,
|
||||
UpdatedAt = state.UpdatedAt,
|
||||
ExpiresAt = state.Request.ExpiresAt
|
||||
};
|
||||
|
||||
return Task.FromResult<ApprovalStatus?>(status);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ApprovalResult> WaitForApprovalAsync(
|
||||
string requestId,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cts.CancelAfter(timeout);
|
||||
|
||||
try
|
||||
{
|
||||
while (!cts.IsCancellationRequested)
|
||||
{
|
||||
var status = await GetApprovalStatusAsync(requestId, cts.Token);
|
||||
|
||||
if (status is null)
|
||||
{
|
||||
return new ApprovalResult
|
||||
{
|
||||
Approved = false,
|
||||
DenialReason = "Approval request not found"
|
||||
};
|
||||
}
|
||||
|
||||
switch (status.State)
|
||||
{
|
||||
case ApprovalState.Approved:
|
||||
var approvalEntry = status.Approvals.LastOrDefault();
|
||||
return new ApprovalResult
|
||||
{
|
||||
Approved = true,
|
||||
ApproverId = approvalEntry?.ApproverId,
|
||||
DecidedAt = approvalEntry?.DecidedAt,
|
||||
Comments = approvalEntry?.Comments
|
||||
};
|
||||
|
||||
case ApprovalState.Denied:
|
||||
var denialEntry = status.Approvals.LastOrDefault(a => !a.Approved);
|
||||
return new ApprovalResult
|
||||
{
|
||||
Approved = false,
|
||||
ApproverId = denialEntry?.ApproverId,
|
||||
DecidedAt = denialEntry?.DecidedAt,
|
||||
Comments = denialEntry?.Comments,
|
||||
DenialReason = denialEntry?.Comments ?? "Request denied"
|
||||
};
|
||||
|
||||
case ApprovalState.Expired:
|
||||
return new ApprovalResult
|
||||
{
|
||||
Approved = false,
|
||||
TimedOut = true,
|
||||
DenialReason = "Approval request expired"
|
||||
};
|
||||
|
||||
case ApprovalState.Cancelled:
|
||||
return new ApprovalResult
|
||||
{
|
||||
Approved = false,
|
||||
Cancelled = true,
|
||||
DenialReason = "Approval request was cancelled"
|
||||
};
|
||||
|
||||
case ApprovalState.Pending:
|
||||
// Continue waiting
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(100), cts.Token);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Timeout or cancellation
|
||||
}
|
||||
|
||||
return new ApprovalResult
|
||||
{
|
||||
Approved = false,
|
||||
TimedOut = true,
|
||||
DenialReason = "Timed out waiting for approval"
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task CancelApprovalRequestAsync(
|
||||
string requestId,
|
||||
string reason,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_requests.TryGetValue(requestId, out var state) && state.State == ApprovalState.Pending)
|
||||
{
|
||||
state.State = ApprovalState.Cancelled;
|
||||
state.UpdatedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Cancelled approval request {RequestId}: {Reason}",
|
||||
requestId, reason);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records an approval decision (used for testing and external approval callbacks).
|
||||
/// </summary>
|
||||
public void RecordApproval(string requestId, string approverId, bool approved, string? comments = null)
|
||||
{
|
||||
if (!_requests.TryGetValue(requestId, out var state))
|
||||
{
|
||||
throw new InvalidOperationException($"Approval request not found: {requestId}");
|
||||
}
|
||||
|
||||
if (state.State != ApprovalState.Pending)
|
||||
{
|
||||
throw new InvalidOperationException($"Request {requestId} is not pending");
|
||||
}
|
||||
|
||||
var entry = new ApprovalEntry
|
||||
{
|
||||
ApproverId = approverId,
|
||||
Approved = approved,
|
||||
Comments = comments,
|
||||
DecidedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
state.Approvals = state.Approvals.Add(entry);
|
||||
state.UpdatedAt = entry.DecidedAt;
|
||||
|
||||
if (!approved)
|
||||
{
|
||||
state.State = ApprovalState.Denied;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Check if all required approvals are met
|
||||
var approvedRoles = state.Approvals
|
||||
.Where(a => a.Approved)
|
||||
.Select(a => a.ApproverId)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Simplified check: any required approver approving is sufficient
|
||||
// In production, would check against actual role membership
|
||||
state.State = ApprovalState.Approved;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Recorded {Decision} for request {RequestId} by {ApproverId}",
|
||||
approved ? "approval" : "denial", requestId, approverId);
|
||||
}
|
||||
|
||||
private sealed class ApprovalRequestState
|
||||
{
|
||||
public required ApprovalRequest Request { get; init; }
|
||||
public ApprovalState State { get; set; }
|
||||
public ImmutableArray<ApprovalEntry> Approvals { get; set; }
|
||||
public DateTimeOffset? UpdatedAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
// <copyright file="IActionAuditLedger.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// Audit ledger for recording all action attempts and outcomes.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-006
|
||||
/// </summary>
|
||||
public interface IActionAuditLedger
|
||||
{
|
||||
/// <summary>
|
||||
/// Records an audit entry for an action.
|
||||
/// </summary>
|
||||
/// <param name="entry">The audit entry to record.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task RecordAsync(ActionAuditEntry entry, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Queries audit entries.
|
||||
/// </summary>
|
||||
/// <param name="query">The query criteria.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Matching audit entries.</returns>
|
||||
Task<ImmutableArray<ActionAuditEntry>> QueryAsync(
|
||||
ActionAuditQuery query,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific audit entry by ID.
|
||||
/// </summary>
|
||||
/// <param name="entryId">The entry ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The audit entry or null if not found.</returns>
|
||||
Task<ActionAuditEntry?> GetAsync(
|
||||
string entryId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets audit entries for a specific run.
|
||||
/// </summary>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Audit entries for the run.</returns>
|
||||
Task<ImmutableArray<ActionAuditEntry>> GetByRunAsync(
|
||||
string runId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An audit entry for an action attempt.
|
||||
/// </summary>
|
||||
public sealed record ActionAuditEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique entry identifier.
|
||||
/// </summary>
|
||||
public required string EntryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the action was attempted.
|
||||
/// </summary>
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of action attempted.
|
||||
/// </summary>
|
||||
public required string ActionType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// User who attempted the action.
|
||||
/// </summary>
|
||||
public required string Actor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of the action attempt.
|
||||
/// </summary>
|
||||
public required ActionAuditOutcome Outcome { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Associated AI run ID.
|
||||
/// </summary>
|
||||
public string? RunId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Associated finding ID.
|
||||
/// </summary>
|
||||
public string? FindingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Associated CVE ID.
|
||||
/// </summary>
|
||||
public string? CveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Associated image digest.
|
||||
/// </summary>
|
||||
public string? ImageDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy that evaluated the action.
|
||||
/// </summary>
|
||||
public string? PolicyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy decision result.
|
||||
/// </summary>
|
||||
public PolicyDecisionKind? PolicyResult { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Approval request ID if approval was required.
|
||||
/// </summary>
|
||||
public string? ApprovalRequestId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// User who approved the action.
|
||||
/// </summary>
|
||||
public string? ApproverId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Action parameters.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Parameters { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Execution result ID if executed.
|
||||
/// </summary>
|
||||
public string? ExecutionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Result digest for verification.
|
||||
/// </summary>
|
||||
public string? ResultDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if failed.
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Attestation digest if attested.
|
||||
/// </summary>
|
||||
public string? AttestationDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Metadata { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of an action attempt.
|
||||
/// </summary>
|
||||
public enum ActionAuditOutcome
|
||||
{
|
||||
/// <summary>
|
||||
/// Action was successfully executed.
|
||||
/// </summary>
|
||||
Executed,
|
||||
|
||||
/// <summary>
|
||||
/// Action was denied by policy.
|
||||
/// </summary>
|
||||
DeniedByPolicy,
|
||||
|
||||
/// <summary>
|
||||
/// Approval was requested.
|
||||
/// </summary>
|
||||
ApprovalRequested,
|
||||
|
||||
/// <summary>
|
||||
/// Action was approved and executed.
|
||||
/// </summary>
|
||||
Approved,
|
||||
|
||||
/// <summary>
|
||||
/// Approval was denied.
|
||||
/// </summary>
|
||||
ApprovalDenied,
|
||||
|
||||
/// <summary>
|
||||
/// Approval request timed out.
|
||||
/// </summary>
|
||||
ApprovalTimedOut,
|
||||
|
||||
/// <summary>
|
||||
/// Execution failed.
|
||||
/// </summary>
|
||||
ExecutionFailed,
|
||||
|
||||
/// <summary>
|
||||
/// Action was skipped due to idempotency.
|
||||
/// </summary>
|
||||
IdempotentSkipped,
|
||||
|
||||
/// <summary>
|
||||
/// Action was rolled back.
|
||||
/// </summary>
|
||||
RolledBack,
|
||||
|
||||
/// <summary>
|
||||
/// Action validation failed.
|
||||
/// </summary>
|
||||
ValidationFailed
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query criteria for audit entries.
|
||||
/// </summary>
|
||||
public sealed record ActionAuditQuery
|
||||
{
|
||||
/// <summary>
|
||||
/// Filter by tenant.
|
||||
/// </summary>
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by action type.
|
||||
/// </summary>
|
||||
public string? ActionType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by actor (user).
|
||||
/// </summary>
|
||||
public string? Actor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by outcome.
|
||||
/// </summary>
|
||||
public ActionAuditOutcome? Outcome { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by run ID.
|
||||
/// </summary>
|
||||
public string? RunId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by CVE ID.
|
||||
/// </summary>
|
||||
public string? CveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by image digest.
|
||||
/// </summary>
|
||||
public string? ImageDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Start of time range (inclusive).
|
||||
/// </summary>
|
||||
public DateTimeOffset? FromTimestamp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// End of time range (exclusive).
|
||||
/// </summary>
|
||||
public DateTimeOffset? ToTimestamp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of results.
|
||||
/// </summary>
|
||||
public int Limit { get; init; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Offset for pagination.
|
||||
/// </summary>
|
||||
public int Offset { get; init; } = 0;
|
||||
}
|
||||
349
src/AdvisoryAI/StellaOps.AdvisoryAI/Actions/IActionExecutor.cs
Normal file
349
src/AdvisoryAI/StellaOps.AdvisoryAI/Actions/IActionExecutor.cs
Normal file
@@ -0,0 +1,349 @@
|
||||
// <copyright file="IActionExecutor.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// Executes AI-proposed actions after policy gate approval.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-002
|
||||
/// </summary>
|
||||
public interface IActionExecutor
|
||||
{
|
||||
/// <summary>
|
||||
/// Executes an action after policy gate approval.
|
||||
/// </summary>
|
||||
/// <param name="proposal">The approved action proposal.</param>
|
||||
/// <param name="decision">The policy decision that approved the action.</param>
|
||||
/// <param name="context">The execution context.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The execution result.</returns>
|
||||
Task<ActionExecutionResult> ExecuteAsync(
|
||||
ActionProposal proposal,
|
||||
ActionPolicyDecision decision,
|
||||
ActionContext context,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Rolls back a previously executed action if supported.
|
||||
/// </summary>
|
||||
/// <param name="executionId">The execution ID to rollback.</param>
|
||||
/// <param name="context">The context for rollback.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The rollback result.</returns>
|
||||
Task<ActionRollbackResult> RollbackAsync(
|
||||
string executionId,
|
||||
ActionContext context,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the status of an action execution.
|
||||
/// </summary>
|
||||
/// <param name="executionId">The execution ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The current execution status.</returns>
|
||||
Task<ActionExecutionStatus?> GetStatusAsync(
|
||||
string executionId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Lists available action types supported by this executor.
|
||||
/// </summary>
|
||||
/// <returns>The available action types.</returns>
|
||||
ImmutableArray<ActionTypeInfo> GetSupportedActionTypes();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of action execution.
|
||||
/// </summary>
|
||||
public sealed record ActionExecutionResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this execution.
|
||||
/// </summary>
|
||||
public required string ExecutionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of the execution.
|
||||
/// </summary>
|
||||
public required ActionExecutionOutcome Outcome { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable message about the execution.
|
||||
/// </summary>
|
||||
public string? Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Output data from the action.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> OutputData { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// When execution started.
|
||||
/// </summary>
|
||||
public required DateTimeOffset StartedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When execution completed (null if still running).
|
||||
/// </summary>
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Duration of the execution.
|
||||
/// </summary>
|
||||
public TimeSpan? Duration => CompletedAt.HasValue
|
||||
? CompletedAt.Value - StartedAt
|
||||
: null;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this action can be rolled back.
|
||||
/// </summary>
|
||||
public bool CanRollback { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error details if execution failed.
|
||||
/// </summary>
|
||||
public ActionError? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Related artifact IDs created or modified by this action.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> AffectedArtifacts { get; init; } =
|
||||
ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of action execution.
|
||||
/// </summary>
|
||||
public enum ActionExecutionOutcome
|
||||
{
|
||||
/// <summary>
|
||||
/// Action executed successfully.
|
||||
/// </summary>
|
||||
Success,
|
||||
|
||||
/// <summary>
|
||||
/// Action partially completed.
|
||||
/// </summary>
|
||||
PartialSuccess,
|
||||
|
||||
/// <summary>
|
||||
/// Action failed.
|
||||
/// </summary>
|
||||
Failed,
|
||||
|
||||
/// <summary>
|
||||
/// Action was cancelled.
|
||||
/// </summary>
|
||||
Cancelled,
|
||||
|
||||
/// <summary>
|
||||
/// Action execution timed out.
|
||||
/// </summary>
|
||||
Timeout,
|
||||
|
||||
/// <summary>
|
||||
/// Action was skipped due to idempotency (already executed).
|
||||
/// </summary>
|
||||
Skipped,
|
||||
|
||||
/// <summary>
|
||||
/// Action is pending approval.
|
||||
/// </summary>
|
||||
PendingApproval,
|
||||
|
||||
/// <summary>
|
||||
/// Action is currently executing.
|
||||
/// </summary>
|
||||
InProgress
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Current status of an action execution.
|
||||
/// </summary>
|
||||
public sealed record ActionExecutionStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// The execution ID.
|
||||
/// </summary>
|
||||
public required string ExecutionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current outcome/state.
|
||||
/// </summary>
|
||||
public required ActionExecutionOutcome Outcome { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Progress percentage if known (0-100).
|
||||
/// </summary>
|
||||
public int? ProgressPercent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current status message.
|
||||
/// </summary>
|
||||
public string? StatusMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When status was last updated.
|
||||
/// </summary>
|
||||
public required DateTimeOffset UpdatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Estimated completion time if known.
|
||||
/// </summary>
|
||||
public DateTimeOffset? EstimatedCompletionAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes an error that occurred during action execution.
|
||||
/// </summary>
|
||||
public sealed record ActionError
|
||||
{
|
||||
/// <summary>
|
||||
/// Error code for programmatic handling.
|
||||
/// </summary>
|
||||
public required string Code { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable error message.
|
||||
/// </summary>
|
||||
public required string Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Detailed error information.
|
||||
/// </summary>
|
||||
public string? Details { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the error is retryable.
|
||||
/// </summary>
|
||||
public bool IsRetryable { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Suggested wait time before retry.
|
||||
/// </summary>
|
||||
public TimeSpan? RetryAfter { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Inner error if this is a wrapper.
|
||||
/// </summary>
|
||||
public ActionError? InnerError { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of rolling back an action.
|
||||
/// </summary>
|
||||
public sealed record ActionRollbackResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the rollback was successful.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Message about the rollback.
|
||||
/// </summary>
|
||||
public string? Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error if rollback failed.
|
||||
/// </summary>
|
||||
public ActionError? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When rollback completed.
|
||||
/// </summary>
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about an available action type.
|
||||
/// </summary>
|
||||
public sealed record ActionTypeInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// The action type identifier.
|
||||
/// </summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable name.
|
||||
/// </summary>
|
||||
public required string DisplayName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Description of what this action does.
|
||||
/// </summary>
|
||||
public required string Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Category for grouping actions.
|
||||
/// </summary>
|
||||
public string? Category { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Required parameters for this action type.
|
||||
/// </summary>
|
||||
public ImmutableArray<ActionParameterInfo> Parameters { get; init; } =
|
||||
ImmutableArray<ActionParameterInfo>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Required permission to execute this action.
|
||||
/// </summary>
|
||||
public string? RequiredPermission { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this action supports rollback.
|
||||
/// </summary>
|
||||
public bool SupportsRollback { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this action is destructive (requires extra confirmation).
|
||||
/// </summary>
|
||||
public bool IsDestructive { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about an action parameter.
|
||||
/// </summary>
|
||||
public sealed record ActionParameterInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Parameter name.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable display name.
|
||||
/// </summary>
|
||||
public required string DisplayName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Description of the parameter.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this parameter is required.
|
||||
/// </summary>
|
||||
public bool IsRequired { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Parameter type (string, integer, boolean, etc.).
|
||||
/// </summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Default value if not specified.
|
||||
/// </summary>
|
||||
public string? DefaultValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Valid values for enum-like parameters.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> AllowedValues { get; init; } =
|
||||
ImmutableArray<string>.Empty;
|
||||
}
|
||||
358
src/AdvisoryAI/StellaOps.AdvisoryAI/Actions/IActionPolicyGate.cs
Normal file
358
src/AdvisoryAI/StellaOps.AdvisoryAI/Actions/IActionPolicyGate.cs
Normal file
@@ -0,0 +1,358 @@
|
||||
// <copyright file="IActionPolicyGate.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates whether AI-proposed actions are allowed by policy.
|
||||
/// Integrates with K4 lattice for VEX-aware decisions and approval workflows.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-001
|
||||
/// </summary>
|
||||
public interface IActionPolicyGate
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluates whether an action is allowed by policy.
|
||||
/// </summary>
|
||||
/// <param name="proposal">The action proposal from the AI.</param>
|
||||
/// <param name="context">The execution context including tenant, user, roles, environment.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The policy decision with any required approvals.</returns>
|
||||
Task<ActionPolicyDecision> EvaluateAsync(
|
||||
ActionProposal proposal,
|
||||
ActionContext context,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a human-readable explanation for a policy decision.
|
||||
/// </summary>
|
||||
/// <param name="decision">The decision to explain.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Human-readable explanation with policy references.</returns>
|
||||
Task<PolicyExplanation> ExplainAsync(
|
||||
ActionPolicyDecision decision,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if an action has already been executed (idempotency check).
|
||||
/// </summary>
|
||||
/// <param name="proposal">The action proposal.</param>
|
||||
/// <param name="context">The execution context.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if the action was already executed with the same parameters.</returns>
|
||||
Task<IdempotencyCheckResult> CheckIdempotencyAsync(
|
||||
ActionProposal proposal,
|
||||
ActionContext context,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context for action policy evaluation.
|
||||
/// </summary>
|
||||
public sealed record ActionContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Tenant identifier for multi-tenancy.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// User identifier who initiated the action.
|
||||
/// </summary>
|
||||
public required string UserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// User's roles/permissions.
|
||||
/// </summary>
|
||||
public required ImmutableArray<string> UserRoles { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target environment (production, staging, development, etc.).
|
||||
/// </summary>
|
||||
public required string Environment { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Associated AI run ID, if any.
|
||||
/// </summary>
|
||||
public string? RunId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Associated finding ID for remediation actions.
|
||||
/// </summary>
|
||||
public string? FindingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CVE ID if this is a vulnerability-related action.
|
||||
/// </summary>
|
||||
public string? CveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Image digest if this is a container-related action.
|
||||
/// </summary>
|
||||
public string? ImageDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional correlation ID for tracing.
|
||||
/// </summary>
|
||||
public string? CorrelationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata for policy evaluation.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Metadata { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An action proposed by the AI system.
|
||||
/// </summary>
|
||||
public sealed record ActionProposal
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this proposal.
|
||||
/// </summary>
|
||||
public required string ProposalId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of action (e.g., "approve", "quarantine", "create_vex").
|
||||
/// </summary>
|
||||
public required string ActionType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable label for the action.
|
||||
/// </summary>
|
||||
public required string Label { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Action parameters.
|
||||
/// </summary>
|
||||
public required ImmutableDictionary<string, string> Parameters { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the proposal was created.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the proposal expires (null = never).
|
||||
/// </summary>
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Idempotency key for deduplication.
|
||||
/// </summary>
|
||||
public string? IdempotencyKey { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of policy gate evaluation.
|
||||
/// </summary>
|
||||
public sealed record ActionPolicyDecision
|
||||
{
|
||||
/// <summary>
|
||||
/// The decision outcome.
|
||||
/// </summary>
|
||||
public required PolicyDecisionKind Decision { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the policy that made this decision.
|
||||
/// </summary>
|
||||
public string? PolicyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Brief reason for the decision.
|
||||
/// </summary>
|
||||
public string? Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Required approvers if decision is AllowWithApproval.
|
||||
/// </summary>
|
||||
public ImmutableArray<RequiredApprover> RequiredApprovers { get; init; } =
|
||||
ImmutableArray<RequiredApprover>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Approval workflow ID if approval is required.
|
||||
/// </summary>
|
||||
public string? ApprovalWorkflowId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// K4 lattice position used in the decision.
|
||||
/// </summary>
|
||||
public string? K4Position { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX status that influenced the decision, if any.
|
||||
/// </summary>
|
||||
public string? VexStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Severity level assigned by policy.
|
||||
/// </summary>
|
||||
public int? SeverityLevel { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this decision expires.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional decision metadata.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Metadata { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Kinds of policy decisions.
|
||||
/// </summary>
|
||||
public enum PolicyDecisionKind
|
||||
{
|
||||
/// <summary>
|
||||
/// Action is allowed and can execute immediately.
|
||||
/// </summary>
|
||||
Allow,
|
||||
|
||||
/// <summary>
|
||||
/// Action is allowed but requires approval workflow.
|
||||
/// </summary>
|
||||
AllowWithApproval,
|
||||
|
||||
/// <summary>
|
||||
/// Action is denied by policy.
|
||||
/// </summary>
|
||||
Deny,
|
||||
|
||||
/// <summary>
|
||||
/// Action is denied but admin can override.
|
||||
/// </summary>
|
||||
DenyWithOverride,
|
||||
|
||||
/// <summary>
|
||||
/// Decision could not be made (missing context).
|
||||
/// </summary>
|
||||
Indeterminate
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes a required approver for AllowWithApproval decisions.
|
||||
/// </summary>
|
||||
public sealed record RequiredApprover
|
||||
{
|
||||
/// <summary>
|
||||
/// Type of approver requirement.
|
||||
/// </summary>
|
||||
public required ApproverType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Identifier (user ID, role name, or group name).
|
||||
/// </summary>
|
||||
public required string Identifier { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable description.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types of approval requirements.
|
||||
/// </summary>
|
||||
public enum ApproverType
|
||||
{
|
||||
/// <summary>
|
||||
/// Specific user must approve.
|
||||
/// </summary>
|
||||
User,
|
||||
|
||||
/// <summary>
|
||||
/// Any user with this role can approve.
|
||||
/// </summary>
|
||||
Role,
|
||||
|
||||
/// <summary>
|
||||
/// Any member of this group can approve.
|
||||
/// </summary>
|
||||
Group
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable explanation of a policy decision.
|
||||
/// </summary>
|
||||
public sealed record PolicyExplanation
|
||||
{
|
||||
/// <summary>
|
||||
/// Natural language summary of the decision.
|
||||
/// </summary>
|
||||
public required string Summary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Detailed explanation points.
|
||||
/// </summary>
|
||||
public required ImmutableArray<string> Details { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// References to policies that were evaluated.
|
||||
/// </summary>
|
||||
public ImmutableArray<PolicyReference> PolicyReferences { get; init; } =
|
||||
ImmutableArray<PolicyReference>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Suggested next steps for the user.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> SuggestedActions { get; init; } =
|
||||
ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to a specific policy.
|
||||
/// </summary>
|
||||
public sealed record PolicyReference
|
||||
{
|
||||
/// <summary>
|
||||
/// Policy identifier.
|
||||
/// </summary>
|
||||
public required string PolicyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy name.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule within the policy that matched.
|
||||
/// </summary>
|
||||
public string? RuleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Link to policy documentation.
|
||||
/// </summary>
|
||||
public string? DocumentationUrl { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of idempotency check.
|
||||
/// </summary>
|
||||
public sealed record IdempotencyCheckResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the action was previously executed.
|
||||
/// </summary>
|
||||
public required bool WasExecuted { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Previous execution ID if executed.
|
||||
/// </summary>
|
||||
public string? PreviousExecutionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the action was previously executed.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ExecutedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Result of the previous execution.
|
||||
/// </summary>
|
||||
public string? PreviousResult { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// <copyright file="IActionRegistry.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// Registry of available action types and their definitions.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-003
|
||||
/// </summary>
|
||||
public interface IActionRegistry
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the definition for an action type.
|
||||
/// </summary>
|
||||
/// <param name="actionType">The action type identifier.</param>
|
||||
/// <returns>The definition or null if not found.</returns>
|
||||
ActionDefinition? GetAction(string actionType);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all registered action definitions.
|
||||
/// </summary>
|
||||
/// <returns>All action definitions.</returns>
|
||||
ImmutableArray<ActionDefinition> GetAllActions();
|
||||
|
||||
/// <summary>
|
||||
/// Gets actions by risk level.
|
||||
/// </summary>
|
||||
/// <param name="riskLevel">The risk level to filter by.</param>
|
||||
/// <returns>Actions matching the risk level.</returns>
|
||||
ImmutableArray<ActionDefinition> GetActionsByRiskLevel(ActionRiskLevel riskLevel);
|
||||
|
||||
/// <summary>
|
||||
/// Gets actions by tag.
|
||||
/// </summary>
|
||||
/// <param name="tag">The tag to filter by.</param>
|
||||
/// <returns>Actions with the specified tag.</returns>
|
||||
ImmutableArray<ActionDefinition> GetActionsByTag(string tag);
|
||||
|
||||
/// <summary>
|
||||
/// Validates action parameters against the definition.
|
||||
/// </summary>
|
||||
/// <param name="actionType">The action type.</param>
|
||||
/// <param name="parameters">The parameters to validate.</param>
|
||||
/// <returns>Validation result.</returns>
|
||||
ActionParameterValidationResult ValidateParameters(
|
||||
string actionType,
|
||||
ImmutableDictionary<string, string> parameters);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of parameter validation.
|
||||
/// </summary>
|
||||
public sealed record ActionParameterValidationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether validation passed.
|
||||
/// </summary>
|
||||
public required bool IsValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Validation errors if any.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Errors { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful validation result.
|
||||
/// </summary>
|
||||
public static ActionParameterValidationResult Success => new() { IsValid = true };
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed validation result.
|
||||
/// </summary>
|
||||
public static ActionParameterValidationResult Failure(params string[] errors) =>
|
||||
new() { IsValid = false, Errors = errors.ToImmutableArray() };
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
// <copyright file="IApprovalWorkflowAdapter.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// Adapter for integrating with approval workflow systems.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-004
|
||||
/// </summary>
|
||||
public interface IApprovalWorkflowAdapter
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates an approval request for an action.
|
||||
/// </summary>
|
||||
/// <param name="proposal">The action proposal.</param>
|
||||
/// <param name="decision">The policy decision requiring approval.</param>
|
||||
/// <param name="context">The action context.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The created approval request.</returns>
|
||||
Task<ApprovalRequest> CreateApprovalRequestAsync(
|
||||
ActionProposal proposal,
|
||||
ActionPolicyDecision decision,
|
||||
ActionContext context,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the status of an approval request.
|
||||
/// </summary>
|
||||
/// <param name="requestId">The request ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The approval status or null if not found.</returns>
|
||||
Task<ApprovalStatus?> GetApprovalStatusAsync(
|
||||
string requestId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Waits for an approval decision with timeout.
|
||||
/// </summary>
|
||||
/// <param name="requestId">The request ID.</param>
|
||||
/// <param name="timeout">Maximum time to wait.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The approval result.</returns>
|
||||
Task<ApprovalResult> WaitForApprovalAsync(
|
||||
string requestId,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Cancels a pending approval request.
|
||||
/// </summary>
|
||||
/// <param name="requestId">The request ID.</param>
|
||||
/// <param name="reason">Cancellation reason.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task CancelApprovalRequestAsync(
|
||||
string requestId,
|
||||
string reason,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An approval request for an action.
|
||||
/// </summary>
|
||||
public sealed record ApprovalRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique request identifier.
|
||||
/// </summary>
|
||||
public required string RequestId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Associated workflow ID.
|
||||
/// </summary>
|
||||
public required string WorkflowId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// User who requested the action.
|
||||
/// </summary>
|
||||
public required string RequesterId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Required approvers.
|
||||
/// </summary>
|
||||
public required ImmutableArray<RequiredApprover> RequiredApprovers { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Request timeout.
|
||||
/// </summary>
|
||||
public required TimeSpan Timeout { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Payload containing action details.
|
||||
/// </summary>
|
||||
public required ApprovalPayload Payload { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the request was created.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the request expires.
|
||||
/// </summary>
|
||||
public DateTimeOffset ExpiresAt => CreatedAt.Add(Timeout);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Payload for an approval request.
|
||||
/// </summary>
|
||||
public sealed record ApprovalPayload
|
||||
{
|
||||
/// <summary>
|
||||
/// Action type being requested.
|
||||
/// </summary>
|
||||
public required string ActionType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable action label.
|
||||
/// </summary>
|
||||
public required string ActionLabel { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Action parameters.
|
||||
/// </summary>
|
||||
public required ImmutableDictionary<string, string> Parameters { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Associated run ID.
|
||||
/// </summary>
|
||||
public string? RunId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Associated finding ID.
|
||||
/// </summary>
|
||||
public string? FindingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy reason for requiring approval.
|
||||
/// </summary>
|
||||
public string? PolicyReason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Current status of an approval request.
|
||||
/// </summary>
|
||||
public sealed record ApprovalStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Request ID.
|
||||
/// </summary>
|
||||
public required string RequestId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current state.
|
||||
/// </summary>
|
||||
public required ApprovalState State { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Approvals received so far.
|
||||
/// </summary>
|
||||
public ImmutableArray<ApprovalEntry> Approvals { get; init; } =
|
||||
ImmutableArray<ApprovalEntry>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// When the request was created.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the state was last updated.
|
||||
/// </summary>
|
||||
public DateTimeOffset? UpdatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the request expires.
|
||||
/// </summary>
|
||||
public required DateTimeOffset ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// State of an approval request.
|
||||
/// </summary>
|
||||
public enum ApprovalState
|
||||
{
|
||||
/// <summary>
|
||||
/// Waiting for approvals.
|
||||
/// </summary>
|
||||
Pending,
|
||||
|
||||
/// <summary>
|
||||
/// All required approvals received.
|
||||
/// </summary>
|
||||
Approved,
|
||||
|
||||
/// <summary>
|
||||
/// Request was denied.
|
||||
/// </summary>
|
||||
Denied,
|
||||
|
||||
/// <summary>
|
||||
/// Request timed out.
|
||||
/// </summary>
|
||||
Expired,
|
||||
|
||||
/// <summary>
|
||||
/// Request was cancelled.
|
||||
/// </summary>
|
||||
Cancelled
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An individual approval entry.
|
||||
/// </summary>
|
||||
public sealed record ApprovalEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// User who approved/denied.
|
||||
/// </summary>
|
||||
public required string ApproverId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether they approved.
|
||||
/// </summary>
|
||||
public required bool Approved { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Comments from the approver.
|
||||
/// </summary>
|
||||
public string? Comments { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the decision was made.
|
||||
/// </summary>
|
||||
public required DateTimeOffset DecidedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of waiting for approval.
|
||||
/// </summary>
|
||||
public sealed record ApprovalResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the action was approved.
|
||||
/// </summary>
|
||||
public required bool Approved { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the request timed out.
|
||||
/// </summary>
|
||||
public bool TimedOut { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the request was cancelled.
|
||||
/// </summary>
|
||||
public bool Cancelled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// User who made the final decision.
|
||||
/// </summary>
|
||||
public string? ApproverId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the decision was made.
|
||||
/// </summary>
|
||||
public DateTimeOffset? DecidedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Comments from the approver.
|
||||
/// </summary>
|
||||
public string? Comments { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason for denial/cancellation.
|
||||
/// </summary>
|
||||
public string? DenialReason { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// <copyright file="IGuidGenerator.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction for GUID generation to enable deterministic testing.
|
||||
/// </summary>
|
||||
public interface IGuidGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates a new GUID.
|
||||
/// </summary>
|
||||
/// <returns>A new GUID.</returns>
|
||||
Guid NewGuid();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation using Guid.NewGuid().
|
||||
/// </summary>
|
||||
internal sealed class DefaultGuidGenerator : IGuidGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Singleton instance.
|
||||
/// </summary>
|
||||
public static readonly IGuidGenerator Instance = new DefaultGuidGenerator();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Guid NewGuid() => Guid.NewGuid();
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// <copyright file="IIdempotencyHandler.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// Handles idempotency checking for action execution.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-005
|
||||
/// </summary>
|
||||
public interface IIdempotencyHandler
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates a deterministic idempotency key for an action.
|
||||
/// </summary>
|
||||
/// <param name="proposal">The action proposal.</param>
|
||||
/// <param name="context">The action context.</param>
|
||||
/// <returns>The idempotency key.</returns>
|
||||
string GenerateKey(ActionProposal proposal, ActionContext context);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if an action was already executed.
|
||||
/// </summary>
|
||||
/// <param name="key">The idempotency key.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Check result with previous execution if found.</returns>
|
||||
Task<IdempotencyResult> CheckAsync(
|
||||
string key,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Records an action execution for idempotency.
|
||||
/// </summary>
|
||||
/// <param name="key">The idempotency key.</param>
|
||||
/// <param name="result">The execution result.</param>
|
||||
/// <param name="context">The action context.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task RecordExecutionAsync(
|
||||
string key,
|
||||
ActionExecutionResult result,
|
||||
ActionContext context,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Removes an idempotency record (for rollback scenarios).
|
||||
/// </summary>
|
||||
/// <param name="key">The idempotency key.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task RemoveAsync(
|
||||
string key,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of idempotency check.
|
||||
/// </summary>
|
||||
public sealed record IdempotencyResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the action was already executed.
|
||||
/// </summary>
|
||||
public required bool AlreadyExecuted { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Previous execution result if executed.
|
||||
/// </summary>
|
||||
public ActionExecutionResult? PreviousResult { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the action was previously executed.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ExecutedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// User who executed the action.
|
||||
/// </summary>
|
||||
public string? ExecutedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a result indicating no previous execution.
|
||||
/// </summary>
|
||||
public static IdempotencyResult NotExecuted => new() { AlreadyExecuted = false };
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
// <copyright file="IdempotencyHandler.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory idempotency handler for development and testing.
|
||||
/// In production, this would use PostgreSQL with TTL cleanup.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-005
|
||||
/// </summary>
|
||||
internal sealed class IdempotencyHandler : IIdempotencyHandler
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, IdempotencyRecord> _records = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IdempotencyOptions _options;
|
||||
private readonly ILogger<IdempotencyHandler> _logger;
|
||||
|
||||
public IdempotencyHandler(
|
||||
TimeProvider timeProvider,
|
||||
IOptions<IdempotencyOptions> options,
|
||||
ILogger<IdempotencyHandler> logger)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_options = options?.Value ?? new IdempotencyOptions();
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GenerateKey(ActionProposal proposal, ActionContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(proposal);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
// Key components: tenant, action type, target identifiers
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(context.TenantId);
|
||||
sb.Append('|');
|
||||
sb.Append(proposal.ActionType);
|
||||
|
||||
// Add target-specific components in sorted order for determinism
|
||||
var targets = new SortedDictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (proposal.Parameters.TryGetValue("cve_id", out var cveId) && !string.IsNullOrEmpty(cveId))
|
||||
{
|
||||
targets["cve"] = cveId;
|
||||
}
|
||||
|
||||
if (proposal.Parameters.TryGetValue("image_digest", out var digest) && !string.IsNullOrEmpty(digest))
|
||||
{
|
||||
targets["image"] = digest;
|
||||
}
|
||||
|
||||
if (proposal.Parameters.TryGetValue("finding_id", out var findingId) && !string.IsNullOrEmpty(findingId))
|
||||
{
|
||||
targets["finding"] = findingId;
|
||||
}
|
||||
|
||||
if (proposal.Parameters.TryGetValue("component", out var component) && !string.IsNullOrEmpty(component))
|
||||
{
|
||||
targets["component"] = component;
|
||||
}
|
||||
|
||||
// If using explicit idempotency key from proposal, include it
|
||||
if (!string.IsNullOrEmpty(proposal.IdempotencyKey))
|
||||
{
|
||||
targets["key"] = proposal.IdempotencyKey;
|
||||
}
|
||||
|
||||
foreach (var (key, value) in targets)
|
||||
{
|
||||
sb.Append('|');
|
||||
sb.Append(key);
|
||||
sb.Append(':');
|
||||
sb.Append(value);
|
||||
}
|
||||
|
||||
var content = sb.ToString();
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IdempotencyResult> CheckAsync(
|
||||
string key,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(key);
|
||||
|
||||
if (!_records.TryGetValue(key, out var record))
|
||||
{
|
||||
return Task.FromResult(IdempotencyResult.NotExecuted);
|
||||
}
|
||||
|
||||
// Check if record has expired
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
if (now >= record.ExpiresAt)
|
||||
{
|
||||
_records.TryRemove(key, out _);
|
||||
return Task.FromResult(IdempotencyResult.NotExecuted);
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Idempotency hit for key {Key}, previously executed at {ExecutedAt} by {ExecutedBy}",
|
||||
key, record.ExecutedAt, record.ExecutedBy);
|
||||
|
||||
return Task.FromResult(new IdempotencyResult
|
||||
{
|
||||
AlreadyExecuted = true,
|
||||
PreviousResult = record.Result,
|
||||
ExecutedAt = record.ExecutedAt,
|
||||
ExecutedBy = record.ExecutedBy
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RecordExecutionAsync(
|
||||
string key,
|
||||
ActionExecutionResult result,
|
||||
ActionContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(key);
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var record = new IdempotencyRecord
|
||||
{
|
||||
Key = key,
|
||||
Result = result,
|
||||
ExecutedAt = now,
|
||||
ExecutedBy = context.UserId,
|
||||
TenantId = context.TenantId,
|
||||
ExpiresAt = now.AddDays(_options.TtlDays)
|
||||
};
|
||||
|
||||
_records[key] = record;
|
||||
|
||||
_logger.LogDebug(
|
||||
"Recorded idempotency for key {Key}, expires at {ExpiresAt}",
|
||||
key, record.ExpiresAt.ToString("O", CultureInfo.InvariantCulture));
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RemoveAsync(
|
||||
string key,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_records.TryRemove(key, out _);
|
||||
|
||||
_logger.LogDebug("Removed idempotency record for key {Key}", key);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleans up expired records. Should be called periodically.
|
||||
/// </summary>
|
||||
public void CleanupExpired()
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var expiredKeys = _records
|
||||
.Where(kvp => now >= kvp.Value.ExpiresAt)
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToList();
|
||||
|
||||
foreach (var key in expiredKeys)
|
||||
{
|
||||
_records.TryRemove(key, out _);
|
||||
}
|
||||
|
||||
if (expiredKeys.Count > 0)
|
||||
{
|
||||
_logger.LogInformation("Cleaned up {Count} expired idempotency records", expiredKeys.Count);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record IdempotencyRecord
|
||||
{
|
||||
public required string Key { get; init; }
|
||||
public required ActionExecutionResult Result { get; init; }
|
||||
public required DateTimeOffset ExecutedAt { get; init; }
|
||||
public required string ExecutedBy { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required DateTimeOffset ExpiresAt { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for idempotency handling.
|
||||
/// </summary>
|
||||
public sealed class IdempotencyOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Days to retain idempotency records before expiration.
|
||||
/// </summary>
|
||||
public int TtlDays { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Whether idempotency checking is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
// <copyright file="AttestationIntegration.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.AdvisoryAI.Attestation;
|
||||
using StellaOps.AdvisoryAI.Attestation.Models;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Integrates AI attestation with the conversation service.
|
||||
/// Sprint: SPRINT_20260109_011_001 Task: AIAT-005
|
||||
/// </summary>
|
||||
public sealed class AttestationIntegration : IAttestationIntegration
|
||||
{
|
||||
private readonly IAiAttestationService _attestationService;
|
||||
private readonly IPromptTemplateRegistry _templateRegistry;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<AttestationIntegration> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AttestationIntegration"/> class.
|
||||
/// </summary>
|
||||
public AttestationIntegration(
|
||||
IAiAttestationService attestationService,
|
||||
IPromptTemplateRegistry templateRegistry,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<AttestationIntegration> logger)
|
||||
{
|
||||
_attestationService = attestationService ?? throw new ArgumentNullException(nameof(attestationService));
|
||||
_templateRegistry = templateRegistry ?? throw new ArgumentNullException(nameof(templateRegistry));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<AiAttestationResult?> AttestTurnAsync(
|
||||
string runId,
|
||||
string tenantId,
|
||||
ConversationTurn turn,
|
||||
GroundingResult? groundingResult,
|
||||
bool sign,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (turn.Role != TurnRole.Assistant)
|
||||
{
|
||||
// Only attest assistant (AI) turns
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Attesting turn {TurnId} for run {RunId}", turn.TurnId, runId);
|
||||
|
||||
try
|
||||
{
|
||||
// Extract claims from grounding result and convert to ClaimEvidence format
|
||||
var claims = groundingResult?.GroundedClaims
|
||||
.Select(c => new ClaimEvidence
|
||||
{
|
||||
Text = c.ClaimText,
|
||||
Position = c.Position,
|
||||
Length = c.Length,
|
||||
GroundingScore = c.Confidence,
|
||||
GroundedBy = c.EvidenceLinks
|
||||
.Select(e => e.ToString())
|
||||
.ToImmutableArray(),
|
||||
Verified = c.Confidence >= 0.7
|
||||
})
|
||||
.ToImmutableArray() ?? ImmutableArray<ClaimEvidence>.Empty;
|
||||
|
||||
// Create attestation for the first claim (representative of the turn)
|
||||
// In practice, you might create multiple claim attestations per turn
|
||||
var contentDigest = ComputeContentDigest(turn.Content);
|
||||
var claimText = turn.Content.Length > 500
|
||||
? turn.Content[..500] + "..."
|
||||
: turn.Content;
|
||||
|
||||
var claimAttestation = new AiClaimAttestation
|
||||
{
|
||||
ClaimId = $"{runId}:{turn.TurnId}:turn-claim",
|
||||
RunId = runId,
|
||||
TurnId = turn.TurnId,
|
||||
TenantId = tenantId,
|
||||
ClaimText = claimText,
|
||||
ClaimDigest = ComputeContentDigest(claimText),
|
||||
GroundedBy = claims.SelectMany(c => c.GroundedBy).Distinct().ToImmutableArray(),
|
||||
GroundingScore = groundingResult?.OverallScore ?? 0.0,
|
||||
Verified = groundingResult?.OverallScore >= 0.7,
|
||||
Timestamp = turn.Timestamp,
|
||||
ContentDigest = contentDigest
|
||||
};
|
||||
|
||||
var result = await _attestationService.CreateClaimAttestationAsync(
|
||||
claimAttestation,
|
||||
sign,
|
||||
ct);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created claim attestation {AttestationId} for turn {TurnId}",
|
||||
result.AttestationId, turn.TurnId);
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to create claim attestation for turn {TurnId}: {Message}",
|
||||
turn.TurnId, ex.Message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<AiAttestationResult?> AttestRunAsync(
|
||||
Conversation conversation,
|
||||
string runId,
|
||||
string promptTemplateName,
|
||||
AiModelInfo modelInfo,
|
||||
bool sign,
|
||||
CancellationToken ct)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Attesting run {RunId} for conversation {ConversationId}",
|
||||
runId, conversation.ConversationId);
|
||||
|
||||
try
|
||||
{
|
||||
// Get prompt template info
|
||||
var templateInfo = _templateRegistry.GetTemplateInfo(promptTemplateName);
|
||||
|
||||
// Collect all evidence URIs from turns
|
||||
var allEvidenceUris = conversation.Turns
|
||||
.SelectMany(t => t.EvidenceLinks)
|
||||
.Select(e => e.Uri)
|
||||
.Distinct()
|
||||
.ToImmutableArray();
|
||||
|
||||
// Build turn summaries with claims
|
||||
var turns = conversation.Turns
|
||||
.Select(t => new AiTurnSummary
|
||||
{
|
||||
TurnId = t.TurnId,
|
||||
Role = MapRole(t.Role),
|
||||
ContentDigest = ComputeContentDigest(t.Content),
|
||||
Timestamp = t.Timestamp,
|
||||
Claims = t.Role == TurnRole.Assistant
|
||||
? ImmutableArray.Create(new ClaimEvidence
|
||||
{
|
||||
Text = t.Content.Length > 200 ? t.Content[..200] + "..." : t.Content,
|
||||
Position = 0,
|
||||
Length = Math.Min(t.Content.Length, 200),
|
||||
GroundedBy = t.EvidenceLinks.Select(e => e.Uri).ToImmutableArray(),
|
||||
GroundingScore = 0.8,
|
||||
Verified = t.EvidenceLinks.Length > 0
|
||||
})
|
||||
: ImmutableArray<ClaimEvidence>.Empty
|
||||
})
|
||||
.ToImmutableArray();
|
||||
|
||||
// Build run context
|
||||
var context = new AiRunContext
|
||||
{
|
||||
FindingId = conversation.Context.ScanId,
|
||||
CveId = conversation.Context.CurrentCveId,
|
||||
Component = conversation.Context.CurrentComponent,
|
||||
ImageDigest = conversation.Context.CurrentImageDigest,
|
||||
PolicyId = conversation.Context.Policy?.PolicyIds.FirstOrDefault(),
|
||||
EvidenceUris = allEvidenceUris
|
||||
};
|
||||
|
||||
var runAttestation = new AiRunAttestation
|
||||
{
|
||||
RunId = runId,
|
||||
TenantId = conversation.TenantId,
|
||||
UserId = conversation.UserId ?? "unknown",
|
||||
ConversationId = conversation.ConversationId,
|
||||
StartedAt = conversation.CreatedAt,
|
||||
CompletedAt = _timeProvider.GetUtcNow(),
|
||||
Model = modelInfo,
|
||||
PromptTemplate = templateInfo,
|
||||
Context = context,
|
||||
Turns = turns,
|
||||
OverallGroundingScore = ComputeOverallGroundingScore(turns)
|
||||
};
|
||||
|
||||
var result = await _attestationService.CreateRunAttestationAsync(
|
||||
runAttestation,
|
||||
sign,
|
||||
ct);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created run attestation {AttestationId} for run {RunId} with {TurnCount} turns",
|
||||
result.AttestationId, runId, turns.Length);
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to create run attestation for {RunId}: {Message}",
|
||||
runId, ex.Message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<AiAttestationVerificationResult> VerifyRunAsync(
|
||||
string runId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
_logger.LogDebug("Verifying run attestation for {RunId}", runId);
|
||||
|
||||
var result = await _attestationService.VerifyRunAttestationAsync(runId, ct);
|
||||
|
||||
if (result.Valid)
|
||||
{
|
||||
_logger.LogInformation("Run {RunId} attestation verified successfully", runId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Run {RunId} attestation verification failed: {Reason}",
|
||||
runId, result.FailureReason);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string ComputeContentDigest(string content)
|
||||
{
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
|
||||
private static double ComputeOverallGroundingScore(ImmutableArray<AiTurnSummary> turns)
|
||||
{
|
||||
var assistantTurns = turns.Where(t => t.Role == Attestation.Models.TurnRole.Assistant).ToList();
|
||||
if (assistantTurns.Count == 0) return 0.0;
|
||||
|
||||
var avgScore = assistantTurns
|
||||
.Where(t => t.GroundingScore.HasValue)
|
||||
.DefaultIfEmpty()
|
||||
.Average(t => t?.GroundingScore ?? 0.0);
|
||||
|
||||
return avgScore;
|
||||
}
|
||||
|
||||
private static Attestation.Models.TurnRole MapRole(TurnRole role) => role switch
|
||||
{
|
||||
TurnRole.User => Attestation.Models.TurnRole.User,
|
||||
TurnRole.Assistant => Attestation.Models.TurnRole.Assistant,
|
||||
TurnRole.System => Attestation.Models.TurnRole.System,
|
||||
_ => Attestation.Models.TurnRole.System
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for attestation integration.
|
||||
/// </summary>
|
||||
public interface IAttestationIntegration
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates an attestation for a conversation turn.
|
||||
/// </summary>
|
||||
/// <param name="runId">The run identifier.</param>
|
||||
/// <param name="tenantId">The tenant identifier.</param>
|
||||
/// <param name="turn">The conversation turn.</param>
|
||||
/// <param name="groundingResult">The grounding validation result.</param>
|
||||
/// <param name="sign">Whether to sign the attestation.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The attestation result, or null if attestation is skipped.</returns>
|
||||
Task<AiAttestationResult?> AttestTurnAsync(
|
||||
string runId,
|
||||
string tenantId,
|
||||
ConversationTurn turn,
|
||||
GroundingResult? groundingResult,
|
||||
bool sign,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an attestation for a completed run.
|
||||
/// </summary>
|
||||
/// <param name="conversation">The conversation.</param>
|
||||
/// <param name="runId">The run identifier.</param>
|
||||
/// <param name="promptTemplateName">The prompt template name used.</param>
|
||||
/// <param name="modelInfo">The AI model information.</param>
|
||||
/// <param name="sign">Whether to sign the attestation.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The attestation result, or null if attestation fails.</returns>
|
||||
Task<AiAttestationResult?> AttestRunAsync(
|
||||
Conversation conversation,
|
||||
string runId,
|
||||
string promptTemplateName,
|
||||
AiModelInfo modelInfo,
|
||||
bool sign,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a run attestation.
|
||||
/// </summary>
|
||||
/// <param name="runId">The run identifier.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The verification result.</returns>
|
||||
Task<AiAttestationVerificationResult> VerifyRunAsync(
|
||||
string runId,
|
||||
CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of grounding validation for a turn.
|
||||
/// </summary>
|
||||
public sealed record GroundingResult
|
||||
{
|
||||
/// <summary>Overall grounding score (0.0-1.0).</summary>
|
||||
public required double OverallScore { get; init; }
|
||||
|
||||
/// <summary>Individual grounded claims.</summary>
|
||||
public ImmutableArray<GroundedClaim> GroundedClaims { get; init; } = ImmutableArray<GroundedClaim>.Empty;
|
||||
|
||||
/// <summary>Ungrounded claims (claims without evidence).</summary>
|
||||
public ImmutableArray<string> UngroundedClaims { get; init; } = ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A claim that has been grounded to evidence.
|
||||
/// </summary>
|
||||
public sealed record GroundedClaim
|
||||
{
|
||||
/// <summary>The claim text.</summary>
|
||||
public required string ClaimText { get; init; }
|
||||
|
||||
/// <summary>Position in the content.</summary>
|
||||
public required int Position { get; init; }
|
||||
|
||||
/// <summary>Length of the claim.</summary>
|
||||
public required int Length { get; init; }
|
||||
|
||||
/// <summary>Confidence score (0.0-1.0).</summary>
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
/// <summary>Evidence links supporting this claim.</summary>
|
||||
public ImmutableArray<Uri> EvidenceLinks { get; init; } = ImmutableArray<Uri>.Empty;
|
||||
}
|
||||
@@ -345,16 +345,31 @@ public sealed record ConversationContext
|
||||
/// </summary>
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the conversation topic.
|
||||
/// </summary>
|
||||
public string? Topic { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current CVE being discussed.
|
||||
/// </summary>
|
||||
public string? CurrentCveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the focused CVE ID (alias for CurrentCveId).
|
||||
/// </summary>
|
||||
public string? FocusedCveId => CurrentCveId;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current component PURL.
|
||||
/// </summary>
|
||||
public string? CurrentComponent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the focused component (alias for CurrentComponent).
|
||||
/// </summary>
|
||||
public string? FocusedComponent => CurrentComponent;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current image digest.
|
||||
/// </summary>
|
||||
@@ -370,6 +385,49 @@ public sealed record ConversationContext
|
||||
/// </summary>
|
||||
public string? SbomId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the finding ID in context.
|
||||
/// Sprint: SPRINT_20260109_011_005 Task: EVPK-006
|
||||
/// </summary>
|
||||
public string? FindingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the run ID in context.
|
||||
/// Sprint: SPRINT_20260109_011_005 Task: EVPK-006
|
||||
/// </summary>
|
||||
public string? RunId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the user ID.
|
||||
/// Sprint: SPRINT_20260109_011_005 Task: EVPK-006
|
||||
/// </summary>
|
||||
public string? UserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the vulnerability severity.
|
||||
/// </summary>
|
||||
public string? Severity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the vulnerability is reachable.
|
||||
/// </summary>
|
||||
public bool? IsReachable { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the CVSS score.
|
||||
/// </summary>
|
||||
public double? CvssScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the EPSS score.
|
||||
/// </summary>
|
||||
public double? EpssScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets context tags.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Tags { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets accumulated evidence links.
|
||||
/// </summary>
|
||||
@@ -413,11 +471,21 @@ public sealed record ConversationTurn
|
||||
/// </summary>
|
||||
public required string TurnId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the turn number in the conversation (1-based).
|
||||
/// </summary>
|
||||
public int TurnNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the role (user/assistant/system).
|
||||
/// </summary>
|
||||
public required TurnRole Role { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the actor identifier (user ID or system ID).
|
||||
/// </summary>
|
||||
public string? ActorId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the message content.
|
||||
/// </summary>
|
||||
@@ -536,11 +604,27 @@ public sealed record ProposedAction
|
||||
/// </summary>
|
||||
public required string Label { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the action subject (CVE, component, etc.).
|
||||
/// </summary>
|
||||
public string? Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the action rationale.
|
||||
/// </summary>
|
||||
public string? Rationale { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the action payload (JSON).
|
||||
/// </summary>
|
||||
public string? Payload { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets action parameters.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Parameters { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this action requires confirmation.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
// <copyright file="EvidencePackChatIntegration.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Evidence.Pack;
|
||||
using StellaOps.Evidence.Pack.Models;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Integrates Evidence Pack creation with chat grounding validation.
|
||||
/// Sprint: SPRINT_20260109_011_005 Task: EVPK-006
|
||||
/// </summary>
|
||||
public sealed class EvidencePackChatIntegration
|
||||
{
|
||||
private readonly IEvidencePackService _evidencePackService;
|
||||
private readonly ILogger<EvidencePackChatIntegration> _logger;
|
||||
private readonly EvidencePackChatOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="EvidencePackChatIntegration"/> class.
|
||||
/// </summary>
|
||||
public EvidencePackChatIntegration(
|
||||
IEvidencePackService evidencePackService,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<EvidencePackChatIntegration> logger,
|
||||
IOptions<EvidencePackChatOptions>? options = null)
|
||||
{
|
||||
_evidencePackService = evidencePackService ?? throw new ArgumentNullException(nameof(evidencePackService));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options?.Value ?? new EvidencePackChatOptions();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an Evidence Pack from a grounding validation result if conditions are met.
|
||||
/// </summary>
|
||||
/// <param name="grounding">The grounding validation result.</param>
|
||||
/// <param name="context">The conversation context.</param>
|
||||
/// <param name="conversationId">The conversation ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The created Evidence Pack, or null if conditions not met.</returns>
|
||||
public async Task<EvidencePack?> TryCreateFromGroundingAsync(
|
||||
GroundingValidationResult grounding,
|
||||
ConversationContext context,
|
||||
string conversationId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_options.AutoCreateEnabled)
|
||||
{
|
||||
_logger.LogDebug("Auto-create Evidence Packs disabled");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!grounding.IsAcceptable)
|
||||
{
|
||||
_logger.LogDebug("Grounding not acceptable (score {Score:F2}), skipping Evidence Pack creation",
|
||||
grounding.GroundingScore);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (grounding.GroundingScore < _options.MinGroundingScore)
|
||||
{
|
||||
_logger.LogDebug("Grounding score {Score:F2} below threshold {Threshold:F2}, skipping Evidence Pack creation",
|
||||
grounding.GroundingScore, _options.MinGroundingScore);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (grounding.ValidatedLinks.Length == 0)
|
||||
{
|
||||
_logger.LogDebug("No validated links in grounding result, skipping Evidence Pack creation");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build claims from grounded claims
|
||||
var claims = BuildClaimsFromGrounding(grounding);
|
||||
if (claims.Length == 0)
|
||||
{
|
||||
_logger.LogDebug("No claims extracted from grounding, skipping Evidence Pack creation");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build evidence items from validated links
|
||||
var evidence = BuildEvidenceFromLinks(grounding.ValidatedLinks);
|
||||
if (evidence.Length == 0)
|
||||
{
|
||||
_logger.LogDebug("No evidence items built from links, skipping Evidence Pack creation");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Determine subject
|
||||
var subject = BuildSubject(context);
|
||||
|
||||
// Create context
|
||||
var packContext = new EvidencePackContext
|
||||
{
|
||||
TenantId = context.TenantId,
|
||||
RunId = context.RunId,
|
||||
ConversationId = conversationId,
|
||||
UserId = context.UserId,
|
||||
GeneratedBy = "AdvisoryAI"
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var pack = await _evidencePackService.CreateAsync(
|
||||
claims.ToArray(),
|
||||
evidence.ToArray(),
|
||||
subject,
|
||||
packContext,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created Evidence Pack {PackId} from chat grounding (score {Score:F2}, {ClaimCount} claims, {EvidenceCount} evidence items)",
|
||||
pack.PackId, grounding.GroundingScore, claims.Length, evidence.Length);
|
||||
|
||||
return pack;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to create Evidence Pack from grounding result");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private ImmutableArray<EvidenceClaim> BuildClaimsFromGrounding(GroundingValidationResult grounding)
|
||||
{
|
||||
var claims = new List<EvidenceClaim>();
|
||||
var claimIndex = 0;
|
||||
|
||||
// Build claims from grounded claims (claims near valid links)
|
||||
var validLinkPositions = grounding.ValidatedLinks
|
||||
.Where(l => l.IsValid)
|
||||
.Select(l => l.Position)
|
||||
.ToHashSet();
|
||||
|
||||
// We need to extract claims that were considered grounded
|
||||
// These are claims that had a nearby link
|
||||
// For now, we infer from the structure - grounded claims = TotalClaims - UngroundedClaims
|
||||
// We'll create claims based on the validated links and their context
|
||||
|
||||
foreach (var link in grounding.ValidatedLinks.Where(l => l.IsValid))
|
||||
{
|
||||
var claimId = $"claim-{claimIndex++:D3}";
|
||||
var evidenceId = $"ev-{link.Type}-{claimIndex:D3}";
|
||||
|
||||
// Determine claim type based on link type
|
||||
var claimType = link.Type switch
|
||||
{
|
||||
"vex" => Evidence.Pack.Models.ClaimType.VulnerabilityStatus,
|
||||
"reach" or "runtime" => Evidence.Pack.Models.ClaimType.Reachability,
|
||||
"sbom" => Evidence.Pack.Models.ClaimType.VulnerabilityStatus,
|
||||
_ => Evidence.Pack.Models.ClaimType.Custom
|
||||
};
|
||||
|
||||
// Build claim text based on link context
|
||||
var claimText = link.Type switch
|
||||
{
|
||||
"vex" => $"VEX statement from {link.Path}",
|
||||
"reach" => $"Reachability analysis for {link.Path}",
|
||||
"runtime" => $"Runtime observation from {link.Path}",
|
||||
"sbom" => $"Component present in SBOM {link.Path}",
|
||||
"ops-mem" => $"OpsMemory context from {link.Path}",
|
||||
_ => $"Evidence from {link.Type}:{link.Path}"
|
||||
};
|
||||
|
||||
claims.Add(new EvidenceClaim
|
||||
{
|
||||
ClaimId = claimId,
|
||||
Text = claimText,
|
||||
Type = claimType,
|
||||
Status = "grounded",
|
||||
Confidence = grounding.GroundingScore,
|
||||
EvidenceIds = [evidenceId],
|
||||
Source = "ai"
|
||||
});
|
||||
}
|
||||
|
||||
return claims.ToImmutableArray();
|
||||
}
|
||||
|
||||
private ImmutableArray<EvidenceItem> BuildEvidenceFromLinks(ImmutableArray<ValidatedLink> validatedLinks)
|
||||
{
|
||||
var evidence = new List<EvidenceItem>();
|
||||
var evidenceIndex = 0;
|
||||
|
||||
foreach (var link in validatedLinks.Where(l => l.IsValid))
|
||||
{
|
||||
var evidenceId = $"ev-{link.Type}-{evidenceIndex++:D3}";
|
||||
|
||||
var evidenceType = link.Type switch
|
||||
{
|
||||
"sbom" => EvidenceType.Sbom,
|
||||
"vex" => EvidenceType.Vex,
|
||||
"reach" => EvidenceType.Reachability,
|
||||
"runtime" => EvidenceType.Runtime,
|
||||
"attest" => EvidenceType.Attestation,
|
||||
"ops-mem" => EvidenceType.OpsMemory,
|
||||
_ => EvidenceType.Custom
|
||||
};
|
||||
|
||||
var snapshot = CreateSnapshotForType(link.Type, link.Path, link.ResolvedUri);
|
||||
|
||||
evidence.Add(new EvidenceItem
|
||||
{
|
||||
EvidenceId = evidenceId,
|
||||
Type = evidenceType,
|
||||
Uri = link.ResolvedUri ?? $"stella://{link.Type}/{link.Path}",
|
||||
Digest = ComputeLinkDigest(link),
|
||||
CollectedAt = _timeProvider.GetUtcNow(),
|
||||
Snapshot = snapshot
|
||||
});
|
||||
}
|
||||
|
||||
return evidence.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static EvidenceSnapshot CreateSnapshotForType(string type, string path, string? resolvedUri)
|
||||
{
|
||||
return type switch
|
||||
{
|
||||
"sbom" => EvidenceSnapshot.Custom("sbom", new Dictionary<string, object?>
|
||||
{
|
||||
["path"] = path,
|
||||
["resolvedUri"] = resolvedUri,
|
||||
["source"] = "chat-grounding"
|
||||
}.ToImmutableDictionary()),
|
||||
|
||||
"vex" => EvidenceSnapshot.Custom("vex", new Dictionary<string, object?>
|
||||
{
|
||||
["path"] = path,
|
||||
["resolvedUri"] = resolvedUri,
|
||||
["source"] = "chat-grounding"
|
||||
}.ToImmutableDictionary()),
|
||||
|
||||
"reach" => EvidenceSnapshot.Custom("reachability", new Dictionary<string, object?>
|
||||
{
|
||||
["path"] = path,
|
||||
["resolvedUri"] = resolvedUri,
|
||||
["source"] = "chat-grounding"
|
||||
}.ToImmutableDictionary()),
|
||||
|
||||
"runtime" => EvidenceSnapshot.Custom("runtime", new Dictionary<string, object?>
|
||||
{
|
||||
["path"] = path,
|
||||
["resolvedUri"] = resolvedUri,
|
||||
["source"] = "chat-grounding"
|
||||
}.ToImmutableDictionary()),
|
||||
|
||||
_ => EvidenceSnapshot.Custom(type, new Dictionary<string, object?>
|
||||
{
|
||||
["path"] = path,
|
||||
["resolvedUri"] = resolvedUri,
|
||||
["source"] = "chat-grounding"
|
||||
}.ToImmutableDictionary())
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputeLinkDigest(ValidatedLink link)
|
||||
{
|
||||
var input = $"{link.Type}:{link.Path}:{link.ResolvedUri}";
|
||||
var hash = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input));
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static EvidenceSubject BuildSubject(ConversationContext context)
|
||||
{
|
||||
// Determine subject type based on available context
|
||||
if (!string.IsNullOrEmpty(context.FindingId))
|
||||
{
|
||||
return new EvidenceSubject
|
||||
{
|
||||
Type = EvidenceSubjectType.Finding,
|
||||
FindingId = context.FindingId,
|
||||
CveId = context.CurrentCveId,
|
||||
Component = context.CurrentComponent,
|
||||
ImageDigest = context.CurrentImageDigest
|
||||
};
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(context.CurrentCveId))
|
||||
{
|
||||
return new EvidenceSubject
|
||||
{
|
||||
Type = EvidenceSubjectType.Cve,
|
||||
CveId = context.CurrentCveId,
|
||||
Component = context.CurrentComponent,
|
||||
ImageDigest = context.CurrentImageDigest
|
||||
};
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(context.CurrentComponent))
|
||||
{
|
||||
return new EvidenceSubject
|
||||
{
|
||||
Type = EvidenceSubjectType.Component,
|
||||
Component = context.CurrentComponent,
|
||||
ImageDigest = context.CurrentImageDigest
|
||||
};
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(context.CurrentImageDigest))
|
||||
{
|
||||
return new EvidenceSubject
|
||||
{
|
||||
Type = EvidenceSubjectType.Image,
|
||||
ImageDigest = context.CurrentImageDigest
|
||||
};
|
||||
}
|
||||
|
||||
// Default to custom subject
|
||||
return new EvidenceSubject
|
||||
{
|
||||
Type = EvidenceSubjectType.Custom
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for Evidence Pack chat integration.
|
||||
/// </summary>
|
||||
public sealed class EvidencePackChatOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets whether to auto-create Evidence Packs.
|
||||
/// Default: true.
|
||||
/// </summary>
|
||||
public bool AutoCreateEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum grounding score for auto-creation.
|
||||
/// Default: 0.7.
|
||||
/// </summary>
|
||||
public double MinGroundingScore { get; set; } = 0.7;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to auto-sign created packs.
|
||||
/// Default: false.
|
||||
/// </summary>
|
||||
public bool AutoSign { get; set; }
|
||||
}
|
||||
@@ -341,7 +341,7 @@ public sealed partial class GroundingValidator
|
||||
return claim[..(maxLength - 3)] + "...";
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\[(?<type>sbom|reach|runtime|vex|attest|auth|docs):(?<path>[^\]]+)\]", RegexOptions.Compiled)]
|
||||
[GeneratedRegex(@"\[(?<type>sbom|reach|runtime|vex|attest|auth|docs|ops-mem):(?<path>[^\]]+)\]", RegexOptions.Compiled)]
|
||||
private static partial Regex ObjectLinkRegex();
|
||||
|
||||
[GeneratedRegex(@"(?:is|are|was|were|has been|have been)\s+(?:not\s+)?(?:affected|vulnerable|exploitable|fixed|patched|mitigated|under investigation)", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
|
||||
|
||||
294
src/AdvisoryAI/StellaOps.AdvisoryAI/Chat/OpsMemoryIntegration.cs
Normal file
294
src/AdvisoryAI/StellaOps.AdvisoryAI/Chat/OpsMemoryIntegration.cs
Normal file
@@ -0,0 +1,294 @@
|
||||
// <copyright file="OpsMemoryIntegration.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.OpsMemory.Integration;
|
||||
using StellaOps.OpsMemory.Models;
|
||||
using OpsMemoryConversationContext = StellaOps.OpsMemory.Integration.ConversationContext;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Integrates OpsMemory with AdvisoryAI chat sessions.
|
||||
/// Enables surfacing past decisions and recording new decisions from chat actions.
|
||||
/// Sprint: SPRINT_20260109_011_002_BE Task: OMCI-004
|
||||
/// </summary>
|
||||
public sealed class OpsMemoryIntegration : IOpsMemoryIntegration
|
||||
{
|
||||
private readonly IOpsMemoryChatProvider _opsMemoryProvider;
|
||||
private readonly OpsMemoryContextEnricher _contextEnricher;
|
||||
private readonly ILogger<OpsMemoryIntegration> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new OpsMemoryIntegration.
|
||||
/// </summary>
|
||||
public OpsMemoryIntegration(
|
||||
IOpsMemoryChatProvider opsMemoryProvider,
|
||||
OpsMemoryContextEnricher contextEnricher,
|
||||
ILogger<OpsMemoryIntegration> logger)
|
||||
{
|
||||
_opsMemoryProvider = opsMemoryProvider ?? throw new ArgumentNullException(nameof(opsMemoryProvider));
|
||||
_contextEnricher = contextEnricher ?? throw new ArgumentNullException(nameof(contextEnricher));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<OpsMemoryEnrichmentResult> EnrichConversationContextAsync(
|
||||
ConversationContext context,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Enriching conversation context with OpsMemory for tenant {TenantId}",
|
||||
tenantId);
|
||||
|
||||
// Build chat context request from conversation context
|
||||
var request = BuildChatContextRequest(context, tenantId);
|
||||
|
||||
// Enrich prompt with OpsMemory context
|
||||
var enrichedPrompt = await _contextEnricher.EnrichPromptAsync(
|
||||
request,
|
||||
existingPrompt: null,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new OpsMemoryEnrichmentResult
|
||||
{
|
||||
SystemPromptAddition = enrichedPrompt.SystemPromptAddition ?? string.Empty,
|
||||
ContextBlock = enrichedPrompt.EnrichedPrompt,
|
||||
ReferencedMemoryIds = enrichedPrompt.DecisionsReferenced,
|
||||
HasEnrichment = enrichedPrompt.HasEnrichment,
|
||||
SimilarDecisionCount = enrichedPrompt.Context.SimilarDecisions.Length,
|
||||
ApplicableTacticCount = enrichedPrompt.Context.ApplicableTactics.Length
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<OpsMemoryRecord?> RecordDecisionFromActionAsync(
|
||||
ProposedAction action,
|
||||
Conversation conversation,
|
||||
ConversationTurn turn,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!ShouldRecordAction(action))
|
||||
{
|
||||
_logger.LogDebug("Skipping non-decision action: {ActionType}", action.ActionType);
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Recording decision from chat action: {ActionType} for {Subject}",
|
||||
action.ActionType, action.Subject ?? "(unknown)");
|
||||
|
||||
// Build action execution result
|
||||
var actionResult = new ActionExecutionResult
|
||||
{
|
||||
Action = MapActionType(action.ActionType),
|
||||
CveId = ExtractCveId(action),
|
||||
Component = ExtractComponent(action),
|
||||
Success = true, // Assuming executed action was successful
|
||||
Rationale = action.Rationale ?? turn.Content,
|
||||
ExecutedAt = turn.Timestamp,
|
||||
ActorId = turn.ActorId ?? "system",
|
||||
Metadata = action.Parameters
|
||||
};
|
||||
|
||||
// Build conversation context for OpsMemory
|
||||
var opsMemoryConversationContext = new OpsMemoryConversationContext
|
||||
{
|
||||
ConversationId = conversation.ConversationId,
|
||||
TenantId = conversation.TenantId,
|
||||
UserId = conversation.UserId ?? "unknown",
|
||||
Topic = conversation.Context.Topic,
|
||||
TurnNumber = turn.TurnNumber,
|
||||
Situation = ExtractSituation(conversation.Context),
|
||||
EvidenceLinks = turn.EvidenceLinks.Select(e => e.Uri).ToImmutableArray()
|
||||
};
|
||||
|
||||
// Record to OpsMemory
|
||||
var record = await _opsMemoryProvider.RecordFromActionAsync(
|
||||
actionResult,
|
||||
opsMemoryConversationContext,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created OpsMemory record {MemoryId} from conversation {ConversationId}",
|
||||
record.MemoryId, conversation.ConversationId);
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<PastDecisionSummary>> GetRecentDecisionsForContextAsync(
|
||||
string tenantId,
|
||||
int limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await _opsMemoryProvider.GetRecentDecisionsAsync(tenantId, limit, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static ChatContextRequest BuildChatContextRequest(
|
||||
ConversationContext context,
|
||||
string tenantId)
|
||||
{
|
||||
return new ChatContextRequest
|
||||
{
|
||||
TenantId = tenantId,
|
||||
CveId = context.FocusedCveId,
|
||||
Component = context.FocusedComponent,
|
||||
Severity = context.Severity,
|
||||
Reachability = context.IsReachable.HasValue
|
||||
? (context.IsReachable.Value ? ReachabilityStatus.Reachable : ReachabilityStatus.NotReachable)
|
||||
: null,
|
||||
CvssScore = context.CvssScore,
|
||||
EpssScore = context.EpssScore,
|
||||
ContextTags = context.Tags,
|
||||
MaxSuggestions = 3,
|
||||
MinSimilarity = 0.6
|
||||
};
|
||||
}
|
||||
|
||||
private static bool ShouldRecordAction(ProposedAction action)
|
||||
{
|
||||
// Only record security decision actions
|
||||
var recordableActions = new[]
|
||||
{
|
||||
"accept_risk", "suppress", "quarantine", "remediate", "defer",
|
||||
"approve", "reject", "escalate", "mitigate", "monitor"
|
||||
};
|
||||
|
||||
return recordableActions.Contains(action.ActionType, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static DecisionAction MapActionType(string actionType)
|
||||
{
|
||||
// Map action types to OpsMemory.Models.DecisionAction enum values
|
||||
return actionType.ToUpperInvariant() switch
|
||||
{
|
||||
"ACCEPT_RISK" or "ACCEPT" or "SUPPRESS" or "APPROVE" => DecisionAction.Accept,
|
||||
"QUARANTINE" => DecisionAction.Quarantine,
|
||||
"REMEDIATE" or "FIX" => DecisionAction.Remediate,
|
||||
"DEFER" or "POSTPONE" or "MONITOR" => DecisionAction.Defer,
|
||||
"REJECT" or "FALSE_POSITIVE" => DecisionAction.FalsePositive,
|
||||
"ESCALATE" => DecisionAction.Escalate,
|
||||
"MITIGATE" => DecisionAction.Mitigate,
|
||||
_ => DecisionAction.Defer // Default to Defer for unknown actions
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ExtractCveId(ProposedAction action)
|
||||
{
|
||||
if (action.Parameters.TryGetValue("cve_id", out var cveId))
|
||||
return cveId;
|
||||
|
||||
if (action.Parameters.TryGetValue("cveId", out cveId))
|
||||
return cveId;
|
||||
|
||||
if (action.Subject?.StartsWith("CVE-", StringComparison.OrdinalIgnoreCase) == true)
|
||||
return action.Subject;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? ExtractComponent(ProposedAction action)
|
||||
{
|
||||
if (action.Parameters.TryGetValue("component", out var component))
|
||||
return component;
|
||||
|
||||
if (action.Parameters.TryGetValue("purl", out var purl))
|
||||
return purl;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static SituationContext? ExtractSituation(ConversationContext context)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(context.FocusedCveId) &&
|
||||
string.IsNullOrWhiteSpace(context.FocusedComponent))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SituationContext
|
||||
{
|
||||
CveId = context.FocusedCveId,
|
||||
Component = context.FocusedComponent,
|
||||
Severity = context.Severity,
|
||||
Reachability = context.IsReachable.HasValue
|
||||
? (context.IsReachable.Value ? ReachabilityStatus.Reachable : ReachabilityStatus.NotReachable)
|
||||
: ReachabilityStatus.Unknown,
|
||||
CvssScore = context.CvssScore,
|
||||
EpssScore = context.EpssScore,
|
||||
ContextTags = context.Tags
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for OpsMemory integration with AdvisoryAI.
|
||||
/// </summary>
|
||||
public interface IOpsMemoryIntegration
|
||||
{
|
||||
/// <summary>
|
||||
/// Enriches conversation context with OpsMemory data.
|
||||
/// </summary>
|
||||
Task<OpsMemoryEnrichmentResult> EnrichConversationContextAsync(
|
||||
ConversationContext context,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Records a decision from an executed chat action to OpsMemory.
|
||||
/// </summary>
|
||||
Task<OpsMemoryRecord?> RecordDecisionFromActionAsync(
|
||||
ProposedAction action,
|
||||
Conversation conversation,
|
||||
ConversationTurn turn,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets recent decisions for context.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<PastDecisionSummary>> GetRecentDecisionsForContextAsync(
|
||||
string tenantId,
|
||||
int limit,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of OpsMemory enrichment for conversation.
|
||||
/// </summary>
|
||||
public sealed record OpsMemoryEnrichmentResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the system prompt addition.
|
||||
/// </summary>
|
||||
public required string SystemPromptAddition { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the context block to include in the conversation.
|
||||
/// </summary>
|
||||
public required string ContextBlock { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the memory IDs referenced.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> ReferencedMemoryIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether any enrichment was added.
|
||||
/// </summary>
|
||||
public bool HasEnrichment { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of similar decisions found.
|
||||
/// </summary>
|
||||
public int SimilarDecisionCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of applicable tactics found.
|
||||
/// </summary>
|
||||
public int ApplicableTacticCount { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// <copyright file="OpsMemoryLinkResolver.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.OpsMemory.Storage;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves ops-mem:// object links to OpsMemory records.
|
||||
/// Sprint: SPRINT_20260109_011_002_BE Task: OMCI-005
|
||||
/// </summary>
|
||||
public sealed class OpsMemoryLinkResolver : ITypedLinkResolver
|
||||
{
|
||||
private readonly IOpsMemoryStore _store;
|
||||
private readonly ILogger<OpsMemoryLinkResolver> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="OpsMemoryLinkResolver"/> class.
|
||||
/// </summary>
|
||||
public OpsMemoryLinkResolver(
|
||||
IOpsMemoryStore store,
|
||||
ILogger<OpsMemoryLinkResolver> logger)
|
||||
{
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the link type this resolver handles.
|
||||
/// </summary>
|
||||
public string LinkType => "ops-mem";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<LinkResolution> ResolveAsync(
|
||||
string path,
|
||||
string? tenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
_logger.LogWarning("Cannot resolve ops-mem link without tenant ID");
|
||||
return new LinkResolution { Exists = false };
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var record = await _store.GetByIdAsync(path, tenantId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (record is null)
|
||||
{
|
||||
_logger.LogDebug("OpsMemory record not found: {MemoryId}", path);
|
||||
return new LinkResolution { Exists = false };
|
||||
}
|
||||
|
||||
return new LinkResolution
|
||||
{
|
||||
Exists = true,
|
||||
Uri = $"ops-mem://{path}",
|
||||
ObjectType = "decision",
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["cveId"] = record.Situation?.CveId ?? string.Empty,
|
||||
["action"] = record.Decision?.Action.ToString() ?? string.Empty,
|
||||
["outcome"] = record.Outcome?.Status.ToString() ?? "pending",
|
||||
["decidedAt"] = record.RecordedAt.ToString("O")
|
||||
}.ToImmutableDictionary()
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error resolving ops-mem link: {Path}", path);
|
||||
return new LinkResolution { Exists = false };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for type-specific link resolvers.
|
||||
/// </summary>
|
||||
public interface ITypedLinkResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the link type this resolver handles.
|
||||
/// </summary>
|
||||
string LinkType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a link of this type.
|
||||
/// </summary>
|
||||
Task<LinkResolution> ResolveAsync(
|
||||
string path,
|
||||
string? tenantId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Composite link resolver that delegates to type-specific resolvers.
|
||||
/// Sprint: SPRINT_20260109_011_002_BE Task: OMCI-005
|
||||
/// </summary>
|
||||
public sealed class CompositeObjectLinkResolver : IObjectLinkResolver
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, ITypedLinkResolver> _resolvers;
|
||||
private readonly ILogger<CompositeObjectLinkResolver> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CompositeObjectLinkResolver"/> class.
|
||||
/// </summary>
|
||||
public CompositeObjectLinkResolver(
|
||||
IEnumerable<ITypedLinkResolver> resolvers,
|
||||
ILogger<CompositeObjectLinkResolver> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(resolvers);
|
||||
|
||||
_resolvers = resolvers.ToDictionary(
|
||||
r => r.LinkType,
|
||||
r => r,
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<LinkResolution> ResolveAsync(
|
||||
string type,
|
||||
string path,
|
||||
string? tenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_resolvers.TryGetValue(type, out var resolver))
|
||||
{
|
||||
_logger.LogDebug("No resolver registered for link type: {Type}", type);
|
||||
return new LinkResolution { Exists = false };
|
||||
}
|
||||
|
||||
return await resolver.ResolveAsync(path, tenantId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// <copyright file="ActionsServiceCollectionExtensions.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.AdvisoryAI.Actions;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering action-related services.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE
|
||||
/// </summary>
|
||||
public static class ActionsServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds action policy integration services to the service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configurePolicy">Optional policy configuration.</param>
|
||||
/// <param name="configureIdempotency">Optional idempotency configuration.</param>
|
||||
/// <param name="configureAudit">Optional audit configuration.</param>
|
||||
/// <param name="configureExecutor">Optional executor configuration.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddActionPolicyIntegration(
|
||||
this IServiceCollection services,
|
||||
Action<ActionPolicyOptions>? configurePolicy = null,
|
||||
Action<IdempotencyOptions>? configureIdempotency = null,
|
||||
Action<AuditLedgerOptions>? configureAudit = null,
|
||||
Action<ActionExecutorOptions>? configureExecutor = null)
|
||||
{
|
||||
// Configure options
|
||||
if (configurePolicy is not null)
|
||||
{
|
||||
services.Configure(configurePolicy);
|
||||
}
|
||||
|
||||
if (configureIdempotency is not null)
|
||||
{
|
||||
services.Configure(configureIdempotency);
|
||||
}
|
||||
|
||||
if (configureAudit is not null)
|
||||
{
|
||||
services.Configure(configureAudit);
|
||||
}
|
||||
|
||||
if (configureExecutor is not null)
|
||||
{
|
||||
services.Configure(configureExecutor);
|
||||
}
|
||||
|
||||
// Register core services
|
||||
services.TryAddSingleton<IActionRegistry, ActionRegistry>();
|
||||
services.TryAddSingleton<IGuidGenerator, DefaultGuidGenerator>();
|
||||
|
||||
// Register policy gate
|
||||
services.TryAddScoped<IActionPolicyGate, ActionPolicyGate>();
|
||||
|
||||
// Register idempotency handler
|
||||
services.TryAddSingleton<IIdempotencyHandler, IdempotencyHandler>();
|
||||
|
||||
// Register approval workflow adapter
|
||||
services.TryAddSingleton<IApprovalWorkflowAdapter, ApprovalWorkflowAdapter>();
|
||||
|
||||
// Register audit ledger
|
||||
services.TryAddSingleton<IActionAuditLedger, ActionAuditLedger>();
|
||||
|
||||
// Register action executor
|
||||
services.TryAddScoped<IActionExecutor, ActionExecutor>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds action policy integration with default configuration.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddDefaultActionPolicyIntegration(
|
||||
this IServiceCollection services)
|
||||
{
|
||||
return services.AddActionPolicyIntegration();
|
||||
}
|
||||
}
|
||||
429
src/AdvisoryAI/StellaOps.AdvisoryAI/Runs/IRunService.cs
Normal file
429
src/AdvisoryAI/StellaOps.AdvisoryAI/Runs/IRunService.cs
Normal file
@@ -0,0 +1,429 @@
|
||||
// <copyright file="IRunService.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Runs;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing AI investigation runs.
|
||||
/// Sprint: SPRINT_20260109_011_003_BE Task: RUN-002
|
||||
/// </summary>
|
||||
public interface IRunService
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new run.
|
||||
/// </summary>
|
||||
/// <param name="request">The create request.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The created run.</returns>
|
||||
Task<Run> CreateAsync(CreateRunRequest request, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a run by ID.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The run, or null if not found.</returns>
|
||||
Task<Run?> GetAsync(string tenantId, string runId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Queries runs.
|
||||
/// </summary>
|
||||
/// <param name="query">The query parameters.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The matching runs.</returns>
|
||||
Task<RunQueryResult> QueryAsync(RunQuery query, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Adds an event to a run.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="eventRequest">The event to add.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The added event.</returns>
|
||||
Task<RunEvent> AddEventAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
AddRunEventRequest eventRequest,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a user turn to the run.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="message">The user message.</param>
|
||||
/// <param name="userId">The user ID.</param>
|
||||
/// <param name="evidenceLinks">Optional evidence links.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The added event.</returns>
|
||||
Task<RunEvent> AddUserTurnAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string message,
|
||||
string userId,
|
||||
ImmutableArray<EvidenceLink>? evidenceLinks = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Adds an assistant turn to the run.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="message">The assistant message.</param>
|
||||
/// <param name="evidenceLinks">Optional evidence links.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The added event.</returns>
|
||||
Task<RunEvent> AddAssistantTurnAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string message,
|
||||
ImmutableArray<EvidenceLink>? evidenceLinks = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Proposes an action in the run.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="action">The proposed action.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The action proposed event.</returns>
|
||||
Task<RunEvent> ProposeActionAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
ProposeActionRequest action,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Requests approval for pending actions.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="approvers">The approver user IDs.</param>
|
||||
/// <param name="reason">The reason for approval request.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The updated run.</returns>
|
||||
Task<Run> RequestApprovalAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
ImmutableArray<string> approvers,
|
||||
string? reason = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Approves or rejects a run.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="approved">Whether to approve or reject.</param>
|
||||
/// <param name="approverId">The approver's user ID.</param>
|
||||
/// <param name="reason">The approval/rejection reason.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The updated run.</returns>
|
||||
Task<Run> ApproveAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
bool approved,
|
||||
string approverId,
|
||||
string? reason = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Executes an approved action.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="actionEventId">The action event ID to execute.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The action executed event.</returns>
|
||||
Task<RunEvent> ExecuteActionAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string actionEventId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Adds an artifact to the run.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="artifact">The artifact to add.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The updated run.</returns>
|
||||
Task<Run> AddArtifactAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
RunArtifact artifact,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Attaches an evidence pack to the run.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="evidencePack">The evidence pack reference.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The updated run.</returns>
|
||||
Task<Run> AttachEvidencePackAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
EvidencePackReference evidencePack,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Completes a run.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="summary">Optional completion summary.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The completed run.</returns>
|
||||
Task<Run> CompleteAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string? summary = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Cancels a run.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="reason">The cancellation reason.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The cancelled run.</returns>
|
||||
Task<Run> CancelAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string? reason = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Hands off a run to another user.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="toUserId">The user to hand off to.</param>
|
||||
/// <param name="message">Optional handoff message.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The updated run.</returns>
|
||||
Task<Run> HandOffAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string toUserId,
|
||||
string? message = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an attestation for a completed run.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The attested run.</returns>
|
||||
Task<Run> AttestAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timeline for a run.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="skip">Number of events to skip.</param>
|
||||
/// <param name="take">Number of events to take.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The timeline events.</returns>
|
||||
Task<ImmutableArray<RunEvent>> GetTimelineAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
int skip = 0,
|
||||
int take = 100,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a new run.
|
||||
/// </summary>
|
||||
public sealed record CreateRunRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the tenant ID.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the initiating user ID.
|
||||
/// </summary>
|
||||
public required string InitiatedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the run title.
|
||||
/// </summary>
|
||||
public required string Title { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the run objective.
|
||||
/// </summary>
|
||||
public string? Objective { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the initial context.
|
||||
/// </summary>
|
||||
public RunContext? Context { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets additional metadata.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to add a run event.
|
||||
/// </summary>
|
||||
public sealed record AddRunEventRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the event type.
|
||||
/// </summary>
|
||||
public required RunEventType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the actor ID.
|
||||
/// </summary>
|
||||
public string? ActorId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the event content.
|
||||
/// </summary>
|
||||
public RunEventContent? Content { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets evidence links.
|
||||
/// </summary>
|
||||
public ImmutableArray<EvidenceLink>? EvidenceLinks { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the parent event ID.
|
||||
/// </summary>
|
||||
public string? ParentEventId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets event metadata.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to propose an action.
|
||||
/// </summary>
|
||||
public sealed record ProposeActionRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the action type.
|
||||
/// </summary>
|
||||
public required string ActionType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the action subject.
|
||||
/// </summary>
|
||||
public string? Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the rationale.
|
||||
/// </summary>
|
||||
public string? Rationale { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether approval is required.
|
||||
/// </summary>
|
||||
public bool RequiresApproval { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets action parameters.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string>? Parameters { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets evidence links.
|
||||
/// </summary>
|
||||
public ImmutableArray<EvidenceLink>? EvidenceLinks { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query parameters for runs.
|
||||
/// </summary>
|
||||
public sealed record RunQuery
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the tenant ID.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets optional status filter.
|
||||
/// </summary>
|
||||
public ImmutableArray<RunStatus>? Statuses { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets optional initiator filter.
|
||||
/// </summary>
|
||||
public string? InitiatedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets optional CVE filter.
|
||||
/// </summary>
|
||||
public string? CveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets optional component filter.
|
||||
/// </summary>
|
||||
public string? Component { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets optional created after filter.
|
||||
/// </summary>
|
||||
public DateTimeOffset? CreatedAfter { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets optional created before filter.
|
||||
/// </summary>
|
||||
public DateTimeOffset? CreatedBefore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number to skip.
|
||||
/// </summary>
|
||||
public int Skip { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number to take.
|
||||
/// </summary>
|
||||
public int Take { get; init; } = 20;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a run query.
|
||||
/// </summary>
|
||||
public sealed record RunQueryResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the matching runs.
|
||||
/// </summary>
|
||||
public required ImmutableArray<Run> Runs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total count.
|
||||
/// </summary>
|
||||
public required int TotalCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether there are more results.
|
||||
/// </summary>
|
||||
public bool HasMore { get; init; }
|
||||
}
|
||||
104
src/AdvisoryAI/StellaOps.AdvisoryAI/Runs/IRunStore.cs
Normal file
104
src/AdvisoryAI/StellaOps.AdvisoryAI/Runs/IRunStore.cs
Normal file
@@ -0,0 +1,104 @@
|
||||
// <copyright file="IRunStore.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Runs;
|
||||
|
||||
/// <summary>
|
||||
/// Store for persisting runs.
|
||||
/// Sprint: SPRINT_20260109_011_003_BE Task: RUN-004
|
||||
/// </summary>
|
||||
public interface IRunStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Saves a run.
|
||||
/// </summary>
|
||||
/// <param name="run">The run to save.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task representing the async operation.</returns>
|
||||
Task SaveAsync(Run run, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a run by ID.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The run, or null if not found.</returns>
|
||||
Task<Run?> GetAsync(string tenantId, string runId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Queries runs.
|
||||
/// </summary>
|
||||
/// <param name="query">The query parameters.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The matching runs and total count.</returns>
|
||||
Task<(ImmutableArray<Run> Runs, int TotalCount)> QueryAsync(
|
||||
RunQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a run.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if deleted, false if not found.</returns>
|
||||
Task<bool> DeleteAsync(string tenantId, string runId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets runs by status.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="statuses">The statuses to filter by.</param>
|
||||
/// <param name="skip">Number to skip.</param>
|
||||
/// <param name="take">Number to take.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The matching runs.</returns>
|
||||
Task<ImmutableArray<Run>> GetByStatusAsync(
|
||||
string tenantId,
|
||||
ImmutableArray<RunStatus> statuses,
|
||||
int skip = 0,
|
||||
int take = 100,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets active runs for a user.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="userId">The user ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The user's active runs.</returns>
|
||||
Task<ImmutableArray<Run>> GetActiveForUserAsync(
|
||||
string tenantId,
|
||||
string userId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets runs pending approval.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="approverId">Optional approver filter.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Runs pending approval.</returns>
|
||||
Task<ImmutableArray<Run>> GetPendingApprovalAsync(
|
||||
string tenantId,
|
||||
string? approverId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates run status.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="newStatus">The new status.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if updated, false if not found.</returns>
|
||||
Task<bool> UpdateStatusAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
RunStatus newStatus,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
161
src/AdvisoryAI/StellaOps.AdvisoryAI/Runs/InMemoryRunStore.cs
Normal file
161
src/AdvisoryAI/StellaOps.AdvisoryAI/Runs/InMemoryRunStore.cs
Normal file
@@ -0,0 +1,161 @@
|
||||
// <copyright file="InMemoryRunStore.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Runs;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IRunStore"/> for development/testing.
|
||||
/// Sprint: SPRINT_20260109_011_003_BE Task: RUN-005
|
||||
/// </summary>
|
||||
public sealed class InMemoryRunStore : IRunStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<(string TenantId, string RunId), Run> _runs = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SaveAsync(Run run, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(run);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
_runs[(run.TenantId, run.RunId)] = run;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Run?> GetAsync(string tenantId, string runId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
_runs.TryGetValue((tenantId, runId), out var run);
|
||||
return Task.FromResult(run);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<(ImmutableArray<Run> Runs, int TotalCount)> QueryAsync(
|
||||
RunQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var runs = _runs.Values
|
||||
.Where(r => r.TenantId == query.TenantId)
|
||||
.Where(r => query.Statuses is null || query.Statuses.Value.Contains(r.Status))
|
||||
.Where(r => query.InitiatedBy is null || r.InitiatedBy == query.InitiatedBy)
|
||||
.Where(r => query.CveId is null || r.Context.FocusedCveId == query.CveId)
|
||||
.Where(r => query.Component is null || r.Context.FocusedComponent == query.Component)
|
||||
.Where(r => query.CreatedAfter is null || r.CreatedAt >= query.CreatedAfter)
|
||||
.Where(r => query.CreatedBefore is null || r.CreatedAt <= query.CreatedBefore)
|
||||
.OrderByDescending(r => r.CreatedAt)
|
||||
.ToList();
|
||||
|
||||
var totalCount = runs.Count;
|
||||
var pagedRuns = runs
|
||||
.Skip(query.Skip)
|
||||
.Take(query.Take)
|
||||
.ToImmutableArray();
|
||||
|
||||
return Task.FromResult((pagedRuns, totalCount));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> DeleteAsync(string tenantId, string runId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
return Task.FromResult(_runs.TryRemove((tenantId, runId), out _));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ImmutableArray<Run>> GetByStatusAsync(
|
||||
string tenantId,
|
||||
ImmutableArray<RunStatus> statuses,
|
||||
int skip = 0,
|
||||
int take = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var runs = _runs.Values
|
||||
.Where(r => r.TenantId == tenantId && statuses.Contains(r.Status))
|
||||
.OrderByDescending(r => r.CreatedAt)
|
||||
.Skip(skip)
|
||||
.Take(take)
|
||||
.ToImmutableArray();
|
||||
|
||||
return Task.FromResult(runs);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ImmutableArray<Run>> GetActiveForUserAsync(
|
||||
string tenantId,
|
||||
string userId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var activeStatuses = new[] { RunStatus.Created, RunStatus.Active, RunStatus.PendingApproval };
|
||||
|
||||
var runs = _runs.Values
|
||||
.Where(r => r.TenantId == tenantId)
|
||||
.Where(r => r.InitiatedBy == userId || r.Metadata.GetValueOrDefault("current_owner") == userId)
|
||||
.Where(r => activeStatuses.Contains(r.Status))
|
||||
.OrderByDescending(r => r.UpdatedAt)
|
||||
.ToImmutableArray();
|
||||
|
||||
return Task.FromResult(runs);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ImmutableArray<Run>> GetPendingApprovalAsync(
|
||||
string tenantId,
|
||||
string? approverId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var runs = _runs.Values
|
||||
.Where(r => r.TenantId == tenantId && r.Status == RunStatus.PendingApproval)
|
||||
.Where(r => approverId is null || (r.Approval?.Approvers.Contains(approverId) ?? false))
|
||||
.OrderByDescending(r => r.UpdatedAt)
|
||||
.ToImmutableArray();
|
||||
|
||||
return Task.FromResult(runs);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> UpdateStatusAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
RunStatus newStatus,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!_runs.TryGetValue((tenantId, runId), out var run))
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
var updated = run with { Status = newStatus };
|
||||
_runs[(tenantId, runId)] = updated;
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all runs (for testing).
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
_runs.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of runs (for testing).
|
||||
/// </summary>
|
||||
public int Count => _runs.Count;
|
||||
}
|
||||
278
src/AdvisoryAI/StellaOps.AdvisoryAI/Runs/Models/Run.cs
Normal file
278
src/AdvisoryAI/StellaOps.AdvisoryAI/Runs/Models/Run.cs
Normal file
@@ -0,0 +1,278 @@
|
||||
// <copyright file="Run.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Runs;
|
||||
|
||||
/// <summary>
|
||||
/// An auditable container for an AI-assisted investigation session.
|
||||
/// Captures the complete lifecycle from initial query through tool calls,
|
||||
/// artifact generation, and approvals.
|
||||
/// Sprint: SPRINT_20260109_011_003_BE Task: RUN-001
|
||||
/// </summary>
|
||||
public sealed record Run
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the unique run identifier.
|
||||
/// Format: run-{timestamp}-{random}
|
||||
/// </summary>
|
||||
public required string RunId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tenant ID for multi-tenancy isolation.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the user who initiated the run.
|
||||
/// </summary>
|
||||
public required string InitiatedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the run title (user-provided or auto-generated).
|
||||
/// </summary>
|
||||
public required string Title { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the run objective/goal.
|
||||
/// </summary>
|
||||
public string? Objective { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current run status.
|
||||
/// </summary>
|
||||
public RunStatus Status { get; init; } = RunStatus.Created;
|
||||
|
||||
/// <summary>
|
||||
/// Gets when the run was created.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when the run was last updated.
|
||||
/// </summary>
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when the run was completed (if completed).
|
||||
/// </summary>
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the ordered timeline of events in this run.
|
||||
/// </summary>
|
||||
public ImmutableArray<RunEvent> Events { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the artifacts produced by this run.
|
||||
/// </summary>
|
||||
public ImmutableArray<RunArtifact> Artifacts { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the evidence packs attached to this run.
|
||||
/// </summary>
|
||||
public ImmutableArray<EvidencePackReference> EvidencePacks { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the run context (CVE focus, component scope, etc.).
|
||||
/// </summary>
|
||||
public RunContext Context { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the approval requirements and status.
|
||||
/// </summary>
|
||||
public ApprovalInfo? Approval { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the content hash of the run for attestation.
|
||||
/// </summary>
|
||||
public string? ContentDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the attestation for this run (if attested).
|
||||
/// </summary>
|
||||
public RunAttestation? Attestation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets additional metadata.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Metadata { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Status of a run in its lifecycle.
|
||||
/// </summary>
|
||||
public enum RunStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Run has been created but not started.
|
||||
/// </summary>
|
||||
Created = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Run is actively in progress.
|
||||
/// </summary>
|
||||
Active = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Run is waiting for approval.
|
||||
/// </summary>
|
||||
PendingApproval = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Run was approved and actions executed.
|
||||
/// </summary>
|
||||
Approved = 3,
|
||||
|
||||
/// <summary>
|
||||
/// Run was rejected.
|
||||
/// </summary>
|
||||
Rejected = 4,
|
||||
|
||||
/// <summary>
|
||||
/// Run completed successfully.
|
||||
/// </summary>
|
||||
Completed = 5,
|
||||
|
||||
/// <summary>
|
||||
/// Run was cancelled.
|
||||
/// </summary>
|
||||
Cancelled = 6,
|
||||
|
||||
/// <summary>
|
||||
/// Run failed with error.
|
||||
/// </summary>
|
||||
Failed = 7,
|
||||
|
||||
/// <summary>
|
||||
/// Run expired without completion.
|
||||
/// </summary>
|
||||
Expired = 8
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context information for a run.
|
||||
/// </summary>
|
||||
public sealed record RunContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the focused CVE ID (if any).
|
||||
/// </summary>
|
||||
public string? FocusedCveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the focused component PURL (if any).
|
||||
/// </summary>
|
||||
public string? FocusedComponent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the SBOM digest (if any).
|
||||
/// </summary>
|
||||
public string? SbomDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the container image reference (if any).
|
||||
/// </summary>
|
||||
public string? ImageReference { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the scope tags.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Tags { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets OpsMemory context if enriched.
|
||||
/// </summary>
|
||||
public OpsMemoryRunContext? OpsMemory { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OpsMemory context attached to a run.
|
||||
/// </summary>
|
||||
public sealed record OpsMemoryRunContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the similar past decisions surfaced.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> SimilarDecisionIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the applicable tactics.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> TacticIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether OpsMemory enrichment was applied.
|
||||
/// </summary>
|
||||
public bool IsEnriched { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Approval information for a run.
|
||||
/// </summary>
|
||||
public sealed record ApprovalInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets whether approval is required.
|
||||
/// </summary>
|
||||
public bool Required { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the approver user IDs.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Approvers { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether approval was granted.
|
||||
/// </summary>
|
||||
public bool? Approved { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets who approved/rejected.
|
||||
/// </summary>
|
||||
public string? ApprovedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when approval was decided.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ApprovedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the approval/rejection reason.
|
||||
/// </summary>
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attestation for a completed run.
|
||||
/// </summary>
|
||||
public sealed record RunAttestation
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the attestation ID.
|
||||
/// </summary>
|
||||
public required string AttestationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the content digest that was attested.
|
||||
/// </summary>
|
||||
public required string ContentDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the attestation statement URI.
|
||||
/// </summary>
|
||||
public required string StatementUri { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the signature.
|
||||
/// </summary>
|
||||
public required string Signature { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when the attestation was created.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
182
src/AdvisoryAI/StellaOps.AdvisoryAI/Runs/Models/RunArtifact.cs
Normal file
182
src/AdvisoryAI/StellaOps.AdvisoryAI/Runs/Models/RunArtifact.cs
Normal file
@@ -0,0 +1,182 @@
|
||||
// <copyright file="RunArtifact.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Runs;
|
||||
|
||||
/// <summary>
|
||||
/// An artifact produced by a run.
|
||||
/// Sprint: SPRINT_20260109_011_003_BE Task: RUN-001
|
||||
/// </summary>
|
||||
public sealed record RunArtifact
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the artifact ID.
|
||||
/// </summary>
|
||||
public required string ArtifactId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the artifact type.
|
||||
/// </summary>
|
||||
public required ArtifactType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the artifact name.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the artifact description.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when the artifact was created.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the content digest (SHA256).
|
||||
/// </summary>
|
||||
public required string ContentDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the content size in bytes.
|
||||
/// </summary>
|
||||
public long ContentSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the media type.
|
||||
/// </summary>
|
||||
public required string MediaType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the storage URI.
|
||||
/// </summary>
|
||||
public string? StorageUri { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the artifact is inline (small enough to embed).
|
||||
/// </summary>
|
||||
public bool IsInline { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the inline content (if IsInline).
|
||||
/// </summary>
|
||||
public string? InlineContent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the event ID that produced this artifact.
|
||||
/// </summary>
|
||||
public string? ProducingEventId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets artifact metadata.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Metadata { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of artifact produced by a run.
|
||||
/// </summary>
|
||||
public enum ArtifactType
|
||||
{
|
||||
/// <summary>
|
||||
/// Evidence pack bundle.
|
||||
/// </summary>
|
||||
EvidencePack = 0,
|
||||
|
||||
/// <summary>
|
||||
/// VEX statement.
|
||||
/// </summary>
|
||||
VexStatement = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Decision record.
|
||||
/// </summary>
|
||||
DecisionRecord = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Action result.
|
||||
/// </summary>
|
||||
ActionResult = 3,
|
||||
|
||||
/// <summary>
|
||||
/// Policy evaluation result.
|
||||
/// </summary>
|
||||
PolicyResult = 4,
|
||||
|
||||
/// <summary>
|
||||
/// Remediation plan.
|
||||
/// </summary>
|
||||
RemediationPlan = 5,
|
||||
|
||||
/// <summary>
|
||||
/// Report document.
|
||||
/// </summary>
|
||||
Report = 6,
|
||||
|
||||
/// <summary>
|
||||
/// SBOM document.
|
||||
/// </summary>
|
||||
Sbom = 7,
|
||||
|
||||
/// <summary>
|
||||
/// Attestation statement.
|
||||
/// </summary>
|
||||
Attestation = 8,
|
||||
|
||||
/// <summary>
|
||||
/// Query result data.
|
||||
/// </summary>
|
||||
QueryResult = 9,
|
||||
|
||||
/// <summary>
|
||||
/// Code snippet.
|
||||
/// </summary>
|
||||
CodeSnippet = 10,
|
||||
|
||||
/// <summary>
|
||||
/// Configuration file.
|
||||
/// </summary>
|
||||
Configuration = 11,
|
||||
|
||||
/// <summary>
|
||||
/// Other artifact type.
|
||||
/// </summary>
|
||||
Other = 99
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to an evidence pack.
|
||||
/// </summary>
|
||||
public sealed record EvidencePackReference
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the evidence pack ID.
|
||||
/// </summary>
|
||||
public required string PackId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the pack digest.
|
||||
/// </summary>
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when the pack was attached.
|
||||
/// </summary>
|
||||
public required DateTimeOffset AttachedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the pack type.
|
||||
/// </summary>
|
||||
public string? PackType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the storage URI.
|
||||
/// </summary>
|
||||
public string? StorageUri { get; init; }
|
||||
}
|
||||
428
src/AdvisoryAI/StellaOps.AdvisoryAI/Runs/Models/RunEvent.cs
Normal file
428
src/AdvisoryAI/StellaOps.AdvisoryAI/Runs/Models/RunEvent.cs
Normal file
@@ -0,0 +1,428 @@
|
||||
// <copyright file="RunEvent.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Runs;
|
||||
|
||||
/// <summary>
|
||||
/// An event in a run's timeline.
|
||||
/// Sprint: SPRINT_20260109_011_003_BE Task: RUN-001
|
||||
/// </summary>
|
||||
public sealed record RunEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the event ID (unique within the run).
|
||||
/// </summary>
|
||||
public required string EventId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the event type.
|
||||
/// </summary>
|
||||
public required RunEventType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when the event occurred.
|
||||
/// </summary>
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the actor who triggered the event (user or system).
|
||||
/// </summary>
|
||||
public string? ActorId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the event content (varies by type).
|
||||
/// </summary>
|
||||
public RunEventContent? Content { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets evidence links attached to this event.
|
||||
/// </summary>
|
||||
public ImmutableArray<EvidenceLink> EvidenceLinks { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the sequence number in the run timeline.
|
||||
/// </summary>
|
||||
public int SequenceNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the parent event ID (for threaded responses).
|
||||
/// </summary>
|
||||
public string? ParentEventId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets event metadata.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Metadata { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of run event.
|
||||
/// </summary>
|
||||
public enum RunEventType
|
||||
{
|
||||
/// <summary>
|
||||
/// Run was created.
|
||||
/// </summary>
|
||||
Created = 0,
|
||||
|
||||
/// <summary>
|
||||
/// User message/turn.
|
||||
/// </summary>
|
||||
UserTurn = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Assistant response/turn.
|
||||
/// </summary>
|
||||
AssistantTurn = 2,
|
||||
|
||||
/// <summary>
|
||||
/// System message.
|
||||
/// </summary>
|
||||
SystemMessage = 3,
|
||||
|
||||
/// <summary>
|
||||
/// Tool was called.
|
||||
/// </summary>
|
||||
ToolCall = 4,
|
||||
|
||||
/// <summary>
|
||||
/// Tool returned result.
|
||||
/// </summary>
|
||||
ToolResult = 5,
|
||||
|
||||
/// <summary>
|
||||
/// Action was proposed.
|
||||
/// </summary>
|
||||
ActionProposed = 6,
|
||||
|
||||
/// <summary>
|
||||
/// Approval was requested.
|
||||
/// </summary>
|
||||
ApprovalRequested = 7,
|
||||
|
||||
/// <summary>
|
||||
/// Approval was granted.
|
||||
/// </summary>
|
||||
ApprovalGranted = 8,
|
||||
|
||||
/// <summary>
|
||||
/// Approval was denied.
|
||||
/// </summary>
|
||||
ApprovalDenied = 9,
|
||||
|
||||
/// <summary>
|
||||
/// Action was executed.
|
||||
/// </summary>
|
||||
ActionExecuted = 10,
|
||||
|
||||
/// <summary>
|
||||
/// Artifact was produced.
|
||||
/// </summary>
|
||||
ArtifactProduced = 11,
|
||||
|
||||
/// <summary>
|
||||
/// Evidence was attached.
|
||||
/// </summary>
|
||||
EvidenceAttached = 12,
|
||||
|
||||
/// <summary>
|
||||
/// Run was handed off to another user.
|
||||
/// </summary>
|
||||
HandedOff = 13,
|
||||
|
||||
/// <summary>
|
||||
/// Run status changed.
|
||||
/// </summary>
|
||||
StatusChanged = 14,
|
||||
|
||||
/// <summary>
|
||||
/// OpsMemory context was enriched.
|
||||
/// </summary>
|
||||
OpsMemoryEnriched = 15,
|
||||
|
||||
/// <summary>
|
||||
/// Error occurred.
|
||||
/// </summary>
|
||||
Error = 16,
|
||||
|
||||
/// <summary>
|
||||
/// Run was completed.
|
||||
/// </summary>
|
||||
Completed = 17,
|
||||
|
||||
/// <summary>
|
||||
/// Run was cancelled.
|
||||
/// </summary>
|
||||
Cancelled = 18
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Content of a run event (polymorphic).
|
||||
/// </summary>
|
||||
public abstract record RunEventContent;
|
||||
|
||||
/// <summary>
|
||||
/// Content for user/assistant turn events.
|
||||
/// </summary>
|
||||
public sealed record TurnContent : RunEventContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the message text.
|
||||
/// </summary>
|
||||
public required string Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the role (user/assistant/system).
|
||||
/// </summary>
|
||||
public required string Role { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets referenced artifacts.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> ArtifactIds { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Content for tool call events.
|
||||
/// </summary>
|
||||
public sealed record ToolCallContent : RunEventContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the tool name.
|
||||
/// </summary>
|
||||
public required string ToolName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tool input parameters.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Parameters { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the call succeeded.
|
||||
/// </summary>
|
||||
public bool? Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the call duration.
|
||||
/// </summary>
|
||||
public TimeSpan? Duration { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Content for tool result events.
|
||||
/// </summary>
|
||||
public sealed record ToolResultContent : RunEventContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the tool name.
|
||||
/// </summary>
|
||||
public required string ToolName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the result summary.
|
||||
/// </summary>
|
||||
public string? ResultSummary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the tool succeeded.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the error message (if failed).
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the result artifact ID (if any).
|
||||
/// </summary>
|
||||
public string? ArtifactId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Content for action proposed events.
|
||||
/// </summary>
|
||||
public sealed record ActionProposedContent : RunEventContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the action type.
|
||||
/// </summary>
|
||||
public required string ActionType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the action subject (CVE, component, etc.).
|
||||
/// </summary>
|
||||
public string? Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the proposed action rationale.
|
||||
/// </summary>
|
||||
public string? Rationale { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether approval is required.
|
||||
/// </summary>
|
||||
public bool RequiresApproval { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the action parameters.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Parameters { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Content for action executed events.
|
||||
/// </summary>
|
||||
public sealed record ActionExecutedContent : RunEventContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the action type.
|
||||
/// </summary>
|
||||
public required string ActionType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the action succeeded.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the result summary.
|
||||
/// </summary>
|
||||
public string? ResultSummary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the OpsMemory record ID (if recorded).
|
||||
/// </summary>
|
||||
public string? OpsMemoryRecordId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the produced artifact IDs.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> ArtifactIds { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Content for artifact produced events.
|
||||
/// </summary>
|
||||
public sealed record ArtifactProducedContent : RunEventContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the artifact ID.
|
||||
/// </summary>
|
||||
public required string ArtifactId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the artifact type.
|
||||
/// </summary>
|
||||
public required string ArtifactType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the artifact name.
|
||||
/// </summary>
|
||||
public string? Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the content digest.
|
||||
/// </summary>
|
||||
public string? ContentDigest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Content for status changed events.
|
||||
/// </summary>
|
||||
public sealed record StatusChangedContent : RunEventContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the previous status.
|
||||
/// </summary>
|
||||
public required RunStatus FromStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the new status.
|
||||
/// </summary>
|
||||
public required RunStatus ToStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the reason for the change.
|
||||
/// </summary>
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Content for OpsMemory enrichment events.
|
||||
/// </summary>
|
||||
public sealed record OpsMemoryEnrichedContent : RunEventContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the similar decision IDs surfaced.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> SimilarDecisionIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the applicable tactic IDs.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> TacticIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of known issues found.
|
||||
/// </summary>
|
||||
public int KnownIssueCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Content for error events.
|
||||
/// </summary>
|
||||
public sealed record ErrorContent : RunEventContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the error code.
|
||||
/// </summary>
|
||||
public required string ErrorCode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the error message.
|
||||
/// </summary>
|
||||
public required string Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the stack trace (if available).
|
||||
/// </summary>
|
||||
public string? StackTrace { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the error is recoverable.
|
||||
/// </summary>
|
||||
public bool IsRecoverable { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to an evidence link.
|
||||
/// </summary>
|
||||
public sealed record EvidenceLink
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the evidence URI.
|
||||
/// </summary>
|
||||
public required string Uri { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the evidence type.
|
||||
/// </summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the content digest.
|
||||
/// </summary>
|
||||
public string? Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the evidence label/description.
|
||||
/// </summary>
|
||||
public string? Label { get; init; }
|
||||
}
|
||||
723
src/AdvisoryAI/StellaOps.AdvisoryAI/Runs/RunService.cs
Normal file
723
src/AdvisoryAI/StellaOps.AdvisoryAI/Runs/RunService.cs
Normal file
@@ -0,0 +1,723 @@
|
||||
// <copyright file="RunService.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Runs;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of the run service.
|
||||
/// Sprint: SPRINT_20260109_011_003_BE Task: RUN-003
|
||||
/// </summary>
|
||||
internal sealed class RunService : IRunService
|
||||
{
|
||||
private readonly IRunStore _store;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<RunService> _logger;
|
||||
private readonly IGuidGenerator _guidGenerator;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RunService"/> class.
|
||||
/// </summary>
|
||||
public RunService(
|
||||
IRunStore store,
|
||||
TimeProvider timeProvider,
|
||||
IGuidGenerator guidGenerator,
|
||||
ILogger<RunService> logger)
|
||||
{
|
||||
_store = store;
|
||||
_timeProvider = timeProvider;
|
||||
_guidGenerator = guidGenerator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Run> CreateAsync(CreateRunRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.TenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.InitiatedBy);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.Title);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var runId = GenerateRunId(now);
|
||||
|
||||
var createdEvent = new RunEvent
|
||||
{
|
||||
EventId = GenerateEventId(),
|
||||
Type = RunEventType.Created,
|
||||
Timestamp = now,
|
||||
ActorId = request.InitiatedBy,
|
||||
SequenceNumber = 0,
|
||||
Content = new StatusChangedContent
|
||||
{
|
||||
FromStatus = RunStatus.Created,
|
||||
ToStatus = RunStatus.Created,
|
||||
Reason = "Run created"
|
||||
}
|
||||
};
|
||||
|
||||
var run = new Run
|
||||
{
|
||||
RunId = runId,
|
||||
TenantId = request.TenantId,
|
||||
InitiatedBy = request.InitiatedBy,
|
||||
Title = request.Title,
|
||||
Objective = request.Objective,
|
||||
Status = RunStatus.Created,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
Context = request.Context ?? new RunContext(),
|
||||
Events = [createdEvent],
|
||||
Metadata = request.Metadata ?? ImmutableDictionary<string, string>.Empty
|
||||
};
|
||||
|
||||
await _store.SaveAsync(run, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created run {RunId} for tenant {TenantId} by user {UserId}",
|
||||
runId, request.TenantId, request.InitiatedBy);
|
||||
|
||||
return run;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Run?> GetAsync(string tenantId, string runId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
|
||||
|
||||
return await _store.GetAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RunQueryResult> QueryAsync(RunQuery query, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
var (runs, totalCount) = await _store.QueryAsync(query, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new RunQueryResult
|
||||
{
|
||||
Runs = runs,
|
||||
TotalCount = totalCount,
|
||||
HasMore = totalCount > query.Skip + runs.Length
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RunEvent> AddEventAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
AddRunEventRequest eventRequest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var run = await GetRequiredRunAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
ValidateCanModify(run);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var newEvent = new RunEvent
|
||||
{
|
||||
EventId = GenerateEventId(),
|
||||
Type = eventRequest.Type,
|
||||
Timestamp = now,
|
||||
ActorId = eventRequest.ActorId,
|
||||
Content = eventRequest.Content,
|
||||
EvidenceLinks = eventRequest.EvidenceLinks ?? [],
|
||||
SequenceNumber = run.Events.Length,
|
||||
ParentEventId = eventRequest.ParentEventId,
|
||||
Metadata = eventRequest.Metadata ?? ImmutableDictionary<string, string>.Empty
|
||||
};
|
||||
|
||||
var updatedRun = run with
|
||||
{
|
||||
Events = run.Events.Add(newEvent),
|
||||
UpdatedAt = now,
|
||||
Status = run.Status == RunStatus.Created ? RunStatus.Active : run.Status
|
||||
};
|
||||
|
||||
await _store.SaveAsync(updatedRun, cancellationToken).ConfigureAwait(false);
|
||||
return newEvent;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RunEvent> AddUserTurnAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string message,
|
||||
string userId,
|
||||
ImmutableArray<EvidenceLink>? evidenceLinks = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await AddEventAsync(tenantId, runId, new AddRunEventRequest
|
||||
{
|
||||
Type = RunEventType.UserTurn,
|
||||
ActorId = userId,
|
||||
Content = new TurnContent
|
||||
{
|
||||
Message = message,
|
||||
Role = "user"
|
||||
},
|
||||
EvidenceLinks = evidenceLinks
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RunEvent> AddAssistantTurnAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string message,
|
||||
ImmutableArray<EvidenceLink>? evidenceLinks = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await AddEventAsync(tenantId, runId, new AddRunEventRequest
|
||||
{
|
||||
Type = RunEventType.AssistantTurn,
|
||||
ActorId = "assistant",
|
||||
Content = new TurnContent
|
||||
{
|
||||
Message = message,
|
||||
Role = "assistant"
|
||||
},
|
||||
EvidenceLinks = evidenceLinks
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RunEvent> ProposeActionAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
ProposeActionRequest action,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(action);
|
||||
|
||||
return await AddEventAsync(tenantId, runId, new AddRunEventRequest
|
||||
{
|
||||
Type = RunEventType.ActionProposed,
|
||||
ActorId = "assistant",
|
||||
Content = new ActionProposedContent
|
||||
{
|
||||
ActionType = action.ActionType,
|
||||
Subject = action.Subject,
|
||||
Rationale = action.Rationale,
|
||||
RequiresApproval = action.RequiresApproval,
|
||||
Parameters = action.Parameters ?? ImmutableDictionary<string, string>.Empty
|
||||
},
|
||||
EvidenceLinks = action.EvidenceLinks
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Run> RequestApprovalAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
ImmutableArray<string> approvers,
|
||||
string? reason = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var run = await GetRequiredRunAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
ValidateCanModify(run);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var approvalEvent = new RunEvent
|
||||
{
|
||||
EventId = GenerateEventId(),
|
||||
Type = RunEventType.ApprovalRequested,
|
||||
Timestamp = now,
|
||||
ActorId = "system",
|
||||
SequenceNumber = run.Events.Length,
|
||||
Content = new StatusChangedContent
|
||||
{
|
||||
FromStatus = run.Status,
|
||||
ToStatus = RunStatus.PendingApproval,
|
||||
Reason = reason ?? "Approval requested for proposed actions"
|
||||
}
|
||||
};
|
||||
|
||||
var updatedRun = run with
|
||||
{
|
||||
Status = RunStatus.PendingApproval,
|
||||
UpdatedAt = now,
|
||||
Events = run.Events.Add(approvalEvent),
|
||||
Approval = new ApprovalInfo
|
||||
{
|
||||
Required = true,
|
||||
Approvers = approvers
|
||||
}
|
||||
};
|
||||
|
||||
await _store.SaveAsync(updatedRun, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Approval requested for run {RunId} from approvers: {Approvers}",
|
||||
runId, string.Join(", ", approvers));
|
||||
|
||||
return updatedRun;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Run> ApproveAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
bool approved,
|
||||
string approverId,
|
||||
string? reason = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var run = await GetRequiredRunAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (run.Status != RunStatus.PendingApproval)
|
||||
{
|
||||
throw new InvalidOperationException($"Run {runId} is not pending approval. Current status: {run.Status}");
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var newStatus = approved ? RunStatus.Approved : RunStatus.Rejected;
|
||||
var eventType = approved ? RunEventType.ApprovalGranted : RunEventType.ApprovalDenied;
|
||||
|
||||
var approvalEvent = new RunEvent
|
||||
{
|
||||
EventId = GenerateEventId(),
|
||||
Type = eventType,
|
||||
Timestamp = now,
|
||||
ActorId = approverId,
|
||||
SequenceNumber = run.Events.Length,
|
||||
Content = new StatusChangedContent
|
||||
{
|
||||
FromStatus = run.Status,
|
||||
ToStatus = newStatus,
|
||||
Reason = reason ?? (approved ? "Approved" : "Rejected")
|
||||
}
|
||||
};
|
||||
|
||||
var updatedRun = run with
|
||||
{
|
||||
Status = newStatus,
|
||||
UpdatedAt = now,
|
||||
Events = run.Events.Add(approvalEvent),
|
||||
Approval = run.Approval! with
|
||||
{
|
||||
Approved = approved,
|
||||
ApprovedBy = approverId,
|
||||
ApprovedAt = now,
|
||||
Reason = reason
|
||||
},
|
||||
CompletedAt = approved ? null : now
|
||||
};
|
||||
|
||||
await _store.SaveAsync(updatedRun, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Run {RunId} {Action} by {ApproverId}: {Reason}",
|
||||
runId, approved ? "approved" : "rejected", approverId, reason ?? "(no reason)");
|
||||
|
||||
return updatedRun;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RunEvent> ExecuteActionAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string actionEventId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var run = await GetRequiredRunAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (run.Status != RunStatus.Approved && run.Status != RunStatus.Active)
|
||||
{
|
||||
throw new InvalidOperationException($"Cannot execute actions on run with status: {run.Status}");
|
||||
}
|
||||
|
||||
var actionEvent = run.Events.FirstOrDefault(e => e.EventId == actionEventId);
|
||||
if (actionEvent is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Action event {actionEventId} not found in run {runId}");
|
||||
}
|
||||
|
||||
if (actionEvent.Type != RunEventType.ActionProposed)
|
||||
{
|
||||
throw new InvalidOperationException($"Event {actionEventId} is not an action proposal");
|
||||
}
|
||||
|
||||
// In a real implementation, this would execute the action
|
||||
// For now, we just record that it was executed
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var actionContent = actionEvent.Content as ActionProposedContent;
|
||||
|
||||
var executedEvent = new RunEvent
|
||||
{
|
||||
EventId = GenerateEventId(),
|
||||
Type = RunEventType.ActionExecuted,
|
||||
Timestamp = now,
|
||||
ActorId = "system",
|
||||
SequenceNumber = run.Events.Length,
|
||||
ParentEventId = actionEventId,
|
||||
Content = new ActionExecutedContent
|
||||
{
|
||||
ActionType = actionContent?.ActionType ?? "unknown",
|
||||
Success = true,
|
||||
ResultSummary = $"Action {actionContent?.ActionType} executed successfully"
|
||||
}
|
||||
};
|
||||
|
||||
var updatedRun = run with
|
||||
{
|
||||
Events = run.Events.Add(executedEvent),
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
await _store.SaveAsync(updatedRun, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Executed action {ActionType} in run {RunId}",
|
||||
actionContent?.ActionType ?? "unknown", runId);
|
||||
|
||||
return executedEvent;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Run> AddArtifactAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
RunArtifact artifact,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var run = await GetRequiredRunAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
ValidateCanModify(run);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var artifactEvent = new RunEvent
|
||||
{
|
||||
EventId = GenerateEventId(),
|
||||
Type = RunEventType.ArtifactProduced,
|
||||
Timestamp = now,
|
||||
ActorId = "system",
|
||||
SequenceNumber = run.Events.Length,
|
||||
Content = new ArtifactProducedContent
|
||||
{
|
||||
ArtifactId = artifact.ArtifactId,
|
||||
ArtifactType = artifact.Type.ToString(),
|
||||
Name = artifact.Name,
|
||||
ContentDigest = artifact.ContentDigest
|
||||
}
|
||||
};
|
||||
|
||||
var updatedRun = run with
|
||||
{
|
||||
Artifacts = run.Artifacts.Add(artifact),
|
||||
Events = run.Events.Add(artifactEvent),
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
await _store.SaveAsync(updatedRun, cancellationToken).ConfigureAwait(false);
|
||||
return updatedRun;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Run> AttachEvidencePackAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
EvidencePackReference evidencePack,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var run = await GetRequiredRunAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
ValidateCanModify(run);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var evidenceEvent = new RunEvent
|
||||
{
|
||||
EventId = GenerateEventId(),
|
||||
Type = RunEventType.EvidenceAttached,
|
||||
Timestamp = now,
|
||||
ActorId = "system",
|
||||
SequenceNumber = run.Events.Length
|
||||
};
|
||||
|
||||
var updatedRun = run with
|
||||
{
|
||||
EvidencePacks = run.EvidencePacks.Add(evidencePack),
|
||||
Events = run.Events.Add(evidenceEvent),
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
await _store.SaveAsync(updatedRun, cancellationToken).ConfigureAwait(false);
|
||||
return updatedRun;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Run> CompleteAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string? summary = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var run = await GetRequiredRunAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
ValidateCanModify(run);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var completedEvent = new RunEvent
|
||||
{
|
||||
EventId = GenerateEventId(),
|
||||
Type = RunEventType.Completed,
|
||||
Timestamp = now,
|
||||
ActorId = "system",
|
||||
SequenceNumber = run.Events.Length,
|
||||
Content = new StatusChangedContent
|
||||
{
|
||||
FromStatus = run.Status,
|
||||
ToStatus = RunStatus.Completed,
|
||||
Reason = summary ?? "Run completed"
|
||||
}
|
||||
};
|
||||
|
||||
var contentDigest = ComputeContentDigest(run);
|
||||
|
||||
var updatedRun = run with
|
||||
{
|
||||
Status = RunStatus.Completed,
|
||||
CompletedAt = now,
|
||||
UpdatedAt = now,
|
||||
Events = run.Events.Add(completedEvent),
|
||||
ContentDigest = contentDigest
|
||||
};
|
||||
|
||||
await _store.SaveAsync(updatedRun, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Completed run {RunId} with digest {Digest}", runId, contentDigest);
|
||||
|
||||
return updatedRun;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Run> CancelAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string? reason = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var run = await GetRequiredRunAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
ValidateCanModify(run);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var cancelledEvent = new RunEvent
|
||||
{
|
||||
EventId = GenerateEventId(),
|
||||
Type = RunEventType.Cancelled,
|
||||
Timestamp = now,
|
||||
ActorId = "system",
|
||||
SequenceNumber = run.Events.Length,
|
||||
Content = new StatusChangedContent
|
||||
{
|
||||
FromStatus = run.Status,
|
||||
ToStatus = RunStatus.Cancelled,
|
||||
Reason = reason ?? "Run cancelled"
|
||||
}
|
||||
};
|
||||
|
||||
var updatedRun = run with
|
||||
{
|
||||
Status = RunStatus.Cancelled,
|
||||
CompletedAt = now,
|
||||
UpdatedAt = now,
|
||||
Events = run.Events.Add(cancelledEvent)
|
||||
};
|
||||
|
||||
await _store.SaveAsync(updatedRun, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Cancelled run {RunId}: {Reason}", runId, reason ?? "(no reason)");
|
||||
|
||||
return updatedRun;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Run> HandOffAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string toUserId,
|
||||
string? message = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var run = await GetRequiredRunAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
ValidateCanModify(run);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var handoffEvent = new RunEvent
|
||||
{
|
||||
EventId = GenerateEventId(),
|
||||
Type = RunEventType.HandedOff,
|
||||
Timestamp = now,
|
||||
ActorId = run.InitiatedBy,
|
||||
SequenceNumber = run.Events.Length,
|
||||
Content = new TurnContent
|
||||
{
|
||||
Message = message ?? $"Handed off to {toUserId}",
|
||||
Role = "system"
|
||||
},
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["to_user"] = toUserId
|
||||
}.ToImmutableDictionary()
|
||||
};
|
||||
|
||||
var updatedRun = run with
|
||||
{
|
||||
Events = run.Events.Add(handoffEvent),
|
||||
UpdatedAt = now,
|
||||
Metadata = run.Metadata.SetItem("current_owner", toUserId)
|
||||
};
|
||||
|
||||
await _store.SaveAsync(updatedRun, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Run {RunId} handed off to {UserId}", runId, toUserId);
|
||||
|
||||
return updatedRun;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Run> AttestAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var run = await GetRequiredRunAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (run.Status != RunStatus.Completed)
|
||||
{
|
||||
throw new InvalidOperationException($"Cannot attest run that is not completed. Current status: {run.Status}");
|
||||
}
|
||||
|
||||
if (run.Attestation is not null)
|
||||
{
|
||||
throw new InvalidOperationException($"Run {runId} is already attested");
|
||||
}
|
||||
|
||||
var contentDigest = run.ContentDigest ?? ComputeContentDigest(run);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// In a real implementation, this would sign the attestation
|
||||
var attestation = new RunAttestation
|
||||
{
|
||||
AttestationId = $"att-{_guidGenerator.NewGuid():N}",
|
||||
ContentDigest = contentDigest,
|
||||
StatementUri = $"stellaops://runs/{runId}/attestation",
|
||||
Signature = "placeholder-signature", // Would be DSSE signature
|
||||
CreatedAt = now
|
||||
};
|
||||
|
||||
var updatedRun = run with
|
||||
{
|
||||
Attestation = attestation,
|
||||
ContentDigest = contentDigest,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
await _store.SaveAsync(updatedRun, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Attested run {RunId} with attestation {AttestationId}", runId, attestation.AttestationId);
|
||||
|
||||
return updatedRun;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ImmutableArray<RunEvent>> GetTimelineAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
int skip = 0,
|
||||
int take = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var run = await GetAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
if (run is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return run.Events
|
||||
.OrderBy(e => e.SequenceNumber)
|
||||
.Skip(skip)
|
||||
.Take(take)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private async Task<Run> GetRequiredRunAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var run = await _store.GetAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
if (run is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Run {runId} not found");
|
||||
}
|
||||
return run;
|
||||
}
|
||||
|
||||
private static void ValidateCanModify(Run run)
|
||||
{
|
||||
if (run.Status is RunStatus.Completed or RunStatus.Cancelled or RunStatus.Failed or RunStatus.Expired)
|
||||
{
|
||||
throw new InvalidOperationException($"Cannot modify run with status: {run.Status}");
|
||||
}
|
||||
}
|
||||
|
||||
private string GenerateRunId(DateTimeOffset timestamp)
|
||||
{
|
||||
var ts = timestamp.ToString("yyyyMMddHHmmss", System.Globalization.CultureInfo.InvariantCulture);
|
||||
var random = _guidGenerator.NewGuid().ToString("N")[..8];
|
||||
return $"run-{ts}-{random}";
|
||||
}
|
||||
|
||||
private string GenerateEventId()
|
||||
{
|
||||
return $"evt-{_guidGenerator.NewGuid():N}";
|
||||
}
|
||||
|
||||
private static string ComputeContentDigest(Run run)
|
||||
{
|
||||
var content = new
|
||||
{
|
||||
run.RunId,
|
||||
run.TenantId,
|
||||
run.Title,
|
||||
run.CreatedAt,
|
||||
EventCount = run.Events.Length,
|
||||
Events = run.Events.Select(e => new { e.EventId, e.Type, e.Timestamp }).ToArray(),
|
||||
Artifacts = run.Artifacts.Select(a => new { a.ArtifactId, a.ContentDigest }).ToArray()
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(content, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
});
|
||||
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for generating GUIDs (injectable for testing).
|
||||
/// </summary>
|
||||
public interface IGuidGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates a new GUID.
|
||||
/// </summary>
|
||||
Guid NewGuid();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default GUID generator using Guid.NewGuid().
|
||||
/// </summary>
|
||||
public sealed class DefaultGuidGenerator : IGuidGenerator
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Guid NewGuid() => Guid.NewGuid();
|
||||
}
|
||||
@@ -17,6 +17,10 @@
|
||||
<ProjectReference Include="..\..\Concelier\__Libraries\StellaOps.Concelier.Core\StellaOps.Concelier.Core.csproj" />
|
||||
<ProjectReference Include="..\..\Concelier\__Libraries\StellaOps.Concelier.RawModels\StellaOps.Concelier.RawModels.csproj" />
|
||||
<ProjectReference Include="..\..\Excititor\__Libraries\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />
|
||||
<ProjectReference Include="..\..\OpsMemory\StellaOps.OpsMemory\StellaOps.OpsMemory.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.AdvisoryAI.Attestation\StellaOps.AdvisoryAI.Attestation.csproj" />
|
||||
<!-- Evidence Packs (Sprint: SPRINT_20260109_011_005 Task: EVPK-006) -->
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Evidence.Pack\StellaOps.Evidence.Pack.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
Reference in New Issue
Block a user