Skip to content

Commit faa2846

Browse files
authored
Merge pull request #470 from spacedock-dev/spacedock-ensign/boot-metrics-record-and-pr-delta
Boot-window metrics — record turns/cache-creation instead of gating on magic thresholds, surface per-PR delta
2 parents 16e5ade + d89c8f7 commit faa2846

20 files changed

Lines changed: 1892 additions & 199 deletions

.github/workflows/release.yml

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -35,32 +35,58 @@ jobs:
3535
with:
3636
go-version: "1.22"
3737

38-
# Find the latest successful Runtime Live E2E run on next and pull its
39-
# journey-metrics artifacts. When no such run exists this is a NON-FATAL
40-
# skip (`exit 0`, found=false) — runtime-live-e2e.yml has no push:next
41-
# trigger, so a fresh cut routinely has no producer run, and blocking the
42-
# ledger here must not fail anything. The emitted `found` output gates the
43-
# downstream Build/Publish steps so they SKIP (job green) rather than run
44-
# `journey-costs` over an empty dir and RED the job.
38+
# Pull journey-metrics from EVERY successful Runtime Live E2E run (PR or
39+
# main) since the immediately-prior release tag of any kind (stable or
40+
# -pre) — an incremental window, so a -pre release does not re-fold runs an
41+
# earlier -pre in the same cycle already covered. A run's inclusion does
42+
# not depend on its triggering PR's eventual merge state: the observation
43+
# is about boot behavior on that commit at that time. When no run exists in
44+
# the window this is a NON-FATAL skip (`exit 0`, found=false) — a fresh cut
45+
# routinely has no producer run, and blocking the ledger here must not fail
46+
# anything. The emitted `found` output gates the downstream Build/Publish
47+
# steps so they SKIP (job green) rather than run `journey-costs` over an
48+
# empty dir and RED the job.
4549
- name: Download latest journey metrics artifacts
4650
id: download_metrics
4751
env:
4852
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
4953
run: |
5054
set -euo pipefail
5155
mkdir -p "$RUNNER_TEMP/runtime-live-artifacts" "$RUNNER_TEMP/journey-metrics"
52-
# `|| true` degrades a gh error (or a missing gh) to an empty run_id so
56+
# `|| true` degrades a gh error (or a missing gh) to the epoch fallback
57+
# so a query failure never aborts the best-effort ledger under set -e.
58+
# jq reads the tag via $ENV (not bash interpolation) so this survives
59+
# set -u even when GITHUB_REF_NAME is unset.
60+
since="$(gh release list --limit 100 --json tagName,createdAt --jq \
61+
'[sort_by(.createdAt)[]] as $r | ($r | map(.tagName) | index($ENV.GITHUB_REF_NAME)) as $i | if $i == null or $i == 0 then "" else $r[$i-1].createdAt end' \
62+
|| true)"
63+
if [ -z "$since" ]; then
64+
since="1970-01-01T00:00:00Z"
65+
fi
66+
echo "journey ledger aggregation window: runs created after $since" >&2
67+
# `|| true` degrades a gh error (or a missing gh) to an empty run list so
5368
# the no-run skip branch below fires (found=false, exit 0) instead of
5469
# aborting under set -e. The journey ledger is best-effort; a query
55-
# failure must never RED the cut.
56-
run_id="$(gh run list --workflow "Runtime Live E2E" --branch main --status success --limit 1 --json databaseId --jq '.[0].databaseId' || true)"
57-
if [ -z "$run_id" ] || [ "$run_id" = "null" ]; then
58-
echo "::warning::no successful Runtime Live E2E run found on main; skipping journey ledger" >&2
70+
# failure must never RED the cut. No --branch filter: PR-triggered runs
71+
# count in the window exactly like main runs.
72+
run_ids="$(gh run list --workflow "Runtime Live E2E" --status success --created ">$since" --limit 100 --json databaseId --jq '.[].databaseId' || true)"
73+
if [ -z "$run_ids" ]; then
74+
echo "::warning::no successful Runtime Live E2E run found since $since; skipping journey ledger" >&2
5975
echo "found=false" >> "$GITHUB_OUTPUT"
6076
exit 0
6177
fi
62-
gh run download "$run_id" --dir "$RUNNER_TEMP/runtime-live-artifacts"
63-
find "$RUNNER_TEMP/runtime-live-artifacts" -path '*/journey-metrics/*.json' -type f -exec cp {} "$RUNNER_TEMP/journey-metrics/" \;
78+
# Each run's metrics land in their OWN subdirectory, never flat-copied
79+
# into one: journeymetrics.recordFilename has no run-distinguishing
80+
# component, so flattening N runs' same-named scenario/model files into
81+
# one directory silently collapses them back to one observation via
82+
# overwrite. journeymetrics.ReadRecordsDir already walks metrics-dir
83+
# recursively, so no Go-side change is needed to consume this layout.
84+
for run_id in $run_ids; do
85+
run_dir="$RUNNER_TEMP/journey-metrics/$run_id"
86+
mkdir -p "$run_dir"
87+
gh run download "$run_id" --dir "$RUNNER_TEMP/runtime-live-artifacts/$run_id"
88+
find "$RUNNER_TEMP/runtime-live-artifacts/$run_id" -path '*/journey-metrics/*.json' -type f -exec cp {} "$run_dir/" \;
89+
done
6490
if [ -z "$(find "$RUNNER_TEMP/journey-metrics" -name '*.json' -type f -print -quit)" ]; then
6591
echo "::warning::downloaded Runtime Live E2E artifacts contained no journey metrics JSON; skipping journey ledger" >&2
6692
echo "found=false" >> "$GITHUB_OUTPUT"

