diff --git a/docs/adr/59572-reuse-compatible-logs-json-run-records.md b/docs/adr/59572-reuse-compatible-logs-json-run-records.md new file mode 100644 index 00000000000..0aca9e45db1 --- /dev/null +++ b/docs/adr/59572-reuse-compatible-logs-json-run-records.md @@ -0,0 +1,50 @@ +# ADR-59572: Reuse compatible logs JSON run records + +**Date**: 2026-09-09 +**Status**: Draft +**Deciders**: gh-aw maintainers + +--- + +### Context + +The `gh aw logs` command currently recomputes run records by downloading and processing artifacts even when equivalent `logs --json` output from an earlier run already exists. This PR adds a new `--cached-json` input, threads it through both standard and stdin-based log collection flows, and rebuilds report output from a mix of reused cached records and newly processed runs. The diff shows strict cache-safety checks around run identity, completion state, attempt, repository, conclusion, and update time, plus explicit fallbacks when requested filters require artifact-level evidence not preserved in compact JSON. The repository needs a documented decision on whether prior JSON output is an acceptable cache source for logs analysis and reporting. + +### Decision + +We will allow `gh aw logs` to reuse prior `--json` output as a cache source when a cached record can be proven compatible with the current workflow run and requested analysis mode. The implementation will only reuse completed runs with matching run ID, repository, attempt, conclusion, and update timestamp, and it will disable cached reuse for artifact-dependent filters or analysis modes that require richer evidence than compact JSON retains. When reuse is valid, the command will preserve cached run records in rebuilt JSON output and recompute aggregate totals from the combined cached and newly processed data. + +### Alternatives Considered + +#### Alternative 1: Always re-download and reprocess run artifacts + +The command could continue treating every invocation as a fresh collection pass with no reuse of earlier JSON output. This was considered because it is the simplest behavior and avoids the risk of stale cached data. It was not chosen because the PR explicitly adds compatibility checks and tests to avoid unnecessary artifact work for unchanged completed runs. + +#### Alternative 2: Reuse cached JSON for all runs and filter modes without validation + +Another option would be to accept any previous logs JSON as authoritative and skip most per-run validation and mode checks. This was considered because it would maximize performance improvements and implementation simplicity. It was not chosen because the diff adds explicit guards for repository, attempt, conclusion, updated timestamp, engine filters, and artifact-dependent modes, showing that unvalidated reuse would be unsafe. + +#### Alternative 3: Introduce a dedicated internal cache format instead of reusing prior JSON output + +The project could have created a separate opaque cache artifact tailored specifically for reuse rather than depending on user-visible JSON output. This was considered because a dedicated format could carry richer evidence and fewer compatibility constraints. It was not chosen in this PR because the implementation intentionally reuses existing `logs --json` output, preserving current user workflows and avoiding an additional cache format. + +### Consequences + +#### Positive +- Re-running `gh aw logs` can avoid downloading and reprocessing artifacts for unchanged completed runs, reducing cost and latency. +- Cache reuse remains conservative because compatibility checks reject stale or insufficient cached records. +- Rebuilt reports can combine cached and fresh records while preserving existing JSON output structure. + +#### Negative +- The logs pipeline becomes more complex because download, stdin, filtering, and aggregation paths must all account for cached records. +- Aggregate values may be approximate when compact cached JSON omits detailed evidence that richer artifact processing would have produced. +- Users must understand when `--cached-json` is ignored due to incompatible filters or analysis modes. + +#### Neutral +- `LogsDownloadOptions`, `StdinLogsOptions`, and related orchestration types now carry a `CachedJSON` field through multiple entry points. +- Report aggregation now has a separate path for accumulating totals from cached `RunData` values. +- The feature relies on previous JSON output remaining parseable and structurally compatible with the current `LogsData` schema. + +--- + +*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.* diff --git a/pkg/cli/logs_cached_json.go b/pkg/cli/logs_cached_json.go new file mode 100644 index 00000000000..69cf71abcb0 --- /dev/null +++ b/pkg/cli/logs_cached_json.go @@ -0,0 +1,127 @@ +package cli + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "strconv" + "strings" +) + +type cachedLogsRuns map[int64]RunData + +func loadCachedLogsJSON(path string) (cachedLogsRuns, error) { + if path == "" { + return nil, nil + } + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read cached logs JSON: %w", err) + } + var logsData LogsData + if err := json.Unmarshal(data, &logsData); err != nil { + return nil, fmt.Errorf("failed to parse cached logs JSON: %w", err) + } + if logsData.Runs == nil { + return nil, errors.New("failed to parse cached logs JSON: missing runs array") + } + runs := make(cachedLogsRuns, len(logsData.Runs)) + for _, run := range logsData.Runs { + if err := normalizeCachedLogRun(&run); err != nil { + return nil, err + } + if run.RunID != 0 { + runs[run.RunID] = run + } + } + logsCacheLog.Printf("Loaded %d run records from cached logs JSON", len(runs)) + return runs, nil +} + +func (runs cachedLogsRuns) lookup(run WorkflowRun, filters runFilterOpts) (RunData, bool) { + cached, ok := runs[run.DatabaseID] + if !ok { + return RunData{}, false + } + if cached.Status != "completed" || run.Status != "completed" || cached.Conclusion != run.Conclusion { + return RunData{}, false + } + if cached.RunAttempt == "" || run.Attempt <= 0 || cached.RunAttempt != strconv.Itoa(run.Attempt) { + return RunData{}, false + } + if cached.UpdatedAt.IsZero() || run.UpdatedAt.IsZero() || !cached.UpdatedAt.Equal(run.UpdatedAt) { + return RunData{}, false + } + if cached.Repository == "" || run.Repository == "" || !strings.EqualFold(cached.Repository, run.Repository) { + return RunData{}, false + } + if filters.engine != "" && + !strings.EqualFold(filters.engine, cached.EngineID) && + !strings.EqualFold(filters.engine, cached.Engine) && + !strings.EqualFold(filters.engine, cached.Agent) { + return RunData{}, false + } + // Compact logs JSON does not retain enough per-run evidence to safely + // re-evaluate these artifact-dependent filters. + if filters.runtime != "" || filters.noStaged || filters.firewallOnly || filters.noFirewall || + filters.safeOutputType != "" || filters.filteredIntegrity || filters.evalsOnly || filters.gradersOnly { + return RunData{}, false + } + return cached, true +} + +func normalizeCachedLogRun(run *RunData) error { + if run.RunAttempt == "" { + return nil + } + attempt, err := strconv.Atoi(run.RunAttempt) + if err != nil || attempt <= 0 { + return fmt.Errorf("failed to parse cached logs JSON: invalid run_attempt for run %d", run.RunID) + } + run.RunAttempt = strconv.Itoa(attempt) + return nil +} + +func cachedJSONCanSatisfy(artifactFilter []string, parse, audit, train, toolGraph bool) bool { + return isUsageOnlyArtifactFilter(artifactFilter) && !parse && !audit && !train && !toolGraph +} + +func processedRunFromCachedData(data RunData) ProcessedRun { + return ProcessedRun{ + Run: WorkflowRun{ + DatabaseID: data.RunID, + Number: data.Number, + URL: data.URL, + Status: data.Status, + Conclusion: data.Conclusion, + WorkflowName: data.WorkflowName, + WorkflowPath: data.WorkflowPath, + CreatedAt: data.CreatedAt, + StartedAt: data.StartedAt, + UpdatedAt: data.UpdatedAt, + Event: data.Event, + HeadBranch: data.Branch, + HeadSha: data.HeadSHA, + DisplayTitle: data.DisplayTitle, + Repository: data.Repository, + Actor: data.Actor, + Duration: parseDurationString(data.Duration), + ActionMinutes: data.ActionMinutes, + TokenUsage: data.TokenUsage, + Turns: data.Turns, + ErrorCount: data.ErrorCount, + WarningCount: data.WarningCount, + MissingToolCount: data.MissingToolCount, + MissingDataCount: data.MissingDataCount, + SafeItemsCount: data.SafeItemsCount, + }, + AwContext: data.AwContext, + TaskDomain: data.TaskDomain, + BehaviorFingerprint: data.BehaviorFingerprint, + AgenticAssessments: data.AgenticAssessments, + TokenUsage: data.TokenUsageSummary, + WorkingSet: data.WorkingSet, + cachedData: &data, + } +} diff --git a/pkg/cli/logs_cached_json_test.go b/pkg/cli/logs_cached_json_test.go new file mode 100644 index 00000000000..8b7d0e2fbda --- /dev/null +++ b/pkg/cli/logs_cached_json_test.go @@ -0,0 +1,191 @@ +//go:build !integration + +package cli + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/github/gh-aw/pkg/constants" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoadCachedLogsJSON(t *testing.T) { + path := filepath.Join(t.TempDir(), "logs.json") + data, err := json.Marshal(LogsData{Runs: []RunData{ + {RunID: 42, WorkflowName: "cached-workflow"}, + {RunID: 0, WorkflowName: "invalid"}, + }}) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, data, 0o600)) + + runs, err := loadCachedLogsJSON(path) + + require.NoError(t, err) + require.Len(t, runs, 1) + assert.Equal(t, "cached-workflow", runs[42].WorkflowName) +} + +func TestLoadCachedLogsJSONRejectsInvalidInput(t *testing.T) { + path := filepath.Join(t.TempDir(), "logs.json") + require.NoError(t, os.WriteFile(path, []byte(`{"summary":{}}`), 0o600)) + + _, err := loadCachedLogsJSON(path) + + require.ErrorContains(t, err, "missing runs array") +} + +func TestLoadCachedLogsJSONRejectsInvalidRunAttempt(t *testing.T) { + path := filepath.Join(t.TempDir(), "logs.json") + require.NoError(t, os.WriteFile(path, []byte(`{"runs":[{"run_id":42,"run_attempt":"bogus"}]}`), 0o600)) + + _, err := loadCachedLogsJSON(path) + + require.ErrorContains(t, err, "invalid run_attempt") +} + +func TestCachedLogsLookupHonorsRepositoryAndFilters(t *testing.T) { + updatedAt := time.Date(2026, time.September, 1, 0, 0, 0, 0, time.UTC) + runs := cachedLogsRuns{ + 42: {RunID: 42, Repository: "github/gh-aw", EngineID: "copilot", Status: "completed", Conclusion: "success", RunAttempt: "1", UpdatedAt: updatedAt}, + } + run := WorkflowRun{DatabaseID: 42, Repository: "github/gh-aw", Status: "completed", Conclusion: "success", Attempt: 1, UpdatedAt: updatedAt} + + _, ok := runs.lookup(run, runFilterOpts{engine: "copilot"}) + assert.True(t, ok) + _, ok = runs.lookup(run, runFilterOpts{engine: "claude"}) + assert.False(t, ok) + _, ok = runs.lookup(run, runFilterOpts{runtime: "gvisor"}) + assert.False(t, ok) + _, ok = runs.lookup(WorkflowRun{DatabaseID: 42, Repository: "other/repo", Status: "completed", Conclusion: "success", Attempt: 1, UpdatedAt: updatedAt}, runFilterOpts{}) + assert.False(t, ok) +} + +func TestCachedLogsLookupRejectsChangedRun(t *testing.T) { + updatedAt := time.Date(2026, time.September, 1, 0, 0, 0, 0, time.UTC) + runs := cachedLogsRuns{ + 42: { + RunID: 42, + Status: "completed", + Conclusion: "success", + RunAttempt: "1", + UpdatedAt: updatedAt, + }, + } + + tests := []WorkflowRun{ + {DatabaseID: 42, Status: "in_progress", Conclusion: "", Attempt: 1, UpdatedAt: updatedAt}, + {DatabaseID: 42, Status: "completed", Conclusion: "failure", Attempt: 1, UpdatedAt: updatedAt}, + {DatabaseID: 42, Status: "completed", Conclusion: "success", Attempt: 2, UpdatedAt: updatedAt}, + {DatabaseID: 42, Status: "completed", Conclusion: "success", Attempt: 1, UpdatedAt: updatedAt.Add(time.Minute)}, + } + for _, run := range tests { + _, ok := runs.lookup(run, runFilterOpts{}) + assert.False(t, ok) + } +} + +func TestCachedLogsLookupRejectsUnknownIdentity(t *testing.T) { + updatedAt := time.Date(2026, time.September, 1, 0, 0, 0, 0, time.UTC) + runs := cachedLogsRuns{ + 42: {RunID: 42, Repository: "github/gh-aw", Status: "completed", Conclusion: "success", RunAttempt: "1", UpdatedAt: updatedAt}, + 43: {RunID: 43, Status: "completed", Conclusion: "success", UpdatedAt: updatedAt}, + } + + tests := []WorkflowRun{ + {DatabaseID: 42, Repository: "github/gh-aw", Status: "completed", Conclusion: "success", UpdatedAt: updatedAt}, + {DatabaseID: 42, Status: "completed", Conclusion: "success", Attempt: 1, UpdatedAt: updatedAt}, + {DatabaseID: 43, Status: "completed", Conclusion: "success", UpdatedAt: updatedAt}, + } + for _, run := range tests { + _, ok := runs.lookup(run, runFilterOpts{}) + assert.False(t, ok) + } +} + +func TestCachedJSONCanSatisfy(t *testing.T) { + usageFilter := []string{constants.UsageArtifactName.String()} + assert.True(t, cachedJSONCanSatisfy(usageFilter, false, false, false, false)) + assert.False(t, cachedJSONCanSatisfy(nil, false, false, false, false)) + assert.False(t, cachedJSONCanSatisfy(usageFilter, true, false, false, false)) + assert.False(t, cachedJSONCanSatisfy(usageFilter, false, true, false, false)) + assert.False(t, cachedJSONCanSatisfy(usageFilter, false, false, true, false)) + assert.False(t, cachedJSONCanSatisfy(usageFilter, false, false, false, true)) +} + +func TestDownloadRunArtifactsConcurrentReusesCachedJSONRecord(t *testing.T) { + cached := RunData{ + RunID: 42, + WorkflowName: "cached-workflow", + Repository: "github/gh-aw", + Status: "completed", + Conclusion: "success", + RunAttempt: "1", + UpdatedAt: time.Date(2026, time.September, 1, 0, 0, 0, 0, time.UTC), + LogsPath: "/previous/run-42", + } + + results := downloadRunArtifactsConcurrent(context.Background(), []WorkflowRun{{DatabaseID: 42, Repository: "github/gh-aw", Status: "completed", Conclusion: "success", Attempt: 1, UpdatedAt: cached.UpdatedAt}}, runArtifactsConcurrentOptions{ + outputDir: t.TempDir(), + maxRuns: 1, + cachedRuns: cachedLogsRuns{42: cached}, + storageLimit: newLogsStorageLimit(t.TempDir(), 0, false), + }) + + require.Len(t, results, 1) + require.NotNil(t, results[0].CachedRun) + assert.True(t, results[0].Cached) + assert.Equal(t, cached, *results[0].CachedRun) +} + +func TestBuildLogsDataPreservesCachedRunRecord(t *testing.T) { + cached := RunData{ + RunID: 42, + WorkflowName: "cached-workflow", + Status: "completed", + Conclusion: "success", + Duration: "2m0s", + TokenUsageSummary: &TokenUsageSummary{TotalSteeringEvents: 2}, + TokenUsage: 1200, + Turns: 4, + ErrorCount: 1, + WarningCount: 2, + GitHubAPICalls: 3, + TemporaryIDMappings: 4, + ChainedTargetCount: 1, + ChainedFollowupActionCount: 2, + DelegatedTempTargetCount: 1, + TemporaryIDMapStatus: temporaryIDMapStatusMissing, + EngineID: "copilot", + CreatedAt: time.Date(2026, time.September, 1, 0, 0, 0, 0, time.UTC), + LogsPath: "/previous/run-42", + Classification: "normal", + IntentionalFailure: true, + } + + processedRun := processedRunFromCachedData(cached) + assert.Empty(t, processedRun.Run.LogsPath) + + data := buildLogsData([]ProcessedRun{processedRun}, t.TempDir(), nil) + + require.Equal(t, []RunData{cached}, data.Runs) + assert.Equal(t, 1, data.Summary.TotalRuns) + assert.Equal(t, "2.0m", data.Summary.TotalDuration) + assert.Equal(t, 2, data.Summary.TotalSteeringEvents) + assert.Equal(t, 1200, data.Summary.TotalTokens) + assert.Equal(t, 4, data.Summary.TotalTurns) + assert.Equal(t, 3, data.Summary.TotalGitHubAPICalls) + assert.Equal(t, 1, data.Summary.RunsWithTemporaryIDChains) + assert.Equal(t, 1, data.Summary.RunsWithDelegatedTempTargets) + assert.Equal(t, 1, data.Summary.RunsWithMissingTemporaryIDMap) + assert.Equal(t, 4, data.Summary.TotalTemporaryIDMappings) + assert.Equal(t, 1, data.Summary.TotalChainedTargets) + assert.Equal(t, 2, data.Summary.TotalChainedFollowupActions) + assert.Equal(t, map[string]int{"copilot": 1}, data.Summary.EngineCounts) + assert.Equal(t, 1, data.Summary.IntentionalFailureRuns) +} diff --git a/pkg/cli/logs_command.go b/pkg/cli/logs_command.go index d7038a44b72..735c5d58a23 100644 --- a/pkg/cli/logs_command.go +++ b/pkg/cli/logs_command.go @@ -91,6 +91,7 @@ const logsCommandExampleTemplate = ` # Basic usage %[1]s logs -v # Verbose compact output (extra columns + sections) %[1]s logs --json # JSON format (compact by default, use -v for full) %[1]s logs --json -v # Full JSON with audit metadata + %[1]s logs --cached-json logs.json # Reuse matching records from earlier JSON output %[1]s logs --format tsv # Tab-separated (minimal, raw data) %[1]s logs --format console # Decorated console tables (human-friendly) %[1]s logs --format markdown # Cross-run security audit report (Markdown) @@ -146,6 +147,10 @@ By default, only the compact usage artifact is downloaded (token usage, run meta Use --artifacts all to download all artifacts, or specify individual sets such as --artifacts agent,firewall to fetch only what you need. +Use --cached-json with JSON output from an earlier logs command to reuse matching run +records without downloading and processing their artifacts again. Aggregate analysis +may be approximate when compact cached records omit detailed data. + All available artifact sets: %s. Downloaded artifacts include (when using --artifacts all): @@ -242,6 +247,7 @@ func loadStdinLogsOptions(cmd *cobra.Command) (StdinLogsOptions, error) { Format: values.Format, ReportFile: values.ReportFile, ArtifactSets: values.ArtifactSets, + CachedJSON: values.CachedJSON, }, nil } @@ -420,6 +426,7 @@ func loadCommonLogsOptions(cmd *cobra.Command) (LogsDownloadOptions, error) { Format: getStringFlag(cmd, "format"), ReportFile: getStringFlag(cmd, "report-file"), ArtifactSets: getStringSliceFlag(cmd, "artifacts"), + CachedJSON: getStringFlag(cmd, "cached-json"), } if err := validateLogsOptions(options); err != nil { return LogsDownloadOptions{}, err @@ -559,6 +566,7 @@ func addLogsCommandFlags(logsCmd *cobra.Command, validArtifactSets string) { logsCmd.Flags().Bool("train", false, "Analyze log patterns across downloaded runs and save pattern weights to drain3_weights.json in the output directory") logsCmd.Flags().String("format", "", "Output format: console (decorated tables), tsv (tab-separated), pretty (cross-run report), markdown (cross-run Markdown). Default: compact agent-optimized output") logsCmd.Flags().String("report-file", "", "Write --format markdown output directly to this file path instead of stdout (creates parent directories as needed)") + logsCmd.Flags().String("cached-json", "", "Path to previous logs JSON output to reuse as a cache for matching runs") logsCmd.Flags().Int("last", 0, "Alias for --count/-c: number of recent runs to download") logsCmd.Flags().StringSlice("artifacts", []string{"usage"}, "Artifact sets to download (default: usage — compact summary for faster downloads). Use 'all' for everything, or comma-separate sets. Valid sets: "+validArtifactSets) logsCmd.Flags().String("cache-before", "", "(Cache eviction) Evict locally cached run folders for runs before this date, prior to downloading. Accepts deltas like -1d, -1w, -1mo (or explicit day counts like -30d), or an absolute date YYYY-MM-DD. Unlike --start-date, this only clears local cache and does not filter which runs are fetched.") diff --git a/pkg/cli/logs_command_test.go b/pkg/cli/logs_command_test.go index aa5c5740f3e..ab87493ca7d 100644 --- a/pkg/cli/logs_command_test.go +++ b/pkg/cli/logs_command_test.go @@ -106,6 +106,9 @@ func TestNewLogsCommand(t *testing.T) { require.NotNil(t, maxStorageFlag, "Should have 'max-storage' flag") pruneOlderRunsFlag := flags.Lookup("prune-older-runs") require.NotNil(t, pruneOlderRunsFlag, "Should have 'prune-older-runs' flag") + cachedJSONFlag := flags.Lookup("cached-json") + require.NotNil(t, cachedJSONFlag, "Should have 'cached-json' flag") + assert.Contains(t, cachedJSONFlag.Usage, "previous logs JSON") } func TestLogsCommandFlagDefaults(t *testing.T) { @@ -129,6 +132,7 @@ func TestLogsCommandFlagDefaults(t *testing.T) { {"max-github-api-rate-limit", "0"}, {"max-storage", "0"}, {"prune-older-runs", "false"}, + {"cached-json", ""}, } for _, tt := range tests { @@ -154,6 +158,16 @@ func TestLogsCommandResourceBudgetFlags(t *testing.T) { assert.True(t, opts.PruneOlderRuns) } +func TestLogsCommandCachedJSONOption(t *testing.T) { + cmd := NewLogsCommand() + require.NoError(t, cmd.Flags().Set("cached-json", "previous.json")) + + opts, err := loadCommonLogsOptions(cmd) + + require.NoError(t, err) + assert.Equal(t, "previous.json", opts.CachedJSON) +} + func TestLogsCommandRejectsNegativeMaxStorage(t *testing.T) { cmd := NewLogsCommand() require.NoError(t, cmd.Flags().Set("max-storage", "-1")) diff --git a/pkg/cli/logs_github_api.go b/pkg/cli/logs_github_api.go index fb70d1343be..2b52f973c76 100644 --- a/pkg/cli/logs_github_api.go +++ b/pkg/cli/logs_github_api.go @@ -403,7 +403,7 @@ type ListWorkflowRunsOptions struct { // The processedCount and targetCount parameters are used to display progress in the spinner message. func listWorkflowRunsWithPagination(opts ListWorkflowRunsOptions) ([]WorkflowRun, int, error) { //nolint:largefunc // Existing run listing keeps pagination, error classification, and filtering together. logsGitHubAPILog.Printf("Listing workflow runs: workflow=%s, limit=%d, startDate=%s, endDate=%s, ref=%s", opts.WorkflowName, opts.Limit, opts.StartDate, opts.EndDate, opts.Ref) - args := []string{"run", "list", "--json", "databaseId,number,url,status,conclusion,workflowName,createdAt,startedAt,updatedAt,event,headBranch,headSha,displayTitle"} + args := []string{"run", "list", "--json", "databaseId,number,url,status,conclusion,workflowName,createdAt,startedAt,updatedAt,event,headBranch,headSha,displayTitle,attempt"} // Add filters if opts.WorkflowName != "" { @@ -528,6 +528,8 @@ func listWorkflowRunsWithPagination(opts ListWorkflowRunsOptions) ([]WorkflowRun spinner.Stop() } + applyWorkflowRunListRepository(runs, opts.RepoOverride) + // Store the total count fetched from API before filtering totalFetched := len(runs) if opts.OldestFetchedCreatedAt != nil { @@ -593,6 +595,37 @@ func listWorkflowRunsWithPagination(opts ListWorkflowRunsOptions) ([]WorkflowRun return agenticRuns, totalFetched, nil } +func applyWorkflowRunListRepository(runs []WorkflowRun, repoOverride string) { + if len(runs) == 0 { + return + } + repository := workflowRunListRepository(repoOverride) + if repository == "" { + return + } + for i := range runs { + if runs[i].Repository == "" { + runs[i].Repository = repository + } + } +} + +func workflowRunListRepository(repoOverride string) string { + if repoOverride != "" { + parts := strings.Split(repoOverride, "/") + if len(parts) >= 2 { + return strings.Join(parts[len(parts)-2:], "/") + } + return repoOverride + } + repository, err := GetCurrentRepoSlug() + if err != nil { + logsGitHubAPILog.Printf("Unable to determine current repository for workflow run list: %v", err) + return "" + } + return repository +} + func workflowRunsSpinnerMessage(opts ListWorkflowRunsOptions) string { if opts.TargetCount > 0 { return fmt.Sprintf("Fetching workflow runs from GitHub... (%d / %d)", opts.ProcessedCount, opts.TargetCount) diff --git a/pkg/cli/logs_github_api_test.go b/pkg/cli/logs_github_api_test.go index 7510e1e393b..2150c87ef45 100644 --- a/pkg/cli/logs_github_api_test.go +++ b/pkg/cli/logs_github_api_test.go @@ -29,7 +29,8 @@ func TestWorkflowRunUnmarshal(t *testing.T) { "conclusion": "success", "createdAt": "2026-01-01T00:00:00Z", "startedAt": "2026-01-01T00:00:01Z", -"updatedAt": "2026-01-01T00:01:00Z" +"updatedAt": "2026-01-01T00:01:00Z", +"attempt": 2 } ]` @@ -40,6 +41,48 @@ func TestWorkflowRunUnmarshal(t *testing.T) { assert.Equal(t, int64(42), runs[0].DatabaseID, "DatabaseID should be populated") assert.Equal(t, "My Workflow", runs[0].WorkflowName, "WorkflowName should be populated") assert.Empty(t, runs[0].WorkflowPath, "WorkflowPath should be empty when 'path' field is absent") + assert.Equal(t, 2, runs[0].Attempt, "Attempt should be populated") +} + +func TestApplyWorkflowRunListRepository(t *testing.T) { + runs := []WorkflowRun{ + {DatabaseID: 1, Repository: ""}, + {DatabaseID: 2, Repository: "cached/repo"}, + } + + applyWorkflowRunListRepository(runs, "github.com/github/gh-aw") + + assert.Equal(t, "github/gh-aw", runs[0].Repository) + assert.Equal(t, "cached/repo", runs[1].Repository) +} + +func TestListWorkflowRunsPopulatesCacheIdentity(t *testing.T) { + fakeBinDir := testutil.TempDir(t, "fake-gh-*") + fakeGH := filepath.Join(fakeBinDir, "gh") + argsLogPath := filepath.Join(fakeBinDir, "gh-args.log") + fakeGHScript := "#!/bin/sh\n" + + "printf '%s\\n' \"$*\" >> \"" + argsLogPath + "\"\n" + + "cat <<'EOF'\n" + + `[{"databaseId":42,"workflowName":"Daily report","status":"completed","conclusion":"success","updatedAt":"2026-01-01T00:01:00Z","attempt":3}]` + "\n" + + "EOF\n" + require.NoError(t, os.WriteFile(fakeGH, []byte(fakeGHScript), 0o755)) + t.Setenv("PATH", fakeBinDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + runs, _, err := listWorkflowRunsWithPagination(ListWorkflowRunsOptions{ + Context: context.Background(), + WorkflowName: "daily-report", + Limit: 1, + RepoOverride: "github/gh-aw", + }) + + require.NoError(t, err) + require.Len(t, runs, 1) + assert.Equal(t, 3, runs[0].Attempt) + assert.Equal(t, "github/gh-aw", runs[0].Repository) + argsLog, err := os.ReadFile(argsLogPath) + require.NoError(t, err) + assert.Contains(t, string(argsLog), "displayTitle,attempt") + assert.Contains(t, string(argsLog), "--repo github/gh-aw") } func TestFetchAndCacheWorkflowRunMetadata(t *testing.T) { diff --git a/pkg/cli/logs_models.go b/pkg/cli/logs_models.go index 90088e3e59e..9212583226c 100644 --- a/pkg/cli/logs_models.go +++ b/pkg/cli/logs_models.go @@ -128,6 +128,7 @@ type ProcessedRun struct { WorkingSet *WorkingSetMetrics GitHubRateLimitUsage *GitHubRateLimitUsage JobDetails []JobInfoWithDuration + cachedData *RunData } // ReportProvenance holds the shared provenance fields common to all report record types. @@ -306,6 +307,7 @@ type DownloadResult struct { Error error Skipped bool Cached bool // True if loaded from cached summary + CachedRun *RunData LogsPath string storageReserved bool } diff --git a/pkg/cli/logs_orchestrator_download.go b/pkg/cli/logs_orchestrator_download.go index 12e53942a39..1292cf96502 100644 --- a/pkg/cli/logs_orchestrator_download.go +++ b/pkg/cli/logs_orchestrator_download.go @@ -22,6 +22,7 @@ type logsDownloadRuntime struct { fetchAllInRange bool filters runFilterOpts storageLimit *logsStorageLimit + cachedRuns cachedLogsRuns } type workflowRunBatch struct { @@ -44,6 +45,7 @@ type processWorkflowRunBatchOptions struct { maxConcurrentDownloads int storageLimit *logsStorageLimit maxGitHubAPIRateLimit int + cachedRuns cachedLogsRuns } func prepareLogsDownload(ctx context.Context, opts LogsDownloadOptions) (logsDownloadRuntime, error) { @@ -55,6 +57,13 @@ func prepareLogsDownload(ctx context.Context, opts LogsDownloadOptions) (logsDow if err != nil { return logsDownloadRuntime{}, err } + cachedRuns, err := loadCachedLogsJSON(opts.CachedJSON) + if err != nil { + return logsDownloadRuntime{}, err + } + if !cachedJSONCanSatisfy(artifactFilter, opts.Parse, opts.Audit, opts.Train, opts.ToolGraph) { + cachedRuns = nil + } if err := prepareLogsDownloadOutput(ctx, opts); err != nil { return logsDownloadRuntime{}, err } @@ -71,6 +80,7 @@ func prepareLogsDownload(ctx context.Context, opts LogsDownloadOptions) (logsDow artifactFilter: artifactFilter, fetchAllInRange: opts.StartDate != "" || opts.EndDate != "", storageLimit: storageLimit, + cachedRuns: cachedRuns, filters: runFilterOpts{ engine: opts.Engine, runtime: opts.Runtime, @@ -289,6 +299,7 @@ func fetchAndProcessLogsBatch(state *logsCollectionState, runtime logsDownloadRu maxConcurrentDownloads: opts.maxConcurrentDownloads, storageLimit: runtime.storageLimit, maxGitHubAPIRateLimit: opts.MaxGitHubAPIRateLimit, + cachedRuns: runtime.cachedRuns, }) state.timeoutReached = state.timeoutReached || batchTimedOut // Only mark this batch as storage-limit-truncated when one of its own @@ -519,9 +530,18 @@ func appendProcessedWorkflowRuns( maxConcurrentDownloads: opts.maxConcurrentDownloads, storageLimit: opts.storageLimit, maxGitHubAPIRateLimit: opts.maxGitHubAPIRateLimit, + cachedRuns: opts.cachedRuns, + filters: opts.filters, }) var storageLimitReached bool for _, result := range downloadResults { + if result.CachedRun != nil { + if len(processedRuns) < opts.count { + processedRuns = append(processedRuns, processedRunFromCachedData(*result.CachedRun)) + batchProcessed++ + } + continue + } if errors.Is(result.Error, errLogsStorageLimitReached) { storageLimitReached = true } diff --git a/pkg/cli/logs_orchestrator_stdin.go b/pkg/cli/logs_orchestrator_stdin.go index 30d0635250d..8dcb3aea1d7 100644 --- a/pkg/cli/logs_orchestrator_stdin.go +++ b/pkg/cli/logs_orchestrator_stdin.go @@ -38,6 +38,13 @@ func DownloadWorkflowLogsFromStdin(ctx context.Context, opts StdinLogsOptions) e fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Artifact filter: downloading only "+strings.Join(artifactFilter, ", "))) } } + cachedRuns, err := loadCachedLogsJSON(opts.CachedJSON) + if err != nil { + return err + } + if !cachedJSONCanSatisfy(artifactFilter, opts.Parse, opts.Audit, opts.Train, opts.ToolGraph) { + cachedRuns = nil + } if err := ensureLogsGitignore(); err != nil { logsOrchestratorLog.Printf("Failed to ensure logs .gitignore: %v", err) @@ -177,8 +184,6 @@ func DownloadWorkflowLogsFromStdin(ctx context.Context, opts StdinLogsOptions) e // Download artifacts for all runs concurrently. storageLimit := newLogsStorageLimit(opts.OutputDir, opts.MaxStorageMB, opts.PruneOlderRuns) - downloadResults := downloadRunArtifactsConcurrent(ctx, runs, runArtifactsConcurrentOptions{outputDir: opts.OutputDir, verbose: opts.Verbose, maxRuns: len(runs), repoOverride: opts.RepoOverride, artifactFilter: artifactFilter, evalsOnly: opts.EvalsOnly, artifactSets: opts.ArtifactSets, storageLimit: storageLimit}) - filters := runFilterOpts{ engine: opts.Engine, runtime: opts.Runtime, @@ -188,12 +193,18 @@ func DownloadWorkflowLogsFromStdin(ctx context.Context, opts StdinLogsOptions) e safeOutputType: opts.SafeOutputType, filteredIntegrity: opts.FilteredIntegrity, evalsOnly: opts.EvalsOnly, + gradersOnly: opts.GradersOnly, } + downloadResults := downloadRunArtifactsConcurrent(ctx, runs, runArtifactsConcurrentOptions{outputDir: opts.OutputDir, verbose: opts.Verbose, maxRuns: len(runs), repoOverride: opts.RepoOverride, artifactFilter: artifactFilter, evalsOnly: opts.EvalsOnly, artifactSets: opts.ArtifactSets, storageLimit: storageLimit, cachedRuns: cachedRuns, filters: filters}) // Process download results applying the same filters as DownloadWorkflowLogs. var processedRuns []ProcessedRun var storageLimitReached bool for _, result := range downloadResults { + if result.CachedRun != nil { + processedRuns = append(processedRuns, processedRunFromCachedData(*result.CachedRun)) + continue + } if errors.Is(result.Error, errLogsStorageLimitReached) { storageLimitReached = true } diff --git a/pkg/cli/logs_orchestrator_types.go b/pkg/cli/logs_orchestrator_types.go index b931f4f550e..3ed546e0845 100644 --- a/pkg/cli/logs_orchestrator_types.go +++ b/pkg/cli/logs_orchestrator_types.go @@ -47,6 +47,7 @@ type LogsDownloadOptions struct { ArtifactSets []string After string ReportFile string + CachedJSON string // SuppressRender downloads and processes runs (including writing the summary // file) without emitting any report to stdout. Callers that only need the // downloaded artifacts, and that own stdout themselves, set this so their own @@ -95,6 +96,7 @@ type StdinLogsOptions struct { Train bool Format string ReportFile string + CachedJSON string // ArtifactSets defaults to nil (download all artifacts) when this API is used // programmatically. The CLI passes ["usage"] to match the logs command default. ArtifactSets []string diff --git a/pkg/cli/logs_report.go b/pkg/cli/logs_report.go index 29556597087..175962068f9 100644 --- a/pkg/cli/logs_report.go +++ b/pkg/cli/logs_report.go @@ -252,6 +252,43 @@ func (agg *logsAggregate) accumulateRunTotals(pr ProcessedRun) { agg.totalSafeItems += run.SafeItemsCount } +func (agg *logsAggregate) accumulateCachedRunTotals(run RunData) { + agg.totalDuration += parseDurationString(run.Duration) + agg.totalAIC += run.AIC + if run.TokenUsageSummary != nil { + agg.totalSteeringEvents += run.TokenUsageSummary.TotalSteeringEvents + } + agg.totalTokens += run.TokenUsage + agg.totalActionMinutes += run.ActionMinutes + agg.totalTurns += run.Turns + agg.totalErrors += run.ErrorCount + agg.totalWarnings += run.WarningCount + agg.totalMissingTools += run.MissingToolCount + agg.totalMissingData += run.MissingDataCount + agg.totalSafeItems += run.SafeItemsCount + agg.totalGitHubAPICalls += run.GitHubAPICalls + agg.accumulateChainMetrics(SafeOutputChainMetrics{ + TemporaryIDMapStatus: run.TemporaryIDMapStatus, + TemporaryIDMappings: run.TemporaryIDMappings, + ChainedTargetCount: run.ChainedTargetCount, + ChainedFollowupActionCount: run.ChainedFollowupActionCount, + DelegatedTempTargetCount: run.DelegatedTempTargetCount, + ClosedTempTargetCount: run.ClosedTempTargetCount, + }) + switch run.FailureKind { + case "driver_exit": + agg.totalDriverExitFailures++ + case "agent_logic": + agg.totalAgentLogicFailures++ + } + if run.EngineID != "" { + agg.engineCounts[run.EngineID]++ + } + if run.IntentionalFailure { + agg.intentionalFailureRuns++ + } +} + // accumulateChainMetrics adds safe-output chain metrics of a run to the aggregate. func (agg *logsAggregate) accumulateChainMetrics(chainMetrics SafeOutputChainMetrics) { agg.totalTemporaryIDMappings += chainMetrics.TemporaryIDMappings @@ -421,6 +458,10 @@ func applyGitHubMetadataToRunData(runData *RunData, run WorkflowRun) { // buildRunData converts a processed run into RunData while accumulating rollup totals. // localRepo guards against cross-repo misclassification of intentional-failure workflows. func buildRunData(pr ProcessedRun, processedRuns []ProcessedRun, localRepo string, agg *logsAggregate) RunData { + if pr.cachedData != nil { + agg.accumulateCachedRunTotals(*pr.cachedData) + return *pr.cachedData + } run := pr.Run agg.accumulateRunTotals(pr) diff --git a/pkg/cli/logs_run_processor.go b/pkg/cli/logs_run_processor.go index 04e49e9ef67..d572d55383a 100644 --- a/pkg/cli/logs_run_processor.go +++ b/pkg/cli/logs_run_processor.go @@ -65,6 +65,8 @@ type runArtifactsConcurrentOptions struct { maxConcurrentDownloads int storageLimit *logsStorageLimit maxGitHubAPIRateLimit int + cachedRuns cachedLogsRuns + filters runFilterOpts } // buildConcurrentDownloadParams constructs download parameters by parsing the optional @@ -156,6 +158,11 @@ func downloadRunArtifactsConcurrent(ctx context.Context, runs []WorkflowRun, opt // Each download task runs concurrently with context awareness. for i, run := range runs { + if cachedResult, ok := cachedJSONDownloadResult(run, opts.cachedRuns, opts.filters); ok { + results[i] = cachedResult + completedCount.Add(1) + continue + } p.Go(func(ctx context.Context) error { result, _ := processSingleRunDownload(ctx, run, params, &completedCount, progressBar) results[i] = result @@ -176,6 +183,20 @@ func downloadRunArtifactsConcurrent(ctx context.Context, runs []WorkflowRun, opt return results } +func cachedJSONDownloadResult(run WorkflowRun, cachedRuns cachedLogsRuns, filters runFilterOpts) (DownloadResult, bool) { + cachedRun, ok := cachedRuns.lookup(run, filters) + if !ok { + return DownloadResult{}, false + } + logsOrchestratorLog.Printf("Reusing run %d from cached logs JSON", run.DatabaseID) + return DownloadResult{ + RunAnalysis: RunAnalysis{Run: run}, + Cached: true, + CachedRun: &cachedRun, + LogsPath: cachedRun.LogsPath, + }, true +} + // resolveRunRepoContext returns a copy of params with dlOwner/dlRepo/dlHost resolved to // the per-run repository. The global override takes precedence; for stdin mode (no global // override), the context is derived from run.URL.