Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions src/Polyphony/Commands/JournalDriftCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
using System.Globalization;
using System.Text.Json;
using ConsoleAppFramework;
using Polyphony.Annotations;
using Polyphony.Journal;
using Polyphony.Journal.Drift;
using Twig.Domain.Interfaces;

namespace Polyphony.Commands;

[VerbGroup("")]
public sealed class JournalDriftCommand(
IJournalStore store,
IWorkItemRepository repository,
JournalDriftAnalyzer analyzer)
{
private readonly IJournalStore _store = store;
private readonly IWorkItemRepository _repository = repository;
private readonly JournalDriftAnalyzer _analyzer = analyzer;

/// <summary>
/// Diff the journal's expected resource state for a root against the current world.
/// </summary>
/// <param name="root">Root work item ID.</param>
/// <param name="render">Output format: json (default) or text.</param>
[Command("journal drift")]
[VerbResult(typeof(DriftResult))]
public async Task<int> Drift(
int root = RequiredInput.MissingInt,
string render = "json",
CancellationToken ct = default)
{
if (RequiredInput.HaltIfMissing("journal drift", ("--root", root == RequiredInput.MissingInt)) is { } halt)
{
return halt;
}

if (root <= 0)
{
EmitError("root must be positive");
return ExitCodes.RoutingFailure;
}

try
{
var item = await _repository.GetByIdAsync(root, ct).ConfigureAwait(false);
if (item is null)
{
Console.WriteLine($$"""{"error":"Work item {{root}} not found","work_item_id":{{root}}}""");
return ExitCodes.CacheError;
}

var entries = await _store.QueryAsync(new JournalQuery { RootId = root }, ct).ConfigureAwait(false);
var analysis = await _analyzer.AnalyzeAsync(root, entries, ct).ConfigureAwait(false);
if (string.Equals(render, "json", StringComparison.OrdinalIgnoreCase))
{
Console.WriteLine(JsonSerializer.Serialize(analysis.Result, PolyphonyJsonContext.Default.DriftResult));
return ExitCodes.Success;
}

if (string.Equals(render, "text", StringComparison.OrdinalIgnoreCase))
{
EmitText(analysis.Result);
return ExitCodes.Success;
}

EmitError("render must be 'json' or 'text'");
return ExitCodes.RoutingFailure;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
EmitError(ex.Message);
return ExitCodes.CacheError;
}
}

private static void EmitText(DriftResult result)
{
Console.WriteLine($"root\t{result.RootId.ToString(CultureInfo.InvariantCulture)}");
Console.WriteLine(
$"summary\tconsistent={result.Summary.Consistent.ToString(CultureInfo.InvariantCulture)}\texternal_delete={result.Summary.ExternalDelete.ToString(CultureInfo.InvariantCulture)}\texternal_mutation={result.Summary.ExternalMutation.ToString(CultureInfo.InvariantCulture)}\texternal_create={result.Summary.ExternalCreate.ToString(CultureInfo.InvariantCulture)}");
Console.WriteLine("classification\tkind\tid\texpected\tactual\towned");
foreach (var finding in result.Findings)
{
Console.WriteLine(string.Join(
"\t",
finding.Classification,
finding.Kind,
finding.Id,
finding.ExpectedState,
finding.ActualState ?? string.Empty,
finding.PolyphonyOwned?.ToString() ?? string.Empty));
}
}

private static void EmitError(string message)
=> Console.WriteLine($$"""{"error":"{{EscapeJsonString(message)}}"}""");

private static string EscapeJsonString(string value)
=> value.Replace("\\", "\\\\", StringComparison.Ordinal)
.Replace("\"", "\\\"", StringComparison.Ordinal)
.Replace("\n", "\\n", StringComparison.Ordinal)
.Replace("\r", "\\r", StringComparison.Ordinal);
}
21 changes: 21 additions & 0 deletions src/Polyphony/Infrastructure/PolyphonyServiceRegistration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
using Polyphony.Configuration;
using Polyphony.Infrastructure.AzureDevOps;
using Polyphony.Journal;
using Polyphony.Journal.Drift;
using Polyphony.Journal.Observers;
using Polyphony.Infrastructure.Processes;
using Polyphony.Infrastructure.Research;
using Polyphony.Postconditions;
Expand Down Expand Up @@ -49,6 +51,25 @@ public static IServiceCollection AddPolyphonyServices(
services.AddSingleton<IJournalLocator, JournalLocator>();
services.AddSingleton<IJournalStore, JournalStore>();
services.AddSingleton<JournaledActionDecorator>();
services.AddSingleton<JournalDriftAnalyzer>();

// Journal drift observers. Every ResourceKind is explicitly accounted for here:
// implemented kinds register a concrete observer; deferred kinds register a
// DeferredResourceObserver so the omission is an intentional, reviewable choice.
services.AddSingleton<IResourceObserver, GitBranchObserver>();
services.AddSingleton<IResourceObserver, GitHubPrObserver>();
services.AddSingleton<IResourceObserver, AdoPrObserver>();
services.AddSingleton<IResourceObserver, AdoWorkItemObserver>();
services.AddSingleton<IResourceObserver, AdoWorkItemStateObserver>();
services.AddSingleton<IResourceObserver, AdoWorkItemTagObserver>();
services.AddSingleton<IResourceObserver>(_ => new DeferredResourceObserver(ResourceKind.GitTag, "Deferred in Phase 4: no journaled git-tag mutators currently require drift coverage."));
services.AddSingleton<IResourceObserver>(_ => new DeferredResourceObserver(ResourceKind.GitWorktree, "Deferred in Phase 4: worktree drift can land in a focused follow-up without bloating the journal drift PR."));
services.AddSingleton<IResourceObserver>(_ => new DeferredResourceObserver(ResourceKind.GitHubPrComment, "Deferred in Phase 4: PR comment drift is informational and not needed for the initial root drift fold."));
services.AddSingleton<IResourceObserver>(_ => new DeferredResourceObserver(ResourceKind.AdoPrComment, "Deferred in Phase 4: ADO PR comment drift is informational and not needed for the initial root drift fold."));
services.AddSingleton<IResourceObserver>(_ => new DeferredResourceObserver(ResourceKind.AdoPrVote, "Deferred in Phase 4: reviewer-vote drift is a follow-up once root drift is proven on branch and PR lifecycles."));
services.AddSingleton<IResourceObserver>(_ => new DeferredResourceObserver(ResourceKind.ManifestFile, "Deferred in Phase 4: manifest-file drift can ship in a follow-up file-observer slice."));
services.AddSingleton<IResourceObserver>(_ => new DeferredResourceObserver(ResourceKind.PlanFile, "Deferred in Phase 4: plan-file drift can ship in a follow-up file-observer slice."));
services.AddSingleton<IResourceObserver>(_ => new DeferredResourceObserver(ResourceKind.LockFile, "Deferred in Phase 4: lock-file drift can ship in a follow-up file-observer slice."));

// Sdlc observers — singleton services that wrap IGitClient/IGhClient/IAdoClient/ITwigClient
// to produce per-RequirementKind observations. Shared by routing-style verbs
Expand Down
197 changes: 197 additions & 0 deletions src/Polyphony/Journal/Drift/JournalDriftAnalyzer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
using Polyphony.Journal.Observers;
using Polyphony.Journal.Projections;

namespace Polyphony.Journal.Drift;

public sealed class JournalDriftAnalyzer(IEnumerable<IResourceObserver> observers)
{
private readonly IReadOnlyDictionary<string, IResourceObserver> _observers = observers
.ToDictionary(observer => observer.Kind, StringComparer.Ordinal);

public async Task<JournalDriftAnalysis> AnalyzeAsync(int rootId, IReadOnlyList<JournalEntry> entries, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(entries);

var currentExpectedState = CurrentExpectedState.Project(entries);
var ownedResources = OwnedResources.Project(currentExpectedState);
var findings = new List<DriftFinding>();
var observedResources = new List<ObservedResourceState>();

foreach (var group in currentExpectedState.Resources.GroupBy(resource => resource.Kind, StringComparer.Ordinal))
{
if (!_observers.TryGetValue(group.Key, out var observer) || !observer.CanObserve)
{
continue;
}

var batch = await observer.ObserveAsync(
new ResourceObservationRequest
{
RootId = rootId,
ExpectedResources = group.OrderBy(resource => resource.Id, StringComparer.Ordinal).ToArray(),
},
ct).ConfigureAwait(false);

observedResources.AddRange(batch.Observations);
findings.AddRange(FoldExpected(group.ToArray(), batch.Observations));
findings.AddRange(FoldDiscovered(group.Key, group.ToArray(), batch.DiscoveredResources));
}

var resetTargets = ResetTargets.Project(currentExpectedState, observedResources);
var orderedFindings = findings
.OrderBy(finding => finding.Kind, StringComparer.Ordinal)
.ThenBy(finding => finding.Id, StringComparer.Ordinal)
.ThenBy(finding => finding.Classification, StringComparer.Ordinal)
.ToArray();

return new JournalDriftAnalysis
{
CurrentExpectedState = currentExpectedState,
OwnedResources = ownedResources,
ResetTargets = resetTargets,
Result = new DriftResult
{
Status = "ok",
RootId = rootId,
Findings = orderedFindings,
Summary = new DriftSummary
{
Consistent = orderedFindings.Count(finding => string.Equals(finding.Classification, DriftClassifications.Consistent, StringComparison.Ordinal)),
ExternalDelete = orderedFindings.Count(finding => string.Equals(finding.Classification, DriftClassifications.ExternalDelete, StringComparison.Ordinal)),
ExternalMutation = orderedFindings.Count(finding => string.Equals(finding.Classification, DriftClassifications.ExternalMutation, StringComparison.Ordinal)),
ExternalCreate = orderedFindings.Count(finding => string.Equals(finding.Classification, DriftClassifications.ExternalCreatePolyphonyNamed, StringComparison.Ordinal)),
},
},
};
}

private static IEnumerable<DriftFinding> FoldExpected(
IReadOnlyList<ProjectedResourceState> expectedResources,
IReadOnlyList<ObservedResourceState> observations)
{
var observedById = observations.ToDictionary(
observation => new ResourceKey { Kind = observation.Kind, Id = observation.Id },
observation => observation);

foreach (var expected in expectedResources)
{
if (!observedById.TryGetValue(expected.Key, out var observation))
{
continue;
}

var classification = ClassifyExpected(expected, observation);
if (classification is null)
{
continue;
}

yield return new DriftFinding
{
Kind = expected.Kind,
Id = expected.Id,
Classification = classification,
ExpectedState = DescribeExpectedState(expected),
ActualState = observation.ActualState,
PolyphonyOwned = expected.PolyphonyOwned,
Platform = expected.Platform,
ParentId = expected.ParentId,
};
}
}

private static IEnumerable<DriftFinding> FoldDiscovered(
string kind,
IReadOnlyList<ProjectedResourceState> expectedResources,
IReadOnlyList<DiscoveredResourceState> discoveredResources)
{
var expectedIds = expectedResources.Select(resource => resource.Id).ToHashSet(StringComparer.Ordinal);
foreach (var discovered in discoveredResources)
{
if (expectedIds.Contains(discovered.Id) || !discovered.MatchesPolyphonyPattern)
{
continue;
}

yield return new DriftFinding
{
Kind = kind,
Id = discovered.Id,
Classification = DriftClassifications.ExternalCreatePolyphonyNamed,
ExpectedState = "untracked",
ActualState = discovered.ActualState,
PolyphonyOwned = false,
};
}
}

private static string? ClassifyExpected(ProjectedResourceState expected, ObservedResourceState observation)
{
if (!observation.Exists)
{
return ExpectsPresence(expected)
? DriftClassifications.ExternalDelete
: DriftClassifications.Consistent;
}

if (observation.MatchesExpectedState)
{
return DriftClassifications.Consistent;
}

return DriftClassifications.ExternalMutation;
}

private static bool ExpectsPresence(ProjectedResourceState expected)
=> expected.Intent != ResourceIntent.EnsureAbsent;

private static string DescribeExpectedState(ProjectedResourceState expected)
{
if (expected.Intent == ResourceIntent.EnsureAbsent)
{
return "absent";
}

var namedState = ResourceObserverSupport.GetStringAttribute(expected.Attributes, "target_state")
?? ResourceObserverSupport.GetStringAttribute(expected.Attributes, "state");
if (!string.IsNullOrWhiteSpace(namedState))
{
return namedState;
}

var sha = ResourceObserverSupport.GetBranchExpectedSha(expected.Attributes);
if (!string.IsNullOrWhiteSpace(sha))
{
return $"present@{sha}";
}

return expected.Intent switch
{
ResourceIntent.EnsurePresent => "present",
ResourceIntent.AdvancePointer => "advanced",
ResourceIntent.SetState => "updated",
ResourceIntent.UpdateMetadata => "metadata_updated",
ResourceIntent.Attach => "attached",
ResourceIntent.Detach => "detached",
ResourceIntent.Observe => "observed",
_ => expected.Intent.ToString(),
};
}
}

public sealed record JournalDriftAnalysis
{
public required CurrentExpectedStateResult CurrentExpectedState { get; init; }
public required OwnedResourcesResult OwnedResources { get; init; }
public required ResetTargetsResult ResetTargets { get; init; }
public required DriftResult Result { get; init; }
}

public static class DriftClassifications
{
public const string Consistent = "consistent";
public const string ExternalDelete = "external_delete";
public const string ExternalMutation = "external_mutation";
public const string ExternalCreatePolyphonyNamed = "external_create_polyphony_named";
public const string Unknown = "unknown";
}
Loading