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 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-08-11 14:38:33 +02:00
parent d05d8ef302
commit b2723168fd
2 changed files with 52 additions and 0 deletions
+4
View File
@@ -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
@@ -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<SessionReportService> logger)
{
_notificationRepo = notificationRepo;
@@ -47,6 +49,7 @@ namespace AutopilotMonitor.Functions.Services
_diagnosticsArchiveCopier = diagnosticsArchiveCopier;
_configService = configService;
_ruleRepo = ruleRepo;
_annotationRepo = annotationRepo;
}
/// <summary>
@@ -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
}
}
/// <summary>
/// Snapshot of the session's human annotations (all lanes) for annotations.json.
/// Fail-soft: a failed read yields null and never blocks the report.
/// </summary>
private async Task<List<object>?> 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;
}
}
/// <summary>
/// Decodes an optional base64 attachment. Returns the bytes plus a status string
/// ("none" | "included" | "invalid-base64") that is persisted in report-metadata.json —