Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/MarkSessionFailedFunction.cs
T
Oliver Kieselbach 7e0c578a59 feat(backend): VerdictPath + PriorStatus/PriorVerdictPath on every session status write
Every status writer now declares the code path that produced the status
(VerdictPaths vocabulary, stamped for all statuses — unlike FailureSource
which is Failed-only), the timeout classifier returns its rule id, and a
write that overrides a prior verdict preserves it as PriorStatus/
PriorVerdictPath (admin mark, late completion, retro). Pre-instrumentation
rows derive a path read-side (VerdictPathDerivation, legacy:* when
ambiguous). Foundation for the verdict calibration aggregate + radar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 11:06:37 +02:00

165 lines
7.1 KiB
C#

using System.Net;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Azure.Functions.Worker.Extensions.SignalRService;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Sessions
{
public class MarkSessionFailedFunction
{
private readonly ILogger<MarkSessionFailedFunction> _logger;
private readonly ISessionRepository _sessionRepo;
private readonly IMaintenanceRepository _maintenanceRepo;
public MarkSessionFailedFunction(
ILogger<MarkSessionFailedFunction> logger,
ISessionRepository sessionRepo,
IMaintenanceRepository maintenanceRepo)
{
_logger = logger;
_sessionRepo = sessionRepo;
_maintenanceRepo = maintenanceRepo;
}
[Function("MarkSessionFailed")]
public async Task<MarkSessionFailedOutput> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "sessions/{sessionId}/mark-failed")] HttpRequestData req,
string sessionId)
{
_logger.LogInformation($"MarkSessionFailed function processing request for session {sessionId}");
try
{
// Authentication + TenantAdminOrGA authorization enforced by PolicyEnforcementMiddleware
string tenantId = TenantHelper.GetTenantId(req);
string userIdentifier = TenantHelper.GetUserIdentifier(req);
_logger.LogInformation($"Marking session {sessionId} as failed for tenant {tenantId} by user {userIdentifier}");
// Update session status to Failed with manual failure reason. AdminMarkedAction is
// the authoritative trigger for the AdminAction response-field sent to agents.
// FailureSource="manual" aligns this code path with the existing rule/agent
// attribution scheme on the SessionSummary.
var success = await _sessionRepo.UpdateSessionStatusAsync(
tenantId,
sessionId,
SessionStatus.Failed,
VerdictPaths.ManualFailed,
currentPhase: null, // Keep current phase
failureReason: "Manually marked as failed by administrator",
failureSource: "manual",
adminMarkedAction: "Failed"
);
if (success)
{
// Log audit entry with actual user identifier
await _maintenanceRepo.LogAuditEntryAsync(
tenantId,
"UPDATE",
"Session",
sessionId,
userIdentifier,
new Dictionary<string, string>
{
{ "Action", "MarkAsFailed" },
{ "Reason", "Manually marked as failed" }
}
);
// Retrieve updated session data to include in SignalR message
var updatedSession = await _sessionRepo.GetSessionAsync(tenantId, sessionId);
_logger.LogInformation($"Successfully marked session {sessionId} as failed");
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
{
success = true,
message = $"Session {sessionId} marked as failed"
});
// Send SignalR notification to update all clients in the tenant
// Only sent to tenant-specific group (not global-admins) to avoid flooding
// Global Admins can refresh or view session details to see status changes
object? sessionDelta = updatedSession != null ? new {
updatedSession.CurrentPhase,
updatedSession.CurrentPhaseDetail,
updatedSession.Status,
updatedSession.FailureReason,
updatedSession.EventCount,
updatedSession.DurationSeconds,
updatedSession.CompletedAt,
updatedSession.DiagnosticsBlobName
} : null;
object messagePayload = new {
sessionId = sessionId,
tenantId = tenantId,
eventCount = 0,
sessionUpdate = sessionDelta
};
var tenantMessage = new SignalRMessageAction(Constants.SignalRMessages.NewEvents)
{
GroupName = $"tenant-{tenantId}",
Arguments = new[] { messagePayload }
};
// Also push to the per-session group: the tenant broadcast group is member-role
// gated, so a roleless Progress-Portal watcher of this session would otherwise
// never see the admin-marked failure.
var sessionMessage = new SignalRMessageAction(Constants.SignalRMessages.NewEvents)
{
GroupName = $"session-{tenantId}-{sessionId}",
Arguments = new[] { messagePayload }
};
return new MarkSessionFailedOutput
{
HttpResponse = response,
SignalRMessages = new[] { tenantMessage, sessionMessage }
};
}
else
{
_logger.LogWarning($"Session {sessionId} not found");
var response = req.CreateResponse(HttpStatusCode.NotFound);
await response.WriteAsJsonAsync(new
{
success = false,
message = $"Session {sessionId} not found"
});
return new MarkSessionFailedOutput { HttpResponse = response };
}
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error marking session {sessionId} as failed");
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
await errorResponse.WriteAsJsonAsync(new
{
success = false,
message = "Internal server error"
});
return new MarkSessionFailedOutput { HttpResponse = errorResponse };
}
}
}
public class MarkSessionFailedOutput
{
[HttpResult]
public HttpResponseData? HttpResponse { get; set; }
[SignalROutput(HubName = SignalRGroupHelper.HubName)]
public SignalRMessageAction[]? SignalRMessages { get; set; }
}
}