Skip to content

Commit 017cdbc

Browse files
Copilotpelikhangithub-actions[bot]
authored
fix: populate SafeItemsCount in run_summary.json so audits report accurate safe-output write counts (#46360)
* Initial plan * fix: populate SafeItemsCount in run_summary.json for accurate safe-output write metrics Three complementary fixes: 1. Add json:"safe_items_count,omitempty" tag to WorkflowRun.SafeItemsCount so it serializes with the snake_case key expected by downstream audit tools. 2. Add flattenSafeOutputsItemsArtifact() to move safe-output-items.jsonl and temporary-id-map.json from safe-outputs-items/ subdirectory to the run root where extractCreatedItemsFromManifest and loadResolvedTemporaryIDTargets look. 3. Persist healed SafeItemsCount back to run_summary.json after cache-hit backfill so downstream readers see the correct count without falling back to activity summary. Closes #46268 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> * fix: use modern octal literal notation in test file (0o755, 0o644) Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> * docs(adr): add draft ADR-46360 for SafeItemsCount fix in run_summary.json --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Peli de Halleux <pelikhan@users.noreply.github.com>
1 parent f6feabb commit 017cdbc

7 files changed

Lines changed: 232 additions & 2 deletions
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# ADR-46360: Fix SafeItemsCount Population in run_summary.json
2+
3+
**Date**: 2026-07-18
4+
**Status**: Draft
5+
**Deciders**: Unknown
6+
7+
---
8+
9+
### Context
10+
11+
Daily audit workflows (API Consumption, Safe Output Health) read `SafeItemsCount` from `run_summary.json` to measure how many safe-output items an agent run actually wrote to GitHub. Three independent bugs caused this field to always be `0`, forcing audits to fall back to `usage/activity/summary.json → safe_outputs.total_items`. This fallback masked the root cause and made audit data less reliable. The bugs were: (1) `WorkflowRun.SafeItemsCount` lacked a `json:"safe_items_count"` tag so it marshaled as PascalCase while readers expected snake_case; (2) the `safe-outputs-items` artifact was skipped by `flattenSingleFileArtifacts` because it contains two files, so `extractCreatedItemsFromManifest` never found either; (3) `backfillCacheHitIfNeeded` healed `SafeItemsCount` in memory but never called `saveRunSummary`, leaving the on-disk file stale for cached runs.
12+
13+
### Decision
14+
15+
We will fix all three root causes simultaneously: add the missing JSON struct tag to `WorkflowRun.SafeItemsCount`; introduce `flattenSafeOutputsItemsArtifact()` following the existing `flattenActivationArtifact` / `flattenAgentOutputsArtifact` pattern to move both files to the run root; and detect when `backfillCacheHitIfNeeded` changes `SafeItemsCount` (via snapshot-and-compare) and call `saveRunSummary` to persist the healed value. All three fixes are required together because any single fix leaves the other failure paths open.
16+
17+
### Alternatives Considered
18+
19+
#### Alternative 1: Treat usage/activity/summary.json as the authoritative source
20+
21+
Rather than fixing `run_summary.json` population, audit tools could always read `SafeItemsCount` from `usage/activity/summary.json`. This eliminates the need to flatten artifacts or persist the backfill value.
22+
23+
This was not chosen because it centralises audit logic on a different data path, requires every downstream consumer to know about the fallback, and obscures the signal that `run_summary.json` is incomplete. The existing fallback was already a workaround; making it permanent would entrench technical debt.
24+
25+
#### Alternative 2: Compute SafeItemsCount from raw JSONL at query time
26+
27+
Each report could count lines in `safe-output-items.jsonl` directly instead of relying on pre-computed fields in `run_summary.json`. This avoids JSON tagging and caching problems.
28+
29+
This was not chosen because it breaks the existing contract where `run_summary.json` is a self-contained, fully resolved snapshot of a run's metrics. Recomputing at query time duplicates parsing logic across consumers and increases I/O for each report run. The bugs are well-understood and fixable at the source.
30+
31+
#### Alternative 3: Add a dedicated artifact-search step instead of flattening
32+
33+
Instead of flattening the `safe-outputs-items` subdirectory, `extractCreatedItemsFromManifest` could be updated to search one level deep for the relevant files.
34+
35+
This was not chosen because the rest of the codebase consistently uses the flatten-to-root pattern for all multi-file artifacts (`activation`, `agent_outputs`). Changing `extractCreatedItemsFromManifest`'s search semantics would be a broader, riskier change and would deviate from the established convention.
36+
37+
### Consequences
38+
39+
#### Positive
40+
- `run_summary.json` now contains accurate `SafeItemsCount` values, eliminating the need for the audit fallback path.
41+
- `flattenSafeOutputsItemsArtifact()` follows the established pattern for artifact flattening, making the codebase consistent.
42+
- Cache hits that previously left `SafeItemsCount = 0` on disk now produce a correct, persisted value for downstream readers.
43+
- Three targeted tests lock in the correct behavior and prevent regression.
44+
45+
#### Negative
46+
- The `omitempty` option on the JSON tag means runs with zero safe outputs emit no `safe_items_count` key; readers that do not handle the absent-key case gracefully may interpret absence as unknown rather than zero.
47+
- The snapshot-and-compare approach in `tryLoadCachedRunResult` triggers an additional `saveRunSummary` disk write for every cache hit where `SafeItemsCount` changes; this is a one-time write per affected run but introduces a new I/O path in the hot cache-load code path.
48+
49+
#### Neutral
50+
- The `flattenSafeOutputsItemsArtifact` function mirrors `flattenActivationArtifact` and `flattenAgentOutputsArtifact` — consistent pattern, but the number of special-cased flatten helpers continues to grow; a future refactor may want to unify them.
51+
- Existing audit code that uses the `usage/activity/summary.json` fallback remains in place as a safety net; it is now dead code for correctly processed runs but still executes for older cached runs that have not been re-processed.
52+
53+
---
54+
55+
*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.*

pkg/cli/logs_download.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,23 @@ func flattenAgentOutputsArtifact(outputDir string, verbose bool) error {
304304
return flattenArtifactTree(agentOutputsDir, agentOutputsDir, outputDir, "agent_outputs artifact", verbose)
305305
}
306306

307+
// flattenSafeOutputsItemsArtifact flattens the safe-outputs-items artifact directory
308+
// structure. The safe-outputs-items artifact contains safe-output-items.jsonl and
309+
// temporary-id-map.json. After flattening, these files land at the run directory root
310+
// where extractCreatedItemsFromManifest and loadResolvedTemporaryIDTargets expect them.
311+
// The artifact may be prefixed in workflow_call context: "<hash>-safe-outputs-items".
312+
func flattenSafeOutputsItemsArtifact(outputDir string, verbose bool) error {
313+
safeOutputsItemsDir := findArtifactDir(outputDir, constants.SafeOutputItemsArtifactName, "")
314+
if safeOutputsItemsDir == "" {
315+
// No safe-outputs-items artifact, nothing to flatten
316+
return nil
317+
}
318+
319+
logsDownloadLog.Printf("Flattening safe-outputs-items artifact directory: %s", safeOutputsItemsDir)
320+
321+
return flattenArtifactTree(safeOutputsItemsDir, safeOutputsItemsDir, outputDir, "safe-outputs-items artifact", verbose)
322+
}
323+
307324
// downloadWorkflowRunLogs downloads and unzips workflow run logs using GitHub API
308325
func downloadWorkflowRunLogs(ctx context.Context, runID int64, outputDir string, verbose bool, owner, repo, hostname string) error {
309326
logsDownloadLog.Printf("Downloading workflow run logs: run_id=%d, output_dir=%s, owner=%s, repo=%s", runID, outputDir, owner, repo)
@@ -930,6 +947,14 @@ func downloadRunArtifacts(ctx context.Context, opts downloadArtifactsOptions) er
930947
return fmt.Errorf("failed to flatten agent_outputs artifact: %w", err)
931948
}
932949

950+
// Flatten safe-outputs-items artifact if present.
951+
// This artifact contains safe-output-items.jsonl and temporary-id-map.json.
952+
// Flattening moves them to the run root so extractCreatedItemsFromManifest
953+
// and loadResolvedTemporaryIDTargets can find them at their expected paths.
954+
if err := flattenSafeOutputsItemsArtifact(opts.outputDir, opts.verbose); err != nil {
955+
return fmt.Errorf("failed to flatten safe-outputs-items artifact: %w", err)
956+
}
957+
933958
// Download and unzip workflow run logs unless caller requested usage-only mode.
934959
if !isUsageOnlyArtifactFilter(opts.artifactFilter) {
935960
if err := downloadWorkflowRunLogs(ctx, opts.runID, opts.outputDir, opts.verbose, opts.owner, opts.repo, opts.hostname); err != nil {

pkg/cli/logs_download_test.go

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -890,7 +890,76 @@ func TestFlattenActivationArtifact(t *testing.T) {
890890
}
891891
}
892892

893-
// TestCountParameterBehavior verifies that the count parameter limits matching results
893+
// TestFlattenSafeOutputsItemsArtifact verifies that the safe-outputs-items artifact
894+
// directory is correctly flattened so extractCreatedItemsFromManifest and
895+
// loadResolvedTemporaryIDTargets can find their files at the run directory root.
896+
// The artifact contains safe-output-items.jsonl and temporary-id-map.json.
897+
func TestFlattenSafeOutputsItemsArtifact(t *testing.T) {
898+
tmpDir := testutil.TempDir(t, "test-flatten-safe-outputs-items-*")
899+
900+
// Simulate the directory structure created by `gh run download` for the safe-outputs-items artifact.
901+
safeOutputsDir := filepath.Join(tmpDir, "safe-outputs-items")
902+
if err := os.MkdirAll(safeOutputsDir, 0o755); err != nil {
903+
t.Fatalf("Failed to create safe-outputs-items dir: %v", err)
904+
}
905+
906+
manifestContent := `{"id":"item1","safe":true}` + "\n"
907+
if err := os.WriteFile(filepath.Join(safeOutputsDir, "safe-output-items.jsonl"), []byte(manifestContent), 0o644); err != nil {
908+
t.Fatalf("Failed to create safe-output-items.jsonl: %v", err)
909+
}
910+
mapContent := `{"map":{"tmp-1":"real-1"}}`
911+
if err := os.WriteFile(filepath.Join(safeOutputsDir, "temporary-id-map.json"), []byte(mapContent), 0o644); err != nil {
912+
t.Fatalf("Failed to create temporary-id-map.json: %v", err)
913+
}
914+
915+
if err := flattenSafeOutputsItemsArtifact(tmpDir, false); err != nil {
916+
t.Fatalf("flattenSafeOutputsItemsArtifact failed: %v", err)
917+
}
918+
919+
// safe-output-items.jsonl must be at the root for extractCreatedItemsFromManifest.
920+
manifestPath := filepath.Join(tmpDir, "safe-output-items.jsonl")
921+
if !fileutil.FileExists(manifestPath) {
922+
t.Error("safe-output-items.jsonl should be at the root output directory after flattening")
923+
} else {
924+
content, err := os.ReadFile(manifestPath)
925+
if err != nil {
926+
t.Fatalf("Failed to read safe-output-items.jsonl: %v", err)
927+
}
928+
if string(content) != manifestContent {
929+
t.Errorf("safe-output-items.jsonl content mismatch: got %q, want %q", string(content), manifestContent)
930+
}
931+
}
932+
933+
// temporary-id-map.json must also be at the root for loadResolvedTemporaryIDTargets.
934+
mapPath := filepath.Join(tmpDir, "temporary-id-map.json")
935+
if !fileutil.FileExists(mapPath) {
936+
t.Error("temporary-id-map.json should be at the root output directory after flattening")
937+
} else {
938+
content, err := os.ReadFile(mapPath)
939+
if err != nil {
940+
t.Fatalf("Failed to read temporary-id-map.json: %v", err)
941+
}
942+
if string(content) != mapContent {
943+
t.Errorf("temporary-id-map.json content mismatch: got %q, want %q", string(content), mapContent)
944+
}
945+
}
946+
947+
// The safe-outputs-items/ subdirectory should have been removed.
948+
if fileutil.DirExists(filepath.Join(tmpDir, "safe-outputs-items")) {
949+
t.Error("safe-outputs-items/ directory should have been removed after flattening")
950+
}
951+
}
952+
953+
// TestFlattenSafeOutputsItemsArtifactMissing verifies that flattenSafeOutputsItemsArtifact
954+
// is a no-op (returns nil) when no safe-outputs-items artifact directory is present.
955+
func TestFlattenSafeOutputsItemsArtifactMissing(t *testing.T) {
956+
tmpDir := testutil.TempDir(t, "test-flatten-safe-outputs-items-missing-*")
957+
958+
if err := flattenSafeOutputsItemsArtifact(tmpDir, false); err != nil {
959+
t.Errorf("flattenSafeOutputsItemsArtifact should return nil when artifact is absent, got: %v", err)
960+
}
961+
}
962+
894963
// not the number of runs fetched when date filters are specified
895964
func TestCountParameterBehavior(t *testing.T) {
896965
// This test documents the expected behavior:

pkg/cli/logs_models.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ type WorkflowRun struct {
7777
MissingToolCount int
7878
MissingDataCount int
7979
NoopCount int
80-
SafeItemsCount int
80+
SafeItemsCount int `json:"safe_items_count,omitempty"` // Count of safe-output items actually written to GitHub
8181
EffectiveTokens int // Cost-normalized token count computed from per-model multipliers
8282
AvgTimeBetweenTurns time.Duration // Average time between consecutive LLM API calls (from per-turn timestamps when available)
8383
LogsPath string

pkg/cli/logs_run_processor.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,21 @@ func tryLoadCachedRunResult(
308308
Cached: true,
309309
}
310310
// Re-apply the usage activity backfill to heal stale cache entries.
311+
// Capture the SafeItemsCount before backfill to detect whether the field was healed.
312+
safeItemsBefore := result.Run.SafeItemsCount
311313
backfillCacheHitIfNeeded(&result, runOutputDir, params.verbose)
314+
// If the backfill populated SafeItemsCount (i.e. it was 0 before and is now non-zero),
315+
// persist the healed value back to run_summary.json so downstream readers (e.g.
316+
// the api-consumption-report) see the correct count without having to fall back to
317+
// usage/activity/summary.json.
318+
if result.Run.SafeItemsCount != safeItemsBefore {
319+
healed := *summary
320+
healed.Run = result.Run
321+
healed.Metrics = result.Metrics
322+
if err := saveRunSummary(runOutputDir, &healed, params.verbose); err != nil {
323+
logsOrchestratorLog.Printf("Warning: failed to persist healed run summary for run %d: %v", result.Run.DatabaseID, err)
324+
}
325+
}
312326
return &result, true
313327
}
314328

pkg/cli/logs_run_processor_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,3 +179,44 @@ func TestTryLoadCachedRunResultUsesCacheWhenEvalsNotRequested(t *testing.T) {
179179
require.NotNil(t, result)
180180
assert.True(t, result.Cached)
181181
}
182+
183+
// TestTryLoadCachedRunResultPersistsSafeItemsCountAfterBackfill verifies that when
184+
// tryLoadCachedRunResult heals a stale SafeItemsCount (0 → N) via backfillCacheHitIfNeeded,
185+
// the healed value is written back to run_summary.json on disk so downstream readers
186+
// (e.g. api-consumption-report) see the correct count without falling back to the
187+
// activity summary.
188+
func TestTryLoadCachedRunResultPersistsSafeItemsCountAfterBackfill(t *testing.T) {
189+
runOutputDir := t.TempDir()
190+
191+
// Write a run_summary.json with SafeItemsCount=0 (stale cache).
192+
summary := &RunSummary{
193+
CLIVersion: GetVersion(),
194+
RunID: 200,
195+
ProcessedAt: time.Now(),
196+
Run: WorkflowRun{
197+
DatabaseID: 200,
198+
SafeItemsCount: 0,
199+
},
200+
}
201+
require.NoError(t, saveRunSummary(runOutputDir, summary, false))
202+
203+
// Write a usage/activity/summary.json so backfill has something to pull from.
204+
activityPath := filepath.Join(runOutputDir, "usage", "activity", "summary.json")
205+
require.NoError(t, os.MkdirAll(filepath.Dir(activityPath), 0o755))
206+
require.NoError(t, os.WriteFile(activityPath, []byte(`{
207+
"schema":"usage-activity-summary/v1",
208+
"safe_outputs":{"total_items":5,"items_by_type":{"create_issue":5}}
209+
}`), 0o644))
210+
211+
result, ok := tryLoadCachedRunResult(context.Background(), WorkflowRun{DatabaseID: 200}, runOutputDir, concurrentRunDownloadParams{})
212+
require.True(t, ok)
213+
require.NotNil(t, result)
214+
215+
// In-memory value should be healed.
216+
assert.Equal(t, 5, result.Run.SafeItemsCount, "in-memory SafeItemsCount should be backfilled")
217+
218+
// The on-disk run_summary.json must also reflect the healed value.
219+
reloaded, ok := loadRunSummary(runOutputDir, false)
220+
require.True(t, ok)
221+
assert.Equal(t, 5, reloaded.Run.SafeItemsCount, "on-disk run_summary.json SafeItemsCount should be persisted after backfill")
222+
}

pkg/cli/logs_summary_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,3 +395,29 @@ func TestSaveAndLoadRunSummary_SafeItemsCount(t *testing.T) {
395395
t.Errorf("SafeItemsCount not persisted: got %d, want 4", loaded.Run.SafeItemsCount)
396396
}
397397
}
398+
399+
// TestSafeItemsCountJSONKey verifies that WorkflowRun.SafeItemsCount serializes to
400+
// "safe_items_count" (snake_case) in JSON so downstream audit tools (e.g. api-consumption-
401+
// report) can read it directly from run_summary.json without a fallback.
402+
func TestSafeItemsCountJSONKey(t *testing.T) {
403+
run := WorkflowRun{SafeItemsCount: 7}
404+
data, err := json.Marshal(run)
405+
if err != nil {
406+
t.Fatalf("json.Marshal failed: %v", err)
407+
}
408+
// The JSON must contain the snake_case key, not the Go field name.
409+
var m map[string]any
410+
if err := json.Unmarshal(data, &m); err != nil {
411+
t.Fatalf("json.Unmarshal failed: %v", err)
412+
}
413+
val, ok := m["safe_items_count"]
414+
if !ok {
415+
t.Errorf("expected JSON key 'safe_items_count' not found in %s", string(data))
416+
}
417+
if v, _ := val.(float64); int(v) != 7 {
418+
t.Errorf("safe_items_count = %v, want 7", val)
419+
}
420+
if _, hasPascal := m["SafeItemsCount"]; hasPascal {
421+
t.Errorf("unexpected PascalCase key 'SafeItemsCount' found in %s; must use snake_case", string(data))
422+
}
423+
}

0 commit comments

Comments
 (0)