From 04270f1fb045b4058ae9503e9fc03bbdb4d9b55b Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Tue, 21 Apr 2026 10:50:14 +0200 Subject: [PATCH] =?UTF-8?q?feat(backend):=20M5.c.2=20GET=20/api/sessions/{?= =?UTF-8?q?id}/decision-graph=20=E2=80=94=20Inspector=20DAG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second V2 Inspector read endpoint. Server-side projection from the persisted DecisionTransitions journal into a renderable DAG so the UI consumes one structured shape instead of rebuilding the graph on every page open, and so "what counts as terminal" stays authoritative next to the backend copy of that list. - Shared DTO DecisionGraphProjection (Nodes + Edges + ReducerVersion). Nodes are de-duplicated stages with IsTerminal + a coarse TerminalOutcome label ("Succeeded" / "Failed" / "PausedForPart2"). Edges are one per transition, preserving dead-ends (Taken=false) for the UI to render in a distinct style. VisitCount counts *taken* transitions only — dead-ends don't advance the heat-map. - IDecisionTransitionRepository.QueryBySessionAsync added alongside StoreBatchAsync. Mirror of the M5.c.1 Signals pattern: PartitionKey filter, explicit StepIndex sort as a redundancy guard (the D10 RowKey padding already matches numeric order). - TableDecisionTransitionRepository.FromEntity — reverse mapping (entity → record) including TableStorageChunking.ReassembleProperty for oversized PayloadJson. Round-trip tested end-to-end. - DecisionGraphBuilder.Build — pure projection function, no IO, no service deps. Unit-testable against canned transition lists; the test suite covers empty input, node de-duplication, terminal classification for all 4 stages, dead-end preservation, StepIndex ordering under shuffled input, and VisitCount semantics. - GetSessionDecisionGraphFunction — same shape as GetSessionSignalsFunction (RequestContext resolution, Global-Admin cross-tenant fallback), plus a truncation flag when the 5000-step cap is hit. - Endpoint registered in EndpointAccessPolicyCatalog under MemberRead + TenantScoping.QueryParam (sibling of sessions/{id}/events). Scope boundary — the plan calls for terminal-node decoration with the richer TerminationReason + TerminationOutcome from the M4.6.β enrollment_terminated event. That data lives in the Events table, not in the transition journal; joining it here would expand scope into cross-table reads. The current projection exposes the coarse TerminalOutcome label (derived from the transition's ToStage) which is enough for the M6 Inspector's initial DAG render. Richer decoration is a dedicated follow-up. Three sources of truth now carry the terminal-stage list: agent-side DecisionCore.SessionStageExtensions.IsTerminal, backend-ingest TelemetryPayloadParser.IsTerminalStage, and now DecisionGraphBuilder's copy. Adding a new terminal stage = three places to update; flagged in the builder's docstring so it doesn't get missed. 627/627 backend tests green (+13: 11 builder + 2 transition round-trip). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../DecisionGraphBuilderTests.cs | 180 ++++++++++++++++++ ...ecisionTransitionRepositoryMappingTests.cs | 73 +++++++ .../TableDecisionTransitionRepository.cs | 52 +++++ .../GetSessionDecisionGraphFunction.cs | 88 +++++++++ .../Security/EndpointAccessPolicyCatalog.cs | 1 + .../Services/DecisionGraphBuilder.cs | 128 +++++++++++++ .../IDecisionTransitionRepository.cs | 8 + .../Models/DecisionGraphProjection.cs | 68 +++++++ 8 files changed, 598 insertions(+) create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/DecisionGraphBuilderTests.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionDecisionGraphFunction.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Services/DecisionGraphBuilder.cs create mode 100644 src/Shared/AutopilotMonitor.Shared/Models/DecisionGraphProjection.cs diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/DecisionGraphBuilderTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/DecisionGraphBuilderTests.cs new file mode 100644 index 00000000..7611e771 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/DecisionGraphBuilderTests.cs @@ -0,0 +1,180 @@ +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Tests for — pure projection, zero IO. Covers node +/// de-duplication, terminal classification, dead-end preservation, visit-count semantics, and +/// empty / out-of-order inputs. +/// +public class DecisionGraphBuilderTests +{ + private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901"; + + private static DecisionTransitionRecord Transition( + int step, string from, string to, bool taken = true, string? deadEnd = null, string trigger = "t", + string reducer = "1.0.0") + => new DecisionTransitionRecord + { + TenantId = TenantId, + SessionId = SessionId, + StepIndex = step, + FromStage = from, + ToStage = to, + Taken = taken, + DeadEndReason = deadEnd, + Trigger = trigger, + ReducerVersion = reducer, + IsTerminal = DecisionGraphBuilder.IsTerminalStage(to), + OccurredAtUtc = new DateTime(2026, 4, 21, 10, step, 0, DateTimeKind.Utc), + }; + + // ============================================================ Empty input + + [Fact] + public void Build_returns_empty_projection_when_no_transitions() + { + var graph = DecisionGraphBuilder.Build(TenantId, SessionId, Array.Empty()); + + Assert.Equal(TenantId, graph.TenantId); + Assert.Equal(SessionId, graph.SessionId); + Assert.Empty(graph.Nodes); + Assert.Empty(graph.Edges); + Assert.Equal(string.Empty, graph.ReducerVersion); + } + + // ============================================================ Node de-duplication + + [Fact] + public void Build_deduplicates_stages_into_unique_nodes() + { + // Same stage visited twice through different paths — appears once in Nodes. + var transitions = new[] + { + Transition(0, "SessionStarted", "EspInProgress"), + Transition(1, "EspInProgress", "AccountSetup"), + Transition(2, "AccountSetup", "EspInProgress"), // back-edge (hypothetical) + Transition(3, "EspInProgress", "Completed"), + }; + + var graph = DecisionGraphBuilder.Build(TenantId, SessionId, transitions); + + // Unique stages: SessionStarted, EspInProgress, AccountSetup, Completed = 4 nodes. + Assert.Equal(4, graph.Nodes.Count); + Assert.Contains(graph.Nodes, n => n.Id == "SessionStarted"); + Assert.Contains(graph.Nodes, n => n.Id == "EspInProgress"); + Assert.Contains(graph.Nodes, n => n.Id == "AccountSetup"); + Assert.Contains(graph.Nodes, n => n.Id == "Completed"); + } + + // ============================================================ Terminal classification + + [Theory] + [InlineData("Completed", "Succeeded")] + [InlineData("Failed", "Failed")] + [InlineData("WhiteGloveSealed", "PausedForPart2")] + [InlineData("WhiteGloveCompletedPart2", "Succeeded")] + public void Build_marks_terminal_stages_and_derives_outcome(string terminalStage, string expectedOutcome) + { + var transitions = new[] + { + Transition(0, "Start", terminalStage), + }; + + var graph = DecisionGraphBuilder.Build(TenantId, SessionId, transitions); + var node = graph.Nodes.Single(n => n.Id == terminalStage); + + Assert.True(node.IsTerminal); + Assert.Equal(expectedOutcome, node.TerminalOutcome); + } + + [Fact] + public void Build_leaves_non_terminal_nodes_unmarked() + { + var transitions = new[] { Transition(0, "SessionStarted", "EspInProgress") }; + + var graph = DecisionGraphBuilder.Build(TenantId, SessionId, transitions); + + Assert.All(graph.Nodes, n => + { + Assert.False(n.IsTerminal); + Assert.Null(n.TerminalOutcome); + }); + } + + // ============================================================ Edges + + [Fact] + public void Build_preserves_all_edges_including_dead_ends() + { + var transitions = new[] + { + Transition(0, "EspInProgress", "Completed", taken: true), + Transition(1, "EspInProgress", "EspInProgress", taken: false, + deadEnd: "hybrid_reboot_gate_blocking", trigger: "EspExiting"), + }; + + var graph = DecisionGraphBuilder.Build(TenantId, SessionId, transitions); + + Assert.Equal(2, graph.Edges.Count); + Assert.Contains(graph.Edges, e => e.Taken && e.ToStage == "Completed"); + var deadEnd = graph.Edges.Single(e => !e.Taken); + Assert.Equal("hybrid_reboot_gate_blocking", deadEnd.DeadEndReason); + Assert.Equal("EspExiting", deadEnd.Trigger); + } + + [Fact] + public void Build_orders_edges_by_StepIndex_even_if_input_is_shuffled() + { + // Repository sorts, but defend anyway — the builder is used directly in tests and + // could be fed arbitrary lists. + var transitions = new[] + { + Transition(2, "AccountSetup", "Completed"), + Transition(0, "SessionStarted", "EspInProgress"), + Transition(1, "EspInProgress", "AccountSetup"), + }; + + var graph = DecisionGraphBuilder.Build(TenantId, SessionId, transitions); + + Assert.Equal(new[] { 0, 1, 2 }, graph.Edges.Select(e => e.StepIndex)); + } + + // ============================================================ VisitCount + + [Fact] + public void Build_counts_only_taken_transitions_toward_VisitCount() + { + var transitions = new[] + { + Transition(0, "Start", "EspInProgress", taken: true), + Transition(1, "EspInProgress", "EspInProgress", taken: false, deadEnd: "blocked"), + Transition(2, "EspInProgress", "Completed", taken: true), + Transition(3, "Start", "EspInProgress", taken: true), // hypothetical retry + }; + + var graph = DecisionGraphBuilder.Build(TenantId, SessionId, transitions); + + var esp = graph.Nodes.Single(n => n.Id == "EspInProgress"); + Assert.Equal(2, esp.VisitCount); // two taken → EspInProgress; the dead-end does not count + + var completed = graph.Nodes.Single(n => n.Id == "Completed"); + Assert.Equal(1, completed.VisitCount); + } + + [Fact] + public void Build_exposes_ReducerVersion_from_first_row() + { + var transitions = new[] + { + Transition(0, "Start", "EspInProgress", reducer: "1.4.2"), + Transition(1, "EspInProgress", "Completed", reducer: "1.4.2"), + }; + + var graph = DecisionGraphBuilder.Build(TenantId, SessionId, transitions); + + Assert.Equal("1.4.2", graph.ReducerVersion); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TableDecisionTransitionRepositoryMappingTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TableDecisionTransitionRepositoryMappingTests.cs index c95bc367..7cb55cbf 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/TableDecisionTransitionRepositoryMappingTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TableDecisionTransitionRepositoryMappingTests.cs @@ -124,4 +124,77 @@ public class TableDecisionTransitionRepositoryMappingTests Assert.Equal("3", entity.GetString("PayloadJson_ChunkCount")); Assert.False(entity.ContainsKey("PayloadJson")); } + + // ============================================================ Reverse mapping (read path) + + [Fact] + public void FromEntity_round_trips_all_typed_columns() + { + var original = new DecisionTransitionRecord + { + TenantId = TenantId, + SessionId = SessionId, + StepIndex = 7, + SessionTraceOrdinal = 117, + SignalOrdinalRef = 42, + OccurredAtUtc = new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc), + Trigger = "EspExiting", + FromStage = "EspInProgress", + ToStage = "WhiteGloveSealed", + Taken = true, + DeadEndReason = null, + ReducerVersion = "1.0.0", + IsTerminal = true, + ClassifierVerdictId = "whiteglove-sealing", + ClassifierHypothesisLevel = "Strong", + PayloadJson = "{\"foo\":\"bar\"}", + }; + + var entity = TableDecisionTransitionRepository.ToEntity(original); + var restored = TableDecisionTransitionRepository.FromEntity(entity); + + Assert.Equal(original.TenantId, restored.TenantId); + Assert.Equal(original.SessionId, restored.SessionId); + Assert.Equal(original.StepIndex, restored.StepIndex); + Assert.Equal(original.SessionTraceOrdinal, restored.SessionTraceOrdinal); + Assert.Equal(original.SignalOrdinalRef, restored.SignalOrdinalRef); + Assert.Equal(original.OccurredAtUtc, restored.OccurredAtUtc.ToUniversalTime()); + Assert.Equal(original.Trigger, restored.Trigger); + Assert.Equal(original.FromStage, restored.FromStage); + Assert.Equal(original.ToStage, restored.ToStage); + Assert.Equal(original.Taken, restored.Taken); + Assert.Equal(original.DeadEndReason, restored.DeadEndReason); + Assert.Equal(original.ReducerVersion, restored.ReducerVersion); + Assert.Equal(original.IsTerminal, restored.IsTerminal); + Assert.Equal(original.ClassifierVerdictId, restored.ClassifierVerdictId); + Assert.Equal(original.ClassifierHypothesisLevel, restored.ClassifierHypothesisLevel); + Assert.Equal(original.PayloadJson, restored.PayloadJson); + } + + [Fact] + public void FromEntity_reassembles_chunked_PayloadJson() + { + var oversized = new string('y', 75_000); + var original = new DecisionTransitionRecord + { + TenantId = TenantId, + SessionId = SessionId, + StepIndex = 1, + SessionTraceOrdinal = 1, + SignalOrdinalRef = 1, + OccurredAtUtc = DateTime.UtcNow, + Trigger = "t", + FromStage = "F", + ToStage = "T", + Taken = true, + ReducerVersion = "1.0.0", + PayloadJson = oversized, + }; + + var entity = TableDecisionTransitionRepository.ToEntity(original); + var restored = TableDecisionTransitionRepository.FromEntity(entity); + + Assert.Equal(75_000, restored.PayloadJson.Length); + Assert.Equal(oversized, restored.PayloadJson); + } } diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableDecisionTransitionRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableDecisionTransitionRepository.cs index db228806..13874e60 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableDecisionTransitionRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableDecisionTransitionRepository.cs @@ -75,6 +75,58 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage return committed; } + public async Task> QueryBySessionAsync( + string tenantId, string sessionId, int maxResults = 1000, CancellationToken cancellationToken = default) + { + SecurityValidator.EnsureValidGuid(tenantId, "TenantId"); + SecurityValidator.EnsureValidGuid(sessionId, "SessionId"); + + var table = _storage.GetTableClient(Constants.TableNames.DecisionTransitions); + var pk = BuildPartitionKey(tenantId, sessionId); + + var results = new List(capacity: Math.Min(maxResults, 128)); + var pages = table.QueryAsync( + filter: $"PartitionKey eq '{pk}'", + maxPerPage: Math.Min(maxResults, 1000), + cancellationToken: cancellationToken); + + await foreach (var entity in pages.ConfigureAwait(false)) + { + if (results.Count >= maxResults) break; + results.Add(FromEntity(entity)); + } + + results.Sort((a, b) => a.StepIndex.CompareTo(b.StepIndex)); + return results; + } + + /// + /// Projects an Azure back into a , + /// reassembling chunked PayloadJson if present. Internal for mapping tests. + /// + internal static DecisionTransitionRecord FromEntity(TableEntity entity) + { + return new DecisionTransitionRecord + { + TenantId = entity.GetString("TenantId") ?? string.Empty, + SessionId = entity.GetString("SessionId") ?? string.Empty, + StepIndex = entity.GetInt32("StepIndex") ?? 0, + SessionTraceOrdinal = entity.GetInt64("SessionTraceOrdinal") ?? 0, + SignalOrdinalRef = entity.GetInt64("SignalOrdinalRef") ?? 0, + OccurredAtUtc = entity.GetDateTime("OccurredAtUtc") ?? default, + Trigger = entity.GetString("Trigger") ?? string.Empty, + FromStage = entity.GetString("FromStage") ?? string.Empty, + ToStage = entity.GetString("ToStage") ?? string.Empty, + Taken = entity.GetBoolean("Taken") ?? false, + DeadEndReason = entity.GetString("DeadEndReason"), + ReducerVersion = entity.GetString("ReducerVersion") ?? string.Empty, + IsTerminal = entity.GetBoolean("IsTerminal") ?? false, + ClassifierVerdictId = entity.GetString("ClassifierVerdictId"), + ClassifierHypothesisLevel = entity.GetString("ClassifierHypothesisLevel"), + PayloadJson = TableStorageChunking.ReassembleProperty(entity, "PayloadJson") ?? string.Empty, + }; + } + /// /// Projects a onto its Azure /// shape. Keys: PK = {TenantId}_{SessionId}, RK = {StepIndex:D10}. diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionDecisionGraphFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionDecisionGraphFunction.cs new file mode 100644 index 00000000..723c629a --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionDecisionGraphFunction.cs @@ -0,0 +1,88 @@ +using System.Net; +using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Functions.Sessions +{ + /// + /// GET /api/sessions/{sessionId}/decision-graph — returns a pre-projected DAG of + /// the session's reducer steps for the Inspector (Plan §M5 / §M6). Building the graph + /// backend-side means the UI receives one structured shape instead of rebuilding it from + /// the raw journal on every page open; it also keeps the "what counts as terminal" rule + /// authoritatively next to 's copy of that list. + /// + public class GetSessionDecisionGraphFunction + { + /// Sane upper bound — a V2 session journal shouldn't produce more steps than this. + internal const int MaxTransitionsToLoad = 5000; + + private readonly ILogger _logger; + private readonly IDecisionTransitionRepository _transitionRepo; + private readonly ISessionRepository _sessionRepo; + + public GetSessionDecisionGraphFunction( + ILogger logger, + IDecisionTransitionRepository transitionRepo, + ISessionRepository sessionRepo) + { + _logger = logger; + _transitionRepo = transitionRepo; + _sessionRepo = sessionRepo; + } + + [Function("GetSessionDecisionGraph")] + public async Task Run( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "sessions/{sessionId}/decision-graph")] HttpRequestData req, + string sessionId) + { + if (string.IsNullOrWhiteSpace(sessionId)) + { + var badRequest = req.CreateResponse(HttpStatusCode.BadRequest); + await badRequest.WriteAsJsonAsync(new { success = false, message = "SessionId is required" }); + return badRequest; + } + + var sessionPrefix = $"[Session: {sessionId.Substring(0, Math.Min(8, sessionId.Length))}]"; + _logger.LogInformation("{Prefix} GetSessionDecisionGraph", sessionPrefix); + + try + { + var requestCtx = req.GetRequestContext(); + + var transitions = await _transitionRepo.QueryBySessionAsync( + requestCtx.TargetTenantId, sessionId, MaxTransitionsToLoad); + + // Global Admin cross-tenant fallback (same pattern as other session-detail reads). + var effectiveTenantId = requestCtx.TargetTenantId; + if (transitions.Count == 0 && requestCtx.IsGlobalAdmin) + { + var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId); + if (resolvedTenantId != null && + !string.Equals(resolvedTenantId, requestCtx.TargetTenantId, StringComparison.OrdinalIgnoreCase)) + { + transitions = await _transitionRepo.QueryBySessionAsync( + resolvedTenantId, sessionId, MaxTransitionsToLoad); + effectiveTenantId = resolvedTenantId; + } + } + + var projection = DecisionGraphBuilder.Build(effectiveTenantId, sessionId, transitions); + + return await req.OkAsync(new + { + success = true, + truncated = transitions.Count >= MaxTransitionsToLoad, + graph = projection, + }); + } + catch (Exception ex) + { + return await req.InternalServerErrorAsync(_logger, ex, $"Get decision graph for session '{sessionId}'"); + } + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs index a426b06e..3dc2eeeb 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs @@ -140,6 +140,7 @@ public static class EndpointAccessPolicyCatalog new("GET", "sessions/{sessionId}", EndpointPolicy.MemberRead, TenantScoping.QueryParam), new("GET", "sessions/{sessionId}/events", EndpointPolicy.MemberRead, TenantScoping.QueryParam), new("GET", "sessions/{sessionId}/signals", EndpointPolicy.MemberRead, TenantScoping.QueryParam), + new("GET", "sessions/{sessionId}/decision-graph", EndpointPolicy.MemberRead, TenantScoping.QueryParam), new("GET", "sessions/{sessionId}/analysis", EndpointPolicy.MemberRead, TenantScoping.QueryParam), new("GET", "sessions/{sessionId}/vulnerability-report", EndpointPolicy.MemberRead, TenantScoping.QueryParam), new("GET", "metrics/app", EndpointPolicy.MemberRead), diff --git a/src/Backend/AutopilotMonitor.Functions/Services/DecisionGraphBuilder.cs b/src/Backend/AutopilotMonitor.Functions/Services/DecisionGraphBuilder.cs new file mode 100644 index 00000000..120b0c52 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/DecisionGraphBuilder.cs @@ -0,0 +1,128 @@ +using System.Collections.Generic; +using System.Linq; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Functions.Services +{ + /// + /// Pure projection from the persisted journal () to + /// the renderable the Inspector consumes (Plan §M5 / + /// §M6). No IO, no service dependencies — unit-testable against canned transition lists. + /// + internal static class DecisionGraphBuilder + { + public static DecisionGraphProjection Build( + string tenantId, + string sessionId, + IReadOnlyList transitions) + { + var projection = new DecisionGraphProjection + { + TenantId = tenantId, + SessionId = sessionId, + Nodes = new List(), + Edges = new List(), + }; + + if (transitions == null || transitions.Count == 0) return projection; + + // ReducerVersion is consistent across a session (enforced by the agent-side Journal + // contract); pick the first row's value. + projection.ReducerVersion = transitions[0].ReducerVersion ?? string.Empty; + + // Build nodes by de-duplicating From+To stages. Visit count = how many edges land on + // the node (in-degree). Also track terminal flag + outcome derived from the ToStage. + var nodeMap = new Dictionary(StringComparer.Ordinal); + + DecisionGraphNode EnsureNode(string stage) + { + if (!nodeMap.TryGetValue(stage, out var node)) + { + node = new DecisionGraphNode + { + Id = stage, + IsTerminal = IsTerminalStage(stage), + TerminalOutcome = DeriveTerminalOutcome(stage), + VisitCount = 0, + }; + nodeMap[stage] = node; + } + return node; + } + + // Edges come out in StepIndex order (repository already sorts). Record them as-is — + // preserving dead-ends because the Inspector renders them in a distinct style. + foreach (var t in transitions.OrderBy(x => x.StepIndex)) + { + // Lazy-init both endpoints so isolated nodes (never-visited stages that show up + // as FromStage only) are still rendered. + var fromNode = EnsureNode(t.FromStage ?? string.Empty); + var toNode = EnsureNode(t.ToStage ?? string.Empty); + + projection.Edges.Add(new DecisionGraphEdge + { + StepIndex = t.StepIndex, + FromStage = t.FromStage ?? string.Empty, + ToStage = t.ToStage ?? string.Empty, + Trigger = t.Trigger ?? string.Empty, + Taken = t.Taken, + DeadEndReason = t.DeadEndReason, + SignalOrdinalRef = t.SignalOrdinalRef, + OccurredAtUtc = t.OccurredAtUtc, + ClassifierVerdictId = t.ClassifierVerdictId, + ClassifierHypothesisLevel = t.ClassifierHypothesisLevel, + }); + + // VisitCount counts *taken* transitions — dead-ends don't advance the visit graph. + // Matches the UI semantics: heat-map should show where execution actually went. + if (t.Taken) toNode.VisitCount++; + } + + // Emit nodes in deterministic order: by first-seen StepIndex. Graph renderers use + // insertion order as a hint when they haven't computed a layout yet. + projection.Nodes = nodeMap.Values.ToList(); + return projection; + } + + /// + /// Mirror of the agent-side SessionStageExtensions.IsTerminal() (DecisionCore) + + /// the lookup. Three sources of + /// truth stay in sync via the commit-time contract call-out (commit message + + /// feedback_table_storage_serialization). + /// + internal static bool IsTerminalStage(string stage) + { + switch (stage) + { + case "Completed": + case "Failed": + case "WhiteGloveSealed": + case "WhiteGloveCompletedPart2": + return true; + default: + return false; + } + } + + /// + /// Maps a terminal stage to a coarse outcome label for the Inspector. WhiteGloveSealed + /// is Part-1 pause (reboot expected, agent exits but session is not finished) — labelled + /// distinctly so the UI can render it with a different colour than the other terminals. + /// + internal static string? DeriveTerminalOutcome(string stage) + { + switch (stage) + { + case "Completed": + case "WhiteGloveCompletedPart2": + return "Succeeded"; + case "Failed": + return "Failed"; + case "WhiteGloveSealed": + return "PausedForPart2"; + default: + return null; + } + } + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IDecisionTransitionRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IDecisionTransitionRepository.cs index 378011c9..1abdf10a 100644 --- a/src/Shared/AutopilotMonitor.Shared/DataAccess/IDecisionTransitionRepository.cs +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IDecisionTransitionRepository.cs @@ -19,5 +19,13 @@ namespace AutopilotMonitor.Shared.DataAccess /// Upserts a batch of transition records. Returns the number of rows committed. /// Task StoreBatchAsync(IReadOnlyList records, CancellationToken cancellationToken = default); + + /// + /// Reads up to transition records for a single session, + /// ordered by ascending. Used by the + /// Inspector decision-graph endpoint (GET /api/sessions/{id}/decision-graph, Plan §M5). + /// + Task> QueryBySessionAsync( + string tenantId, string sessionId, int maxResults = 1000, CancellationToken cancellationToken = default); } } diff --git a/src/Shared/AutopilotMonitor.Shared/Models/DecisionGraphProjection.cs b/src/Shared/AutopilotMonitor.Shared/Models/DecisionGraphProjection.cs new file mode 100644 index 00000000..0d088d04 --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Models/DecisionGraphProjection.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; + +namespace AutopilotMonitor.Shared.Models +{ + /// + /// Server-side projection of a session's s into a + /// renderable DAG for the Inspector (Plan §M5, §M6). Pre-computed on the backend so the + /// UI receives one structured shape instead of rebuilding the graph from the raw journal. + /// + public sealed class DecisionGraphProjection + { + public string TenantId { get; set; } = string.Empty; + public string SessionId { get; set; } = string.Empty; + + /// Unique stages reached in the session (de-duplicated from Transition From/To stages). + public List Nodes { get; set; } = new List(); + + /// One entry per transition — edges preserve chronological order via . + public List Edges { get; set; } = new List(); + + /// Plan §2.10 — lets the UI flag sessions running on an older ReducerVersion than current. + public string ReducerVersion { get; set; } = string.Empty; + } + + /// + /// One stage the session visited. Identified by the enum name so + /// JSON payloads stay forward-compatible with new stages (string, not int ordinal). + /// + public sealed class DecisionGraphNode + { + /// Stage enum name (e.g. "EspInProgress"). Also used as the graph-node ID. + public string Id { get; set; } = string.Empty; + + public bool IsTerminal { get; set; } + + /// + /// Outcome label derived from the terminal — "Succeeded", + /// "Failed", "PausedForPart2", or null for non-terminal nodes. + /// The richer termination metadata (TerminationReason + TerminationOutcome + /// from the enrollment_terminated event in M4.6.β) is not inlined here — + /// a future revision can enrich terminal nodes by joining the Events table if the UI + /// needs it. Today's Inspector gets the high-level label. + /// + public string? TerminalOutcome { get; set; } + + /// Number of edges that target this node (for UI sizing / heat-map rendering). + public int VisitCount { get; set; } + } + + /// + /// One reducer step. false = dead-end edge (guard blocked the transition) + /// so the Inspector can render the blocked path in a different style. + /// + public sealed class DecisionGraphEdge + { + public int StepIndex { get; set; } + public string FromStage { get; set; } = string.Empty; + public string ToStage { get; set; } = string.Empty; + public string Trigger { get; set; } = string.Empty; + public bool Taken { get; set; } + public string? DeadEndReason { get; set; } + public long SignalOrdinalRef { get; set; } + public DateTime OccurredAtUtc { get; set; } + public string? ClassifierVerdictId { get; set; } + public string? ClassifierHypothesisLevel { get; set; } + } +}