mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 21:19:38 +00:00
feat(backend): M5.c.2 GET /api/sessions/{id}/decision-graph — Inspector DAG
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) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="DecisionGraphBuilder"/> — pure projection, zero IO. Covers node
|
||||
/// de-duplication, terminal classification, dead-end preservation, visit-count semantics, and
|
||||
/// empty / out-of-order inputs.
|
||||
/// </summary>
|
||||
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<DecisionTransitionRecord>());
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
+73
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+52
@@ -75,6 +75,58 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
return committed;
|
||||
}
|
||||
|
||||
public async Task<List<DecisionTransitionRecord>> 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<DecisionTransitionRecord>(capacity: Math.Min(maxResults, 128));
|
||||
var pages = table.QueryAsync<TableEntity>(
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Projects an Azure <see cref="TableEntity"/> back into a <see cref="DecisionTransitionRecord"/>,
|
||||
/// reassembling chunked <c>PayloadJson</c> if present. Internal for mapping tests.
|
||||
/// </summary>
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Projects a <see cref="DecisionTransitionRecord"/> onto its Azure <see cref="TableEntity"/>
|
||||
/// shape. Keys: PK = <c>{TenantId}_{SessionId}</c>, RK = <c>{StepIndex:D10}</c>.
|
||||
|
||||
+88
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// <c>GET /api/sessions/{sessionId}/decision-graph</c> — 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 <see cref="DecisionGraphBuilder"/>'s copy of that list.
|
||||
/// </summary>
|
||||
public class GetSessionDecisionGraphFunction
|
||||
{
|
||||
/// <summary>Sane upper bound — a V2 session journal shouldn't produce more steps than this.</summary>
|
||||
internal const int MaxTransitionsToLoad = 5000;
|
||||
|
||||
private readonly ILogger<GetSessionDecisionGraphFunction> _logger;
|
||||
private readonly IDecisionTransitionRepository _transitionRepo;
|
||||
private readonly ISessionRepository _sessionRepo;
|
||||
|
||||
public GetSessionDecisionGraphFunction(
|
||||
ILogger<GetSessionDecisionGraphFunction> logger,
|
||||
IDecisionTransitionRepository transitionRepo,
|
||||
ISessionRepository sessionRepo)
|
||||
{
|
||||
_logger = logger;
|
||||
_transitionRepo = transitionRepo;
|
||||
_sessionRepo = sessionRepo;
|
||||
}
|
||||
|
||||
[Function("GetSessionDecisionGraph")]
|
||||
public async Task<HttpResponseData> 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}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Pure projection from the persisted journal (<see cref="DecisionTransitionRecord"/>) to
|
||||
/// the renderable <see cref="DecisionGraphProjection"/> the Inspector consumes (Plan §M5 /
|
||||
/// §M6). No IO, no service dependencies — unit-testable against canned transition lists.
|
||||
/// </summary>
|
||||
internal static class DecisionGraphBuilder
|
||||
{
|
||||
public static DecisionGraphProjection Build(
|
||||
string tenantId,
|
||||
string sessionId,
|
||||
IReadOnlyList<DecisionTransitionRecord> transitions)
|
||||
{
|
||||
var projection = new DecisionGraphProjection
|
||||
{
|
||||
TenantId = tenantId,
|
||||
SessionId = sessionId,
|
||||
Nodes = new List<DecisionGraphNode>(),
|
||||
Edges = new List<DecisionGraphEdge>(),
|
||||
};
|
||||
|
||||
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<string, DecisionGraphNode>(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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mirror of the agent-side <c>SessionStageExtensions.IsTerminal()</c> (DecisionCore) +
|
||||
/// the <see cref="Functions.Ingest.TelemetryPayloadParser"/> lookup. Three sources of
|
||||
/// truth stay in sync via the commit-time contract call-out (commit message +
|
||||
/// feedback_table_storage_serialization).
|
||||
/// </summary>
|
||||
internal static bool IsTerminalStage(string stage)
|
||||
{
|
||||
switch (stage)
|
||||
{
|
||||
case "Completed":
|
||||
case "Failed":
|
||||
case "WhiteGloveSealed":
|
||||
case "WhiteGloveCompletedPart2":
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,5 +19,13 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
/// Upserts a batch of transition records. Returns the number of rows committed.
|
||||
/// </summary>
|
||||
Task<int> StoreBatchAsync(IReadOnlyList<DecisionTransitionRecord> records, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Reads up to <paramref name="maxResults"/> transition records for a single session,
|
||||
/// ordered by <see cref="DecisionTransitionRecord.StepIndex"/> ascending. Used by the
|
||||
/// Inspector decision-graph endpoint (<c>GET /api/sessions/{id}/decision-graph</c>, Plan §M5).
|
||||
/// </summary>
|
||||
Task<List<DecisionTransitionRecord>> QueryBySessionAsync(
|
||||
string tenantId, string sessionId, int maxResults = 1000, CancellationToken cancellationToken = default);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AutopilotMonitor.Shared.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Server-side projection of a session's <see cref="DecisionTransitionRecord"/>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.
|
||||
/// </summary>
|
||||
public sealed class DecisionGraphProjection
|
||||
{
|
||||
public string TenantId { get; set; } = string.Empty;
|
||||
public string SessionId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Unique stages reached in the session (de-duplicated from Transition From/To stages).</summary>
|
||||
public List<DecisionGraphNode> Nodes { get; set; } = new List<DecisionGraphNode>();
|
||||
|
||||
/// <summary>One entry per transition — edges preserve chronological order via <see cref="DecisionGraphEdge.StepIndex"/>.</summary>
|
||||
public List<DecisionGraphEdge> Edges { get; set; } = new List<DecisionGraphEdge>();
|
||||
|
||||
/// <summary>Plan §2.10 — lets the UI flag sessions running on an older ReducerVersion than current.</summary>
|
||||
public string ReducerVersion { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One stage the session visited. Identified by the <see cref="SessionStage"/> enum name so
|
||||
/// JSON payloads stay forward-compatible with new stages (string, not int ordinal).
|
||||
/// </summary>
|
||||
public sealed class DecisionGraphNode
|
||||
{
|
||||
/// <summary>Stage enum name (e.g. <c>"EspInProgress"</c>). Also used as the graph-node ID.</summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
public bool IsTerminal { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Outcome label derived from the terminal <see cref="Id"/> — <c>"Succeeded"</c>,
|
||||
/// <c>"Failed"</c>, <c>"PausedForPart2"</c>, or <c>null</c> for non-terminal nodes.
|
||||
/// The richer termination metadata (<c>TerminationReason</c> + <c>TerminationOutcome</c>
|
||||
/// from the <c>enrollment_terminated</c> event in M4.6.β) is <b>not</b> 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.
|
||||
/// </summary>
|
||||
public string? TerminalOutcome { get; set; }
|
||||
|
||||
/// <summary>Number of edges that target this node (for UI sizing / heat-map rendering).</summary>
|
||||
public int VisitCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One reducer step. <see cref="Taken"/> false = dead-end edge (guard blocked the transition)
|
||||
/// so the Inspector can render the blocked path in a different style.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user