Skip to content

Commit 35ce059

Browse files
spboyerCopilot
andauthored
fix: Address PR #471 review feedback (#473)
## Addresses review feedback from PR #471 10 items identified and fixed from Copilot reviewer comments on the Azure Blob Storage PR. ### Code fixes (Linus) - **Added `--format` flag** to `waza results list` and `waza results compare` — supports `table` (default) and `json` - **Generic error messages** — replaced hardcoded "Azure Storage" with `cfg.Storage.Provider` in `autoUploadOutcomes` - **Fixed `Storage.Enabled` merge** — now allows `enabled: false` to override `enabled: true` ### Azure fixes (Virgil) - **CI environment detection** — `isCI()` checks for `CI`, `GITHUB_ACTIONS`, `TF_BUILD`, `JENKINS_URL`, `CODEBUILD_BUILD_ID` before attempting `az login` - **Download optimization** — tries suffix-based blob lookup first before falling back to full scan - **Shared `buildMetricDeltas`** — moved from local.go to store.go for both implementations ### Doc fixes (Livingston) - **Blob path examples** — corrected from date-based to `{skill-name}/{run-id}.json` format - **`--limit` default** — fixed from 10 to 20 to match implementation - **`--format json` examples** — verified consistent across README, CLI docs, and guide --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 2183b16 commit 35ce059

8 files changed

Lines changed: 161 additions & 67 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -490,7 +490,7 @@ List all evaluation runs from configured cloud storage or local results director
490490

491491
| Flag | Description |
492492
|------|-------------|
493-
| `--limit <n>` | Maximum results to display (default: 10) |
493+
| `--limit <n>` | Maximum results to display (default: 20) |
494494
| `--format <fmt>` | Output format: `table` or `json` (default: `table`) |
495495

