save checkpoint: save features

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

View File

@@ -0,0 +1,285 @@
using System.Collections.Immutable;
using StellaOps.Doctor.AdvisoryAI.Models;
using StellaOps.Doctor.Models;
namespace StellaOps.Doctor.AdvisoryAI;
/// <summary>
/// Deterministic implementation of diagnosis service for offline-safe Doctor analysis.
/// </summary>
public sealed class DeterministicDoctorAIDiagnosisService : IDoctorAIDiagnosisService
{
public Task<DoctorAIDiagnosisResponse> AnalyzeAsync(
DoctorAIContext context,
DoctorAIDiagnosisOptions? options = null,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(context);
ct.ThrowIfCancellationRequested();
var effectiveOptions = options ?? new DoctorAIDiagnosisOptions();
var focused = FilterResults(context.Results, effectiveOptions.FocusOnChecks);
var issues = BuildIssues(focused);
var rootCauses = effectiveOptions.IncludeRootCauseAnalysis ? BuildRootCauses(focused) : [];
var correlations = effectiveOptions.IncludeCorrelationAnalysis ? BuildCorrelations(focused) : [];
var actions = effectiveOptions.IncludeRemediationSuggestions ? BuildActions(focused) : [];
var documentation = focused
.Select(static result => result.Remediation?.RunbookUrl)
.Where(static url => !string.IsNullOrWhiteSpace(url))
.Select(static url => url!)
.Distinct(StringComparer.Ordinal)
.OrderBy(static url => url, StringComparer.Ordinal)
.ToImmutableArray();
var response = new DoctorAIDiagnosisResponse
{
Assessment = BuildAssessment(focused),
HealthScore = CalculateHealthScore(focused),
Issues = issues,
RootCauses = rootCauses,
Correlations = correlations,
RecommendedActions = actions,
RelatedDocumentation = documentation
};
return Task.FromResult(response);
}
public Task<AICheckRecommendations> GetRecommendationsAsync(
AICheckResult checkResult,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(checkResult);
ct.ThrowIfCancellationRequested();
var recommendations = new AICheckRecommendations
{
CheckId = checkResult.CheckId,
Explanation = checkResult.Diagnosis,
MostLikelyCause = checkResult.LikelyCauses.FirstOrDefault()?.Cause,
Steps = checkResult.Remediation?.Steps
.OrderBy(static step => step.Order)
.Select(FormatRemediationStep)
.ToImmutableArray() ?? [],
VerificationCommand = checkResult.Remediation?.Steps
.OrderBy(static step => step.Order)
.Select(static step => step.VerificationCommand)
.FirstOrDefault(static command => !string.IsNullOrWhiteSpace(command)),
RelatedChecks = []
};
return Task.FromResult(recommendations);
}
private static ImmutableArray<AICheckResult> FilterResults(
ImmutableArray<AICheckResult> results,
IReadOnlyList<string>? focusOnChecks)
{
IEnumerable<AICheckResult> query = results;
if (focusOnChecks is { Count: > 0 })
{
var focusSet = focusOnChecks
.Where(static checkId => !string.IsNullOrWhiteSpace(checkId))
.ToHashSet(StringComparer.Ordinal);
if (focusSet.Count > 0)
{
query = query.Where(result => focusSet.Contains(result.CheckId));
}
}
return query
.OrderBy(static result => SeverityRank(result.Severity))
.ThenBy(static result => result.CheckId, StringComparer.Ordinal)
.ToImmutableArray();
}
private static ImmutableArray<AIIdentifiedIssue> BuildIssues(ImmutableArray<AICheckResult> results)
{
return results
.Where(static result => result.Severity is DoctorSeverity.Fail or DoctorSeverity.Warn)
.Select(result => new AIIdentifiedIssue
{
Summary = $"{result.CheckId}: {result.Diagnosis}",
AffectedChecks = [result.CheckId],
Severity = result.Severity.ToString().ToLowerInvariant(),
Impact = $"Category '{result.Category}' health is degraded.",
Urgency = result.Severity == DoctorSeverity.Fail ? "high" : "medium"
})
.ToImmutableArray();
}
private static ImmutableArray<AIRootCause> BuildRootCauses(ImmutableArray<AICheckResult> results)
{
var failing = results.Where(static result => result.Severity is DoctorSeverity.Fail or DoctorSeverity.Warn).ToImmutableArray();
if (failing.Length == 0)
{
return [];
}
var causes = failing
.SelectMany(result =>
result.LikelyCauses.Select(cause => new
{
cause.Cause,
result.CheckId,
Evidence = result.Evidence.Fields.Keys.OrderBy(static key => key, StringComparer.Ordinal).Take(3).ToImmutableArray()
}))
.GroupBy(static entry => entry.Cause, StringComparer.Ordinal)
.OrderByDescending(static group => group.Count())
.ThenBy(static group => group.Key, StringComparer.Ordinal)
.ToImmutableArray();
if (causes.Length == 0)
{
return [];
}
return causes
.Select(group => new AIRootCause
{
Cause = group.Key,
Confidence = (float)group.Count() / failing.Length,
SupportingEvidence = group
.SelectMany(static entry => entry.Evidence)
.Distinct(StringComparer.Ordinal)
.OrderBy(static evidence => evidence, StringComparer.Ordinal)
.ToImmutableArray(),
AffectedChecks = group
.Select(static entry => entry.CheckId)
.Distinct(StringComparer.Ordinal)
.OrderBy(static check => check, StringComparer.Ordinal)
.ToImmutableArray()
})
.ToImmutableArray();
}
private static ImmutableArray<AICorrelation> BuildCorrelations(ImmutableArray<AICheckResult> results)
{
return results
.Where(static result => result.Severity is DoctorSeverity.Fail or DoctorSeverity.Warn)
.GroupBy(static result => result.Category, StringComparer.Ordinal)
.SelectMany(group =>
{
var checks = group
.Select(static result => result.CheckId)
.Distinct(StringComparer.Ordinal)
.OrderBy(static check => check, StringComparer.Ordinal)
.ToImmutableArray();
if (checks.Length < 2)
{
return Enumerable.Empty<AICorrelation>();
}
return new[]
{
new AICorrelation
{
Issue1 = checks[0],
Issue2 = checks[1],
Description = $"Both issues are in category '{group.Key}' and likely share the same subsystem dependency.",
Strength = 0.7f
}
};
})
.ToImmutableArray();
}
private static ImmutableArray<string> BuildActions(ImmutableArray<AICheckResult> results)
{
var remediationSteps = results
.SelectMany(static result => result.Remediation is null
? Enumerable.Empty<AIRemediationStep>()
: result.Remediation.Steps)
.OrderBy(static step => step.Order)
.ThenBy(static step => step.Description, StringComparer.Ordinal)
.Select(FormatRemediationStep);
var verificationSteps = results
.SelectMany(static result => result.Remediation is null
? Enumerable.Empty<AIRemediationStep>()
: result.Remediation.Steps)
.OrderBy(static step => step.Order)
.Select(static step => step.VerificationCommand)
.Where(static command => !string.IsNullOrWhiteSpace(command))
.Select(static command => $"Verify: {command}");
var actions = remediationSteps
.Concat(verificationSteps)
.Distinct(StringComparer.Ordinal)
.ToImmutableArray();
if (actions.Length > 0)
{
return actions;
}
return results.Any(static result => result.Severity is DoctorSeverity.Fail or DoctorSeverity.Warn)
? ["Re-run failed checks after validating environment configuration and dependencies."]
: ["No remediation actions required; system health is stable."];
}
private static string BuildAssessment(ImmutableArray<AICheckResult> results)
{
var failCount = results.Count(static result => result.Severity == DoctorSeverity.Fail);
var warnCount = results.Count(static result => result.Severity == DoctorSeverity.Warn);
var total = results.Length;
if (total == 0)
{
return "No Doctor check results were provided for diagnosis.";
}
if (failCount > 0)
{
return $"Critical health issues detected: {failCount} failing checks and {warnCount} warnings.";
}
if (warnCount > 0)
{
return $"No critical failures detected; {warnCount} warning checks require follow-up.";
}
return "Doctor checks indicate a healthy system state.";
}
private static int CalculateHealthScore(ImmutableArray<AICheckResult> results)
{
if (results.Length == 0)
{
return 100;
}
var penalty = results.Sum(static result => result.Severity switch
{
DoctorSeverity.Fail => 100,
DoctorSeverity.Warn => 40,
DoctorSeverity.Info => 10,
DoctorSeverity.Skip => 5,
_ => 0
});
var maxPenalty = results.Length * 100;
var score = 100 - ((penalty * 100) / maxPenalty);
return Math.Clamp(score, 0, 100);
}
private static string FormatRemediationStep(AIRemediationStep step)
{
return string.IsNullOrWhiteSpace(step.Command)
? step.Description
: $"{step.Description}: {step.Command}";
}
private static int SeverityRank(DoctorSeverity severity) => severity switch
{
DoctorSeverity.Fail => 0,
DoctorSeverity.Warn => 1,
DoctorSeverity.Info => 2,
DoctorSeverity.Skip => 3,
DoctorSeverity.Pass => 4,
_ => 5
};
}

