diff --git a/controller/internal/stacks/unattended.go b/controller/internal/stacks/unattended.go index 18a8891..98046d6 100644 --- a/controller/internal/stacks/unattended.go +++ b/controller/internal/stacks/unattended.go @@ -186,7 +186,7 @@ func (m *Manager) LastUpdateLegSummary() *UpdateLegSummary { return nil } c := *m.leg.last - c.Steps = append([]LegStep(nil), m.leg.last.Steps...) + c.Steps = append([]LegStep{}, m.leg.last.Steps...) // R-687: `[]`, never `null`, in the hub report return &c } @@ -437,6 +437,8 @@ func (m *Manager) legCandidate(ctx context.Context, name string, o *UpdateLegOpt m.logger.Printf("[INFO] [update-leg] %s: the step may change the app's files and no fresh whole copy exists (%s)", name, why) return e, LegSkipFilesNoCopy } + // R-687: the TAKEN case names the copy that allowed it, as the skip names why not. + m.logger.Printf("[INFO] [update-leg] %s: the step may change the app's files — taken, a fresh whole copy exists: %s", name, why) } return e, "" } diff --git a/controller/internal/stacks/unattended_test.go b/controller/internal/stacks/unattended_test.go index 65efd2c..ec1a8cc 100644 --- a/controller/internal/stacks/unattended_test.go +++ b/controller/internal/stacks/unattended_test.go @@ -3,6 +3,7 @@ package stacks import ( "bytes" "context" + "encoding/json" "path/filepath" "strconv" "strings" @@ -227,13 +228,13 @@ func TestLeg_NeedsPersonIsNeverTaken(t *testing.T) { // TestLeg_FilesMayChangeNeedsAWholeCopy — decision 13's other mark: taken only when a fresh WHOLE copy // exists (the backup side's truth table, asked through FreshWholeCopy). func TestLeg_FilesMayChangeNeedsAWholeCopy(t *testing.T) { - m, dir, _, ups, _ := legManager(t) + m, dir, _, ups, logBuf := legManager(t) writeLadder(t, m, ladderLineMarks(ladderA, ladderB, "proven", `{"files_may_change": true, "needs_person": null, "memory_tight": false}`), ladderLine(ladderB, ladderC)) whole := false legOpts(m, func(o *UpdateLegOptions) { - o.FreshWholeCopy = func(context.Context, string) (bool, string) { return whole, "fake" } + o.FreshWholeCopy = func(context.Context, string) (bool, string) { return whole, "tier 2 copy from FAKE-TIME" } }) s := mustLeg(t, m) if len(*ups) != 0 || legStepFor(s, "nextcloud").Reason != LegSkipFilesNoCopy { @@ -244,6 +245,10 @@ func TestLeg_FilesMayChangeNeedsAWholeCopy(t *testing.T) { if len(*ups) != 1 || pinOf(t, dir) != ladderB || s.Done != 1 { t.Fatalf("with a fresh whole copy the step must be taken: ups=%v pin=%s", *ups, pinOf(t, dir)) } + // R-687 (v0.273.0): the taken step names the copy that allowed it. COMPANION RED-PROOF: drop the log line. + if !strings.Contains(logBuf.String(), "taken, a fresh whole copy exists: tier 2 copy from FAKE-TIME") { + t.Fatal("a taken files_may_change step must log which whole copy allowed it") + } // unwired check = no whole copy (fail closed) m2, _, _, ups2, _ := legManager(t) writeLadder(t, m2, @@ -452,3 +457,19 @@ func TestLegDeadline(t *testing.T) { } } } + +// TestR687_EmptyLegReportsEmptySteps — a leg that pressed nothing reports `"steps": []` to the hub, not +// `null` (read live 2026-09-25 from demo-hp's report). COMPANION RED-PROOF: copy with append(nil, …). +func TestR687_EmptyLegReportsEmptySteps(t *testing.T) { + m, _, _, _, _ := legManager(t) + m.leg.mu.Lock() + m.leg.last = &UpdateLegSummary{Trigger: "after-offsite", Steps: []LegStep{}} + m.leg.mu.Unlock() + b, err := json.Marshal(m.LastUpdateLegSummary()) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(b), `"steps":[]`) { + t.Fatalf("an empty leg must report steps as [], got %s", b) + } +}