From b2723168fd0c1fff9b98ca435c2aa1d759d56405 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Tue, 11 Aug 2026 14:38:33 +0200 Subject: [PATCH] reports: snapshot session annotations into the report ZIP SubmitReportAsync adds annotations.json (all lanes) to the package and annotationCount to report-metadata.json. All lanes are safe: report blobs are readable only through the GA-only session-reports routes. Fail-soft like the tenant-context snapshot; the copy outlives the session=s retention/delete. Co-Authored-By: Claude Fable 5 --- docs/backend/session-annotations.md | 4 ++ .../Services/SessionReportService.cs | 48 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/docs/backend/session-annotations.md b/docs/backend/session-annotations.md index 0c0e093b..0cbd8fa4 100644 --- a/docs/backend/session-annotations.md +++ b/docs/backend/session-annotations.md @@ -87,6 +87,10 @@ in the tenant-visible audit log — same convention as GA session-report submiss never consume page budget. * **MCP write**: `annotate_session` (`strictGa`-gated, MUTATING) always PUTs the `globaladmin` lane — no lane argument by design. This is the frictionless labeling path after a session-debug. +* **Session reports**: `SessionReportService.SubmitReportAsync` snapshots ALL lanes into + `annotations.json` inside the report ZIP (`annotationCount` in report-metadata.json; fail-soft). + Safe because report blobs are readable only through the GA-only session-reports routes, and + valuable because the ZIP outlives the session's retention/delete. # Citations diff --git a/src/Backend/AutopilotMonitor.Functions/Services/SessionReportService.cs b/src/Backend/AutopilotMonitor.Functions/Services/SessionReportService.cs index 56a93463..984a4a5a 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/SessionReportService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/SessionReportService.cs @@ -29,6 +29,7 @@ namespace AutopilotMonitor.Functions.Services private readonly SessionReportDiagnosticsArchiveCopier _diagnosticsArchiveCopier; private readonly TenantConfigurationService _configService; private readonly IRuleRepository _ruleRepo; + private readonly ISessionAnnotationRepository _annotationRepo; private const string ContainerName = "session-reports"; public SessionReportService( @@ -38,6 +39,7 @@ namespace AutopilotMonitor.Functions.Services SessionReportDiagnosticsArchiveCopier diagnosticsArchiveCopier, TenantConfigurationService configService, IRuleRepository ruleRepo, + ISessionAnnotationRepository annotationRepo, ILogger logger) { _notificationRepo = notificationRepo; @@ -47,6 +49,7 @@ namespace AutopilotMonitor.Functions.Services _diagnosticsArchiveCopier = diagnosticsArchiveCopier; _configService = configService; _ruleRepo = ruleRepo; + _annotationRepo = annotationRepo; } /// @@ -95,6 +98,13 @@ namespace AutopilotMonitor.Functions.Services // have changed by the time an operator investigates). Fail-soft; never blocks. var tenantContext = await CollectTenantContextAsync(request.TenantId, reportId); + // Human annotations at report time — the submitter's own verdict/notes are exactly + // the context an investigating operator wants first, and the snapshot survives the + // session's retention/delete. ALL lanes are included: the report blob is readable + // only through the GA-only session-reports routes, so the platform-internal + // globaladmin lane never reaches tenant callers this way. Fail-soft; never blocks. + var annotations = await CollectAnnotationsAsync(request.TenantId, request.SessionId, reportId); + // Decode attachments up front so report-metadata.json can record their outcome // instead of silently dropping invalid payloads. var (screenshotBytes, screenshotStatus) = DecodeAttachment(request.ScreenshotBase64, reportId, "screenshot"); @@ -128,6 +138,12 @@ namespace AutopilotMonitor.Functions.Services AddTextEntry(archive, "timeline.txt", request.TimelineExportTxt); } + // Human annotations snapshot (all lanes) — omitted when none exist. + if (annotations is { Count: > 0 }) + { + AddJsonEntry(archive, "annotations.json", annotations); + } + // Report metadata AddJsonEntry(archive, "report-metadata.json", new { @@ -157,6 +173,9 @@ namespace AutopilotMonitor.Functions.Services screenshot = screenshotStatus, agentLog = agentLogStatus }, + // Count only — the rows themselves live in annotations.json. 0 with a + // missing annotations.json ⇒ either none existed or the read failed soft. + annotationCount = annotations?.Count ?? 0, tenantContext }); @@ -360,6 +379,35 @@ namespace AutopilotMonitor.Functions.Services } } + /// + /// Snapshot of the session's human annotations (all lanes) for annotations.json. + /// Fail-soft: a failed read yields null and never blocks the report. + /// + private async Task?> CollectAnnotationsAsync(string tenantId, string sessionId, string reportId) + { + try + { + var rows = await _annotationRepo.GetForSessionAsync(tenantId, sessionId); + return rows.Select(a => (object)new + { + lane = a.Lane, + verdict = a.Verdict, + note = a.Note, + authorUpn = a.AuthorUpn, + authorDisplayName = a.AuthorDisplayName, + createdByUpn = a.CreatedByUpn, + createdAtUtc = a.CreatedAtUtc, + updatedAtUtc = a.UpdatedAtUtc, + ruleIds = a.RuleIds, + }).ToList(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Report {ReportId}: annotations snapshot failed — omitted from package", reportId); + return null; + } + } + /// /// Decodes an optional base64 attachment. Returns the bytes plus a status string /// ("none" | "included" | "invalid-base64") that is persisted in report-metadata.json —