View File

@@ -0,0 +1,185 @@
using System.Collections.Immutable;
using System.Globalization;
using Microsoft.Extensions.Logging;
using StellaOps.Doctor.AdvisoryAI.Models;
using StellaOps.Doctor.Models;
namespace StellaOps.Doctor.AdvisoryAI;
/// <summary>
/// Default context adapter that projects Doctor reports into AI context models.
/// </summary>
public sealed class DoctorContextAdapter : IDoctorContextAdapter
{
private readonly IEvidenceSchemaRegistry _schemaRegistry;
private readonly ILogger<DoctorContextAdapter> _logger;
private readonly TimeProvider _timeProvider;
public DoctorContextAdapter(
IEvidenceSchemaRegistry schemaRegistry,
ILogger<DoctorContextAdapter> logger,
TimeProvider timeProvider)
{
_schemaRegistry = schemaRegistry;
_logger = logger;
_timeProvider = timeProvider;
}
public Task<DoctorAIContext> CreateContextAsync(DoctorReport report, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(report);
var projectedResults = report.Results
.OrderBy(static result => result.CheckId, StringComparer.Ordinal)
.Select(ConvertResult)
.ToImmutableArray();
var summary = new DoctorSummary
{
TotalChecks = report.Summary.Total,
PassedChecks = report.Summary.Passed,
InfoChecks = report.Summary.Info,
WarnedChecks = report.Summary.Warnings,
FailedChecks = report.Summary.Failed,
SkippedChecks = report.Summary.Skipped,
CategoriesWithIssues = projectedResults
.Where(static result => result.Severity is DoctorSeverity.Warn or DoctorSeverity.Fail)
.Select(static result => result.Category)
.Distinct(StringComparer.Ordinal)
.OrderBy(static category => category, StringComparer.Ordinal)
.ToImmutableArray()
};
var platformContext = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
platformContext["captured_at_utc"] = _timeProvider.GetUtcNow().ToString("o", CultureInfo.InvariantCulture);
platformContext["doctor_version"] = typeof(DoctorContextAdapter).Assembly.GetName().Version?.ToString() ?? "unknown";
var context = new DoctorAIContext
{
RunId = report.RunId,
ExecutedAt = report.CompletedAt,
OverallSeverity = report.OverallSeverity,
Summary = summary,
Results = projectedResults,
PlatformContext = platformContext.ToImmutable()
};
_logger.LogDebug("Created DoctorAIContext for run {RunId} with {Count} results", report.RunId, projectedResults.Length);
return Task.FromResult(context);
}
public AICheckResult ConvertResult(DoctorCheckResult result)
{
ArgumentNullException.ThrowIfNull(result);
var remediation = ConvertRemediation(result);
var likelyCauses = (result.LikelyCauses ?? [])
.OrderBy(static cause => cause, StringComparer.Ordinal)
.Select(static cause => new AICause
{
Cause = cause
})
.ToImmutableArray();
return new AICheckResult
{
CheckId = result.CheckId,
PluginId = result.PluginId,
Category = result.Category,
CheckName = result.CheckId,
Severity = result.Severity,
Diagnosis = result.Diagnosis,
Evidence = CreateAIEvidence(result),
LikelyCauses = likelyCauses,
Remediation = remediation
};
}
public AIEvidence CreateAIEvidence(DoctorCheckResult result)
{
var fields = ImmutableDictionary.CreateBuilder<string, AIEvidenceField>(StringComparer.Ordinal);
foreach (var entry in result.Evidence.Data.OrderBy(static kv => kv.Key, StringComparer.Ordinal))
{
var schema = _schemaRegistry.GetFieldSchema(result.CheckId, entry.Key);
fields[entry.Key] = new AIEvidenceField
{
Value = entry.Value,
Type = DetermineType(entry.Value),
Description = schema?.Description,
ExpectedRange = schema?.ExpectedRange,
AbsenceSemantics = schema?.AbsenceSemantics,
DiscriminatesFor = schema?.DiscriminatesFor ?? []
};
}
return new AIEvidence
{
Description = BuildEvidenceDescription(result),
Fields = fields.ToImmutable()
};
}
private static AIRemediation? ConvertRemediation(DoctorCheckResult result)
{
if (result.Remediation is null || result.Remediation.Steps.Count == 0)
{
return null;
}
var steps = result.Remediation.Steps
.OrderBy(static step => step.Order)
.Select(step => new AIRemediationStep
{
Order = step.Order,
Description = step.Description,
Command = step.Command,
CommandType = step.CommandType.ToString().ToLowerInvariant(),
IsSafeToAutoExecute = step.CommandType != CommandType.Manual && !step.IsDestructive,
VerificationCommand = result.VerificationCommand
})
.ToImmutableArray();
return new AIRemediation
{
RequiresBackup = result.Remediation.RequiresBackup,
SafetyNote = result.Remediation.SafetyNote,
Steps = steps,
RunbookUrl = result.Remediation.RunbookUrl
};
}
private static string DetermineType(string value)
{
if (bool.TryParse(value, out _))
{
return "bool";
}
if (long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out _))
{
return "int";
}
if (decimal.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out _))
{
return "float";
}
return "string";
}
private static string BuildEvidenceDescription(DoctorCheckResult result)
{
if (result.Evidence.Data.Count == 0)
{
return result.Evidence.Description;
}
var preview = result.Evidence.Data
.OrderBy(static kv => kv.Key, StringComparer.Ordinal)
.Take(4)
.Select(static kv => $"{kv.Key}={kv.Value}");
return $"{result.Evidence.Description} ({string.Join(", ", preview)})";
}
}

