diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/BackupWireParityTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/BackupWireParityTests.cs new file mode 100644 index 00000000..a7cf82ce --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/BackupWireParityTests.cs @@ -0,0 +1,156 @@ +using System.Text.Json; +using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Shared.Models; +using AutopilotMonitor.Shared.Models.Backup; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Wire-identity proof for moving the backup job, row-restore and queued-run bodies off their +/// private WriteJsonAsync(req, status, object) wrappers onto ResponseHelper. +/// Two things changed at once and both are pinned here: the anonymous 202 bodies became DTOs, +/// and the typed bodies left for the worker +/// pipeline (). The two option sets differ only in how they READ +/// enums (allowIntegerValues); the facts below keep the WRITE side honest if either set +/// ever gains a naming policy, a key policy or a converter. +/// +public class BackupWireParityTests +{ + private static readonly JsonSerializerOptions WireOptions = ApiJsonOptions.Create(); + + private static JsonElement Json(string raw) + { + using var doc = JsonDocument.Parse(raw); + return doc.RootElement.Clone(); + } + + private static void AssertSameUnderBothOptionSets(object body) + { + var backupSurface = JsonSerializer.Serialize(body, body.GetType(), BackupManifestJson.SerializerOptions); + var pipeline = JsonSerializer.Serialize(body, body.GetType(), WireOptions); + Assert.Equal(backupSurface, pipeline); + } + + [Fact] + public void BackupJobStatus_serializes_identically_under_both_option_sets() + { + var t = new DateTime(2026, 9, 21, 4, 0, 0, DateTimeKind.Utc); + + AssertSameUnderBothOptionSets(new BackupJobStatus + { + JobId = "0f8fad5bd9cb469fa16570867728950e", + Kind = BackupJobKind.RestoreTable, + State = BackupJobState.BlockedTerminal, + RequestedBy = "admin@contoso.example", + QueuedAtUtc = t, + StartedAtUtc = t.AddSeconds(3), + CompletedAtUtc = t.AddMinutes(2), + LastHeartbeatUtc = t.AddMinutes(2), + BackupId = "20260921T040000Z_a1b2c3d4", + SourceBackupId = "20260920T040000Z_e5f6a7b8", + TableName = "TenantConfiguration", + Strategy = "upsert-only", + Progress = "{\"phase\":\"copy\",\"rows\":12}", + Error = "IntegrityCheckFailed", + BackupOutcome = BackupOutcome.Partial, + }); + + // Freshly queued: every nullable slot absent on both sides. + AssertSameUnderBothOptionSets(new BackupJobStatus + { + JobId = "0f8fad5bd9cb469fa16570867728950e", + Kind = BackupJobKind.Backup, + State = BackupJobState.Queued, + RequestedBy = "admin@contoso.example", + QueuedAtUtc = t, + LastHeartbeatUtc = t, + }); + } + + [Fact] + public void RestoreRowPreviewResponse_serializes_identically_under_both_option_sets() + { + var text = new RestoreRowPropertySnapshot { EdmType = "Edm.String", Value = Json("\"Pro\"") }; + var flag = new RestoreRowPropertySnapshot { EdmType = "Edm.Boolean", Value = Json("true") }; + var number = new RestoreRowPropertySnapshot { EdmType = "Edm.Int64", Value = Json("\"9007199254740993\"") }; + + AssertSameUnderBothOptionSets(new RestoreRowPreviewResponse + { + BackupId = "20260921T040000Z_a1b2c3d4", + TableName = "TenantConfiguration", + PartitionKey = "tenant/with+odd%chars", + RowKey = "config", + // Case-faithful column names: a key policy on either side would rewrite them. + BackupProperties = { ["PlanTier"] = text, ["IsEnabled"] = flag, ["lowerCaseColumn"] = number }, + CurrentProperties = new Dictionary { ["PlanTier"] = text }, + Diff = + { + new RestoreRowPropertyDiff { Name = "IsEnabled", Kind = RestoreRowDiffKind.Added, Backup = flag }, + new RestoreRowPropertyDiff { Name = "PlanTier", Kind = RestoreRowDiffKind.Unchanged, Backup = text, Current = text }, + }, + RowSha256 = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", + CurrentETag = "W/\"datetime'2026-09-21T04%3A00%3A00Z'\"", + IsAuthTable = true, + }); + + // Live row absent: currentProperties and currentETag vanish on both sides. + AssertSameUnderBothOptionSets(new RestoreRowPreviewResponse + { + BackupId = "20260921T040000Z_a1b2c3d4", + TableName = "TenantConfiguration", + PartitionKey = "pk", + RowKey = "rk", + BackupProperties = { ["PlanTier"] = text }, + Diff = { new RestoreRowPropertyDiff { Name = "PlanTier", Kind = RestoreRowDiffKind.Added, Backup = text } }, + RowSha256 = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", + }); + } + + [Fact] + public void RestoreRowCommitResponse_serializes_identically_under_both_option_sets() + { + AssertSameUnderBothOptionSets(new RestoreRowCommitResponse + { + BackupId = "20260921T040000Z_a1b2c3d4", + TableName = "TenantConfiguration", + PartitionKey = "pk", + RowKey = "rk", + Outcome = RestoreRowCommitOutcome.Replaced, + }); + } + + [Fact] + public void BackupTriggerResponse_matches_the_anonymous_202_body() + { + const string jobId = "0f8fad5bd9cb469fa16570867728950e"; + var old = new { jobId, statusUrl = $"/api/global/backups/jobs/{jobId}" }; + + Assert.Equal( + JsonSerializer.Serialize(old, old.GetType(), BackupManifestJson.SerializerOptions), + JsonSerializer.Serialize( + new BackupTriggerResponse { JobId = jobId, StatusUrl = $"/api/global/backups/jobs/{jobId}" }, + WireOptions)); + } + + [Fact] + public void SessionDeletionMaintenanceTriggerResponse_matches_the_anonymous_202_body() + { + var old = new { message = "Maintenance run queued.", triggeredBy = "admin@contoso.example" }; + + // The old wrapper serialized with DEFAULT options; its keys were camelCase by hand. + Assert.Equal( + JsonSerializer.Serialize(old, old.GetType()), + JsonSerializer.Serialize( + new SessionDeletionMaintenanceTriggerResponse { Message = "Maintenance run queued.", TriggeredBy = "admin@contoso.example" }, + WireOptions)); + } + + [Fact] + public void Delegation_acknowledgements_match_the_anonymous_message_body() + { + ApiResponseWireParityTests.AssertWireIdentical( + new { message = "Delegation ended" }, + new MessageResponse { Message = "Delegation ended" }); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SharedManifestParityTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SharedManifestParityTests.cs index 1e0e4ad9..759b5843 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/SharedManifestParityTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/SharedManifestParityTests.cs @@ -107,6 +107,7 @@ public sealed class SharedManifestParityTests ["apiErrorCodes"] = ConstStrings(typeof(Constants.ApiErrorCodes)) .Concat(ConstStrings(typeof(Constants.DelegationCodes))) .Concat(ConstStrings(typeof(Constants.DelegatedSlots))) + .Concat(ConstStrings(typeof(Constants.BackupErrorCodes))) .ToArray(), ["tenantRoles"] = ConstStrings(typeof(Constants.TenantRoles)), ["globalRoles"] = ConstStrings(typeof(Constants.GlobalRoles)), diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TypedResponseGuardTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TypedResponseGuardTests.cs index e8a3c1e5..08294d6e 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/TypedResponseGuardTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TypedResponseGuardTests.cs @@ -53,14 +53,16 @@ public class TypedResponseGuardTests private static readonly Dictionary WriteBaseline = new(StringComparer.OrdinalIgnoreCase); // ── Bypass-shape guards (closed 2026-08-31) ───────────────────────────────────────── - // Regexes A/B only see the INLINE literal `...(new { ... })`. Three bypass shapes let a + // Regexes A/B only see the INLINE literal `...(new { ... })`. Four bypass shapes let a // response ship untyped anyway and are ratcheted here with EMPTY baselines: // C: the literal parked in a variable first (`var result = new { ... };` → // `WriteAsJsonAsync(result)`), which is how rule-stats escaped the 08-31 migration, // D: hand-serialized bodies (`WriteStringAsync(JsonSerializer.Serialize(new { ... }))`), // E: builder methods declared `object` / `Task` returning `new { ... }` - // (verdict-calibration's Build). A fourth shape — local `WriteJson(req, object)` - // wrappers — is closed structurally: wrappers take a `T : IApiResponse` generic. + // (verdict-calibration's Build), + // F: local response wrappers taking the body as `object` (`WriteJsonAsync(req, status, + // object body)`) — every call site behind one is invisible to A–D, which is how the + // backup, session-restore and delegation bodies stayed anonymous until 2026-09-21. /// Identifier passed to WriteAsJsonAsync — flagged when the SAME file assigns that identifier an anonymous object. private static readonly Regex WriteAsJsonIdentifier = @@ -72,6 +74,15 @@ public class TypedResponseGuardTests private static readonly Regex ObjectReturningBuilder = new(@"\b(?:object|Task)\s+\w*(?:Build|Compute|Payload|Response)\w*\s*\(", RegexOptions.Compiled); + private static readonly Regex ObjectBodyResponseWrapper = + new(@"\b(?:Task|HttpResponseData)\s+\w+\s*\([^)]*\bobject\??\s+\w+[^)]*\)", RegexOptions.Compiled); + + /// + /// EMPTY. A response writer takes T : IApiResponse / T : IApiErrorResponse + /// (ResponseHelper), never object. + /// + private static readonly Dictionary ObjectWrapperBaseline = new(StringComparer.OrdinalIgnoreCase); + /// EMPTY — an anonymous object smuggled through a local variable is the same regression as an inline one. private static readonly Dictionary IdentifierBaseline = new(StringComparer.OrdinalIgnoreCase); @@ -140,6 +151,15 @@ public class TypedResponseGuardTests "object/Task-returning Build*/Compute*/*Payload/*Response method"); } + [Fact] + public void Response_wrappers_taking_an_object_body_are_flagged() + { + AssertRatchet( + text => ObjectBodyResponseWrapper.Matches(text).Count, + ObjectWrapperBaseline, + "HttpResponseData-returning wrapper with an object parameter"); + } + /// /// Wire DTOs must be flat: System.Text.Json serializes derived properties BEFORE base /// properties, so any base class would silently reorder JSON keys — and key order is part diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DelegationSelfServiceFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DelegationSelfServiceFunction.cs index dacdbebc..20ff27d9 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DelegationSelfServiceFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DelegationSelfServiceFunction.cs @@ -41,7 +41,7 @@ public class DelegationSelfServiceFunction [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "delegations/slots")] HttpRequestData req) { var usage = await _slots.GetUsageAsync(req.GetRequestContext().TenantId); - return await OkAsync(req, DelegatedSlotManagementFunction.ToResponse(usage)); + return await req.OkAsync(DelegatedSlotManagementFunction.ToResponse(usage)); } /// GET /api/delegations/managed — the tenants the caller's tenant manages. @@ -66,7 +66,7 @@ public class DelegationSelfServiceFunction }); } - return await OkAsync(req, new ManagedTenantListResponse + return await req.OkAsync(new ManagedTenantListResponse { HomeTenantId = home, Slots = DelegatedSlotManagementFunction.ToResponse(view.Slots), @@ -88,7 +88,7 @@ public class DelegationSelfServiceFunction return await BadAsync(req, "a valid tenantId (GUID) is required"); var result = await _svc.RemoveManagedAsync(ctx.TenantId, body.TenantId, ctx.UserPrincipalName); - return result.Ok ? await OkAsync(req, new { message = "Delegation ended" }) : await FailAsync(req, result.Failure!); + return result.Ok ? await req.OkAsync(new MessageResponse { Message = "Delegation ended" }) : await FailAsync(req, result.Failure!); } // ── Invitations ────────────────────────────────────────────────────────────── @@ -118,7 +118,7 @@ public class DelegationSelfServiceFunction HoldUntilUtc = r.HoldUntilUtc, }); } - return await OkAsync(req, new DelegationInvitationListResponse { HomeTenantId = home, Invitations = items }); + return await req.OkAsync(new DelegationInvitationListResponse { HomeTenantId = home, Invitations = items }); } /// POST /api/delegations/invitations — mint a single-use invitation link (token shown once). TenantAdminOrGA. @@ -147,7 +147,7 @@ public class DelegationSelfServiceFunction { var ctx = req.GetRequestContext(); var result = await _svc.CancelInvitationAsync(ctx.TenantId, invitationId, ctx.UserPrincipalName); - return result.Ok ? await OkAsync(req, new { message = "Invitation cancelled" }) : await FailAsync(req, result.Failure!); + return result.Ok ? await req.OkAsync(new MessageResponse { Message = "Invitation cancelled" }) : await FailAsync(req, result.Failure!); } // ── Assignees (the managing tenant's own users) ────────────────────────────── @@ -160,7 +160,7 @@ public class DelegationSelfServiceFunction { var home = req.GetRequestContext().TenantId; var assignees = await _svc.ListAssigneesAsync(home); - return await OkAsync(req, new DelegationAssigneeListResponse + return await req.OkAsync(new DelegationAssigneeListResponse { HomeTenantId = home, GroupId = Constants.TenantGroupIds.ForHomeTenant(home), @@ -200,7 +200,7 @@ public class DelegationSelfServiceFunction { var ctx = req.GetRequestContext(); var result = await _svc.UnassignAsync(ctx.TenantId, upn, ctx.UserPrincipalName); - return result.Ok ? await OkAsync(req, new { message = "Unassigned" }) : await FailAsync(req, result.Failure!); + return result.Ok ? await req.OkAsync(new MessageResponse { Message = "Unassigned" }) : await FailAsync(req, result.Failure!); } // ── Accept (the customer's tenant admin) ───────────────────────────────────── @@ -217,7 +217,7 @@ public class DelegationSelfServiceFunction if (!result.Ok) return await FailAsync(req, result.Failure!); var p = result.Value!; - return await OkAsync(req, new DelegationAcceptPreviewResponse + return await req.OkAsync(new DelegationAcceptPreviewResponse { HomeTenantId = p.HomeTenantId, HomeTenantDomain = p.HomeTenantDomain, @@ -245,7 +245,7 @@ public class DelegationSelfServiceFunction if (!result.Ok) return await FailAsync(req, result.Failure!); var o = result.Value!; - return await OkAsync(req, new AcceptDelegationInvitationResponse { HomeTenantId = o.HomeTenantId, HomeTenantDomain = o.HomeTenantDomain, ManagedTenantId = o.ManagedTenantId }); + return await req.OkAsync(new AcceptDelegationInvitationResponse { HomeTenantId = o.HomeTenantId, HomeTenantDomain = o.HomeTenantDomain, ManagedTenantId = o.ManagedTenantId }); } // ── Helpers ───────────────────────────────────────────────────────────────── @@ -257,13 +257,6 @@ public class DelegationSelfServiceFunction return await req.ErrorAsync((HttpStatusCode)failure.Status, failure.Code, failure.Message); } - private static async Task OkAsync(HttpRequestData req, object body) - { - var response = req.CreateResponse(HttpStatusCode.OK); - await response.WriteAsJsonAsync(body); - return response; - } - private static async Task BadAsync(HttpRequestData req, string error) { return await req.BadRequestAsync(error); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Backup/GetBackupJobStatusFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Backup/GetBackupJobStatusFunction.cs index 6680fdf7..b9c761d8 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Backup/GetBackupJobStatusFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Backup/GetBackupJobStatusFunction.cs @@ -1,7 +1,7 @@ -using System.Net; -using System.Text.Json; using System.Threading.Tasks; using AutopilotMonitor.Functions.DataAccess.TableStorage; +using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Shared; using AutopilotMonitor.Shared.Models.Backup; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; @@ -36,18 +36,9 @@ namespace AutopilotMonitor.Functions.Functions.Backup var (job, _) = await _jobs.GetWithETagAsync(jobId, req.FunctionContext.CancellationToken); if (job is null) { - return await WriteJsonAsync(req, HttpStatusCode.NotFound, new { error = "JobNotFound", jobId }); + return await req.NotFoundAsync($"Backup job {jobId} not found.", Constants.BackupErrorCodes.JobNotFound); } - return await WriteJsonAsync(req, HttpStatusCode.OK, job); - } - - private static async Task WriteJsonAsync(HttpRequestData req, HttpStatusCode status, object body) - { - var response = req.CreateResponse(status); - response.Headers.Add("Content-Type", "application/json; charset=utf-8"); - var json = JsonSerializer.Serialize(body, BackupManifestJson.SerializerOptions); - await response.WriteStringAsync(json); - return response; + return await req.OkAsync(job); } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Backup/RestoreRowFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Backup/RestoreRowFunction.cs index 8898fb4f..8867b1f4 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Backup/RestoreRowFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Backup/RestoreRowFunction.cs @@ -1,10 +1,10 @@ using System; using System.Net; -using System.Text.Json; using System.Threading.Tasks; using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Functions.Services.Backup; +using AutopilotMonitor.Shared; using AutopilotMonitor.Shared.Models.Backup; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; @@ -61,7 +61,7 @@ namespace AutopilotMonitor.Functions.Functions.Backup } catch (BackupTerminalException ex) { - return await WriteErrorAsync(req, HttpStatusCode.BadRequest, ex.Code, ex.Message).ConfigureAwait(false); + return await req.BadRequestAsync(ex.Message, ex.Code).ConfigureAwait(false); } // 3. Dispatch on mode @@ -71,7 +71,7 @@ namespace AutopilotMonitor.Functions.Functions.Backup { var preview = await _restoreService.PreviewRowAsync( backupId, body.TableName, body.PartitionKey, body.RowKey, ct).ConfigureAwait(false); - return await WriteJsonAsync(req, HttpStatusCode.OK, preview).ConfigureAwait(false); + return await req.OkAsync(preview).ConfigureAwait(false); } else { @@ -92,13 +92,13 @@ namespace AutopilotMonitor.Functions.Functions.Backup _logger.LogWarning(evtEx, "RestoreRow: ops event recording failed — write itself succeeded"); } - return await WriteJsonAsync(req, HttpStatusCode.OK, commit).ConfigureAwait(false); + return await req.OkAsync(commit).ConfigureAwait(false); } } catch (BackupTerminalException ex) { var status = MapErrorCodeToStatus(ex.Code); - return await WriteErrorAsync(req, status, ex.Code, ex.Message).ConfigureAwait(false); + return await req.ErrorAsync(status, ex.Code, ex.Message).ConfigureAwait(false); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { @@ -107,39 +107,26 @@ namespace AutopilotMonitor.Functions.Functions.Backup } catch (Exception ex) { - _logger.LogError(ex, "RestoreRow: unexpected failure for backupId={BackupId} table={Table}", backupId, body.TableName); - return await WriteErrorAsync(req, HttpStatusCode.InternalServerError, "InternalError", ex.Message).ConfigureAwait(false); + // Sanitized envelope: the raw exception text stays in the log, the correlation id is the handle. + return await req.InternalServerErrorAsync( + _logger, ex, $"RestoreRow backupId={backupId} table={body.TableName}").ConfigureAwait(false); } } private static HttpStatusCode MapErrorCodeToStatus(string code) => code switch { - "BackupNotFound" => HttpStatusCode.NotFound, - "RowNotInBackup" => HttpStatusCode.NotFound, - "TableNotInBackup" => HttpStatusCode.Conflict, - "ManifestCorrupt" => HttpStatusCode.Conflict, - "ManifestSchemaUnsupported" => HttpStatusCode.Conflict, - "IntegrityCheckFailed" => HttpStatusCode.Conflict, - "BlobChangedSinceValidation" => HttpStatusCode.Conflict, - "RowChangedSinceValidation" => HttpStatusCode.Conflict, - "CurrentRowChanged" => HttpStatusCode.Conflict, - "MaintenanceInProgress" => HttpStatusCode.Conflict, - "MaintenanceLeaseLost" => HttpStatusCode.Conflict, - _ => HttpStatusCode.BadRequest, + Constants.BackupErrorCodes.BackupNotFound => HttpStatusCode.NotFound, + Constants.BackupErrorCodes.RowNotInBackup => HttpStatusCode.NotFound, + Constants.BackupErrorCodes.TableNotInBackup => HttpStatusCode.Conflict, + Constants.BackupErrorCodes.ManifestCorrupt => HttpStatusCode.Conflict, + Constants.BackupErrorCodes.ManifestSchemaUnsupported => HttpStatusCode.Conflict, + Constants.BackupErrorCodes.IntegrityCheckFailed => HttpStatusCode.Conflict, + Constants.BackupErrorCodes.BlobChangedSinceValidation => HttpStatusCode.Conflict, + Constants.BackupErrorCodes.RowChangedSinceValidation => HttpStatusCode.Conflict, + Constants.BackupErrorCodes.CurrentRowChanged => HttpStatusCode.Conflict, + Constants.BackupErrorCodes.MaintenanceInProgress => HttpStatusCode.Conflict, + Constants.BackupErrorCodes.MaintenanceLeaseLost => HttpStatusCode.Conflict, + _ => HttpStatusCode.BadRequest, }; - - private static async Task WriteJsonAsync(HttpRequestData req, HttpStatusCode status, object body) - { - var response = req.CreateResponse(status); - response.Headers.Add("Content-Type", "application/json; charset=utf-8"); - var json = JsonSerializer.Serialize(body, BackupManifestJson.SerializerOptions); - await response.WriteStringAsync(json).ConfigureAwait(false); - return response; - } - - private static async Task WriteErrorAsync(HttpRequestData req, HttpStatusCode status, string code, string message) - { - return await WriteJsonAsync(req, status, new { error = code, message }).ConfigureAwait(false); - } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Backup/TriggerCriticalTableBackupFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Backup/TriggerCriticalTableBackupFunction.cs index a6f56a6d..303877c0 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Backup/TriggerCriticalTableBackupFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Backup/TriggerCriticalTableBackupFunction.cs @@ -1,10 +1,10 @@ using System; using System.Net; -using System.Text.Json; using System.Threading.Tasks; using AutopilotMonitor.Functions.DataAccess.TableStorage; using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Services.Backup.Queue; +using AutopilotMonitor.Shared; using AutopilotMonitor.Shared.Models.Backup; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; @@ -63,11 +63,9 @@ namespace AutopilotMonitor.Functions.Functions.Backup if (!inserted) { // Guid-N collision (effectively impossible) — surface so the operator can retry. - return await WriteJsonAsync(req, HttpStatusCode.Conflict, new - { - error = "JobIdCollision", - message = "Failed to allocate a unique jobId — please retry.", - }); + return await req.ConflictAsync( + "Failed to allocate a unique jobId — please retry.", + Constants.BackupErrorCodes.JobIdCollision); } try @@ -91,28 +89,17 @@ namespace AutopilotMonitor.Functions.Functions.Backup await _jobs.TryUpdateWithCasAsync(fresh, etag.Value, req.FunctionContext.CancellationToken); } - return await WriteJsonAsync(req, HttpStatusCode.InternalServerError, new - { - error = "EnqueueFailed", - message = "Failed to enqueue backup job — job status has been marked Failed.", - jobId, - }); + return await req.ErrorAsync( + HttpStatusCode.InternalServerError, + Constants.ApiErrorCodes.EnqueueFailed, + $"Failed to enqueue backup job {jobId} — job status has been marked Failed."); } - return await WriteJsonAsync(req, HttpStatusCode.Accepted, new + return await req.JsonAsync(HttpStatusCode.Accepted, new BackupTriggerResponse { - jobId, - statusUrl = $"/api/global/backups/jobs/{jobId}", + JobId = jobId, + StatusUrl = $"/api/global/backups/jobs/{jobId}", }); } - - private static async Task WriteJsonAsync(HttpRequestData req, HttpStatusCode status, object body) - { - var response = req.CreateResponse(status); - response.Headers.Add("Content-Type", "application/json; charset=utf-8"); - var json = JsonSerializer.Serialize(body, BackupManifestJson.SerializerOptions); - await response.WriteStringAsync(json); - return response; - } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Maintenance/TriggerSessionDeletionMaintenanceFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Maintenance/TriggerSessionDeletionMaintenanceFunction.cs index 5252d60b..258868ae 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Maintenance/TriggerSessionDeletionMaintenanceFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Maintenance/TriggerSessionDeletionMaintenanceFunction.cs @@ -1,10 +1,11 @@ using System; using System.Net; -using System.Text.Json; using System.Threading.Tasks; using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Services.Backup; using AutopilotMonitor.Functions.Services.Deletion; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.Models; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; @@ -65,11 +66,9 @@ namespace AutopilotMonitor.Functions.Functions.Maintenance } catch (LeaseHeldException) { - return await WriteJsonAsync(req, HttpStatusCode.Conflict, new - { - error = "RunAlreadyActive", - message = "A session-deletion maintenance run is already active — check the Session Cleanup page for progress.", - }); + return await req.ConflictAsync( + "A session-deletion maintenance run is already active — check the Session Cleanup page for progress.", + Constants.ApiErrorCodes.RunAlreadyActive); } try @@ -79,27 +78,18 @@ namespace AutopilotMonitor.Functions.Functions.Maintenance catch (Exception ex) { _logger.LogError(ex, "TriggerSessionDeletionMaintenance: enqueue failed (triggeredBy={TriggeredBy})", actor); - return await WriteJsonAsync(req, HttpStatusCode.InternalServerError, new - { - error = "EnqueueFailed", - message = "Failed to enqueue the maintenance run — please retry.", - }); + return await req.ErrorAsync( + HttpStatusCode.InternalServerError, + Constants.ApiErrorCodes.EnqueueFailed, + "Failed to enqueue the maintenance run — please retry."); } _logger.LogInformation("TriggerSessionDeletionMaintenance: run queued (triggeredBy={TriggeredBy})", actor); - return await WriteJsonAsync(req, HttpStatusCode.Accepted, new + return await req.JsonAsync(HttpStatusCode.Accepted, new SessionDeletionMaintenanceTriggerResponse { - message = "Maintenance run queued — progress surfaces as SessionDeletionMaintenance* ops events.", - triggeredBy = actor, + Message = "Maintenance run queued — progress surfaces as SessionDeletionMaintenance* ops events.", + TriggeredBy = actor, }); } - - private static async Task WriteJsonAsync(HttpRequestData req, HttpStatusCode status, object body) - { - var response = req.CreateResponse(status); - response.Headers.Add("Content-Type", "application/json; charset=utf-8"); - await response.WriteStringAsync(JsonSerializer.Serialize(body)); - return response; - } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RestoreSessionFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RestoreSessionFunction.cs index a752d2d5..f415b15d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RestoreSessionFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RestoreSessionFunction.cs @@ -1,6 +1,5 @@ using System; using System.Net; -using System.Text.Json; using System.Threading.Tasks; using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Services.Deletion; @@ -51,7 +50,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions { if (string.IsNullOrWhiteSpace(sessionId)) { - return await WriteJsonAsync(req, HttpStatusCode.BadRequest, new { success = false, message = "sessionId is required" }); + return await req.BadRequestAsync("sessionId is required"); } var read = await req.ReadAsync(); @@ -95,12 +94,9 @@ namespace AutopilotMonitor.Functions.Functions.Sessions // the tenantId in the request body (or via the X-Target-Tenant header). if (string.IsNullOrWhiteSpace(body.TenantId)) { - return await WriteJsonAsync(req, HttpStatusCode.NotFound, new - { - success = false, - message = $"Session {sessionId} not found in any active SessionsIndex entry. " + - "If the cascade completed (Sessions row gone), pass the tenantId explicitly in the request body." - }); + return await req.NotFoundAsync( + $"Session {sessionId} not found in any active SessionsIndex entry.", + hint: "If the cascade completed (Sessions row gone), pass the tenantId explicitly in the request body."); } tenantId = body.TenantId!; } @@ -133,11 +129,8 @@ namespace AutopilotMonitor.Functions.Functions.Sessions } catch (Exception ex) { - _logger.LogError(ex, - "RestoreSession: unhandled exception for tenant={TenantId} session={SessionId} manifestId={ManifestId}", - tenantId, sessionId, body.ManifestId); - return await WriteJsonAsync(req, HttpStatusCode.InternalServerError, - new { success = false, message = "Internal error during restore — see audit log + telemetry for details.", exceptionType = ex.GetType().Name }); + return await req.InternalServerErrorAsync( + _logger, ex, $"RestoreSession tenant={tenantId} session={sessionId} manifestId={body.ManifestId}"); } return await WriteResultAsync(req, result); @@ -175,14 +168,8 @@ namespace AutopilotMonitor.Functions.Functions.Sessions DurationMs = result.DurationMs, }; - return await WriteJsonAsync(req, status, body); - } - - private static async Task WriteJsonAsync(HttpRequestData req, HttpStatusCode status, object body) - { - var response = req.CreateResponse(status); - await response.WriteAsJsonAsync(body); - return response; + // One shape for every outcome (see SessionRestoreResponse): the status carries the verdict. + return await req.JsonAsync(status, body); } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Backup/BackupRestoreInputValidator.cs b/src/Backend/AutopilotMonitor.Functions/Services/Backup/BackupRestoreInputValidator.cs index dbf3fc78..323223b1 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Backup/BackupRestoreInputValidator.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Backup/BackupRestoreInputValidator.cs @@ -5,6 +5,7 @@ using System.Security.Cryptography; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using AutopilotMonitor.Shared; using AutopilotMonitor.Shared.Models.Backup; using Azure; using Microsoft.Extensions.Logging; @@ -58,32 +59,32 @@ namespace AutopilotMonitor.Functions.Services.Backup /// public async Task ValidateAsync(string backupId, string tableName, CancellationToken ct = default) { - if (string.IsNullOrEmpty(backupId)) throw new BackupTerminalException("InvalidBackupId", "backupId is required"); - if (string.IsNullOrEmpty(tableName)) throw new BackupTerminalException("InvalidTable", "tableName is required"); + if (string.IsNullOrEmpty(backupId)) throw new BackupTerminalException(Constants.BackupErrorCodes.InvalidBackupId, "backupId is required"); + if (string.IsNullOrEmpty(tableName)) throw new BackupTerminalException(Constants.BackupErrorCodes.InvalidTable, "tableName is required"); // 1. Manifest exists + parseable var (payload, _) = await _store.ReadManifestAsync(backupId, ct).ConfigureAwait(false); if (payload == null) { - throw new BackupTerminalException("BackupNotFound", $"manifest for backupId '{backupId}' was not found"); + throw new BackupTerminalException(Constants.BackupErrorCodes.BackupNotFound, $"manifest for backupId '{backupId}' was not found"); } CriticalTableBackupManifest manifest; try { manifest = JsonSerializer.Deserialize(payload, BackupManifestJson.SerializerOptions) - ?? throw new BackupTerminalException("ManifestCorrupt", "manifest deserialised to null"); + ?? throw new BackupTerminalException(Constants.BackupErrorCodes.ManifestCorrupt, "manifest deserialised to null"); } catch (BackupTerminalException) { throw; } catch (Exception ex) { - throw new BackupTerminalException("ManifestCorrupt", $"manifest JSON parse failed: {ex.Message}", ex); + throw new BackupTerminalException(Constants.BackupErrorCodes.ManifestCorrupt, $"manifest JSON parse failed: {ex.Message}", ex); } if (manifest.SchemaVersion != SupportedSchemaVersion) { throw new BackupTerminalException( - "ManifestSchemaUnsupported", + Constants.BackupErrorCodes.ManifestSchemaUnsupported, $"manifest schemaVersion={manifest.SchemaVersion} not supported by this restore endpoint (expected {SupportedSchemaVersion})"); } @@ -92,13 +93,13 @@ namespace AutopilotMonitor.Functions.Services.Backup if (entry == null) { throw new BackupTerminalException( - "TableNotInBackup", + Constants.BackupErrorCodes.TableNotInBackup, $"no entry for tableName '{tableName}' in manifest {backupId}"); } if (entry.Status != TableBackupStatus.Ok && entry.Status != TableBackupStatus.Empty) { throw new BackupTerminalException( - "TableNotInBackup", + Constants.BackupErrorCodes.TableNotInBackup, $"manifest entry for '{tableName}' has Status={entry.Status} — restore is only allowed from Ok or Empty entries"); } @@ -107,7 +108,7 @@ namespace AutopilotMonitor.Functions.Services.Backup if (!string.Equals(entry.BlobName, expectedBlobName, StringComparison.Ordinal)) { throw new BackupTerminalException( - "ManifestCorrupt", + Constants.BackupErrorCodes.ManifestCorrupt, $"manifest entry BlobName='{entry.BlobName}' does not match canonical '{expectedBlobName}'"); } @@ -116,7 +117,7 @@ namespace AutopilotMonitor.Functions.Services.Backup if (openResult == null) { throw new BackupTerminalException( - "BackupNotFound", + Constants.BackupErrorCodes.BackupNotFound, $"NDJSON blob '{expectedBlobName}' is missing despite manifest entry present — orphan manifest"); } @@ -136,7 +137,7 @@ namespace AutopilotMonitor.Functions.Services.Backup // The IfMatch precondition can also fire mid-stream if the blob was // overwritten between OpenRead and the final byte. Same recovery as // OpenNdjsonReadAsync's 412 path. - throw new BackupTerminalException("BlobChangedSinceValidation", "NDJSON blob changed during SHA verification", ex); + throw new BackupTerminalException(Constants.BackupErrorCodes.BlobChangedSinceValidation, "NDJSON blob changed during SHA verification", ex); } var expectedSha = (entry.Sha256Hex ?? string.Empty).ToLowerInvariant(); @@ -146,7 +147,7 @@ namespace AutopilotMonitor.Functions.Services.Backup "BackupRestoreInputValidator: SHA mismatch for backupId={BackupId} table={Table} (expected={Expected}, actual={Actual}) — refusing restore", backupId, tableName, expectedSha, actualSha); throw new BackupTerminalException( - "IntegrityCheckFailed", + Constants.BackupErrorCodes.IntegrityCheckFailed, $"SHA-256 mismatch for table '{tableName}' (expected {expectedSha}, got {actualSha}) — backup may be tampered or partially written"); } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Backup/BlobBackupStore.cs b/src/Backend/AutopilotMonitor.Functions/Services/Backup/BlobBackupStore.cs index 044f6e64..bd0f81eb 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Backup/BlobBackupStore.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Backup/BlobBackupStore.cs @@ -218,7 +218,7 @@ namespace AutopilotMonitor.Functions.Services.Backup catch (RequestFailedException ex) when (ex.Status == 412) { throw new BackupTerminalException( - "BlobChangedSinceValidation", + Constants.BackupErrorCodes.BlobChangedSinceValidation, $"NDJSON blob '{BuildNdjsonBlobName(backupId, tableName)}' changed between GetProperties and OpenRead — refusing to read a half-version", ex); } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Backup/CriticalTableRestoreService.cs b/src/Backend/AutopilotMonitor.Functions/Services/Backup/CriticalTableRestoreService.cs index d588559b..512c439b 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Backup/CriticalTableRestoreService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Backup/CriticalTableRestoreService.cs @@ -88,7 +88,7 @@ namespace AutopilotMonitor.Functions.Services.Backup if (dump == null) { throw new BackupTerminalException( - "RowNotInBackup", + Constants.BackupErrorCodes.RowNotInBackup, $"row (pk='{partitionKey}', rk='{rowKey}') was not found in backup {backupId}/{tableName}"); } @@ -161,7 +161,7 @@ namespace AutopilotMonitor.Functions.Services.Backup catch (LeaseHeldException ex) { throw new BackupTerminalException( - "MaintenanceInProgress", + Constants.BackupErrorCodes.MaintenanceInProgress, "another maintenance operation is in progress — please retry shortly", ex); } @@ -185,7 +185,7 @@ namespace AutopilotMonitor.Functions.Services.Backup if (dump == null) { throw new BackupTerminalException( - "RowNotInBackup", + Constants.BackupErrorCodes.RowNotInBackup, $"row (pk='{partitionKey}', rk='{rowKey}') was not found in backup {backupId}/{tableName}"); } @@ -196,7 +196,7 @@ namespace AutopilotMonitor.Functions.Services.Backup if (!string.Equals(freshSha, (ifSha256 ?? string.Empty).ToLowerInvariant(), StringComparison.Ordinal)) { throw new BackupTerminalException( - "RowChangedSinceValidation", + Constants.BackupErrorCodes.RowChangedSinceValidation, $"backup row SHA-256 changed between preview and commit (preview echoed {ifSha256}, fresh {freshSha}) — re-open the preview to refresh"); } @@ -217,7 +217,7 @@ namespace AutopilotMonitor.Functions.Services.Backup catch (RequestFailedException ex) when (ex.Status == 412 || ex.Status == 404) { throw new BackupTerminalException( - "CurrentRowChanged", + Constants.BackupErrorCodes.CurrentRowChanged, $"live row was modified or deleted since preview (status={ex.Status}) — re-open the preview", ex); } @@ -241,7 +241,7 @@ namespace AutopilotMonitor.Functions.Services.Backup catch (RequestFailedException ex) when (StorageErrors.IsAlreadyExists(ex)) { throw new BackupTerminalException( - "CurrentRowChanged", + Constants.BackupErrorCodes.CurrentRowChanged, "live row was created since preview — re-open the preview", ex); } @@ -266,7 +266,7 @@ namespace AutopilotMonitor.Functions.Services.Backup // up to the HTTP layer's generic catch and renders as a 500. var reason = holder.RenewalFailureReason ?? "handler cancelled without explicit renewal failure"; throw new BackupTerminalException( - "MaintenanceLeaseLost", + Constants.BackupErrorCodes.MaintenanceLeaseLost, $"maintenance lease lost during commit — retry shortly ({reason})", ex); } @@ -287,7 +287,7 @@ namespace AutopilotMonitor.Functions.Services.Backup if (open == null) { throw new BackupTerminalException( - "BackupNotFound", + Constants.BackupErrorCodes.BackupNotFound, $"NDJSON blob for {backupId}/{tableName}.ndjson disappeared between validation and scan"); } if (!open.Value.ETag.Equals(expectedETag)) @@ -296,7 +296,7 @@ namespace AutopilotMonitor.Functions.Services.Backup // validator and scan, the blob was overwritten. try { open.Value.Stream.Dispose(); } catch { /* best effort */ } throw new BackupTerminalException( - "BlobChangedSinceValidation", + Constants.BackupErrorCodes.BlobChangedSinceValidation, "NDJSON blob ETag changed between validation hash and scan — refusing to read mismatching bytes"); } return open.Value; @@ -331,7 +331,7 @@ namespace AutopilotMonitor.Functions.Services.Backup catch (JsonException ex) { throw new BackupTerminalException( - "ManifestCorrupt", + Constants.BackupErrorCodes.ManifestCorrupt, $"NDJSON line failed to parse as DeletionRowDump: {ex.Message}", ex); } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Backup/RestoreTablePreflightValidator.cs b/src/Backend/AutopilotMonitor.Functions/Services/Backup/RestoreTablePreflightValidator.cs index d9109c9b..de73890c 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Backup/RestoreTablePreflightValidator.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Backup/RestoreTablePreflightValidator.cs @@ -28,21 +28,21 @@ namespace AutopilotMonitor.Functions.Services.Backup { if (string.IsNullOrEmpty(backupId)) { - throw new BackupTerminalException("InvalidBackupId", "backupId route segment is empty"); + throw new BackupTerminalException(Constants.BackupErrorCodes.InvalidBackupId, "backupId route segment is empty"); } if (request == null) { - throw new BackupTerminalException("MissingBody", "request body is required"); + throw new BackupTerminalException(Constants.BackupErrorCodes.MissingBody, "request body is required"); } if (string.IsNullOrEmpty(request.TableName)) { - throw new BackupTerminalException("InvalidTable", "tableName is required"); + throw new BackupTerminalException(Constants.BackupErrorCodes.InvalidTable, "tableName is required"); } if (!Constants.CriticalBackupTables.All.Contains(request.TableName, StringComparer.Ordinal)) { throw new BackupTerminalException( - "InvalidTable", + Constants.BackupErrorCodes.InvalidTable, $"tableName '{request.TableName}' is not in the critical-backup catalog — only the 15 critical tables are restorable"); } @@ -51,16 +51,16 @@ namespace AutopilotMonitor.Functions.Services.Backup // is not found for the given keys. if (request.PartitionKey == null) { - throw new BackupTerminalException("InvalidKeys", "partitionKey is required (may be empty string but not null)"); + throw new BackupTerminalException(Constants.BackupErrorCodes.InvalidKeys, "partitionKey is required (may be empty string but not null)"); } if (request.RowKey == null) { - throw new BackupTerminalException("InvalidKeys", "rowKey is required (may be empty string but not null)"); + throw new BackupTerminalException(Constants.BackupErrorCodes.InvalidKeys, "rowKey is required (may be empty string but not null)"); } if (request.Mode != RestoreRowMode.Preview && request.Mode != RestoreRowMode.Commit) { - throw new BackupTerminalException("InvalidMode", $"mode must be 'preview' or 'commit', got '{request.Mode}'"); + throw new BackupTerminalException(Constants.BackupErrorCodes.InvalidMode, $"mode must be 'preview' or 'commit', got '{request.Mode}'"); } if (request.Mode == RestoreRowMode.Commit) @@ -68,13 +68,13 @@ namespace AutopilotMonitor.Functions.Services.Backup if (string.IsNullOrEmpty(request.IfSha256)) { throw new BackupTerminalException( - "MissingPrecondition", + Constants.BackupErrorCodes.MissingPrecondition, "ifSha256 is required on commit — echo the rowSha256 from the preview response"); } if (!IsLowerHex64(request.IfSha256)) { throw new BackupTerminalException( - "MissingPrecondition", + Constants.BackupErrorCodes.MissingPrecondition, "ifSha256 must be 64 lowercase hex characters (SHA-256 hex digest)"); } // ifCurrentETag is intentionally nullable: null carries the precondition diff --git a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts index 852ddf24..0dc2f895 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts @@ -912,6 +912,12 @@ export interface BackupJobStatus { /** Manifest-level outcome. Job-level state (Failed for fatal errors) is tracked separately on BackupJobStatus; a manifest only exists when the run reached the "all tables attempted, manifest write succeeded" milestone. */ export type BackupOutcome = "Success" | "Partial"; +/** Response of POST global/backups/trigger (202 Accepted): the job is queued, not done. The portal polls StatusUrl for the BackupJobStatus. */ +export interface BackupTriggerResponse { + jobId: string; + statusUrl: string; +} + /** Body of POST devices/block (Global Admin). */ export interface BlockDeviceRequest { tenantId: string; @@ -3934,6 +3940,12 @@ export interface SessionDeletionListItem { ageMinutes: number; } +/** Response of POST global/session-deletions/maintenance/trigger (202 Accepted): the run is queued, not done. Its progress surfaces as SessionDeletionMaintenance* ops events. */ +export interface SessionDeletionMaintenanceTriggerResponse { + message: string; + triggeredBy: string; +} + /** Worker progress projection nested in GetSessionDeletionManifestResponse. */ export interface SessionDeletionProgressWire { snapshotSha256: string; diff --git a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-vocabularies.generated.ts b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-vocabularies.generated.ts index 1f99b4ae..8f2455f2 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-vocabularies.generated.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-vocabularies.generated.ts @@ -226,6 +226,8 @@ export const API_ERROR_CODES = [ "CascadePoisonedUseRestore", "KillSwitchActive", "CasExhaustedRetryLater", + "RunAlreadyActive", + "EnqueueFailed", "InvalidInvitation", "InvitationNotFound", "InvitationExpired", @@ -240,5 +242,24 @@ export const API_ERROR_CODES = [ "AssigneeNotFound", "IdentityBindingConflict", "DelegatedSlotLimitReached", + "InvalidBackupId", + "MissingBody", + "InvalidTable", + "InvalidKeys", + "InvalidMode", + "MissingPrecondition", + "BackupNotFound", + "RowNotInBackup", + "JobNotFound", + "TableNotInBackup", + "ManifestCorrupt", + "ManifestSchemaUnsupported", + "IntegrityCheckFailed", + "BlobChangedSinceValidation", + "RowChangedSinceValidation", + "CurrentRowChanged", + "MaintenanceInProgress", + "MaintenanceLeaseLost", + "JobIdCollision", ] as const; export type ApiErrorCode = (typeof API_ERROR_CODES)[number]; diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs index 9030ebeb..ebd15798 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -280,8 +280,8 @@ namespace AutopilotMonitor.Shared /// /// Machine-readable code values of the typed error envelope (ApiErrorResponse). /// Status defaults first (one per HTTP status the API emits), then the codes a consumer - /// branches on. Domain code classes (, ) - /// keep their own values; the manifest exports all of them together as apiErrorCodes. + /// branches on. Domain code classes (, , + /// ) keep their own values; the manifest exports all of them together as apiErrorCodes. /// Wire contract — keep stable, PascalCase. /// public static class ApiErrorCodes @@ -335,6 +335,45 @@ namespace AutopilotMonitor.Shared public const string CascadePoisonedUseRestore = "CascadePoisonedUseRestore"; public const string KillSwitchActive = "KillSwitchActive"; public const string CasExhaustedRetryLater = "CasExhaustedRetryLater"; + + // ── queued operator runs (backup trigger, session-deletion maintenance trigger) ── + /// 409: a run of the same kind already holds the lease. + public const string RunAlreadyActive = "RunAlreadyActive"; + /// 500: the queue refused the message; nothing was scheduled. + public const string EnqueueFailed = "EnqueueFailed"; + } + + /// + /// Machine-readable code values of the critical-table backup and restore surface + /// (global/backups/*). BackupTerminalException.Code carries one of these; + /// RestoreRowFunction maps it to the HTTP status. Wire contract — keep stable. + /// + public static class BackupErrorCodes + { + // ── request validation (400) ── + public const string InvalidBackupId = "InvalidBackupId"; + public const string MissingBody = "MissingBody"; + public const string InvalidTable = "InvalidTable"; + public const string InvalidKeys = "InvalidKeys"; + public const string InvalidMode = "InvalidMode"; + public const string MissingPrecondition = "MissingPrecondition"; + + // ── not found (404) ── + public const string BackupNotFound = "BackupNotFound"; + public const string RowNotInBackup = "RowNotInBackup"; + public const string JobNotFound = "JobNotFound"; + + // ── state conflicts (409) ── + public const string TableNotInBackup = "TableNotInBackup"; + public const string ManifestCorrupt = "ManifestCorrupt"; + public const string ManifestSchemaUnsupported = "ManifestSchemaUnsupported"; + public const string IntegrityCheckFailed = "IntegrityCheckFailed"; + public const string BlobChangedSinceValidation = "BlobChangedSinceValidation"; + public const string RowChangedSinceValidation = "RowChangedSinceValidation"; + public const string CurrentRowChanged = "CurrentRowChanged"; + public const string MaintenanceInProgress = "MaintenanceInProgress"; + public const string MaintenanceLeaseLost = "MaintenanceLeaseLost"; + public const string JobIdCollision = "JobIdCollision"; } /// diff --git a/src/Shared/AutopilotMonitor.Shared/Models/AdminApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/AdminApiModels.cs index cf88bf97..a6ee988d 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/AdminApiModels.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/AdminApiModels.cs @@ -161,6 +161,17 @@ namespace AutopilotMonitor.Shared.Models public bool AggregateOnly { get; set; } } + /// + /// Response of POST global/session-deletions/maintenance/trigger (202 Accepted): the run is + /// queued, not done. Its progress surfaces as SessionDeletionMaintenance* ops events. + /// + // Declaration order == wire order. + public class SessionDeletionMaintenanceTriggerResponse : IApiResponse + { + public string Message { get; set; } = default!; + public string TriggeredBy { get; set; } = default!; + } + /// /// Run report of the one-shot OccurredUtc backfill (POST maintenance/backfill-occurred-utc). /// diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Backup/BackupJobStatus.cs b/src/Shared/AutopilotMonitor.Shared/Models/Backup/BackupJobStatus.cs index 05fb71fe..55870bda 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Backup/BackupJobStatus.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Backup/BackupJobStatus.cs @@ -11,7 +11,7 @@ namespace AutopilotMonitor.Shared.Models.Backup /// risk after enum refactorings. /// [WireContract] - public sealed class BackupJobStatus + public sealed class BackupJobStatus : IApiResponse { public string JobId { get; set; } = string.Empty; public BackupJobKind Kind { get; set; } diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Backup/BackupTriggerResponse.cs b/src/Shared/AutopilotMonitor.Shared/Models/Backup/BackupTriggerResponse.cs new file mode 100644 index 00000000..7f339e47 --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Models/Backup/BackupTriggerResponse.cs @@ -0,0 +1,13 @@ +namespace AutopilotMonitor.Shared.Models.Backup +{ + /// + /// Response of POST global/backups/trigger (202 Accepted): the job is queued, not + /// done. The portal polls for the . + /// + // Declaration order == wire order. + public sealed class BackupTriggerResponse : IApiResponse + { + public string JobId { get; set; } = string.Empty; + public string StatusUrl { get; set; } = string.Empty; + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Backup/RestoreRow.cs b/src/Shared/AutopilotMonitor.Shared/Models/Backup/RestoreRow.cs index 5ee7b6c6..3a480396 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Backup/RestoreRow.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Backup/RestoreRow.cs @@ -53,7 +53,7 @@ namespace AutopilotMonitor.Shared.Models.Backup /// live ETag (or null) to echo on commit. /// [WireContract] - public sealed class RestoreRowPreviewResponse + public sealed class RestoreRowPreviewResponse : IApiResponse { public string BackupId { get; set; } = string.Empty; public string TableName { get; set; } = string.Empty; @@ -93,7 +93,7 @@ namespace AutopilotMonitor.Shared.Models.Backup /// Successful response body of mode=commit. Echoes the write outcome. /// [WireContract] - public sealed class RestoreRowCommitResponse + public sealed class RestoreRowCommitResponse : IApiResponse { public string BackupId { get; set; } = string.Empty; public string TableName { get; set; } = string.Empty; diff --git a/src/Web/autopilot-monitor-web/app/admin/backups/page.tsx b/src/Web/autopilot-monitor-web/app/admin/backups/page.tsx index b3616fe1..76548c75 100644 --- a/src/Web/autopilot-monitor-web/app/admin/backups/page.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/backups/page.tsx @@ -3,8 +3,8 @@ import { backupUrl } from "@/lib/routes"; import { useCallback, useEffect, useState } from "react"; import Link from "next/link"; -import { api, type BackupTriggerResponse } from "@/lib/api"; -import type { BackupJobStatus, ListBackupsResponse } from "@/utils/wire-types.generated"; +import { api } from "@/lib/api"; +import type { BackupJobStatus, BackupTriggerResponse, ListBackupsResponse } from "@/utils/wire-types.generated"; import { apiErrorText, fetchJson } from "@/lib/apiClient"; import { useAdminConfig } from "../AdminConfigContext"; import { AdminNotifications } from "../AdminNotifications"; diff --git a/src/Web/autopilot-monitor-web/lib/api.ts b/src/Web/autopilot-monitor-web/lib/api.ts index 85cb282e..6b4fd45c 100644 --- a/src/Web/autopilot-monitor-web/lib/api.ts +++ b/src/Web/autopilot-monitor-web/lib/api.ts @@ -848,9 +848,4 @@ export interface BackupManifest { tables: BackupTableEntry[]; } -export interface BackupTriggerResponse { - jobId: string; - statusUrl: string; -} - // ── PR2: single-row restore — request/response types are generated (RestoreRowRequest, RestoreRowMode) ── diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts b/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts index 139ab38d..3201d337 100644 --- a/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts +++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts @@ -637,6 +637,8 @@ export const SHARED_MANIFEST = { "CascadePoisonedUseRestore", "KillSwitchActive", "CasExhaustedRetryLater", + "RunAlreadyActive", + "EnqueueFailed", "InvalidInvitation", "InvitationNotFound", "InvitationExpired", @@ -650,7 +652,26 @@ export const SHARED_MANIFEST = { "NotATenantMember", "AssigneeNotFound", "IdentityBindingConflict", - "DelegatedSlotLimitReached" + "DelegatedSlotLimitReached", + "InvalidBackupId", + "MissingBody", + "InvalidTable", + "InvalidKeys", + "InvalidMode", + "MissingPrecondition", + "BackupNotFound", + "RowNotInBackup", + "JobNotFound", + "TableNotInBackup", + "ManifestCorrupt", + "ManifestSchemaUnsupported", + "IntegrityCheckFailed", + "BlobChangedSinceValidation", + "RowChangedSinceValidation", + "CurrentRowChanged", + "MaintenanceInProgress", + "MaintenanceLeaseLost", + "JobIdCollision" ], "tenantRoles": [ "Admin", diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.json b/src/Web/autopilot-monitor-web/utils/shared-manifests.json index 946a6f5f..9f9ed3ca 100644 --- a/src/Web/autopilot-monitor-web/utils/shared-manifests.json +++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.json @@ -633,6 +633,8 @@ "CascadePoisonedUseRestore", "KillSwitchActive", "CasExhaustedRetryLater", + "RunAlreadyActive", + "EnqueueFailed", "InvalidInvitation", "InvitationNotFound", "InvitationExpired", @@ -646,7 +648,26 @@ "NotATenantMember", "AssigneeNotFound", "IdentityBindingConflict", - "DelegatedSlotLimitReached" + "DelegatedSlotLimitReached", + "InvalidBackupId", + "MissingBody", + "InvalidTable", + "InvalidKeys", + "InvalidMode", + "MissingPrecondition", + "BackupNotFound", + "RowNotInBackup", + "JobNotFound", + "TableNotInBackup", + "ManifestCorrupt", + "ManifestSchemaUnsupported", + "IntegrityCheckFailed", + "BlobChangedSinceValidation", + "RowChangedSinceValidation", + "CurrentRowChanged", + "MaintenanceInProgress", + "MaintenanceLeaseLost", + "JobIdCollision" ], "tenantRoles": [ "Admin", @@ -4879,6 +4900,26 @@ "Partial" ] }, + "BackupTriggerResponse": { + "kind": "object", + "doc": "Response of POST global/backups/trigger (202 Accepted): the job is queued, not done. The portal polls StatusUrl for the BackupJobStatus.", + "fields": [ + { + "name": "jobId", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "statusUrl", + "type": { + "kind": "primitive", + "name": "string" + } + } + ] + }, "BlockDeviceRequest": { "kind": "object", "doc": "Body of POST devices/block (Global Admin).", @@ -18179,6 +18220,26 @@ } ] }, + "SessionDeletionMaintenanceTriggerResponse": { + "kind": "object", + "doc": "Response of POST global/session-deletions/maintenance/trigger (202 Accepted): the run is queued, not done. Its progress surfaces as SessionDeletionMaintenance* ops events.", + "fields": [ + { + "name": "message", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "triggeredBy", + "type": { + "kind": "primitive", + "name": "string" + } + } + ] + }, "SessionDeletionProgressWire": { "kind": "object", "doc": "Worker progress projection nested in GetSessionDeletionManifestResponse.", diff --git a/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts b/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts index 9f87edcd..fb6d3f88 100644 --- a/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts +++ b/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts @@ -912,6 +912,12 @@ export interface BackupJobStatus { /** Manifest-level outcome. Job-level state (Failed for fatal errors) is tracked separately on BackupJobStatus; a manifest only exists when the run reached the "all tables attempted, manifest write succeeded" milestone. */ export type BackupOutcome = "Success" | "Partial"; +/** Response of POST global/backups/trigger (202 Accepted): the job is queued, not done. The portal polls StatusUrl for the BackupJobStatus. */ +export interface BackupTriggerResponse { + jobId: string; + statusUrl: string; +} + /** Body of POST devices/block (Global Admin). */ export interface BlockDeviceRequest { tenantId: string; @@ -3934,6 +3940,12 @@ export interface SessionDeletionListItem { ageMinutes: number; } +/** Response of POST global/session-deletions/maintenance/trigger (202 Accepted): the run is queued, not done. Its progress surfaces as SessionDeletionMaintenance* ops events. */ +export interface SessionDeletionMaintenanceTriggerResponse { + message: string; + triggeredBy: string; +} + /** Worker progress projection nested in GetSessionDeletionManifestResponse. */ export interface SessionDeletionProgressWire { snapshotSha256: string;