mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 21:19:38 +00:00
04270f1fb0
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>
201 lines
8.6 KiB
C#
201 lines
8.6 KiB
C#
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
|
using AutopilotMonitor.Shared.Models;
|
|
|
|
namespace AutopilotMonitor.Functions.Tests;
|
|
|
|
/// <summary>
|
|
/// Pure mapping tests for <see cref="TableDecisionTransitionRepository"/>. Sibling of
|
|
/// <see cref="TableSignalRepositoryMappingTests"/> — same rationale.
|
|
/// </summary>
|
|
public class TableDecisionTransitionRepositoryMappingTests
|
|
{
|
|
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
|
private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
|
|
|
|
[Fact]
|
|
public void BuildPartitionKey_is_tenant_underscore_session()
|
|
{
|
|
var pk = TableDecisionTransitionRepository.BuildPartitionKey(TenantId, SessionId);
|
|
Assert.Equal($"{TenantId}_{SessionId}", pk);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildRowKey_pads_stepIndex_to_10_digits()
|
|
{
|
|
Assert.Equal("0000000000", TableDecisionTransitionRepository.BuildRowKey(0));
|
|
Assert.Equal("0000000042", TableDecisionTransitionRepository.BuildRowKey(42));
|
|
Assert.Equal("2147483647", TableDecisionTransitionRepository.BuildRowKey(int.MaxValue));
|
|
}
|
|
|
|
[Fact]
|
|
public void ToEntity_projects_all_typed_columns()
|
|
{
|
|
var record = 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(record);
|
|
|
|
Assert.Equal($"{TenantId}_{SessionId}", entity.PartitionKey);
|
|
Assert.Equal("0000000007", entity.RowKey);
|
|
Assert.Equal(TenantId, entity.GetString("TenantId"));
|
|
Assert.Equal(SessionId, entity.GetString("SessionId"));
|
|
Assert.Equal(7, entity.GetInt32("StepIndex"));
|
|
Assert.Equal(117L, entity.GetInt64("SessionTraceOrdinal"));
|
|
Assert.Equal(42L, entity.GetInt64("SignalOrdinalRef"));
|
|
Assert.Equal(record.OccurredAtUtc, entity.GetDateTime("OccurredAtUtc"));
|
|
Assert.Equal("EspExiting", entity.GetString("Trigger"));
|
|
Assert.Equal("EspInProgress", entity.GetString("FromStage"));
|
|
Assert.Equal("WhiteGloveSealed", entity.GetString("ToStage"));
|
|
Assert.Equal(true, entity.GetBoolean("Taken"));
|
|
Assert.Null(entity.GetString("DeadEndReason"));
|
|
Assert.Equal("1.0.0", entity.GetString("ReducerVersion"));
|
|
Assert.Equal(true, entity.GetBoolean("IsTerminal"));
|
|
Assert.Equal("whiteglove-sealing", entity.GetString("ClassifierVerdictId"));
|
|
Assert.Equal("Strong", entity.GetString("ClassifierHypothesisLevel"));
|
|
Assert.Equal("{\"foo\":\"bar\"}", entity.GetString("PayloadJson"));
|
|
}
|
|
|
|
[Fact]
|
|
public void ToEntity_preserves_dead_end_reason_for_blocked_transitions()
|
|
{
|
|
var record = new DecisionTransitionRecord
|
|
{
|
|
TenantId = TenantId,
|
|
SessionId = SessionId,
|
|
StepIndex = 3,
|
|
SessionTraceOrdinal = 5,
|
|
SignalOrdinalRef = 3,
|
|
OccurredAtUtc = DateTime.UtcNow,
|
|
Trigger = "EspExiting",
|
|
FromStage = "EspInProgress",
|
|
ToStage = "EspInProgress", // no transition — dead end
|
|
Taken = false,
|
|
DeadEndReason = "hybrid_reboot_gate_blocking",
|
|
ReducerVersion = "1.0.0",
|
|
IsTerminal = false,
|
|
PayloadJson = "{}",
|
|
};
|
|
|
|
var entity = TableDecisionTransitionRepository.ToEntity(record);
|
|
|
|
Assert.Equal(false, entity.GetBoolean("Taken"));
|
|
Assert.Equal("hybrid_reboot_gate_blocking", entity.GetString("DeadEndReason"));
|
|
}
|
|
|
|
[Fact]
|
|
public void ToEntity_chunks_oversized_PayloadJson()
|
|
{
|
|
var oversized = new string('x', 75_000);
|
|
var record = 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(record);
|
|
|
|
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);
|
|
}
|
|
}
|