.github/workflows/runtime-live-e2e.yml

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -646,3 +646,84 @@ jobs:
646646
live-artifacts/pi/**
647647
live-artifacts/journey-metrics/**
648648
if-no-files-found: warn
649+
650+
# AC-3: posts a per-PR delta of this run's journey-cost observations against
651+
# the latest published release ledger, as a single sticky-updating PR comment
652+
# (`gh pr comment --edit-last`, so repeated pushes update ONE comment instead
653+
# of appending a new one). PR-only — a workflow_dispatch run has no PR to
654+
# comment on. needs: claude-live with no explicit `if:` on that edge means
655+
# this job runs only once claude-live's matrix completed successfully;
656+
# shallow-boot-window (like every scenario this comments on) is Claude-only.
657+
# A job-level permissions block grants pull-requests: write beyond the
658+
# workflow-level contents: read default; specifying any job-level permissions
659+
# replaces (not merges with) the workflow default, so contents/actions read
660+
# are restated here for checkout and cross-job artifact download.
661+
journey-delta-comment:
662+
needs: claude-live
663+
if: github.event_name == 'pull_request'
664+
runs-on: ubuntu-latest
665+
permissions:
666+
contents: read
667+
actions: read
668+
pull-requests: write
669+
steps:
670+
- uses: actions/checkout@v5
671+
672+
- uses: actions/setup-go@v6
673+
with:
674+
go-version: "1.22"
675+
676+
- name: Download this run's Claude journey metrics
677+
uses: actions/download-artifact@v5
678+
with:
679+
pattern: runtime-live-e2e-claude-live-*
680+
path: ${{ runner.temp }}/current-run-artifacts
681+
merge-multiple: true
682+
683+
# Best-effort: no previously published release (or one with no
684+
# journey-costs asset) means no baseline to diff against. `|| true` /
685+
# explicit exit-0 branches degrade either gap to a non-fatal skip so a
686+
# missing baseline never REDs the PR.
687+
- name: Download the latest published release's journey-cost ledger
688+
id: download_previous_ledger
689+
env:
690+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
691+
run: |
692+
set -euo pipefail
693+
previous_tag="$(gh release list --limit 1 --json tagName --jq '.[0].tagName' || true)"
694+
if [ -z "$previous_tag" ] || [ "$previous_tag" = "null" ]; then
695+
echo "::warning::no published release found; skipping journey-cost delta comment" >&2
696+
echo "found=false" >> "$GITHUB_OUTPUT"
697+
exit 0
698+
fi
699+
if ! gh release download "$previous_tag" -p 'journey-costs-*.json' -O "$RUNNER_TEMP/previous-journey-costs.json"; then
700+
echo "::warning::published release $previous_tag has no journey-costs asset; skipping journey-cost delta comment" >&2
701+
echo "found=false" >> "$GITHUB_OUTPUT"
702+
exit 0
703+
fi
704+
echo "found=true" >> "$GITHUB_OUTPUT"
705+
706+
# download-artifact's merge-multiple flat-copies every matched artifact's
707+
# files into current-run-artifacts, but PRESERVES each artifact's own
708+
# internal path prefix (verified against a real run's downloaded zip:
709+
# the journey-metrics JSON lands several directories deep, not directly
710+
# under "live-artifacts/journey-metrics/"). A hardcoded exact subpath is
711+
# brittle to that prefix; find + cp (release.yml's own pattern for the
712+
# same class of problem) is layout-agnostic — it locates the metrics
713+
# files wherever they actually landed.
714+
- name: Locate this run's journey metrics
715+
if: steps.download_previous_ledger.outputs.found == 'true'
716+
run: |
717+
set -euo pipefail
718+
mkdir -p "$RUNNER_TEMP/current-run-metrics"
719+
find "$RUNNER_TEMP/current-run-artifacts" -path '*/journey-metrics/*.json' -type f -exec cp {} "$RUNNER_TEMP/current-run-metrics/" \;
720+
721+
- name: Post the journey-cost delta PR comment
722+
if: steps.download_previous_ledger.outputs.found == 'true'
723+
env:
724+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
725+
run: |
726+
set -euo pipefail
727+
go run ./cmd/spacedock-release journey-delta "$RUNNER_TEMP/previous-journey-costs.json" \
728+
--metrics-dir "$RUNNER_TEMP/current-run-metrics" \
729+
--pr "${{ github.event.pull_request.number }}"

cmd/spacedock-release/journey_costs_test.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,12 +101,98 @@ func TestJourneyCostsCommandRejectsMismatchedOutputFilename(t *testing.T) {
101101
}
102102
}
103103

104+
// TestJourneyCostsCommandAggregatesPerRunSubdirectories proves the AC-2 fix: once
105+
// release.yml downloads each discovered run into its OWN subdirectory instead of
106+
// flat-copying, journeymetrics.ReadRecordsDir's existing recursive walk aggregates
107+
// both runs' observations for the SAME scenario/model without collision, and each
108+
// observation carries the distinct, non-empty run_id/run_url of the source run that
109+
// produced it — proving "traceable to more than one run" against actual
110+
// provenance data, not just a count.
111+
func TestJourneyCostsCommandAggregatesPerRunSubdirectories(t *testing.T) {
112+
metricsDir := t.TempDir()
113+
runADir := filepath.Join(metricsDir, "27931963802")
114+
runBDir := filepath.Join(metricsDir, "28432388663")
115+
116+
base := journeymetrics.Record{
117+
SchemaVersion: journeymetrics.RecordSchemaVersion,
118+
ScenarioID: "shallow-boot-window",
119+
Source: "live-harness",
120+
Mode: journeymetrics.ModeLLMLive,
121+
Runtime: "claude",
122+
Executor: "llm",
123+
Host: "claude",
124+
Model: "claude-sonnet-4-6",
125+
MetricsState: journeymetrics.StateMeasured,
126+
Outcome: journeymetrics.Outcome{Status: "passed"},
127+
}
128+
runA := base
129+
runA.Turns, runA.Tokens = 18, journeymetrics.TokenTotals{CacheCreation: 1562}
130+
runA.RunID = "27931963802"
131+
runA.RunURL = "https://github.com/spacedock-dev/spacedock/actions/runs/27931963802"
132+
runA.CapturedAt = "2026-06-20T00:00:00Z"
133+
runB := base
134+
runB.Turns, runB.Tokens = 20, journeymetrics.TokenTotals{CacheCreation: 1576}
135+
runB.RunID = "28432388663"
136+
runB.RunURL = "https://github.com/spacedock-dev/spacedock/actions/runs/28432388663"
137+
runB.CapturedAt = "2026-06-27T00:00:00Z"
138+
139+
writeMetricRecord(t, runADir, runA)
140+
writeMetricRecord(t, runBDir, runB)
141+
142+
out := filepath.Join(t.TempDir(), "journey-costs-v1.2.3.json")
143+
if code := journeyCosts([]string{"1.2.3", "--metrics-dir", metricsDir, "--out", out}); code != 0 {
144+
t.Fatalf("journeyCosts exit = %d, want 0", code)
145+
}
146+
ledger := readLedger(t, out)
147+
entry := findScenario(t, ledger, "shallow-boot-window")
148+
if len(entry.Observations) != 2 {
149+
t.Fatalf("per-run-subdirectory shape must yield 2 observations for shallow-boot-window, got %d: %+v", len(entry.Observations), entry.Observations)
150+
}
151+
seenRunIDs := map[string]bool{}
152+
for _, obs := range entry.Observations {
153+
if obs.RunID == "" || obs.RunURL == "" {
154+
t.Fatalf("observation missing run provenance: %+v", obs)
155+
}
156+
seenRunIDs[obs.RunID] = true
157+
}
158+
if !seenRunIDs["27931963802"] || !seenRunIDs["28432388663"] {
159+
t.Fatalf("observations did not carry the two distinct source run ids, got %+v", seenRunIDs)
160+
}
161+
}
162+
163+
func readLedger(t *testing.T, path string) journeymetrics.Ledger {
164+
t.Helper()
165+
data, err := os.ReadFile(path)
166+
if err != nil {
167+
t.Fatal(err)
168+
}
169+
var ledger journeymetrics.Ledger
170+
if err := json.Unmarshal(data, &ledger); err != nil {
171+
t.Fatalf("parse ledger %s: %v\n%s", path, err, data)
172+
}
173+
return ledger
174+
}
175+
176+
func findScenario(t *testing.T, ledger journeymetrics.Ledger, scenarioID string) journeymetrics.ScenarioLedgerEntry {
177+
t.Helper()
178+
for _, entry := range ledger.Scenarios {
179+
if entry.ScenarioID == scenarioID {
180+
return entry
181+
}
182+
}
183+
t.Fatalf("ledger has no scenario %q; scenarios: %+v", scenarioID, ledger.Scenarios)
184+
return journeymetrics.ScenarioLedgerEntry{}
185+
}
186+
104187
func writeMetricRecord(t *testing.T, dir string, record journeymetrics.Record) {
105188
t.Helper()
106189
data, err := json.Marshal(record)
107190
if err != nil {
108191
t.Fatal(err)
109192
}
193+
if err := os.MkdirAll(dir, 0o755); err != nil {
194+
t.Fatal(err)
195+
}
110196
name := strings.Join([]string{record.ScenarioID, record.Runtime, record.Model}, "--") + ".json"
111197
if err := os.WriteFile(filepath.Join(dir, name), data, 0o644); err != nil {
112198
t.Fatal(err)

0 commit comments

Comments
 (0)