View File

@@ -0,0 +1,98 @@
using StellaOps.Doctor.AdvisoryAI.Models;
namespace StellaOps.Doctor.AdvisoryAI;
/// <summary>
/// Service contract for generating deterministic diagnosis from Doctor run context.
/// </summary>
public interface IDoctorAIDiagnosisService
{
Task<DoctorAIDiagnosisResponse> AnalyzeAsync(
DoctorAIContext context,
DoctorAIDiagnosisOptions? options = null,
CancellationToken ct = default);
Task<AICheckRecommendations> GetRecommendationsAsync(
AICheckResult checkResult,
CancellationToken ct = default);
}
public sealed record DoctorAIDiagnosisOptions
{
public bool IncludeRootCauseAnalysis { get; init; } = true;
public bool IncludeRemediationSuggestions { get; init; } = true;
public bool IncludeCorrelationAnalysis { get; init; } = true;
public int? MaxResponseTokens { get; init; }
public IReadOnlyList<string>? FocusOnChecks { get; init; }
}
public sealed record DoctorAIDiagnosisResponse
{
public required string Assessment { get; init; }
public int HealthScore { get; init; }
public IReadOnlyList<AIIdentifiedIssue> Issues { get; init; } = [];
public IReadOnlyList<AIRootCause> RootCauses { get; init; } = [];
public IReadOnlyList<AICorrelation> Correlations { get; init; } = [];
public IReadOnlyList<string> RecommendedActions { get; init; } = [];
public IReadOnlyList<string> RelatedDocumentation { get; init; } = [];
}
public sealed record AIIdentifiedIssue
{
public required string Summary { get; init; }
public IReadOnlyList<string> AffectedChecks { get; init; } = [];
public string Severity { get; init; } = "unknown";
public string? Impact { get; init; }
public string Urgency { get; init; } = "normal";
}
public sealed record AIRootCause
{
public required string Cause { get; init; }
public float Confidence { get; init; }
public IReadOnlyList<string> SupportingEvidence { get; init; } = [];
public IReadOnlyList<string> AffectedChecks { get; init; } = [];
}
public sealed record AICorrelation
{
public required string Issue1 { get; init; }
public required string Issue2 { get; init; }
public required string Description { get; init; }
public float Strength { get; init; }
}
public sealed record AICheckRecommendations
{
public required string CheckId { get; init; }
public required string Explanation { get; init; }
public string? MostLikelyCause { get; init; }
public IReadOnlyList<string> Steps { get; init; } = [];
public string? VerificationCommand { get; init; }
public IReadOnlyList<string> RelatedChecks { get; init; } = [];
}

