stela ops usage fixes roles propagation and timoeut, one account to support multi tenants, migrations consolidation, search to support documentation, doctor and open api vector db search
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
using System.CommandLine;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Infrastructure.Postgres.Migrations;
|
||||
using StellaOps.Platform.Database;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Spectre.Console;
|
||||
|
||||
|
||||
@@ -89,6 +89,8 @@ internal static class CommandFactory
|
||||
root.Add(BundleCommandGroup.BuildBundleCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildRiskProfileCommand(verboseOption, cancellationToken));
|
||||
root.Add(BuildAdvisoryCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(KnowledgeSearchCommandGroup.BuildSearchCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(KnowledgeSearchCommandGroup.BuildAdvisoryAiCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildForensicCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildPromotionCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildDetscoreCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
@@ -56,6 +56,7 @@ internal static class DoctorCommandGroup
|
||||
doctor.Add(BuildListCommand(services, verboseOption, cancellationToken));
|
||||
doctor.Add(BuildExportCommand(services, verboseOption, cancellationToken));
|
||||
doctor.Add(BuildFixCommand(services, verboseOption, cancellationToken));
|
||||
doctor.Add(KnowledgeSearchCommandGroup.BuildDoctorSuggestCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
return doctor;
|
||||
}
|
||||
|
||||
587
src/Cli/StellaOps.Cli/Commands/KnowledgeSearchCommandGroup.cs
Normal file
587
src/Cli/StellaOps.Cli/Commands/KnowledgeSearchCommandGroup.cs
Normal file
@@ -0,0 +1,587 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Cli.Services.Models.AdvisoryAi;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.CommandLine;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
internal static class KnowledgeSearchCommandGroup
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOutputOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> AllowedTypes = new(StringComparer.Ordinal)
|
||||
{
|
||||
"docs",
|
||||
"api",
|
||||
"doctor"
|
||||
};
|
||||
|
||||
internal static Command BuildSearchCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var queryArgument = new Argument<string>("query")
|
||||
{
|
||||
Description = "Knowledge query (error text, endpoint question, runbook task)."
|
||||
};
|
||||
|
||||
var typeOption = new Option<string[]>("--type")
|
||||
{
|
||||
Description = "Filter by result type: docs, api, doctor (repeatable or comma-separated).",
|
||||
Arity = ArgumentArity.ZeroOrMore,
|
||||
AllowMultipleArgumentsPerToken = true
|
||||
};
|
||||
|
||||
var productOption = new Option<string?>("--product")
|
||||
{
|
||||
Description = "Filter by product identifier."
|
||||
};
|
||||
|
||||
var versionOption = new Option<string?>("--version")
|
||||
{
|
||||
Description = "Filter by product version."
|
||||
};
|
||||
|
||||
var serviceOption = new Option<string?>("--service")
|
||||
{
|
||||
Description = "Filter by service (especially useful for API operations)."
|
||||
};
|
||||
|
||||
var tagOption = new Option<string[]>("--tag")
|
||||
{
|
||||
Description = "Filter by tags (repeatable or comma-separated).",
|
||||
Arity = ArgumentArity.ZeroOrMore,
|
||||
AllowMultipleArgumentsPerToken = true
|
||||
};
|
||||
|
||||
var topKOption = new Option<int?>("--k")
|
||||
{
|
||||
Description = "Number of results to return (1-100, default 10)."
|
||||
};
|
||||
|
||||
var jsonOption = new Option<bool>("--json")
|
||||
{
|
||||
Description = "Emit machine-readable JSON output."
|
||||
};
|
||||
|
||||
var search = new Command("search", "Search AdvisoryAI knowledge index across docs, API operations, and doctor checks.");
|
||||
search.Add(queryArgument);
|
||||
search.Add(typeOption);
|
||||
search.Add(productOption);
|
||||
search.Add(versionOption);
|
||||
search.Add(serviceOption);
|
||||
search.Add(tagOption);
|
||||
search.Add(topKOption);
|
||||
search.Add(jsonOption);
|
||||
search.Add(verboseOption);
|
||||
|
||||
search.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
var query = parseResult.GetValue(queryArgument) ?? string.Empty;
|
||||
var types = parseResult.GetValue(typeOption) ?? Array.Empty<string>();
|
||||
var tags = parseResult.GetValue(tagOption) ?? Array.Empty<string>();
|
||||
var product = parseResult.GetValue(productOption);
|
||||
var version = parseResult.GetValue(versionOption);
|
||||
var service = parseResult.GetValue(serviceOption);
|
||||
var topK = parseResult.GetValue(topKOption);
|
||||
var emitJson = parseResult.GetValue(jsonOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
await ExecuteSearchAsync(
|
||||
services,
|
||||
query,
|
||||
types,
|
||||
tags,
|
||||
product,
|
||||
version,
|
||||
service,
|
||||
topK,
|
||||
emitJson,
|
||||
verbose,
|
||||
suggestMode: false,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
});
|
||||
|
||||
return search;
|
||||
}
|
||||
|
||||
internal static Command BuildAdvisoryAiCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var advisoryAi = new Command("advisoryai", "AdvisoryAI maintenance commands.");
|
||||
var index = new Command("index", "Knowledge index operations.");
|
||||
var rebuild = new Command("rebuild", "Rebuild AdvisoryAI deterministic knowledge index.");
|
||||
var jsonOption = new Option<bool>("--json")
|
||||
{
|
||||
Description = "Emit machine-readable JSON output."
|
||||
};
|
||||
|
||||
rebuild.Add(jsonOption);
|
||||
rebuild.Add(verboseOption);
|
||||
rebuild.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
var emitJson = parseResult.GetValue(jsonOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
await ExecuteRebuildAsync(services, emitJson, verbose, cancellationToken).ConfigureAwait(false);
|
||||
});
|
||||
|
||||
index.Add(rebuild);
|
||||
advisoryAi.Add(index);
|
||||
return advisoryAi;
|
||||
}
|
||||
|
||||
internal static Command BuildDoctorSuggestCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var symptomArgument = new Argument<string>("symptom")
|
||||
{
|
||||
Description = "Symptom text, log fragment, or error message."
|
||||
};
|
||||
|
||||
var productOption = new Option<string?>("--product")
|
||||
{
|
||||
Description = "Optional product filter."
|
||||
};
|
||||
|
||||
var versionOption = new Option<string?>("--version")
|
||||
{
|
||||
Description = "Optional version filter."
|
||||
};
|
||||
|
||||
var topKOption = new Option<int?>("--k")
|
||||
{
|
||||
Description = "Number of results to return (1-100, default 10)."
|
||||
};
|
||||
|
||||
var jsonOption = new Option<bool>("--json")
|
||||
{
|
||||
Description = "Emit machine-readable JSON output."
|
||||
};
|
||||
|
||||
var suggest = new Command("suggest", "Suggest checks, docs, and API operations for a symptom via AdvisoryAI knowledge search.");
|
||||
suggest.Add(symptomArgument);
|
||||
suggest.Add(productOption);
|
||||
suggest.Add(versionOption);
|
||||
suggest.Add(topKOption);
|
||||
suggest.Add(jsonOption);
|
||||
suggest.Add(verboseOption);
|
||||
|
||||
suggest.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
var symptom = parseResult.GetValue(symptomArgument) ?? string.Empty;
|
||||
var product = parseResult.GetValue(productOption);
|
||||
var version = parseResult.GetValue(versionOption);
|
||||
var topK = parseResult.GetValue(topKOption);
|
||||
var emitJson = parseResult.GetValue(jsonOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
await ExecuteSearchAsync(
|
||||
services,
|
||||
symptom,
|
||||
types: Array.Empty<string>(),
|
||||
tags: Array.Empty<string>(),
|
||||
product,
|
||||
version,
|
||||
service: null,
|
||||
topK,
|
||||
emitJson,
|
||||
verbose,
|
||||
suggestMode: true,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
});
|
||||
|
||||
return suggest;
|
||||
}
|
||||
|
||||
private static async Task ExecuteSearchAsync(
|
||||
IServiceProvider services,
|
||||
string query,
|
||||
IReadOnlyList<string> types,
|
||||
IReadOnlyList<string> tags,
|
||||
string? product,
|
||||
string? version,
|
||||
string? service,
|
||||
int? topK,
|
||||
bool emitJson,
|
||||
bool verbose,
|
||||
bool suggestMode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var normalizedQuery = (query ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalizedQuery))
|
||||
{
|
||||
Console.Error.WriteLine("Query text is required.");
|
||||
Environment.ExitCode = CliExitCodes.MissingRequiredOption;
|
||||
return;
|
||||
}
|
||||
|
||||
var normalizedTypes = NormalizeTypes(types);
|
||||
var normalizedTags = NormalizeTags(tags);
|
||||
var boundedTopK = topK.HasValue ? Math.Clamp(topK.Value, 1, 100) : (int?)null;
|
||||
|
||||
var filter = BuildFilter(normalizedTypes, normalizedTags, product, version, service);
|
||||
var request = new AdvisoryKnowledgeSearchRequestModel
|
||||
{
|
||||
Q = normalizedQuery,
|
||||
K = boundedTopK,
|
||||
Filters = filter,
|
||||
IncludeDebug = verbose
|
||||
};
|
||||
|
||||
var backend = services.GetRequiredService<IBackendOperationsClient>();
|
||||
AdvisoryKnowledgeSearchResponseModel response;
|
||||
try
|
||||
{
|
||||
response = await backend.SearchAdvisoryKnowledgeAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Knowledge search failed: {ex.Message}");
|
||||
Environment.ExitCode = CliExitCodes.GeneralError;
|
||||
return;
|
||||
}
|
||||
|
||||
if (emitJson)
|
||||
{
|
||||
WriteJson(ToJsonPayload(response));
|
||||
return;
|
||||
}
|
||||
|
||||
if (suggestMode)
|
||||
{
|
||||
RenderSuggestionOutput(response, verbose);
|
||||
return;
|
||||
}
|
||||
|
||||
RenderSearchOutput(response, verbose);
|
||||
}
|
||||
|
||||
private static async Task ExecuteRebuildAsync(
|
||||
IServiceProvider services,
|
||||
bool emitJson,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var backend = services.GetRequiredService<IBackendOperationsClient>();
|
||||
AdvisoryKnowledgeRebuildResponseModel summary;
|
||||
try
|
||||
{
|
||||
summary = await backend.RebuildAdvisoryKnowledgeIndexAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"AdvisoryAI index rebuild failed: {ex.Message}");
|
||||
Environment.ExitCode = CliExitCodes.GeneralError;
|
||||
return;
|
||||
}
|
||||
|
||||
if (emitJson)
|
||||
{
|
||||
WriteJson(new
|
||||
{
|
||||
documentCount = summary.DocumentCount,
|
||||
chunkCount = summary.ChunkCount,
|
||||
apiSpecCount = summary.ApiSpecCount,
|
||||
apiOperationCount = summary.ApiOperationCount,
|
||||
doctorProjectionCount = summary.DoctorProjectionCount,
|
||||
durationMs = summary.DurationMs
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine("AdvisoryAI knowledge index rebuilt.");
|
||||
Console.WriteLine($" Documents: {summary.DocumentCount}");
|
||||
Console.WriteLine($" Chunks: {summary.ChunkCount}");
|
||||
Console.WriteLine($" API specs: {summary.ApiSpecCount}");
|
||||
Console.WriteLine($" API operations: {summary.ApiOperationCount}");
|
||||
Console.WriteLine($" Doctor projections: {summary.DoctorProjectionCount}");
|
||||
Console.WriteLine($" Duration: {summary.DurationMs.ToString(CultureInfo.InvariantCulture)} ms");
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
Console.WriteLine(" Rebuild scope: markdown + openapi + doctor projection.");
|
||||
}
|
||||
}
|
||||
|
||||
private static AdvisoryKnowledgeSearchFilterModel? BuildFilter(
|
||||
IReadOnlyList<string> types,
|
||||
IReadOnlyList<string> tags,
|
||||
string? product,
|
||||
string? version,
|
||||
string? service)
|
||||
{
|
||||
var normalizedProduct = NormalizeOptional(product);
|
||||
var normalizedVersion = NormalizeOptional(version);
|
||||
var normalizedService = NormalizeOptional(service);
|
||||
|
||||
if (types.Count == 0 &&
|
||||
tags.Count == 0 &&
|
||||
normalizedProduct is null &&
|
||||
normalizedVersion is null &&
|
||||
normalizedService is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AdvisoryKnowledgeSearchFilterModel
|
||||
{
|
||||
Type = types.Count == 0 ? null : types,
|
||||
Product = normalizedProduct,
|
||||
Version = normalizedVersion,
|
||||
Service = normalizedService,
|
||||
Tags = tags.Count == 0 ? null : tags
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> NormalizeTypes(IEnumerable<string> types)
|
||||
{
|
||||
var result = new SortedSet<string>(StringComparer.Ordinal);
|
||||
foreach (var raw in types)
|
||||
{
|
||||
foreach (var token in SplitCsvTokens(raw))
|
||||
{
|
||||
var normalized = token.ToLowerInvariant();
|
||||
if (AllowedTypes.Contains(normalized))
|
||||
{
|
||||
result.Add(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> NormalizeTags(IEnumerable<string> tags)
|
||||
{
|
||||
var result = new SortedSet<string>(StringComparer.Ordinal);
|
||||
foreach (var raw in tags)
|
||||
{
|
||||
foreach (var token in SplitCsvTokens(raw))
|
||||
{
|
||||
result.Add(token.ToLowerInvariant());
|
||||
}
|
||||
}
|
||||
|
||||
return result.ToArray();
|
||||
}
|
||||
|
||||
private static IEnumerable<string> SplitCsvTokens(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var token in value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
yield return token.Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string? NormalizeOptional(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
private static void RenderSearchOutput(AdvisoryKnowledgeSearchResponseModel response, bool verbose)
|
||||
{
|
||||
Console.WriteLine($"Query: {response.Query}");
|
||||
Console.WriteLine($"Results: {response.Results.Count.ToString(CultureInfo.InvariantCulture)} / topK {response.TopK.ToString(CultureInfo.InvariantCulture)}");
|
||||
Console.WriteLine($"Mode: {response.Diagnostics.Mode} (fts={response.Diagnostics.FtsMatches.ToString(CultureInfo.InvariantCulture)}, vector={response.Diagnostics.VectorMatches.ToString(CultureInfo.InvariantCulture)}, duration={response.Diagnostics.DurationMs.ToString(CultureInfo.InvariantCulture)}ms)");
|
||||
Console.WriteLine();
|
||||
|
||||
if (response.Results.Count == 0)
|
||||
{
|
||||
Console.WriteLine("No results found.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (var index = 0; index < response.Results.Count; index++)
|
||||
{
|
||||
var result = response.Results[index];
|
||||
Console.WriteLine($"[{(index + 1).ToString(CultureInfo.InvariantCulture)}] {result.Type.ToUpperInvariant()} score={result.Score.ToString("F6", CultureInfo.InvariantCulture)}");
|
||||
Console.WriteLine($" {result.Title}");
|
||||
var snippet = CollapseWhitespace(result.Snippet);
|
||||
if (!string.IsNullOrWhiteSpace(snippet))
|
||||
{
|
||||
Console.WriteLine($" {snippet}");
|
||||
}
|
||||
|
||||
var reference = FormatOpenReference(result);
|
||||
if (!string.IsNullOrWhiteSpace(reference))
|
||||
{
|
||||
Console.WriteLine($" {reference}");
|
||||
}
|
||||
|
||||
if (verbose && result.Debug is { Count: > 0 })
|
||||
{
|
||||
foreach (var pair in result.Debug.OrderBy(static pair => pair.Key, StringComparer.Ordinal))
|
||||
{
|
||||
Console.WriteLine($" debug.{pair.Key}: {pair.Value}");
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
}
|
||||
}
|
||||
|
||||
private static void RenderSuggestionOutput(AdvisoryKnowledgeSearchResponseModel response, bool verbose)
|
||||
{
|
||||
Console.WriteLine($"Symptom: {response.Query}");
|
||||
Console.WriteLine($"Mode: {response.Diagnostics.Mode} (duration={response.Diagnostics.DurationMs.ToString(CultureInfo.InvariantCulture)}ms)");
|
||||
Console.WriteLine();
|
||||
|
||||
var doctor = response.Results.Where(static result => result.Type.Equals("doctor", StringComparison.OrdinalIgnoreCase)).ToArray();
|
||||
var docs = response.Results.Where(static result => result.Type.Equals("docs", StringComparison.OrdinalIgnoreCase)).ToArray();
|
||||
var api = response.Results.Where(static result => result.Type.Equals("api", StringComparison.OrdinalIgnoreCase)).ToArray();
|
||||
|
||||
RenderSuggestionGroup("Recommended checks", doctor, verbose);
|
||||
RenderSuggestionGroup("Related docs", docs, verbose);
|
||||
RenderSuggestionGroup("Related endpoints", api, verbose);
|
||||
}
|
||||
|
||||
private static void RenderSuggestionGroup(string label, IReadOnlyList<AdvisoryKnowledgeSearchResultModel> results, bool verbose)
|
||||
{
|
||||
Console.WriteLine(label + ":");
|
||||
if (results.Count == 0)
|
||||
{
|
||||
Console.WriteLine(" (none)");
|
||||
Console.WriteLine();
|
||||
return;
|
||||
}
|
||||
|
||||
for (var index = 0; index < results.Count; index++)
|
||||
{
|
||||
var result = results[index];
|
||||
Console.WriteLine($" {(index + 1).ToString(CultureInfo.InvariantCulture)}. {result.Title} (score={result.Score.ToString("F6", CultureInfo.InvariantCulture)})");
|
||||
var reference = FormatOpenReference(result);
|
||||
if (!string.IsNullOrWhiteSpace(reference))
|
||||
{
|
||||
Console.WriteLine($" {reference}");
|
||||
}
|
||||
|
||||
var snippet = CollapseWhitespace(result.Snippet);
|
||||
if (!string.IsNullOrWhiteSpace(snippet))
|
||||
{
|
||||
Console.WriteLine($" {snippet}");
|
||||
}
|
||||
|
||||
if (verbose && result.Debug is { Count: > 0 })
|
||||
{
|
||||
foreach (var pair in result.Debug.OrderBy(static pair => pair.Key, StringComparer.Ordinal))
|
||||
{
|
||||
Console.WriteLine($" debug.{pair.Key}: {pair.Value}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
private static string FormatOpenReference(AdvisoryKnowledgeSearchResultModel result)
|
||||
{
|
||||
if (result.Open is null)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if (result.Type.Equals("docs", StringComparison.OrdinalIgnoreCase) && result.Open.Docs is not null)
|
||||
{
|
||||
var docs = result.Open.Docs;
|
||||
return $"docs: {docs.Path}#{docs.Anchor} lines {docs.SpanStart.ToString(CultureInfo.InvariantCulture)}-{docs.SpanEnd.ToString(CultureInfo.InvariantCulture)}";
|
||||
}
|
||||
|
||||
if (result.Type.Equals("api", StringComparison.OrdinalIgnoreCase) && result.Open.Api is not null)
|
||||
{
|
||||
var api = result.Open.Api;
|
||||
return $"api: {api.Method} {api.Path} operationId={api.OperationId} service={api.Service}";
|
||||
}
|
||||
|
||||
if (result.Type.Equals("doctor", StringComparison.OrdinalIgnoreCase) && result.Open.Doctor is not null)
|
||||
{
|
||||
var doctor = result.Open.Doctor;
|
||||
return $"doctor: {doctor.CheckCode} severity={doctor.Severity} run=\"{doctor.RunCommand}\"";
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private static string CollapseWhitespace(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var collapsed = string.Join(
|
||||
' ',
|
||||
value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
|
||||
|
||||
const int maxLength = 320;
|
||||
if (collapsed.Length <= maxLength)
|
||||
{
|
||||
return collapsed;
|
||||
}
|
||||
|
||||
return collapsed[..maxLength] + "...";
|
||||
}
|
||||
|
||||
private static object ToJsonPayload(AdvisoryKnowledgeSearchResponseModel response)
|
||||
{
|
||||
return new
|
||||
{
|
||||
query = response.Query,
|
||||
topK = response.TopK,
|
||||
diagnostics = new
|
||||
{
|
||||
ftsMatches = response.Diagnostics.FtsMatches,
|
||||
vectorMatches = response.Diagnostics.VectorMatches,
|
||||
durationMs = response.Diagnostics.DurationMs,
|
||||
usedVector = response.Diagnostics.UsedVector,
|
||||
mode = response.Diagnostics.Mode
|
||||
},
|
||||
results = response.Results.Select(static result => new
|
||||
{
|
||||
type = result.Type,
|
||||
title = result.Title,
|
||||
snippet = result.Snippet,
|
||||
score = result.Score,
|
||||
open = new
|
||||
{
|
||||
kind = result.Open.Kind,
|
||||
docs = result.Open.Docs,
|
||||
api = result.Open.Api,
|
||||
doctor = result.Open.Doctor
|
||||
},
|
||||
debug = result.Debug is null
|
||||
? null
|
||||
: result.Debug
|
||||
.OrderBy(static pair => pair.Key, StringComparer.Ordinal)
|
||||
.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal)
|
||||
}).ToArray()
|
||||
};
|
||||
}
|
||||
|
||||
private static void WriteJson(object payload)
|
||||
{
|
||||
Console.WriteLine(JsonSerializer.Serialize(payload, JsonOutputOptions));
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Cli.Extensions;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Infrastructure.Postgres.Migrations;
|
||||
using StellaOps.Platform.Database;
|
||||
using System;
|
||||
using System.CommandLine;
|
||||
using System.Linq;
|
||||
@@ -35,9 +36,10 @@ internal static class SystemCommandBuilder
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var moduleChoices = string.Join(", ", MigrationModuleRegistry.ModuleNames.OrderBy(static n => n));
|
||||
var moduleOption = new Option<string?>("--module")
|
||||
{
|
||||
Description = "Module name (Authority, Scheduler, Concelier, Policy, Notify, Excititor, all)"
|
||||
Description = $"Module name ({moduleChoices}, all)"
|
||||
};
|
||||
var categoryOption = new Option<string?>("--category")
|
||||
{
|
||||
|
||||
@@ -1393,6 +1393,87 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AdvisoryKnowledgeSearchResponseModel> SearchAdvisoryKnowledgeAsync(
|
||||
AdvisoryKnowledgeSearchRequestModel request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Q))
|
||||
{
|
||||
throw new ArgumentException("Knowledge search query is required.", nameof(request));
|
||||
}
|
||||
|
||||
using var httpRequest = CreateRequest(HttpMethod.Post, "v1/advisory-ai/search");
|
||||
ApplyAdvisoryAiEndpoint(httpRequest, "advisory:run advisory:search");
|
||||
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
httpRequest.Content = JsonContent.Create(request, options: SerializerOptions);
|
||||
|
||||
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException(failure);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var payload = await response.Content.ReadFromJsonAsync<AdvisoryKnowledgeSearchResponseModel>(SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (payload is null)
|
||||
{
|
||||
throw new InvalidOperationException("Advisory knowledge search response was empty.");
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
var raw = response.Content is null
|
||||
? string.Empty
|
||||
: await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException($"Failed to parse advisory knowledge search response. {ex.Message}", ex)
|
||||
{
|
||||
Data = { ["payload"] = raw }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AdvisoryKnowledgeRebuildResponseModel> RebuildAdvisoryKnowledgeIndexAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var request = CreateRequest(HttpMethod.Post, "v1/advisory-ai/index/rebuild");
|
||||
ApplyAdvisoryAiEndpoint(request, "advisory:run advisory:admin advisory:index:write");
|
||||
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException(failure);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var summary = await response.Content.ReadFromJsonAsync<AdvisoryKnowledgeRebuildResponseModel>(SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (summary is null)
|
||||
{
|
||||
throw new InvalidOperationException("Advisory knowledge index rebuild response was empty.");
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
var raw = response.Content is null
|
||||
? string.Empty
|
||||
: await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException($"Failed to parse advisory knowledge index rebuild response. {ex.Message}", ex)
|
||||
{
|
||||
Data = { ["payload"] = raw }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ExcititorProviderSummary>> GetExcititorProvidersAsync(bool includeDisabled, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
@@ -2284,6 +2365,12 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
}
|
||||
|
||||
private void ApplyAdvisoryAiEndpoint(HttpRequestMessage request, AdvisoryAiTaskType taskType)
|
||||
{
|
||||
var taskScope = $"advisory:{taskType.ToString().ToLowerInvariant()}";
|
||||
ApplyAdvisoryAiEndpoint(request, $"{AdvisoryRunScope} {taskScope}");
|
||||
}
|
||||
|
||||
private void ApplyAdvisoryAiEndpoint(HttpRequestMessage request, string scopeHeaderValue)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
@@ -2309,15 +2396,20 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
EnsureBackendConfigured();
|
||||
}
|
||||
|
||||
var taskScope = $"advisory:{taskType.ToString().ToLowerInvariant()}";
|
||||
var combined = $"{AdvisoryRunScope} {taskScope}";
|
||||
var normalizedScopes = string.IsNullOrWhiteSpace(scopeHeaderValue)
|
||||
? AdvisoryRunScope
|
||||
: string.Join(
|
||||
' ',
|
||||
scopeHeaderValue
|
||||
.Split([' ', ','], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
if (request.Headers.Contains(AdvisoryScopesHeader))
|
||||
{
|
||||
request.Headers.Remove(AdvisoryScopesHeader);
|
||||
}
|
||||
|
||||
request.Headers.TryAddWithoutValidation(AdvisoryScopesHeader, combined);
|
||||
request.Headers.TryAddWithoutValidation(AdvisoryScopesHeader, normalizedScopes);
|
||||
}
|
||||
|
||||
private static void ApplyTenantHeader(HttpRequestMessage request, string? tenantId)
|
||||
|
||||
@@ -68,6 +68,10 @@ internal interface IBackendOperationsClient
|
||||
|
||||
Task<AdvisoryPipelineOutputModel?> TryGetAdvisoryPipelineOutputAsync(string cacheKey, AdvisoryAiTaskType taskType, string profile, CancellationToken cancellationToken);
|
||||
|
||||
Task<AdvisoryKnowledgeSearchResponseModel> SearchAdvisoryKnowledgeAsync(AdvisoryKnowledgeSearchRequestModel request, CancellationToken cancellationToken);
|
||||
|
||||
Task<AdvisoryKnowledgeRebuildResponseModel> RebuildAdvisoryKnowledgeIndexAsync(CancellationToken cancellationToken);
|
||||
|
||||
// CLI-VEX-30-001: VEX consensus operations
|
||||
Task<VexConsensusListResponse> ListVexConsensusAsync(VexConsensusListRequest request, string? tenant, CancellationToken cancellationToken);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Infrastructure.Postgres.Migrations;
|
||||
using StellaOps.Platform.Database;
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
|
||||
using StellaOps.Authority.Persistence.Postgres;
|
||||
using StellaOps.Concelier.Persistence.Postgres;
|
||||
using StellaOps.Excititor.Persistence.Postgres;
|
||||
using StellaOps.Notify.Persistence.Postgres;
|
||||
using StellaOps.Policy.Persistence.Postgres;
|
||||
using StellaOps.Scheduler.Persistence.Postgres;
|
||||
using System.Reflection;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a PostgreSQL module with its migration metadata.
|
||||
/// </summary>
|
||||
public sealed record MigrationModuleInfo(
|
||||
string Name,
|
||||
string SchemaName,
|
||||
Assembly MigrationsAssembly,
|
||||
string? ResourcePrefix = null);
|
||||
|
||||
/// <summary>
|
||||
/// Registry of all PostgreSQL modules and their migration assemblies.
|
||||
/// Stub implementation - actual module assemblies will be wired in Wave 3-8.
|
||||
/// </summary>
|
||||
public static class MigrationModuleRegistry
|
||||
{
|
||||
private static readonly List<MigrationModuleInfo> _modules =
|
||||
[
|
||||
new(
|
||||
Name: "Authority",
|
||||
SchemaName: "authority",
|
||||
MigrationsAssembly: typeof(AuthorityDataSource).Assembly,
|
||||
ResourcePrefix: "StellaOps.Authority.Persistence.Migrations"),
|
||||
new(
|
||||
Name: "Scheduler",
|
||||
SchemaName: "scheduler",
|
||||
MigrationsAssembly: typeof(SchedulerDataSource).Assembly,
|
||||
ResourcePrefix: "StellaOps.Scheduler.Persistence.Migrations"),
|
||||
new(
|
||||
Name: "Concelier",
|
||||
SchemaName: "vuln",
|
||||
MigrationsAssembly: typeof(ConcelierDataSource).Assembly,
|
||||
ResourcePrefix: "StellaOps.Concelier.Persistence.Migrations"),
|
||||
new(
|
||||
Name: "Policy",
|
||||
SchemaName: "policy",
|
||||
MigrationsAssembly: typeof(PolicyDataSource).Assembly,
|
||||
ResourcePrefix: "StellaOps.Policy.Persistence.Migrations"),
|
||||
new(
|
||||
Name: "Notify",
|
||||
SchemaName: "notify",
|
||||
MigrationsAssembly: typeof(NotifyDataSource).Assembly,
|
||||
ResourcePrefix: "StellaOps.Notify.Persistence.Migrations"),
|
||||
new(
|
||||
Name: "Excititor",
|
||||
SchemaName: "vex",
|
||||
MigrationsAssembly: typeof(ExcititorDataSource).Assembly,
|
||||
ResourcePrefix: "StellaOps.Excititor.Persistence.Migrations"),
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Gets all registered modules.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<MigrationModuleInfo> Modules => _modules;
|
||||
|
||||
/// <summary>
|
||||
/// Gets module names for CLI completion.
|
||||
/// </summary>
|
||||
public static IEnumerable<string> ModuleNames => _modules.Select(m => m.Name);
|
||||
|
||||
/// <summary>
|
||||
/// Finds a module by name (case-insensitive).
|
||||
/// </summary>
|
||||
public static MigrationModuleInfo? FindModule(string name) =>
|
||||
_modules.FirstOrDefault(m =>
|
||||
string.Equals(m.Name, name, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
/// <summary>
|
||||
/// Gets modules matching the filter, or all if filter is null/empty.
|
||||
/// </summary>
|
||||
public static IEnumerable<MigrationModuleInfo> GetModules(string? moduleFilter)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(moduleFilter) || moduleFilter.Equals("all", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return _modules;
|
||||
}
|
||||
|
||||
var module = FindModule(moduleFilter);
|
||||
return module != null ? [module] : [];
|
||||
}
|
||||
}
|
||||
@@ -146,3 +146,125 @@ internal sealed class AdvisoryOutputProvenanceModel
|
||||
|
||||
public IReadOnlyList<string> Signatures { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryKnowledgeSearchRequestModel
|
||||
{
|
||||
public string Q { get; init; } = string.Empty;
|
||||
|
||||
public int? K { get; init; }
|
||||
|
||||
public AdvisoryKnowledgeSearchFilterModel? Filters { get; init; }
|
||||
|
||||
public bool IncludeDebug { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryKnowledgeSearchFilterModel
|
||||
{
|
||||
public IReadOnlyList<string>? Type { get; init; }
|
||||
|
||||
public string? Product { get; init; }
|
||||
|
||||
public string? Version { get; init; }
|
||||
|
||||
public string? Service { get; init; }
|
||||
|
||||
public IReadOnlyList<string>? Tags { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryKnowledgeSearchResponseModel
|
||||
{
|
||||
public string Query { get; init; } = string.Empty;
|
||||
|
||||
public int TopK { get; init; }
|
||||
|
||||
public IReadOnlyList<AdvisoryKnowledgeSearchResultModel> Results { get; init; } = Array.Empty<AdvisoryKnowledgeSearchResultModel>();
|
||||
|
||||
public AdvisoryKnowledgeSearchDiagnosticsModel Diagnostics { get; init; } = new();
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryKnowledgeSearchResultModel
|
||||
{
|
||||
public string Type { get; init; } = "docs";
|
||||
|
||||
public string Title { get; init; } = string.Empty;
|
||||
|
||||
public string Snippet { get; init; } = string.Empty;
|
||||
|
||||
public double Score { get; init; }
|
||||
|
||||
public AdvisoryKnowledgeOpenActionModel Open { get; init; } = new();
|
||||
|
||||
public IReadOnlyDictionary<string, string>? Debug { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryKnowledgeOpenActionModel
|
||||
{
|
||||
public string Kind { get; init; } = "docs";
|
||||
|
||||
public AdvisoryKnowledgeOpenDocActionModel? Docs { get; init; }
|
||||
|
||||
public AdvisoryKnowledgeOpenApiActionModel? Api { get; init; }
|
||||
|
||||
public AdvisoryKnowledgeOpenDoctorActionModel? Doctor { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryKnowledgeOpenDocActionModel
|
||||
{
|
||||
public string Path { get; init; } = string.Empty;
|
||||
|
||||
public string Anchor { get; init; } = "overview";
|
||||
|
||||
public int SpanStart { get; init; }
|
||||
|
||||
public int SpanEnd { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryKnowledgeOpenApiActionModel
|
||||
{
|
||||
public string Service { get; init; } = string.Empty;
|
||||
|
||||
public string Method { get; init; } = "GET";
|
||||
|
||||
public string Path { get; init; } = "/";
|
||||
|
||||
public string OperationId { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryKnowledgeOpenDoctorActionModel
|
||||
{
|
||||
public string CheckCode { get; init; } = string.Empty;
|
||||
|
||||
public string Severity { get; init; } = "warn";
|
||||
|
||||
public bool CanRun { get; init; } = true;
|
||||
|
||||
public string RunCommand { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryKnowledgeSearchDiagnosticsModel
|
||||
{
|
||||
public int FtsMatches { get; init; }
|
||||
|
||||
public int VectorMatches { get; init; }
|
||||
|
||||
public long DurationMs { get; init; }
|
||||
|
||||
public bool UsedVector { get; init; }
|
||||
|
||||
public string Mode { get; init; } = "fts-only";
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryKnowledgeRebuildResponseModel
|
||||
{
|
||||
public int DocumentCount { get; init; }
|
||||
|
||||
public int ChunkCount { get; init; }
|
||||
|
||||
public int ApiSpecCount { get; init; }
|
||||
|
||||
public int ApiOperationCount { get; init; }
|
||||
|
||||
public int DoctorProjectionCount { get; init; }
|
||||
|
||||
public long DurationMs { get; init; }
|
||||
}
|
||||
|
||||
@@ -64,12 +64,14 @@
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.DeltaVerdict/StellaOps.DeltaVerdict.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Verdict/StellaOps.Verdict.csproj" />
|
||||
<ProjectReference Include="../../AirGap/__Libraries/StellaOps.AirGap.Bundle/StellaOps.AirGap.Bundle.csproj" />
|
||||
<ProjectReference Include="../../AirGap/__Libraries/StellaOps.AirGap.Persistence/StellaOps.AirGap.Persistence.csproj" />
|
||||
<ProjectReference Include="../../AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy.csproj" />
|
||||
<ProjectReference Include="../../AirGap/StellaOps.AirGap.Importer/StellaOps.AirGap.Importer.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.EntryTrace/StellaOps.Scanner.EntryTrace.csproj" />
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Storage/StellaOps.Scanner.Storage.csproj" />
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Storage.Oci/StellaOps.Scanner.Storage.Oci.csproj" />
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang/StellaOps.Scanner.Analyzers.Lang.csproj" />
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Node/StellaOps.Scanner.Analyzers.Lang.Node.csproj" />
|
||||
@@ -103,6 +105,8 @@
|
||||
<ProjectReference Include="../../Excititor/__Libraries/StellaOps.Excititor.Core/StellaOps.Excititor.Core.csproj" />
|
||||
<ProjectReference Include="../../Excititor/__Libraries/StellaOps.Excititor.Persistence/StellaOps.Excititor.Persistence.csproj" />
|
||||
<ProjectReference Include="../../Policy/StellaOps.Policy.Scoring/StellaOps.Policy.Scoring.csproj" />
|
||||
<ProjectReference Include="../../Platform/__Libraries/StellaOps.Platform.Database/StellaOps.Platform.Database.csproj" />
|
||||
<ProjectReference Include="../../TimelineIndexer/StellaOps.TimelineIndexer/StellaOps.TimelineIndexer.Infrastructure/StellaOps.TimelineIndexer.Infrastructure.csproj" />
|
||||
<ProjectReference Include="../../ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Client/StellaOps.ExportCenter.Client.csproj" />
|
||||
<ProjectReference Include="../../ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Core/StellaOps.ExportCenter.Core.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.AuditPack/StellaOps.AuditPack.csproj" />
|
||||
|
||||
@@ -5,6 +5,8 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| SPRINT_20260222_051-MGC-04-W1 | DONE | Expanded migration registry coverage to `AirGap`, `Scanner`, `TimelineIndexer`, and `Platform` (10 total modules); moved registry ownership to `StellaOps.Platform.Database` and rewired CLI migration commands to consume the platform-owned registry. |
|
||||
| SPRINT_20260222_051-MGC-04-W1-PLUGINS | DONE | CLI migration commands now consume plugin auto-discovered module catalog from `StellaOps.Platform.Database` (`IMigrationModulePlugin`) instead of hardcoded module registration. |
|
||||
| SPRINT_20260221_043-CLI-SEED-001 | DONE | Sprint `docs/implplan/SPRINT_20260221_043_DOCS_setup_seed_error_handling_stabilization.md`: harden seed/migration first-run flow and fix dry-run migration reporting semantics. |
|
||||
| AUDIT-0137-M | DONE | Revalidated 2026-01-06. |
|
||||
| AUDIT-0137-T | DONE | Revalidated 2026-01-06. |
|
||||
|
||||
@@ -132,4 +132,19 @@ public sealed class CommandFactoryTests
|
||||
var sbomLake = Assert.Single(analytics.Subcommands, command => string.Equals(command.Name, "sbom-lake", StringComparison.Ordinal));
|
||||
Assert.Contains(sbomLake.Subcommands, command => string.Equals(command.Name, "suppliers", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_ExposesKnowledgeSearchCommands()
|
||||
{
|
||||
using var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.None));
|
||||
var services = new ServiceCollection().BuildServiceProvider();
|
||||
var root = CommandFactory.Create(services, new StellaOpsCliOptions(), CancellationToken.None, loggerFactory);
|
||||
|
||||
var search = Assert.Single(root.Subcommands, command => string.Equals(command.Name, "search", StringComparison.Ordinal));
|
||||
Assert.NotNull(search);
|
||||
|
||||
var advisoryAi = Assert.Single(root.Subcommands, command => string.Equals(command.Name, "advisoryai", StringComparison.Ordinal));
|
||||
var index = Assert.Single(advisoryAi.Subcommands, command => string.Equals(command.Name, "index", StringComparison.Ordinal));
|
||||
Assert.Contains(index.Subcommands, command => string.Equals(command.Name, "rebuild", StringComparison.Ordinal));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +93,22 @@ public sealed class DoctorCommandGroupTests
|
||||
fixCommand!.Description.Should().Contain("fix");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildDoctorCommand_HasSuggestSubcommand()
|
||||
{
|
||||
// Arrange
|
||||
var services = CreateTestServices();
|
||||
var verboseOption = new Option<bool>("--verbose");
|
||||
|
||||
// Act
|
||||
var command = DoctorCommandGroup.BuildDoctorCommand(services, verboseOption, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
var suggestCommand = command.Subcommands.FirstOrDefault(c => c.Name == "suggest");
|
||||
suggestCommand.Should().NotBeNull();
|
||||
suggestCommand!.Description.Should().Contain("Suggest");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Run Subcommand Options Tests
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.CommandLine;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Moq;
|
||||
using StellaOps.Cli.Commands;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Cli.Services.Models.AdvisoryAi;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public sealed class KnowledgeSearchCommandGroupTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SearchCommand_JsonOutput_UsesDeterministicPayloadAndNormalizedFilters()
|
||||
{
|
||||
AdvisoryKnowledgeSearchRequestModel? capturedRequest = null;
|
||||
var backend = new Mock<IBackendOperationsClient>(MockBehavior.Strict);
|
||||
backend
|
||||
.Setup(client => client.SearchAdvisoryKnowledgeAsync(
|
||||
It.IsAny<AdvisoryKnowledgeSearchRequestModel>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<AdvisoryKnowledgeSearchRequestModel, CancellationToken>((request, _) => capturedRequest = request)
|
||||
.ReturnsAsync(CreateSearchResponse());
|
||||
|
||||
using var services = new ServiceCollection()
|
||||
.AddSingleton(backend.Object)
|
||||
.BuildServiceProvider();
|
||||
|
||||
var root = new RootCommand();
|
||||
root.Add(KnowledgeSearchCommandGroup.BuildSearchCommand(
|
||||
services,
|
||||
new Option<bool>("--verbose"),
|
||||
CancellationToken.None));
|
||||
|
||||
var invocation = await InvokeWithCapturedConsoleAsync(
|
||||
root,
|
||||
"search \"OIDC cert failure\" --type doctor,api --type docs --tag Auth --tag oidc,cert --product stella --version 1.2.3 --service gateway --k 7 --json");
|
||||
|
||||
Assert.Equal(0, invocation.ExitCode);
|
||||
Assert.NotNull(capturedRequest);
|
||||
Assert.Equal("OIDC cert failure", capturedRequest!.Q);
|
||||
Assert.Equal(7, capturedRequest.K);
|
||||
Assert.NotNull(capturedRequest.Filters);
|
||||
Assert.Equal(new[] { "api", "docs", "doctor" }, capturedRequest.Filters!.Type);
|
||||
Assert.Equal(new[] { "auth", "cert", "oidc" }, capturedRequest.Filters!.Tags);
|
||||
Assert.Equal("stella", capturedRequest.Filters.Product);
|
||||
Assert.Equal("1.2.3", capturedRequest.Filters.Version);
|
||||
Assert.Equal("gateway", capturedRequest.Filters.Service);
|
||||
Assert.False(capturedRequest.IncludeDebug);
|
||||
|
||||
using var payload = JsonDocument.Parse(invocation.StdOut);
|
||||
var rootElement = payload.RootElement;
|
||||
Assert.Equal("OIDC cert failure", rootElement.GetProperty("query").GetString());
|
||||
Assert.Equal(7, rootElement.GetProperty("topK").GetInt32());
|
||||
var results = rootElement.GetProperty("results");
|
||||
Assert.Equal(3, results.GetArrayLength());
|
||||
Assert.Equal("doctor", results[0].GetProperty("type").GetString());
|
||||
Assert.Equal("check.auth.oidc.cert", results[0].GetProperty("open").GetProperty("doctor").GetProperty("checkCode").GetString());
|
||||
|
||||
backend.VerifyAll();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DoctorSuggestCommand_RendersGroupedOutput()
|
||||
{
|
||||
AdvisoryKnowledgeSearchRequestModel? capturedRequest = null;
|
||||
var backend = new Mock<IBackendOperationsClient>(MockBehavior.Strict);
|
||||
backend
|
||||
.Setup(client => client.SearchAdvisoryKnowledgeAsync(
|
||||
It.IsAny<AdvisoryKnowledgeSearchRequestModel>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<AdvisoryKnowledgeSearchRequestModel, CancellationToken>((request, _) => capturedRequest = request)
|
||||
.ReturnsAsync(CreateSearchResponse());
|
||||
|
||||
using var services = new ServiceCollection()
|
||||
.AddSingleton(backend.Object)
|
||||
.BuildServiceProvider();
|
||||
|
||||
var root = new RootCommand();
|
||||
root.Add(DoctorCommandGroup.BuildDoctorCommand(
|
||||
services,
|
||||
new Option<bool>("--verbose"),
|
||||
CancellationToken.None));
|
||||
|
||||
var invocation = await InvokeWithCapturedConsoleAsync(
|
||||
root,
|
||||
"doctor suggest \"x509: certificate signed by unknown authority\" --k 5");
|
||||
|
||||
Assert.Equal(0, invocation.ExitCode);
|
||||
Assert.NotNull(capturedRequest);
|
||||
Assert.Equal("x509: certificate signed by unknown authority", capturedRequest!.Q);
|
||||
Assert.Equal(5, capturedRequest.K);
|
||||
Assert.Null(capturedRequest.Filters);
|
||||
|
||||
Assert.Contains("Recommended checks:", invocation.StdOut, StringComparison.Ordinal);
|
||||
Assert.Contains("doctor: check.auth.oidc.cert", invocation.StdOut, StringComparison.Ordinal);
|
||||
Assert.Contains("Related docs:", invocation.StdOut, StringComparison.Ordinal);
|
||||
Assert.Contains("docs: docs/operations/oidc.md#tls_trust_chain", invocation.StdOut, StringComparison.Ordinal);
|
||||
Assert.Contains("Related endpoints:", invocation.StdOut, StringComparison.Ordinal);
|
||||
Assert.Contains("api: POST /api/v1/authority/oidc/test", invocation.StdOut, StringComparison.Ordinal);
|
||||
|
||||
backend.VerifyAll();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdvisoryAiRebuildCommand_JsonOutput_ContainsCounts()
|
||||
{
|
||||
var backend = new Mock<IBackendOperationsClient>(MockBehavior.Strict);
|
||||
backend
|
||||
.Setup(client => client.RebuildAdvisoryKnowledgeIndexAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new AdvisoryKnowledgeRebuildResponseModel
|
||||
{
|
||||
DocumentCount = 120,
|
||||
ChunkCount = 640,
|
||||
ApiSpecCount = 8,
|
||||
ApiOperationCount = 114,
|
||||
DoctorProjectionCount = 36,
|
||||
DurationMs = 2485
|
||||
});
|
||||
|
||||
using var services = new ServiceCollection()
|
||||
.AddSingleton(backend.Object)
|
||||
.BuildServiceProvider();
|
||||
|
||||
var root = new RootCommand();
|
||||
root.Add(KnowledgeSearchCommandGroup.BuildAdvisoryAiCommand(
|
||||
services,
|
||||
new Option<bool>("--verbose"),
|
||||
CancellationToken.None));
|
||||
|
||||
var invocation = await InvokeWithCapturedConsoleAsync(root, "advisoryai index rebuild --json");
|
||||
|
||||
Assert.Equal(0, invocation.ExitCode);
|
||||
using var payload = JsonDocument.Parse(invocation.StdOut);
|
||||
var rootElement = payload.RootElement;
|
||||
Assert.Equal(120, rootElement.GetProperty("documentCount").GetInt32());
|
||||
Assert.Equal(640, rootElement.GetProperty("chunkCount").GetInt32());
|
||||
Assert.Equal(8, rootElement.GetProperty("apiSpecCount").GetInt32());
|
||||
Assert.Equal(114, rootElement.GetProperty("apiOperationCount").GetInt32());
|
||||
Assert.Equal(36, rootElement.GetProperty("doctorProjectionCount").GetInt32());
|
||||
Assert.Equal(2485, rootElement.GetProperty("durationMs").GetInt64());
|
||||
|
||||
backend.VerifyAll();
|
||||
}
|
||||
|
||||
private static AdvisoryKnowledgeSearchResponseModel CreateSearchResponse()
|
||||
{
|
||||
return new AdvisoryKnowledgeSearchResponseModel
|
||||
{
|
||||
Query = "OIDC cert failure",
|
||||
TopK = 7,
|
||||
Diagnostics = new AdvisoryKnowledgeSearchDiagnosticsModel
|
||||
{
|
||||
FtsMatches = 18,
|
||||
VectorMatches = 0,
|
||||
DurationMs = 14,
|
||||
UsedVector = false,
|
||||
Mode = "fts-only"
|
||||
},
|
||||
Results =
|
||||
[
|
||||
new AdvisoryKnowledgeSearchResultModel
|
||||
{
|
||||
Type = "doctor",
|
||||
Title = "check.auth.oidc.cert - Validate OIDC trust chain",
|
||||
Snippet = "OIDC issuer TLS certificate chain is untrusted.",
|
||||
Score = 0.991,
|
||||
Open = new AdvisoryKnowledgeOpenActionModel
|
||||
{
|
||||
Kind = "doctor",
|
||||
Doctor = new AdvisoryKnowledgeOpenDoctorActionModel
|
||||
{
|
||||
CheckCode = "check.auth.oidc.cert",
|
||||
Severity = "fail",
|
||||
CanRun = true,
|
||||
RunCommand = "stella doctor run --check check.auth.oidc.cert"
|
||||
}
|
||||
},
|
||||
Debug = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["rrf"] = "1.0"
|
||||
}
|
||||
},
|
||||
new AdvisoryKnowledgeSearchResultModel
|
||||
{
|
||||
Type = "docs",
|
||||
Title = "OIDC troubleshooting - trust chain",
|
||||
Snippet = "Import the issuer CA bundle and verify trust store ordering.",
|
||||
Score = 0.876,
|
||||
Open = new AdvisoryKnowledgeOpenActionModel
|
||||
{
|
||||
Kind = "docs",
|
||||
Docs = new AdvisoryKnowledgeOpenDocActionModel
|
||||
{
|
||||
Path = "docs/operations/oidc.md",
|
||||
Anchor = "tls_trust_chain",
|
||||
SpanStart = 122,
|
||||
SpanEnd = 168
|
||||
}
|
||||
}
|
||||
},
|
||||
new AdvisoryKnowledgeSearchResultModel
|
||||
{
|
||||
Type = "api",
|
||||
Title = "POST /api/v1/authority/oidc/test",
|
||||
Snippet = "Validates OIDC configuration and returns certificate diagnostics.",
|
||||
Score = 0.744,
|
||||
Open = new AdvisoryKnowledgeOpenActionModel
|
||||
{
|
||||
Kind = "api",
|
||||
Api = new AdvisoryKnowledgeOpenApiActionModel
|
||||
{
|
||||
Service = "authority",
|
||||
Method = "POST",
|
||||
Path = "/api/v1/authority/oidc/test",
|
||||
OperationId = "Authority_TestOidcConfiguration"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<CommandInvocationResult> InvokeWithCapturedConsoleAsync(
|
||||
RootCommand root,
|
||||
string commandLine)
|
||||
{
|
||||
var originalOut = Console.Out;
|
||||
var originalError = Console.Error;
|
||||
var stdout = new StringWriter(CultureInfo.InvariantCulture);
|
||||
var stderr = new StringWriter(CultureInfo.InvariantCulture);
|
||||
try
|
||||
{
|
||||
Console.SetOut(stdout);
|
||||
Console.SetError(stderr);
|
||||
var exitCode = await root.Parse(commandLine).InvokeAsync();
|
||||
return new CommandInvocationResult(exitCode, stdout.ToString(), stderr.ToString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(originalOut);
|
||||
Console.SetError(originalError);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record CommandInvocationResult(int ExitCode, string StdOut, string StdErr);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Platform.Database;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
@@ -9,6 +9,6 @@ public class MigrationCommandHandlersTests
|
||||
[Fact]
|
||||
public void Registry_Has_All_Modules()
|
||||
{
|
||||
Assert.Equal(6, MigrationModuleRegistry.Modules.Count);
|
||||
Assert.Equal(10, MigrationModuleRegistry.Modules.Count);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using System.Linq;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Platform.Database;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
@@ -10,14 +10,34 @@ public class MigrationModuleRegistryTests
|
||||
public void Modules_Populated_With_All_Postgres_Modules()
|
||||
{
|
||||
var modules = MigrationModuleRegistry.Modules;
|
||||
Assert.Equal(6, modules.Count);
|
||||
Assert.Equal(10, modules.Count);
|
||||
Assert.Contains(modules, m => m.Name == "AirGap" && m.SchemaName == "airgap");
|
||||
Assert.Contains(modules, m => m.Name == "Authority" && m.SchemaName == "authority");
|
||||
Assert.Contains(modules, m => m.Name == "Scheduler" && m.SchemaName == "scheduler");
|
||||
Assert.Contains(modules, m => m.Name == "Concelier" && m.SchemaName == "vuln");
|
||||
Assert.Contains(modules, m => m.Name == "Policy" && m.SchemaName == "policy");
|
||||
Assert.Contains(modules, m => m.Name == "Notify" && m.SchemaName == "notify");
|
||||
Assert.Contains(modules, m => m.Name == "Excititor" && m.SchemaName == "vex");
|
||||
Assert.Equal(6, MigrationModuleRegistry.ModuleNames.Count());
|
||||
Assert.Contains(modules, m => m.Name == "Platform" && m.SchemaName == "release");
|
||||
Assert.Contains(modules, m => m.Name == "Scanner" && m.SchemaName == "scanner");
|
||||
Assert.Contains(modules, m => m.Name == "TimelineIndexer" && m.SchemaName == "timeline");
|
||||
Assert.Equal(10, MigrationModuleRegistry.ModuleNames.Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Modules_Are_AutoDiscovered_From_Plugins()
|
||||
{
|
||||
var pluginTypes = typeof(MigrationModuleRegistry)
|
||||
.Assembly
|
||||
.GetTypes()
|
||||
.Where(static type =>
|
||||
typeof(IMigrationModulePlugin).IsAssignableFrom(type) &&
|
||||
!type.IsAbstract &&
|
||||
!type.IsInterface)
|
||||
.ToArray();
|
||||
|
||||
Assert.NotEmpty(pluginTypes);
|
||||
Assert.Equal(pluginTypes.Length, MigrationModuleRegistry.Modules.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -40,6 +60,6 @@ public class MigrationModuleRegistryTests
|
||||
public void GetModules_All_Returns_All()
|
||||
{
|
||||
var result = MigrationModuleRegistry.GetModules(null);
|
||||
Assert.Equal(6, result.Count());
|
||||
Assert.Equal(10, result.Count());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Cli.Commands;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Platform.Database;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
@@ -23,12 +24,16 @@ public class SystemCommandBuilderTests
|
||||
[Fact]
|
||||
public void ModuleNames_Contains_All_Modules()
|
||||
{
|
||||
Assert.Contains("AirGap", MigrationModuleRegistry.ModuleNames);
|
||||
Assert.Contains("Authority", MigrationModuleRegistry.ModuleNames);
|
||||
Assert.Contains("Scheduler", MigrationModuleRegistry.ModuleNames);
|
||||
Assert.Contains("Concelier", MigrationModuleRegistry.ModuleNames);
|
||||
Assert.Contains("Policy", MigrationModuleRegistry.ModuleNames);
|
||||
Assert.Contains("Notify", MigrationModuleRegistry.ModuleNames);
|
||||
Assert.Contains("Excititor", MigrationModuleRegistry.ModuleNames);
|
||||
Assert.Contains("Platform", MigrationModuleRegistry.ModuleNames);
|
||||
Assert.Contains("Scanner", MigrationModuleRegistry.ModuleNames);
|
||||
Assert.Contains("TimelineIndexer", MigrationModuleRegistry.ModuleNames);
|
||||
}
|
||||
|
||||
private static Command BuildSystemCommand()
|
||||
|
||||
@@ -5,6 +5,7 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| SPRINT_20260222_051-MGC-04-W1-TESTS | DONE | Updated migration registry/system command tests for platform-owned 10-module coverage and validated with `dotnet test` (1182 passed on 2026-02-22). |
|
||||
| AUDIT-0143-M | DONE | Revalidated 2026-01-06. |
|
||||
| AUDIT-0143-T | DONE | Revalidated 2026-01-06. |
|
||||
| AUDIT-0143-A | DONE | Waived (test project; revalidated 2026-01-06). |
|
||||
|
||||
Reference in New Issue
Block a user