496496
```bash
@@ -563,7 +563,7 @@ waza run eval.yaml # Results auto-upload to Azure Storage
563563
### How It Works
564564

565565
1. **Auto-upload on run:** When `storage:` is configured, `waza run` automatically uploads results to Azure Blob Storage
566-
2. **Organized by timestamp:** Results are stored as `{year}/{month}/{day}/{timestamp}-{result-id}.json`
566+
2. **Organized by skill:** Results are stored as `{skill-name}/{run-id}.json`
567567
3. **Local copy kept:** Results are also saved locally (via `-o` flag)
568568
4. **List remote results:** Use `waza results list` to browse uploaded runs
569569
5. **Compare runs:** Use `waza results compare` to diff two remote results

cmd/waza/cmd_results.go

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package main
22

33
import (
44
"context"
5+
"encoding/json"
56
"fmt"
67
"strings"
78
"time"
@@ -38,6 +39,7 @@ func newResultsListCommand() *cobra.Command {
3839
modelFilter string
3940
sinceStr string
4041
limit int
42+
format string
4143
)
4244

4345
cmd := &cobra.Command{
@@ -48,8 +50,12 @@ func newResultsListCommand() *cobra.Command {
4850
Examples:
4951
waza results list
5052
waza results list --skill my-skill --model gpt-4o
51-
waza results list --since 2026-01-01 --limit 10`,
53+
waza results list --since 2026-01-01 --limit 10
54+
waza results list --format json`,
5255
RunE: func(cmd *cobra.Command, args []string) error {
56+
if format != "table" && format != "json" {
57+
return fmt.Errorf("invalid format %q: expected table or json", format)
58+
}
5359
cfg, err := projectconfig.Load(".")
5460
if err != nil || cfg == nil {
5561
cfg = projectconfig.New()
@@ -93,6 +99,15 @@ Examples:
9399
return nil
94100
}
95101

102+
if format == "json" {
103+
data, err := json.MarshalIndent(results, "", " ")
104+
if err != nil {
105+
return fmt.Errorf("marshaling results: %w", err)
106+
}
107+
_, _ = fmt.Fprintln(cmd.OutOrStdout(), string(data))
108+
return nil
109+
}
110+
96111
// Print table header
97112
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%-36s %-20s %-24s %9s %s\n",
98113
"Run ID", "Skill", "Model", "Pass Rate", "Timestamp")
@@ -124,11 +139,14 @@ Examples:
124139
cmd.Flags().StringVar(&modelFilter, "model", "", "Filter by model ID")
125140
cmd.Flags().StringVar(&sinceStr, "since", "", "Filter by date (YYYY-MM-DD)")
126141
cmd.Flags().IntVar(&limit, "limit", 20, "Maximum number of results to show")
142+
cmd.Flags().StringVar(&format, "format", "table", "Output format: table | json")
127143

128144
return cmd
129145
}
130146

131147
func newResultsCompareCommand() *cobra.Command {
148+
var format string
149+
132150
cmd := &cobra.Command{
133151
Use: "compare <run-id-1> <run-id-2>",
134152
Short: "Compare two evaluation runs",
@@ -138,9 +156,13 @@ Shows pass rate delta, score delta, and per-metric differences.
138156
Green indicates improvements, red indicates regressions.
139157
140158
Examples:
141-
waza results compare abc123 def456`,
159+
waza results compare abc123 def456
160+
waza results compare abc123 def456 --format json`,
142161
Args: cobra.ExactArgs(2),
143162
RunE: func(cmd *cobra.Command, args []string) error {
163+
if format != "table" && format != "json" {
164+
return fmt.Errorf("invalid format %q: expected table or json", format)
165+
}
144166
cfg, err := projectconfig.Load(".")
145167
if err != nil || cfg == nil {
146168
cfg = projectconfig.New()
@@ -165,6 +187,15 @@ Examples:
165187
return fmt.Errorf("comparing runs: %w", err)
166188
}
167189

190+
if format == "json" {
191+
data, err := json.MarshalIndent(report, "", " ")
192+
if err != nil {
193+
return fmt.Errorf("marshaling comparison: %w", err)
194+
}
195+
_, _ = fmt.Fprintln(cmd.OutOrStdout(), string(data))
196+
return nil
197+
}
198+
168199
out := cmd.OutOrStdout()
169200

170201
// Header
@@ -202,6 +233,8 @@ Examples:
202233
},
203234
}
204235

236+
cmd.Flags().StringVar(&format, "format", "table", "Output format: table | json")
237+
205238
return cmd
206239
}
207240

cmd/waza/cmd_run.go

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1037,7 +1037,11 @@ func autoUploadOutcomes(cmd *cobra.Command, cfg *projectconfig.ProjectConfig, re
10371037

10381038
store, err := storage.NewStore(&cfg.Storage, outputDir)
10391039
if err != nil {
1040-
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "⚠️ Azure Storage setup failed: %v. Results saved locally.\n", err)
1040+
provider := cfg.Storage.Provider
1041+
if provider == "" {
1042+
provider = "cloud storage"
1043+
}
1044+
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "⚠️ %s setup failed: %v. Results saved locally.\n", provider, err)
10411045
return
10421046
}
10431047

@@ -1050,10 +1054,14 @@ func autoUploadOutcomes(cmd *cobra.Command, cfg *projectconfig.ProjectConfig, re
10501054
if mr.outcome == nil {
10511055
continue
10521056
}
1057+
provider := cfg.Storage.Provider
1058+
if provider == "" {
1059+
provider = "cloud storage"
1060+
}
10531061
if err := store.Upload(ctx, mr.outcome); err != nil {
1054-
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "⚠️ Failed to upload results to Azure Storage: %v. Results saved locally.\n", err)
1062+
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "⚠️ Failed to upload results to %s: %v. Results saved locally.\n", provider, err)
10551063
} else {
1056-
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "☁️ Results uploaded to Azure Storage\n")
1064+
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "☁️ Results uploaded to %s\n", provider)
10571065
}
10581066
}
10591067
}

internal/projectconfig/config.go

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -306,9 +306,7 @@ func mergeConfig(dst, src *ProjectConfig) {
306306
if src.Storage.ContainerName != "" {
307307
dst.Storage.ContainerName = src.Storage.ContainerName
308308
}
309-
if src.Storage.Enabled {
310-
dst.Storage.Enabled = true
311-
}
309+
dst.Storage.Enabled = src.Storage.Enabled
312310
}
313311

314312
func boolPtr(b bool) *bool {

internal/storage/azure_blob.go

Lines changed: 81 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -56,17 +56,36 @@ func NewAzureBlobStore(ctx context.Context, accountName, containerName string) (
5656
}, nil
5757
}
5858

59+
// ciEnvVars lists environment variables that indicate a CI/CD environment.
60+
var ciEnvVars = []string{"CI", "GITHUB_ACTIONS", "TF_BUILD", "JENKINS_URL", "CODEBUILD_BUILD_ID"}
61+
62+
// isCI returns true if any common CI environment variable is set.
63+
func isCI() bool {
64+
for _, v := range ciEnvVars {
65+
if os.Getenv(v) != "" {
66+
return true
67+
}
68+
}
69+
return false
70+
}
71+
5972
// getCredentialWithAutoLogin attempts to create DefaultAzureCredential.
60-
// If it fails, it runs 'az login' and retries once.
73+
// If it fails and the environment is not CI, it runs 'az login' and retries once.
74+
// In CI environments, auto-login is skipped to avoid hanging on interactive prompts.
6175
func getCredentialWithAutoLogin(ctx context.Context) (azcore.TokenCredential, error) {
6276
cred, err := azidentity.NewDefaultAzureCredential(nil)
6377
if err == nil {
6478
return cred, nil
6579
}
6680

81+
// In CI environments, skip interactive az login and return a clear message.
82+
if isCI() {
83+
return nil, fmt.Errorf("azure credentials not available in CI: set AZURE_CLIENT_ID/AZURE_CLIENT_SECRET/AZURE_TENANT_ID environment variables (original error: %v)", err)
84+
}
85+
6786
// If credential creation failed, attempt auto-login.
68-
// This handles cases where no auth is configured (no env vars, no managed identity, etc.)
69-
fmt.Fprintln(os.Stderr, "Azure credentials not available, attempting 'az login'...")
87+
// This handles interactive cases where no auth is configured (no env vars, no managed identity, etc.)
88+
_, _ = fmt.Fprintln(os.Stderr, "Azure credentials not available, attempting 'az login'...")
7089
cmd := exec.CommandContext(ctx, "az", "login")
7190
cmd.Stdout = os.Stdout
7291
cmd.Stderr = os.Stderr
@@ -182,33 +201,25 @@ func (abs *AzureBlobStore) List(ctx context.Context, opts ListOptions) ([]Result
182201
}
183202

184203
// Download retrieves a single evaluation outcome by run ID.
185-
// It attempts to download {skill}/{run-id}.json. If not found and skill is
186-
// not known, it lists all blobs to find a match by run ID.
204+
// Optimization: we first attempt a prefix-scoped list using the runID suffix
205+
// pattern (*/{runID}.json) to avoid scanning all blobs. If no match is found,
206+
// we fall back to a full blob scan matching on metadata. The prefix approach is
207+
// O(1) when the blob naming convention is followed; the fallback is O(N) but
208+
// handles legacy or misnamed blobs.
187209
func (abs *AzureBlobStore) Download(ctx context.Context, runID string) (*models.EvaluationOutcome, error) {
188-
// First, try listing all blobs to find the one matching the run ID.
189-
// This is necessary because we don't know the skill name from just the run ID.
190-
pager := abs.client.NewListBlobsFlatPager(abs.containerName, nil)
210+
// Fast path: try a direct download using the known blob naming pattern.
211+
// Blobs are stored as {skill}/{runID}.json, so we can list with suffix match.
212+
blobSuffix := sanitizePathSegment(runID) + ".json"
213+
blobPath, err := abs.findBlobBySuffix(ctx, blobSuffix)
214+
if err != nil {
215+
return nil, fmt.Errorf("azure blob download: %w", err)
216+
}
191217

192-
var blobPath string
193-
for pager.More() {
194-
page, err := pager.NextPage(ctx)
218+
// Slow path: fall back to full scan matching on metadata if suffix match failed.
219+
if blobPath == "" {
220+
blobPath, err = abs.findBlobByMetadata(ctx, runID)
195221
if err != nil {
196-
return nil, fmt.Errorf("azure blob download: listing blobs: %w", err)
197-
}
198-
199-
for _, blob := range page.Segment.BlobItems {
200-
if blob.Name == nil || blob.Metadata == nil {
201-
continue
202-
}
203-
204-
if metaRunID, ok := blob.Metadata["runid"]; ok && metaRunID != nil && *metaRunID == runID {
205-
blobPath = *blob.Name
206-
break
207-
}
208-
}
209-
210-
if blobPath != "" {
211-
break
222+
return nil, fmt.Errorf("azure blob download: %w", err)
212223
}
213224
}
214225

@@ -236,6 +247,49 @@ func (abs *AzureBlobStore) Download(ctx context.Context, runID string) (*models.
236247
return &outcome, nil
237248
}
238249

250+
// findBlobBySuffix lists blobs and returns the first whose name ends with suffix.
251+
// This is faster than a full metadata scan when the blob naming convention is followed.
252+
func (abs *AzureBlobStore) findBlobBySuffix(ctx context.Context, suffix string) (string, error) {
253+
pager := abs.client.NewListBlobsFlatPager(abs.containerName, nil)
254+
255+
for pager.More() {
256+
page, err := pager.NextPage(ctx)
257+
if err != nil {
258+
return "", fmt.Errorf("listing blobs by suffix: %w", err)
259+
}
260+
261+
for _, blob := range page.Segment.BlobItems {
262+
if blob.Name != nil && strings.HasSuffix(*blob.Name, "/"+suffix) {
263+
return *blob.Name, nil
264+
}
265+
}
266+
}
267+
return "", nil
268+
}
269+
270+
// findBlobByMetadata lists all blobs and returns the first whose "runid" metadata matches.
271+
// This is the O(N) fallback for blobs that don't follow the naming convention.
272+
func (abs *AzureBlobStore) findBlobByMetadata(ctx context.Context, runID string) (string, error) {
273+
pager := abs.client.NewListBlobsFlatPager(abs.containerName, nil)
274+
275+
for pager.More() {
276+
page, err := pager.NextPage(ctx)
277+
if err != nil {
278+
return "", fmt.Errorf("listing blobs by metadata: %w", err)
279+
}
280+
281+
for _, blob := range page.Segment.BlobItems {
282+
if blob.Name == nil || blob.Metadata == nil {
283+
continue
284+
}
285+
if metaRunID, ok := blob.Metadata["runid"]; ok && metaRunID != nil && *metaRunID == runID {
286+
return *blob.Name, nil
287+
}
288+
}
289+
}
290+
return "", nil
291+
}
292+
239293
// Compare downloads two runs and produces a comparison report with deltas.
240294
func (abs *AzureBlobStore) Compare(ctx context.Context, runID1, runID2 string) (*ComparisonReport, error) {
241295
o1, err := abs.Download(ctx, runID1)

internal/storage/local.go

Lines changed: 0 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import (
44
"context"
55
"encoding/json"
66
"fmt"
7-
"math"
87
"os"
98
"path/filepath"
109
"sort"
@@ -239,33 +238,6 @@ func matchesFilter(o *models.EvaluationOutcome, opts ListOptions) bool {
239238
return true
240239
}
241240

242-
// buildMetricDeltas computes per-metric deltas between two outcomes.
243-
func buildMetricDeltas(o1, o2 *models.EvaluationOutcome) map[string]MetricDelta {
244-
deltas := make(map[string]MetricDelta)
245-
246-
// Collect all metric names from both runs.
247-
names := make(map[string]struct{})
248-
for k := range o1.Measures {
249-
names[k] = struct{}{}
250-
}
251-
for k := range o2.Measures {
252-
names[k] = struct{}{}
253-
}
254-
255-
for name := range names {
256-
v1 := o1.Measures[name].Value
257-
v2 := o2.Measures[name].Value
258-
deltas[name] = MetricDelta{
259-
Name: name,
260-
Value1: v1,
261-
Value2: v2,
262-
Delta: math.Round((v2-v1)*1000) / 1000,
263-
}
264-
}
265-
266-
return deltas
267-
}
268-
269241
// sanitizeFilename replaces path separators and other unsafe characters
270242
// so the run ID can be used as a filename.
271243
func sanitizeFilename(id string) string {

internal/storage/store.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package storage
66
import (
77
"context"
88
"errors"
9+
"math"
910
"time"
1011

1112
"github.com/spboyer/waza/internal/models"
@@ -75,3 +76,31 @@ func NewStore(cfg *projectconfig.StorageConfig, localDir string) (ResultStore, e
7576
}
7677
return NewLocalStore(localDir), nil
7778
}
79+
80+
// buildMetricDeltas computes per-metric deltas between two outcomes.
81+
// Used by both LocalStore and AzureBlobStore in their Compare methods.
82+
func buildMetricDeltas(o1, o2 *models.EvaluationOutcome) map[string]MetricDelta {
83+
deltas := make(map[string]MetricDelta)
84+
85+
// Collect all metric names from both runs.
86+
names := make(map[string]struct{})
87+
for k := range o1.Measures {
88+
names[k] = struct{}{}
89+
}
90+
for k := range o2.Measures {
91+
names[k] = struct{}{}
92+
}
93+
94+
for name := range names {
95+
v1 := o1.Measures[name].Value
96+
v2 := o2.Measures[name].Value
97+
deltas[name] = MetricDelta{
98+
Name: name,
99+
Value1: v1,
100+
Value2: v2,
101+
Delta: math.Round((v2-v1)*1000) / 1000,
102+
}
103+
}
104+
105+
return deltas
106+
}

site/src/content/docs/guides/azure-storage.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ waza run evals/my-skill/eval.yaml -v
148148
During the run, you'll see:
149149
```
150150
📤 Uploading results to Azure Blob Storage...
151-
✅ Results uploaded: waza-results/2025/02/26/20250226-143025-abc123.json
151+
✅ Results uploaded: waza-results/code-explainer/20250226-143025-abc123.json
152152
```
153153

154154
## Using Results from Storage
@@ -311,7 +311,7 @@ az storage blob list -c waza-results -a myteamwaza
311311
# Download a specific result
312312
az storage blob download \
313313
-c waza-results \
314-
-n 2025/02/26/20250226-143025-abc123.json \
314+
-n code-explainer/20250226-143025-abc123.json \
315315
-a myteamwaza \
316316
-f result.json
317317
```

0 commit comments

Comments
 (0)