View File

@@ -0,0 +1,16 @@
using StellaOps.Doctor.AdvisoryAI.Models;
using StellaOps.Doctor.Models;
namespace StellaOps.Doctor.AdvisoryAI;
/// <summary>
/// Translates Doctor run data into an AdvisoryAI diagnosis context.
/// </summary>
public interface IDoctorContextAdapter
{
Task<DoctorAIContext> CreateContextAsync(DoctorReport report, CancellationToken ct = default);
AICheckResult ConvertResult(DoctorCheckResult result);
AIEvidence CreateAIEvidence(DoctorCheckResult result);
}

View File

@@ -0,0 +1,138 @@
using System.Collections.Immutable;
namespace StellaOps.Doctor.AdvisoryAI;
/// <summary>
/// Registry of field-level evidence metadata used to enrich AI context.
/// </summary>
public interface IEvidenceSchemaRegistry
{
EvidenceFieldSchema? GetFieldSchema(string checkId, string fieldName);
IReadOnlyDictionary<string, EvidenceFieldSchema> GetCheckSchemas(string checkId);
void RegisterSchema(string checkId, string fieldName, EvidenceFieldSchema schema);
}
public sealed record EvidenceFieldSchema
{
public string? Description { get; init; }
public string? ExpectedRange { get; init; }
public string? AbsenceSemantics { get; init; }
public ImmutableArray<string> DiscriminatesFor { get; init; } = [];
public string? Unit { get; init; }
public bool IsKeyDiagnostic { get; init; }
}
/// <summary>
/// In-memory deterministic registry implementation.
/// </summary>
public sealed class InMemoryEvidenceSchemaRegistry : IEvidenceSchemaRegistry
{
private readonly object _sync = new();
private readonly Dictionary<string, Dictionary<string, EvidenceFieldSchema>> _schemas =
new(StringComparer.Ordinal);
public EvidenceFieldSchema? GetFieldSchema(string checkId, string fieldName)
{
lock (_sync)
{
if (_schemas.TryGetValue(checkId, out var checkSchemas) &&
checkSchemas.TryGetValue(fieldName, out var schema))
{
return schema;
}
if (_schemas.TryGetValue("*", out var wildcardSchemas) &&
wildcardSchemas.TryGetValue(fieldName, out var wildcardSchema))
{
return wildcardSchema;
}
}
return null;
}
public IReadOnlyDictionary<string, EvidenceFieldSchema> GetCheckSchemas(string checkId)
{
lock (_sync)
{
if (_schemas.TryGetValue(checkId, out var checkSchemas))
{
return new Dictionary<string, EvidenceFieldSchema>(checkSchemas, StringComparer.Ordinal);
}
}
return new Dictionary<string, EvidenceFieldSchema>(StringComparer.Ordinal);
}
public void RegisterSchema(string checkId, string fieldName, EvidenceFieldSchema schema)
{
ArgumentException.ThrowIfNullOrWhiteSpace(checkId);
ArgumentException.ThrowIfNullOrWhiteSpace(fieldName);
ArgumentNullException.ThrowIfNull(schema);
lock (_sync)
{
if (!_schemas.TryGetValue(checkId, out var checkSchemas))
{
checkSchemas = new Dictionary<string, EvidenceFieldSchema>(StringComparer.Ordinal);
_schemas[checkId] = checkSchemas;
}
checkSchemas[fieldName] = schema;
}
}
public void RegisterCommonSchemas()
{
RegisterSchema("*", "connection_latency_ms", new EvidenceFieldSchema
{
Description = "Time to establish connection.",
ExpectedRange = "< 1000",
Unit = "milliseconds",
IsKeyDiagnostic = true,
DiscriminatesFor = ["network_issue", "service_overloaded"]
});
RegisterSchema("*", "disk_usage_percent", new EvidenceFieldSchema
{
Description = "Disk space utilization percentage.",
ExpectedRange = "< 80",
Unit = "percent",
IsKeyDiagnostic = true,
DiscriminatesFor = ["disk_full", "log_growth"]
});
RegisterSchema("*", "memory_usage_percent", new EvidenceFieldSchema
{
Description = "Memory utilization percentage.",
ExpectedRange = "< 85",
Unit = "percent",
IsKeyDiagnostic = true,
DiscriminatesFor = ["memory_pressure", "load_spike"]
});
RegisterSchema("*", "cpu_usage_percent", new EvidenceFieldSchema
{
Description = "CPU utilization percentage.",
ExpectedRange = "< 80",
Unit = "percent",
IsKeyDiagnostic = true,
DiscriminatesFor = ["cpu_saturation", "runaway_process"]
});
RegisterSchema("*", "queue_depth", new EvidenceFieldSchema
{
Description = "Pending items waiting in queue.",
ExpectedRange = "< 100",
AbsenceSemantics = "Queue may be unavailable or empty.",
DiscriminatesFor = ["backpressure", "slow_consumer"]
});
}
}

