diff --git a/.changeset/patch-add-logs-command-caching.md b/.changeset/patch-add-logs-command-caching.md new file mode 100644 index 0000000000..1b2e09b937 --- /dev/null +++ b/.changeset/patch-add-logs-command-caching.md @@ -0,0 +1,5 @@ +--- +"gh-aw": patch +--- + +Add run summary caching to logs command for faster reprocessing diff --git a/pkg/cli/logs.go b/pkg/cli/logs.go index 7808deeea7..e5065371b1 100644 --- a/pkg/cli/logs.go +++ b/pkg/cli/logs.go @@ -25,6 +25,8 @@ import ( const ( // defaultAgentStdioLogPath is the default log file path for agent stdout/stderr defaultAgentStdioLogPath = "/tmp/gh-aw/agent-stdio.log" + // runSummaryFileName is the name of the summary file created in each run folder + runSummaryFileName = "run_summary.json" ) // WorkflowRun represents a GitHub Actions workflow run with metrics @@ -98,6 +100,32 @@ type MissingToolSummary struct { // ErrNoArtifacts indicates that a workflow run has no artifacts var ErrNoArtifacts = errors.New("no artifacts found for this run") +// RunSummary represents a complete summary of a workflow run's artifacts and metrics. +// This file is written to each run folder as "run_summary.json" to cache processing results +// and avoid re-downloading and re-processing already analyzed runs. +// +// Key features: +// - Acts as a marker that a run has been fully processed +// - Stores all extracted metrics and analysis results +// - Includes CLI version for cache invalidation when the tool is updated +// - Enables fast reloading of run data without re-parsing logs +// +// Cache invalidation: +// - If the CLI version in the summary doesn't match the current version, the run is reprocessed +// - This ensures that bug fixes and improvements in log parsing are automatically applied +type RunSummary struct { + CLIVersion string `json:"cli_version"` // CLI version used to process this run + RunID int64 `json:"run_id"` // Workflow run database ID + ProcessedAt time.Time `json:"processed_at"` // When this summary was created + Run WorkflowRun `json:"run"` // Full workflow run metadata + Metrics LogMetrics `json:"metrics"` // Extracted log metrics + AccessAnalysis *DomainAnalysis `json:"access_analysis"` // Network access analysis + MissingTools []MissingToolReport `json:"missing_tools"` // Missing tool reports + MCPFailures []MCPFailureReport `json:"mcp_failures"` // MCP server failures + ArtifactsList []string `json:"artifacts_list"` // List of downloaded artifact files + JobDetails []JobInfoWithDuration `json:"job_details"` // Job execution details +} + // fetchJobStatuses gets job information for a workflow run and counts failed jobs func fetchJobStatuses(runID int64, verbose bool) (int, error) { args := []string{"api", fmt.Sprintf("repos/{owner}/{repo}/actions/runs/%d/jobs", runID), "--jq", ".jobs[] | {name: .name, status: .status, conclusion: .conclusion}"} @@ -736,6 +764,22 @@ func downloadRunArtifactsConcurrent(runs []WorkflowRun, outputDir string, verbos // Download artifacts and logs for this run runOutputDir := filepath.Join(outputDir, fmt.Sprintf("run-%d", run.DatabaseID)) + + // Try to load cached summary first + if summary, ok := loadRunSummary(runOutputDir, verbose); ok { + // Valid cached summary exists, use it directly + result := DownloadResult{ + Run: summary.Run, + Metrics: summary.Metrics, + AccessAnalysis: summary.AccessAnalysis, + MissingTools: summary.MissingTools, + MCPFailures: summary.MCPFailures, + LogsPath: runOutputDir, + } + return result + } + + // No cached summary or version mismatch - download and process err := downloadRunArtifacts(run.DatabaseID, runOutputDir, verbose) result := DownloadResult{ @@ -789,6 +833,42 @@ func downloadRunArtifactsConcurrent(runs []WorkflowRun, outputDir string, verbos } } result.MCPFailures = mcpFailures + + // Fetch job details for the summary + jobDetails, jobErr := fetchJobDetails(run.DatabaseID, verbose) + if jobErr != nil { + if verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to fetch job details for run %d: %v", run.DatabaseID, jobErr))) + } + } + + // List all artifacts + artifacts, listErr := listArtifacts(runOutputDir) + if listErr != nil { + if verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to list artifacts for run %d: %v", run.DatabaseID, listErr))) + } + } + + // Create and save run summary + summary := &RunSummary{ + CLIVersion: GetVersion(), + RunID: run.DatabaseID, + ProcessedAt: time.Now(), + Run: run, + Metrics: metrics, + AccessAnalysis: accessAnalysis, + MissingTools: missingTools, + MCPFailures: mcpFailures, + ArtifactsList: artifacts, + JobDetails: jobDetails, + } + + if saveErr := saveRunSummary(runOutputDir, summary, verbose); saveErr != nil { + if verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to save run summary for run %d: %v", run.DatabaseID, saveErr))) + } + } } return result @@ -1120,14 +1200,119 @@ func extractZipFile(f *zip.File, destDir string, verbose bool) error { return nil } +// loadRunSummary attempts to load a run summary from disk +// Returns the summary and a boolean indicating if it was successfully loaded and is valid +func loadRunSummary(outputDir string, verbose bool) (*RunSummary, bool) { + summaryPath := filepath.Join(outputDir, runSummaryFileName) + + // Check if summary file exists + if _, err := os.Stat(summaryPath); os.IsNotExist(err) { + return nil, false + } + + // Read the summary file + data, err := os.ReadFile(summaryPath) + if err != nil { + if verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to read run summary: %v", err))) + } + return nil, false + } + + // Parse the JSON + var summary RunSummary + if err := json.Unmarshal(data, &summary); err != nil { + if verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to parse run summary: %v", err))) + } + return nil, false + } + + // Validate CLI version matches + currentVersion := GetVersion() + if summary.CLIVersion != currentVersion { + if verbose { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Run summary version mismatch (cached: %s, current: %s), will reprocess", summary.CLIVersion, currentVersion))) + } + return nil, false + } + + if verbose { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Loaded cached run summary for run %d (processed at %s)", summary.RunID, summary.ProcessedAt.Format(time.RFC3339)))) + } + + return &summary, true +} + +// saveRunSummary saves a run summary to disk +func saveRunSummary(outputDir string, summary *RunSummary, verbose bool) error { + summaryPath := filepath.Join(outputDir, runSummaryFileName) + + // Marshal to JSON with indentation for readability + data, err := json.MarshalIndent(summary, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal run summary: %w", err) + } + + // Write to file + if err := os.WriteFile(summaryPath, data, 0644); err != nil { + return fmt.Errorf("failed to write run summary: %w", err) + } + + if verbose { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Saved run summary to %s", summaryPath))) + } + + return nil +} + +// listArtifacts creates a list of all artifact files in the output directory +func listArtifacts(outputDir string) ([]string, error) { + var artifacts []string + + err := filepath.Walk(outputDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Skip directories and the summary file itself + if info.IsDir() || filepath.Base(path) == runSummaryFileName { + return nil + } + + // Get relative path from outputDir + relPath, err := filepath.Rel(outputDir, path) + if err != nil { + return err + } + + artifacts = append(artifacts, relPath) + return nil + }) + + if err != nil { + return nil, err + } + + return artifacts, nil +} + // downloadRunArtifacts downloads artifacts for a specific workflow run func downloadRunArtifacts(runID int64, outputDir string, verbose bool) error { // Check if artifacts already exist on disk (since they're immutable) if dirExists(outputDir) && !isDirEmpty(outputDir) { + // Try to load cached summary + if summary, ok := loadRunSummary(outputDir, verbose); ok { + // Valid cached summary exists, skip download + if verbose { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Using cached artifacts for run %d at %s (from %s)", runID, outputDir, summary.ProcessedAt.Format(time.RFC3339)))) + } + return nil + } + // Summary doesn't exist or version mismatch - will reprocess below if verbose { - fmt.Println(console.FormatInfoMessage(fmt.Sprintf("Artifacts for run %d already exist at %s, skipping download", runID, outputDir))) + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Run folder exists but no valid summary, will reprocess run %d", runID))) } - return nil } if err := os.MkdirAll(outputDir, 0755); err != nil { diff --git a/pkg/cli/logs_summary_integration_test.go b/pkg/cli/logs_summary_integration_test.go new file mode 100644 index 0000000000..e96ceb25e8 --- /dev/null +++ b/pkg/cli/logs_summary_integration_test.go @@ -0,0 +1,265 @@ +package cli + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/githubnext/gh-aw/pkg/workflow" +) + +// TestRunSummaryCachingBehavior tests the complete caching behavior of run summaries +func TestRunSummaryCachingBehavior(t *testing.T) { + // Create a temporary directory for testing + tmpDir := t.TempDir() + runDir := filepath.Join(tmpDir, "run-99999") + if err := os.MkdirAll(runDir, 0755); err != nil { + t.Fatalf("Failed to create test directory: %v", err) + } + + // Set a test version + originalVersion := GetVersion() + SetVersionInfo("1.0.0-test") + defer SetVersionInfo(originalVersion) + + // Create some test artifact files + testFiles := map[string]string{ + "aw_info.json": `{ + "engine_id": "claude", + "engine_name": "Claude Code", + "model": "claude-sonnet-4", + "version": "1.0.0", + "workflow_name": "Test Workflow" + }`, + "agent-stdio.log": "Test log content\nSome agent output\n", + "safe_output.jsonl": `{"type":"output","content":"test"}`, + } + + for filename, content := range testFiles { + filePath := filepath.Join(runDir, filename) + if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { + t.Fatalf("Failed to create test file %s: %v", filename, err) + } + } + + // Create a test run summary (simulating what would happen after first download) + testRun := WorkflowRun{ + DatabaseID: 99999, + Number: 1, + WorkflowName: "Test Workflow", + Status: "completed", + Conclusion: "success", + CreatedAt: time.Now().Add(-1 * time.Hour), + StartedAt: time.Now().Add(-50 * time.Minute), + UpdatedAt: time.Now().Add(-10 * time.Minute), + } + + testMetrics := workflow.LogMetrics{ + TokenUsage: 5000, + EstimatedCost: 0.25, + Turns: 3, + } + + testSummary := &RunSummary{ + CLIVersion: GetVersion(), + RunID: 99999, + ProcessedAt: time.Now(), + Run: testRun, + Metrics: testMetrics, + ArtifactsList: []string{ + "aw_info.json", + "agent-stdio.log", + "safe_output.jsonl", + }, + MissingTools: []MissingToolReport{}, + MCPFailures: []MCPFailureReport{}, + } + + // Save the summary + if err := saveRunSummary(runDir, testSummary, false); err != nil { + t.Fatalf("Failed to save initial run summary: %v", err) + } + + // Verify summary file exists + summaryPath := filepath.Join(runDir, runSummaryFileName) + if _, err := os.Stat(summaryPath); os.IsNotExist(err) { + t.Fatal("Summary file was not created") + } + + // Test 1: Load with same version should succeed + loadedSummary, ok := loadRunSummary(runDir, false) + if !ok { + t.Fatal("Failed to load run summary with matching version") + } + + if loadedSummary.RunID != testSummary.RunID { + t.Errorf("Loaded RunID mismatch: got %d, want %d", loadedSummary.RunID, testSummary.RunID) + } + if loadedSummary.Metrics.TokenUsage != testMetrics.TokenUsage { + t.Errorf("Loaded TokenUsage mismatch: got %d, want %d", loadedSummary.Metrics.TokenUsage, testMetrics.TokenUsage) + } + + // Test 2: Change version and verify cache invalidation + SetVersionInfo("2.0.0-different") + loadedSummary, ok = loadRunSummary(runDir, false) + if ok { + t.Fatal("Expected cache invalidation due to version change, but load succeeded") + } + if loadedSummary != nil { + t.Error("Expected nil summary after version mismatch") + } + + // Reset version for next test + SetVersionInfo("1.0.0-test") + + // Test 3: Verify the summary contains all expected data + loadedSummary, ok = loadRunSummary(runDir, false) + if !ok { + t.Fatal("Failed to load run summary after resetting version") + } + + // Verify artifacts list + if len(loadedSummary.ArtifactsList) != 3 { + t.Errorf("Expected 3 artifacts, got %d", len(loadedSummary.ArtifactsList)) + } + + // Verify run details + if loadedSummary.Run.WorkflowName != "Test Workflow" { + t.Errorf("WorkflowName mismatch: got %s, want %s", loadedSummary.Run.WorkflowName, "Test Workflow") + } + if loadedSummary.Run.Status != "completed" { + t.Errorf("Status mismatch: got %s, want %s", loadedSummary.Run.Status, "completed") + } + + // Test 4: Verify summary file is valid JSON and human-readable + summaryData, err := os.ReadFile(summaryPath) + if err != nil { + t.Fatalf("Failed to read summary file: %v", err) + } + + // Should be valid JSON + var jsonCheck map[string]interface{} + if err := json.Unmarshal(summaryData, &jsonCheck); err != nil { + t.Fatalf("Summary file is not valid JSON: %v", err) + } + + // Should contain expected top-level keys + expectedKeys := []string{"cli_version", "run_id", "processed_at", "run", "metrics", "artifacts_list"} + for _, key := range expectedKeys { + if _, exists := jsonCheck[key]; !exists { + t.Errorf("Summary JSON missing expected key: %s", key) + } + } +} + +// TestRunSummaryPreventsReprocessing tests that summary files prevent redundant processing +func TestRunSummaryPreventsReprocessing(t *testing.T) { + tmpDir := t.TempDir() + runDir := filepath.Join(tmpDir, "run-88888") + if err := os.MkdirAll(runDir, 0755); err != nil { + t.Fatalf("Failed to create test directory: %v", err) + } + + // Set a test version + originalVersion := GetVersion() + SetVersionInfo("1.5.0-test") + defer SetVersionInfo(originalVersion) + + // Create minimal test artifacts + if err := os.WriteFile(filepath.Join(runDir, "aw_info.json"), []byte("{}"), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + // Simulate first processing: create summary + firstProcessTime := time.Now() + summary := &RunSummary{ + CLIVersion: GetVersion(), + RunID: 88888, + ProcessedAt: firstProcessTime, + Run: WorkflowRun{DatabaseID: 88888}, + Metrics: workflow.LogMetrics{TokenUsage: 1000}, + ArtifactsList: []string{"aw_info.json"}, + } + + if err := saveRunSummary(runDir, summary, false); err != nil { + t.Fatalf("Failed to save summary: %v", err) + } + + // Simulate second attempt to process same run + // Load should succeed and return cached data + time.Sleep(10 * time.Millisecond) // Small delay to ensure different timestamp if recreated + loaded, ok := loadRunSummary(runDir, false) + if !ok { + t.Fatal("Failed to load cached summary on second access") + } + + // Verify we got the cached version (same ProcessedAt time) + timeDiff := loaded.ProcessedAt.Sub(firstProcessTime) + if timeDiff > time.Millisecond { + t.Errorf("ProcessedAt time changed unexpectedly (cached: %v, loaded: %v), suggests reprocessing occurred", + firstProcessTime, loaded.ProcessedAt) + } + + // Verify data is unchanged + if loaded.Metrics.TokenUsage != 1000 { + t.Errorf("TokenUsage changed from cached value: got %d, want 1000", loaded.Metrics.TokenUsage) + } +} + +// TestListArtifactsExcludesSummary verifies that the summary file itself is not listed as an artifact +func TestListArtifactsExcludesSummary(t *testing.T) { + tmpDir := t.TempDir() + runDir := filepath.Join(tmpDir, "run-77777") + if err := os.MkdirAll(runDir, 0755); err != nil { + t.Fatalf("Failed to create test directory: %v", err) + } + + // Create test files including the summary + testFiles := []string{ + "aw_info.json", + "agent-stdio.log", + runSummaryFileName, // This should be excluded from the list + } + + for _, filename := range testFiles { + filePath := filepath.Join(runDir, filename) + if err := os.WriteFile(filePath, []byte("test"), 0644); err != nil { + t.Fatalf("Failed to create test file %s: %v", filename, err) + } + } + + // List artifacts + artifacts, err := listArtifacts(runDir) + if err != nil { + t.Fatalf("Failed to list artifacts: %v", err) + } + + // Should have 2 artifacts (excluding the summary) + if len(artifacts) != 2 { + t.Errorf("Expected 2 artifacts (excluding summary), got %d: %v", len(artifacts), artifacts) + } + + // Verify summary is not in the list + for _, artifact := range artifacts { + if artifact == runSummaryFileName { + t.Errorf("Summary file %s should not be in artifacts list", runSummaryFileName) + } + } + + // Verify expected files are in the list + expectedFiles := []string{"aw_info.json", "agent-stdio.log"} + for _, expected := range expectedFiles { + found := false + for _, artifact := range artifacts { + if artifact == expected { + found = true + break + } + } + if !found { + t.Errorf("Expected artifact %s not found in list: %v", expected, artifacts) + } + } +} diff --git a/pkg/cli/logs_summary_test.go b/pkg/cli/logs_summary_test.go new file mode 100644 index 0000000000..f1aad9a48b --- /dev/null +++ b/pkg/cli/logs_summary_test.go @@ -0,0 +1,325 @@ +package cli + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/githubnext/gh-aw/pkg/workflow" +) + +func TestSaveAndLoadRunSummary(t *testing.T) { + // Create a temporary directory for testing + tmpDir := t.TempDir() + runDir := filepath.Join(tmpDir, "run-12345") + if err := os.MkdirAll(runDir, 0755); err != nil { + t.Fatalf("Failed to create test directory: %v", err) + } + + // Set a test version + originalVersion := GetVersion() + SetVersionInfo("1.2.3-test") + defer SetVersionInfo(originalVersion) + + // Create a test summary + testSummary := &RunSummary{ + CLIVersion: GetVersion(), + RunID: 12345, + ProcessedAt: time.Now(), + Run: WorkflowRun{ + DatabaseID: 12345, + Number: 42, + WorkflowName: "Test Workflow", + Status: "completed", + Conclusion: "success", + }, + Metrics: workflow.LogMetrics{ + TokenUsage: 1000, + EstimatedCost: 0.05, + Turns: 5, + }, + MissingTools: []MissingToolReport{ + { + Tool: "test_tool", + Reason: "Tool not available", + }, + }, + ArtifactsList: []string{ + "aw_info.json", + "agent-stdio.log", + }, + } + + // Save the summary + if err := saveRunSummary(runDir, testSummary, false); err != nil { + t.Fatalf("Failed to save run summary: %v", err) + } + + // Verify the file was created + summaryPath := filepath.Join(runDir, runSummaryFileName) + if _, err := os.Stat(summaryPath); os.IsNotExist(err) { + t.Fatalf("Summary file was not created at %s", summaryPath) + } + + // Load the summary + loadedSummary, ok := loadRunSummary(runDir, false) + if !ok { + t.Fatal("Failed to load run summary") + } + + // Verify the loaded data matches + if loadedSummary.CLIVersion != testSummary.CLIVersion { + t.Errorf("CLIVersion mismatch: got %s, want %s", loadedSummary.CLIVersion, testSummary.CLIVersion) + } + if loadedSummary.RunID != testSummary.RunID { + t.Errorf("RunID mismatch: got %d, want %d", loadedSummary.RunID, testSummary.RunID) + } + if loadedSummary.Run.DatabaseID != testSummary.Run.DatabaseID { + t.Errorf("Run.DatabaseID mismatch: got %d, want %d", loadedSummary.Run.DatabaseID, testSummary.Run.DatabaseID) + } + if loadedSummary.Metrics.TokenUsage != testSummary.Metrics.TokenUsage { + t.Errorf("Metrics.TokenUsage mismatch: got %d, want %d", loadedSummary.Metrics.TokenUsage, testSummary.Metrics.TokenUsage) + } + if len(loadedSummary.MissingTools) != len(testSummary.MissingTools) { + t.Errorf("MissingTools length mismatch: got %d, want %d", len(loadedSummary.MissingTools), len(testSummary.MissingTools)) + } +} + +func TestLoadRunSummaryVersionMismatch(t *testing.T) { + // Create a temporary directory for testing + tmpDir := t.TempDir() + runDir := filepath.Join(tmpDir, "run-12345") + if err := os.MkdirAll(runDir, 0755); err != nil { + t.Fatalf("Failed to create test directory: %v", err) + } + + // Set a test version and create a summary + originalVersion := GetVersion() + SetVersionInfo("1.2.3-test") + defer SetVersionInfo(originalVersion) + + testSummary := &RunSummary{ + CLIVersion: GetVersion(), + RunID: 12345, + ProcessedAt: time.Now(), + Run: WorkflowRun{ + DatabaseID: 12345, + Number: 42, + }, + } + + // Save the summary + if err := saveRunSummary(runDir, testSummary, false); err != nil { + t.Fatalf("Failed to save run summary: %v", err) + } + + // Change the version + SetVersionInfo("2.0.0-different") + + // Try to load with different version + loadedSummary, ok := loadRunSummary(runDir, false) + if ok { + t.Fatal("Expected loadRunSummary to return false due to version mismatch, but it returned true") + } + if loadedSummary != nil { + t.Errorf("Expected nil summary due to version mismatch, but got: %+v", loadedSummary) + } +} + +func TestLoadRunSummaryMissingFile(t *testing.T) { + // Create a temporary directory for testing + tmpDir := t.TempDir() + runDir := filepath.Join(tmpDir, "run-12345") + if err := os.MkdirAll(runDir, 0755); err != nil { + t.Fatalf("Failed to create test directory: %v", err) + } + + // Try to load from directory with no summary file + loadedSummary, ok := loadRunSummary(runDir, false) + if ok { + t.Fatal("Expected loadRunSummary to return false for missing file, but it returned true") + } + if loadedSummary != nil { + t.Errorf("Expected nil summary for missing file, but got: %+v", loadedSummary) + } +} + +func TestLoadRunSummaryInvalidJSON(t *testing.T) { + // Create a temporary directory for testing + tmpDir := t.TempDir() + runDir := filepath.Join(tmpDir, "run-12345") + if err := os.MkdirAll(runDir, 0755); err != nil { + t.Fatalf("Failed to create test directory: %v", err) + } + + // Write invalid JSON to the summary file + summaryPath := filepath.Join(runDir, runSummaryFileName) + if err := os.WriteFile(summaryPath, []byte("invalid json {"), 0644); err != nil { + t.Fatalf("Failed to write invalid JSON: %v", err) + } + + // Try to load the invalid summary + loadedSummary, ok := loadRunSummary(runDir, false) + if ok { + t.Fatal("Expected loadRunSummary to return false for invalid JSON, but it returned true") + } + if loadedSummary != nil { + t.Errorf("Expected nil summary for invalid JSON, but got: %+v", loadedSummary) + } +} + +func TestListArtifacts(t *testing.T) { + // Create a temporary directory structure for testing + tmpDir := t.TempDir() + runDir := filepath.Join(tmpDir, "run-12345") + + // Create some test files and directories + testFiles := []string{ + "aw_info.json", + "agent-stdio.log", + "safe_output.jsonl", + "workflow-logs/job-1.txt", + "workflow-logs/job-2.txt", + "agent_output/output.json", + } + + for _, file := range testFiles { + fullPath := filepath.Join(runDir, file) + if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil { + t.Fatalf("Failed to create directory for %s: %v", file, err) + } + if err := os.WriteFile(fullPath, []byte("test content"), 0644); err != nil { + t.Fatalf("Failed to create test file %s: %v", file, err) + } + } + + // List the artifacts + artifacts, err := listArtifacts(runDir) + if err != nil { + t.Fatalf("Failed to list artifacts: %v", err) + } + + // Verify all test files are in the list + for _, expectedFile := range testFiles { + found := false + for _, artifact := range artifacts { + if artifact == expectedFile { + found = true + break + } + } + if !found { + t.Errorf("Expected artifact %s not found in list: %v", expectedFile, artifacts) + } + } + + // Verify the summary file itself is not in the list + for _, artifact := range artifacts { + if artifact == runSummaryFileName { + t.Errorf("Summary file %s should not be in artifacts list", runSummaryFileName) + } + } +} + +func TestRunSummaryJSONStructure(t *testing.T) { + // Verify the RunSummary struct can be properly marshaled and unmarshaled + originalVersion := GetVersion() + SetVersionInfo("1.2.3-test") + defer SetVersionInfo(originalVersion) + + testSummary := &RunSummary{ + CLIVersion: GetVersion(), + RunID: 12345, + ProcessedAt: time.Now(), + Run: WorkflowRun{ + DatabaseID: 12345, + Number: 42, + URL: "https://github.com/test/repo/actions/runs/12345", + Status: "completed", + Conclusion: "success", + WorkflowName: "Test Workflow", + CreatedAt: time.Now().Add(-1 * time.Hour), + StartedAt: time.Now().Add(-50 * time.Minute), + UpdatedAt: time.Now().Add(-10 * time.Minute), + Event: "push", + HeadBranch: "main", + HeadSha: "abc123", + DisplayTitle: "Test Run", + Duration: 40 * time.Minute, + TokenUsage: 1000, + EstimatedCost: 0.05, + Turns: 5, + ErrorCount: 0, + WarningCount: 1, + LogsPath: "/tmp/run-12345", + }, + Metrics: workflow.LogMetrics{ + TokenUsage: 1000, + EstimatedCost: 0.05, + Turns: 5, + Errors: []workflow.LogError{}, + }, + AccessAnalysis: &DomainAnalysis{ + AllowedDomains: []string{"github.com", "api.github.com"}, + DeniedDomains: []string{}, + TotalRequests: 10, + AllowedCount: 10, + DeniedCount: 0, + }, + MissingTools: []MissingToolReport{ + { + Tool: "test_tool", + Reason: "Tool not available", + Alternatives: "alternative_tool", + Timestamp: time.Now().Format(time.RFC3339), + }, + }, + MCPFailures: []MCPFailureReport{ + { + ServerName: "test-server", + Status: "failed", + Timestamp: time.Now().Format(time.RFC3339), + }, + }, + ArtifactsList: []string{ + "aw_info.json", + "agent-stdio.log", + "safe_output.jsonl", + }, + JobDetails: []JobInfoWithDuration{ + { + JobInfo: JobInfo{ + Name: "test-job", + Status: "completed", + Conclusion: "success", + }, + Duration: 5 * time.Minute, + }, + }, + } + + // Marshal to JSON + jsonData, err := json.MarshalIndent(testSummary, "", " ") + if err != nil { + t.Fatalf("Failed to marshal RunSummary to JSON: %v", err) + } + + // Verify it's valid JSON + var testUnmarshal RunSummary + if err := json.Unmarshal(jsonData, &testUnmarshal); err != nil { + t.Fatalf("Failed to unmarshal RunSummary JSON: %v", err) + } + + // Verify key fields + if testUnmarshal.CLIVersion != testSummary.CLIVersion { + t.Errorf("CLIVersion mismatch after round-trip: got %s, want %s", testUnmarshal.CLIVersion, testSummary.CLIVersion) + } + if testUnmarshal.RunID != testSummary.RunID { + t.Errorf("RunID mismatch after round-trip: got %d, want %d", testUnmarshal.RunID, testSummary.RunID) + } + if len(testUnmarshal.ArtifactsList) != len(testSummary.ArtifactsList) { + t.Errorf("ArtifactsList length mismatch after round-trip: got %d, want %d", len(testUnmarshal.ArtifactsList), len(testSummary.ArtifactsList)) + } +}