View File

@@ -0,0 +1,132 @@
using System.Collections.Immutable;
using StellaOps.Doctor.Models;
namespace StellaOps.Doctor.AdvisoryAI.Models;
/// <summary>
/// Context pack containing Doctor run data structured for deterministic diagnosis.
/// </summary>
public sealed record DoctorAIContext
{
public required string RunId { get; init; }
public DateTimeOffset ExecutedAt { get; init; }
public DoctorSeverity OverallSeverity { get; init; }
public required DoctorSummary Summary { get; init; }
public ImmutableArray<AICheckResult> Results { get; init; } = [];
public ImmutableDictionary<string, string> PlatformContext { get; init; } = ImmutableDictionary<string, string>.Empty;
}
/// <summary>
/// Aggregate summary for a Doctor run.
/// </summary>
public sealed record DoctorSummary
{
public int TotalChecks { get; init; }
public int PassedChecks { get; init; }
public int InfoChecks { get; init; }
public int WarnedChecks { get; init; }
public int FailedChecks { get; init; }
public int SkippedChecks { get; init; }
public ImmutableArray<string> CategoriesWithIssues { get; init; } = [];
}
/// <summary>
/// AI-friendly projection of a Doctor check result.
/// </summary>
public sealed record AICheckResult
{
public required string CheckId { get; init; }
public required string PluginId { get; init; }
public required string Category { get; init; }
public required string CheckName { get; init; }
public DoctorSeverity Severity { get; init; }
public required string Diagnosis { get; init; }
public required AIEvidence Evidence { get; init; }
public ImmutableArray<AICause> LikelyCauses { get; init; } = [];
public AIRemediation? Remediation { get; init; }
public ImmutableArray<string> Tags { get; init; } = [];
}
/// <summary>
/// Evidence container enriched with semantic metadata.
/// </summary>
public sealed record AIEvidence
{
public required string Description { get; init; }
public ImmutableDictionary<string, AIEvidenceField> Fields { get; init; } = ImmutableDictionary<string, AIEvidenceField>.Empty;
}
public sealed record AIEvidenceField
{
public required string Value { get; init; }
public required string Type { get; init; }
public string? Description { get; init; }
public string? ExpectedRange { get; init; }
public string? AbsenceSemantics { get; init; }
public ImmutableArray<string> DiscriminatesFor { get; init; } = [];
}
public sealed record AICause
{
public required string Cause { get; init; }
public float? Probability { get; init; }
public ImmutableArray<string> EvidenceIndicating { get; init; } = [];
public string? Discriminator { get; init; }
}
public sealed record AIRemediation
{
public bool RequiresBackup { get; init; }
public string? SafetyNote { get; init; }
public ImmutableArray<AIRemediationStep> Steps { get; init; } = [];
public string? RunbookUrl { get; init; }
}
public sealed record AIRemediationStep
{
public int Order { get; init; }
public required string Description { get; init; }
public string? Command { get; init; }
public required string CommandType { get; init; }
public bool IsSafeToAutoExecute { get; init; }
public string? ExpectedOutcome { get; init; }
public string? VerificationCommand { get; init; }
}