settings-4 task 2: release.Target, settings spec §4.3
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
5 files changed,
+385
−10
internal/release/buildstate_test.go+2 −3internal/release/updatetarget.go+187 −0internal/release/updatetarget_test.go+166 −0internal/updatewords/words_approved_test.go+13 −7internal/updatewords/words_box.go+17 −0
diff --git a/internal/release/buildstate_test.go b/internal/release/buildstate_test.go| index 06acc9315..366d4d687 100644 |
| --- a/internal/release/buildstate_test.go |
| +++ b/internal/release/buildstate_test.go |
| @@ -19,14 +19,13 @@ func TestConsumedRoundTrip(t *testing.T) { |
| if c, err := ReadConsumed(opt, "a.example.com"); c != nil || err != nil { |
| t.Fatalf("absent = %+v, %v", c, err) |
| } |
| - // Reason is the plain string "request"; Task 2 gives it a named constant. |
| want := Consumed{Schema: 1, Host: "a.example.com", Resolved: &ResolvedRequest{ID: 3, Target: "v2"}, Completed: 2, |
| - Desired: "v2", Reason: "request", At: time.Now().UTC().Truncate(time.Second)} |
| + Desired: "v2", Reason: string(ReasonRequest), At: time.Now().UTC().Truncate(time.Second)} |
| if err := WriteConsumed(opt, want); err != nil { |
| t.Fatal(err) |
| } |
| got, err := ReadConsumed(opt, "a.example.com") |
| - if err != nil || got.Resolved.Target != "v2" || got.Completed != 2 || got.Reason != "request" { |
| + if err != nil || got.Resolved.Target != "v2" || got.Completed != 2 || got.Reason != string(ReasonRequest) { |
| t.Fatalf("round trip = %+v, %v", got, err) |
| } |
| if ConsumedPath(opt, "a.example.com") != filepath.Join(opt, "consumed", "a.example.com") { |
diff --git a/internal/release/updatetarget.go b/internal/release/updatetarget.go| new file mode 100644 |
| index 000000000..1f54ae604 |
| --- /dev/null |
| +++ b/internal/release/updatetarget.go |
| @@ -0,0 +1,187 @@ |
| +package release |
| + |
| +import "github.com/carlosframework/platform/internal/registry" |
| + |
| +// TargetReason is why Target chose what it did; plan 5 renders each. |
| +type TargetReason string |
| + |
| +const ( |
| + ReasonAdopt TargetReason = "adopt" |
| + ReasonPin TargetReason = "pin" |
| + ReasonRequest TargetReason = "request" |
| + ReasonManual TargetReason = "manual" |
| + ReasonMajor TargetReason = "major" |
| + ReasonUnknown TargetReason = "unknown" |
| + ReasonNew TargetReason = "new" |
| + ReasonBaking TargetReason = "baking" |
| + ReasonFailed TargetReason = "failed" |
| + ReasonUnauthorized TargetReason = "unauthorized" |
| +) |
| + |
| +// Evidence is what authorizes an install (settings spec §4.1). |
| +type Evidence int |
| + |
| +const ( |
| + EvidencePointer Evidence = iota // the signed head (and its plan-1 digest): today's adoption only |
| + EvidenceRecord // a signed promotion record with a matching manifest digest |
| + EvidenceStaged // the box's own staged, verified copy (adoptHost's fallback from Record) |
| +) |
| + |
| +// TargetInput is everything §4.3 decides on, read by the caller so Target |
| +// stays pure. |
| +type TargetInput struct { |
| + Route registry.Route |
| + Pointer *ChannelPointer // the verified head; nil when unresolved |
| + HeadTag string // the head's semver tag, "" when it has none |
| + Settings UpdateSettings |
| + Build BuildStateCopy |
| + Consumed *Consumed |
| + Installed string |
| + HeadHeld string // Hold(...) for the head on this box: "" = eligible |
| + Failed func(version string) bool // the zero-downtime failure records for this host |
| +} |
| + |
| +// TargetDecision is root's one answer per route per pass. |
| +type TargetDecision struct { |
| + Version string // "" installs nothing |
| + Reason TargetReason |
| + Evidence Evidence |
| + Bake bool // the caller applies today's Hold to Version |
| + Resolve *ResolvedRequest // record before staging: this pass resolves R to T |
| + Fail uint64 // record request Fail as completed, failed |
| + Detail string // the build or number a reason names |
| +} |
| + |
| +// Target is settings spec §4.3, rules in order, with the zero-downtime |
| +// failure records outranking them all. The console never calls it; root is |
| +// the only caller that installs (§4). |
| +func Target(in TargetInput) TargetDecision { |
| + u := in.Settings |
| + head := "" |
| + if in.Pointer != nil { |
| + head = in.Pointer.Version |
| + } |
| + failed := func(v string) bool { return v != "" && in.Failed != nil && in.Failed(v) } |
| + state := in.Build.State |
| + wm := Watermark(in.Consumed, state) |
| + |
| + // Request bookkeeping comes first, before any selection rule, pins |
| + // included (§4.3 rule 2: "resolved at the start of every pass"; Astra I3 |
| + // #6). A pending R is resolved onto the head eligible now; a resolved R |
| + // whose T failed completes failed. A pin then decides what runs, and the |
| + // resolution waits, so clearing the pin later installs this T, not a |
| + // head that moved on. A completed request never enters here again. |
| + var ( |
| + resolve *ResolvedRequest |
| + failedReq uint64 |
| + reqTarget string // a resolved T, not failed, above the watermark |
| + ) |
| + if u.InstallRequest > wm { |
| + if res := resolvedFor(in.Consumed, state, u.InstallRequest); res != nil { |
| + if failed(res.Target) { |
| + failedReq = res.ID // completes failed, once; the rules below decide what runs |
| + } else { |
| + reqTarget = res.Target |
| + } |
| + } else if head != "" && (in.HeadHeld == "" || u.InstallFastTrack) && !failed(head) { |
| + resolve = &ResolvedRequest{ID: u.InstallRequest, Target: head} |
| + reqTarget = head |
| + } |
| + // Unresolved (the head is baking here): it blocks nothing. |
| + } |
| + // A completed request whose target failed after it first ran (a later |
| + // health, settle or hold failure) is recorded completed-failed, once, |
| + // without reopening it (Astra I1 #10, §4.3 rule 2); also before the pin. |
| + if failedReq == 0 && u.InstallRequest != 0 && u.InstallRequest <= wm && !completedFailed(in.Consumed, state, wm) { |
| + if res := resolvedFor(in.Consumed, state, wm); res != nil && failed(res.Target) { |
| + failedReq = res.ID |
| + } |
| + } |
| + carry := func(d TargetDecision) TargetDecision { |
| + d.Resolve, d.Fail = resolve, failedReq |
| + return d |
| + } |
| + |
| + // Rule 1: a pin. A person chose it, so bake does not apply. |
| + if u.Pin != "" { |
| + if failed(u.Pin) { |
| + return carry(TargetDecision{Reason: ReasonFailed, Detail: u.Pin}) |
| + } |
| + return carry(TargetDecision{Version: u.Pin, Reason: ReasonPin, Evidence: EvidenceRecord, Detail: u.Pin}) |
| + } |
| + |
| + // Rule 2: a resolved request above the watermark (this pass's resolution |
| + // included). |
| + if reqTarget != "" { |
| + ev := EvidenceRecord |
| + if reqTarget == head { |
| + ev = EvidencePointer |
| + } |
| + return carry(TargetDecision{Version: reqTarget, Reason: ReasonRequest, Evidence: ev, Detail: reqTarget}) |
| + } |
| + |
| + // Rule 3: held. |
| + majorHeld := u.Major != nil && !tagInMajor(in.HeadTag, *u.Major) |
| + if u.Updates == UpdatesManual || majorHeld { |
| + reason := ReasonManual |
| + if u.Updates != UpdatesManual { |
| + reason = ReasonMajor |
| + } |
| + var d TargetDecision |
| + switch { |
| + case !in.Build.Known: |
| + d = TargetDecision{Reason: ReasonUnknown} |
| + case state != nil && state.LastExecuted != "": |
| + d = TargetDecision{Version: state.LastExecuted, Reason: reason, Evidence: EvidenceRecord, Detail: state.LastExecuted} |
| + case in.Installed != "": |
| + d = TargetDecision{Version: in.Installed, Reason: reason, Evidence: EvidenceRecord, Detail: in.Installed} |
| + case in.Build.Replica: |
| + d = TargetDecision{Reason: ReasonUnknown} |
| + case head != "" && !failed(head): |
| + d = TargetDecision{Version: head, Reason: ReasonNew, Evidence: EvidencePointer, Bake: true, Detail: head} |
| + case head == "": |
| + d = TargetDecision{Reason: ReasonNew} // new, and no readable head to give it: nothing yet |
| + default: |
| + d = TargetDecision{Reason: ReasonFailed, Detail: head} |
| + } |
| + if failed(d.Version) { |
| + d = TargetDecision{Reason: ReasonFailed, Detail: d.Version} |
| + } |
| + d.Fail = failedReq |
| + return d |
| + } |
| + |
| + // Rule 4: today's adoption. |
| + if head == "" { |
| + return TargetDecision{Fail: failedReq} |
| + } |
| + if failed(head) { |
| + return TargetDecision{Reason: ReasonFailed, Detail: head, Fail: failedReq} |
| + } |
| + return TargetDecision{Version: head, Reason: ReasonAdopt, Evidence: EvidencePointer, Bake: true, Detail: head, Fail: failedReq} |
| +} |
| + |
| +// completedFailed reports whether either source already records request id |
| +// as completed, failed. |
| +func completedFailed(c *Consumed, b *BuildState, id uint64) bool { |
| + return (c != nil && c.Completed == id && c.CompletedFailed) || (b != nil && b.Completed == id && b.CompletedFailed) |
| +} |
| + |
| +// resolvedFor is R's T from either source (§4.3 rule 2: "locally or in the |
| +// build state"), or nil. |
| +func resolvedFor(c *Consumed, b *BuildState, id uint64) *ResolvedRequest { |
| + if c != nil && c.Resolved != nil && c.Resolved.ID == id { |
| + return c.Resolved |
| + } |
| + if b != nil && b.Resolved != nil && b.Resolved.ID == id { |
| + return b.Resolved |
| + } |
| + return nil |
| +} |
| + |
| +// tagInMajor: a head whose semver tag is absent or has another major is |
| +// held (§3.1). |
| +func tagInMajor(tag string, major int) bool { |
| + v, ok := ParseSemVer(tag) |
| + return ok && v.Major == major |
| +} |
diff --git a/internal/release/updatetarget_test.go b/internal/release/updatetarget_test.go| new file mode 100644 |
| index 000000000..5ce21bf17 |
| --- /dev/null |
| +++ b/internal/release/updatetarget_test.go |
| @@ -0,0 +1,166 @@ |
| +package release |
| + |
| +import ( |
| + "testing" |
| +) |
| + |
| +func ip(n int) *int { return &n } |
| + |
| +type tcase struct { |
| + name string |
| + in TargetInput |
| + want TargetDecision |
| +} |
| + |
| +func head(v string) *ChannelPointer { return &ChannelPointer{Version: v} } |
| + |
| +func known(replica bool, b *BuildState) BuildStateCopy { |
| + return BuildStateCopy{Known: true, Replica: replica, State: b} |
| +} |
| + |
| +func runTargetCases(t *testing.T, cases []tcase) { |
| + t.Helper() |
| + for _, c := range cases { |
| + got := Target(c.in) |
| + w := c.want |
| + if got.Version != w.Version || got.Reason != w.Reason || got.Evidence != w.Evidence || got.Bake != w.Bake || |
| + got.Fail != w.Fail || (got.Resolve == nil) != (w.Resolve == nil) || |
| + (got.Resolve != nil && *got.Resolve != *w.Resolve) { |
| + t.Errorf("%s:\n got %+v\nwant %+v", c.name, got, w) |
| + } |
| + } |
| +} |
| + |
| +func auto() UpdateSettings { return UpdateSettings{Updates: UpdatesAuto, Channel: "stable"} } |
| +func manual() UpdateSettings { return UpdateSettings{Updates: UpdatesManual, Channel: "stable"} } |
| + |
| +func TestTargetRules(t *testing.T) { |
| + failedV9 := func(v string) bool { return v == "v9" } |
| + runTargetCases(t, []tcase{ |
| + {"auto: today's adoption", TargetInput{Pointer: head("v3"), Settings: auto(), Installed: "v2"}, |
| + TargetDecision{Version: "v3", Reason: ReasonAdopt, Evidence: EvidencePointer, Bake: true}}, |
| + {"pin: the pinned build, a record, no bake", TargetInput{Pointer: head("v3"), Settings: UpdateSettings{Updates: UpdatesAuto, Pin: "v1"}, Installed: "v3"}, |
| + TargetDecision{Version: "v1", Reason: ReasonPin, Evidence: EvidenceRecord}}, |
| + // Astra I3 #6: the request is still resolved at the start of the pass, |
| + // pin or no pin, onto the head eligible now; the pin decides what runs. |
| + {"pin outranks manual and a request, which still resolves", TargetInput{Pointer: head("v3"), Settings: UpdateSettings{Updates: UpdatesManual, Pin: "v1", InstallRequest: 5}}, |
| + TargetDecision{Version: "v1", Reason: ReasonPin, Evidence: EvidenceRecord, Resolve: &ResolvedRequest{ID: 5, Target: "v3"}}}, |
| + {"pin, request already resolved to T: the pin runs, T is kept for later", TargetInput{Pointer: head("v4"), Settings: UpdateSettings{Updates: UpdatesAuto, Pin: "v1", InstallRequest: 2}, |
| + Consumed: &Consumed{Resolved: &ResolvedRequest{ID: 2, Target: "v3"}, Completed: 1}}, |
| + TargetDecision{Version: "v1", Reason: ReasonPin, Evidence: EvidenceRecord}}, |
| + {"pin, the request's T failed: completes failed under the pin", TargetInput{Pointer: head("v3"), Settings: UpdateSettings{Updates: UpdatesAuto, Pin: "v1", InstallRequest: 2}, |
| + Consumed: &Consumed{Resolved: &ResolvedRequest{ID: 2, Target: "v9"}, Completed: 1}, Failed: failedV9}, |
| + TargetDecision{Version: "v1", Reason: ReasonPin, Evidence: EvidenceRecord, Fail: 2}}, |
| + {"pin, request waiting on bake: unresolved", TargetInput{Pointer: head("v3"), HeadHeld: "held 20h", Settings: UpdateSettings{Updates: UpdatesAuto, Pin: "v1", InstallRequest: 2}}, |
| + TargetDecision{Version: "v1", Reason: ReasonPin, Evidence: EvidenceRecord}}, |
| + {"pending request, head eligible: resolves to the head", TargetInput{Pointer: head("v3"), Settings: UpdateSettings{Updates: UpdatesManual, InstallRequest: 2}, |
| + Build: known(true, &BuildState{LastExecuted: "v1", Completed: 1}), Installed: "v1"}, |
| + TargetDecision{Version: "v3", Reason: ReasonRequest, Evidence: EvidencePointer, Resolve: &ResolvedRequest{ID: 2, Target: "v3"}}}, |
| + {"pending request, head baking: unresolved, held instance recovers", TargetInput{Pointer: head("v3"), HeadHeld: "held 20h", Settings: UpdateSettings{Updates: UpdatesManual, InstallRequest: 2}, |
| + Build: known(true, &BuildState{LastExecuted: "v1", Completed: 1}), Installed: "v0"}, |
| + TargetDecision{Version: "v1", Reason: ReasonManual, Evidence: EvidenceRecord}}, |
| + {"pending request, head baking, FastTrack: resolves anyway", TargetInput{Pointer: head("v3"), HeadHeld: "held 20h", Settings: UpdateSettings{Updates: UpdatesManual, InstallRequest: 2, InstallFastTrack: true}, |
| + Build: known(true, &BuildState{Completed: 1}), Installed: "v1"}, |
| + TargetDecision{Version: "v3", Reason: ReasonRequest, Evidence: EvidencePointer, Resolve: &ResolvedRequest{ID: 2, Target: "v3"}}}, |
| + {"resolved request, T no longer the head: a record", TargetInput{Pointer: head("v4"), Settings: UpdateSettings{Updates: UpdatesManual, InstallRequest: 2}, |
| + Consumed: &Consumed{Resolved: &ResolvedRequest{ID: 2, Target: "v3"}, Completed: 1}, Installed: "v1"}, |
| + TargetDecision{Version: "v3", Reason: ReasonRequest, Evidence: EvidenceRecord}}, |
| + {"resolved request ignores manual and major", TargetInput{Pointer: head("v3"), HeadTag: "2.0.0", Settings: UpdateSettings{Updates: UpdatesManual, Major: ip(1), InstallRequest: 2}, |
| + Consumed: &Consumed{Resolved: &ResolvedRequest{ID: 2, Target: "v3"}}}, |
| + TargetDecision{Version: "v3", Reason: ReasonRequest, Evidence: EvidencePointer}}, |
| + {"resolved request whose T failed: completes failed, installs nothing", TargetInput{Pointer: head("v9"), Settings: UpdateSettings{Updates: UpdatesManual, InstallRequest: 2}, |
| + Consumed: &Consumed{Resolved: &ResolvedRequest{ID: 2, Target: "v9"}}, Failed: failedV9, Build: known(true, &BuildState{LastExecuted: "v1"}), Installed: "v1"}, |
| + TargetDecision{Version: "v1", Reason: ReasonManual, Evidence: EvidenceRecord, Fail: 2}}, |
| + {"manual, build state differs from installed: install last_executed", TargetInput{Pointer: head("v3"), Settings: manual(), |
| + Build: known(true, &BuildState{LastExecuted: "v1"}), Installed: "v2"}, |
| + TargetDecision{Version: "v1", Reason: ReasonManual, Evidence: EvidenceRecord}}, |
| + {"manual, no build state, something installed: keep it", TargetInput{Pointer: head("v3"), Settings: manual(), Build: known(true, nil), Installed: "v2"}, |
| + TargetDecision{Version: "v2", Reason: ReasonManual, Evidence: EvidenceRecord}}, |
| + {"manual, no build state, nothing installed, a replica: unknown", TargetInput{Pointer: head("v3"), Settings: manual(), Build: known(true, nil)}, |
| + TargetDecision{Reason: ReasonUnknown}}, |
| + {"manual, none of these: new, gets the eligible head", TargetInput{Pointer: head("v3"), Settings: manual(), Build: known(false, nil)}, |
| + TargetDecision{Version: "v3", Reason: ReasonNew, Evidence: EvidencePointer, Bake: true}}, |
| + {"major matching head tag: today's adoption", TargetInput{Pointer: head("v3"), HeadTag: "2.1.0", Settings: UpdateSettings{Updates: UpdatesAuto, Channel: "stable", Major: ip(2)}, Installed: "v2"}, |
| + TargetDecision{Version: "v3", Reason: ReasonAdopt, Evidence: EvidencePointer, Bake: true}}, |
| + {"major, head of another major: held", TargetInput{Pointer: head("v3"), HeadTag: "3.0.0", Settings: UpdateSettings{Updates: UpdatesAuto, Channel: "stable", Major: ip(2)}, |
| + Build: known(true, &BuildState{LastExecuted: "v2"}), Installed: "v2"}, |
| + TargetDecision{Version: "v2", Reason: ReasonMajor, Evidence: EvidenceRecord}}, |
| + {"major, untagged head: held", TargetInput{Pointer: head("v3"), Settings: UpdateSettings{Updates: UpdatesAuto, Channel: "stable", Major: ip(2)}, |
| + Build: known(true, &BuildState{LastExecuted: "v2"}), Installed: "v2"}, |
| + TargetDecision{Version: "v2", Reason: ReasonMajor, Evidence: EvidenceRecord}}, |
| + {"auto onto a failed head: nothing new", TargetInput{Pointer: head("v9"), Settings: auto(), Installed: "v2", Failed: failedV9}, |
| + TargetDecision{Reason: ReasonFailed}}, |
| + }) |
| +} |
| + |
| +// Review Focus 3. |
| +func TestFailureRecordsOutrankAPin(t *testing.T) { |
| + runTargetCases(t, []tcase{{"pin of a failed build", TargetInput{Pointer: head("v3"), Settings: UpdateSettings{Updates: UpdatesAuto, Pin: "v9"}, |
| + Installed: "v2", Failed: func(v string) bool { return v == "v9" }}, TargetDecision{Reason: ReasonFailed}}}) |
| +} |
| + |
| +// Review Focus 2. |
| +func TestARequestBelowTheWatermarkNeverRuns(t *testing.T) { |
| + runTargetCases(t, []tcase{{"completed in the build state, older local file", TargetInput{Pointer: head("v4"), |
| + Settings: UpdateSettings{Updates: UpdatesManual, InstallRequest: 3}, |
| + Consumed: &Consumed{Resolved: &ResolvedRequest{ID: 3, Target: "v3"}, Completed: 2}, |
| + Build: known(true, &BuildState{LastExecuted: "v3", Completed: 3}), Installed: "v3"}, |
| + TargetDecision{Version: "v3", Reason: ReasonManual, Evidence: EvidenceRecord}}}) |
| +} |
| + |
| +// Review Focus 4. |
| +func TestAnUnreadBuildStateIsNeverNew(t *testing.T) { |
| + runTargetCases(t, []tcase{{"held, build state never read", TargetInput{Pointer: head("v3"), Settings: manual(), Build: BuildStateCopy{}}, |
| + TargetDecision{Reason: ReasonUnknown}}}) |
| +} |
| + |
| +// Review Focus 5. |
| +func TestAResolutionInTheBuildStateIsReused(t *testing.T) { |
| + runTargetCases(t, []tcase{{"resolved on another box", TargetInput{Pointer: head("v5"), |
| + Settings: UpdateSettings{Updates: UpdatesManual, InstallRequest: 4}, |
| + Build: known(true, &BuildState{LastExecuted: "v2", Resolved: &ResolvedRequest{ID: 4, Target: "v3"}, Completed: 3})}, |
| + TargetDecision{Version: "v3", Reason: ReasonRequest, Evidence: EvidenceRecord}}}) |
| +} |
| + |
| +// Astra I1 #10: T ran (the request completed at the pre-exec write), then |
| +// failed its health check. The completed request is marked failed once, |
| +// and is not reopened. |
| +func TestAFailureAfterCompletionMarksTheRequestFailed(t *testing.T) { |
| + failedV3 := func(v string) bool { return v == "v3" } |
| + runTargetCases(t, []tcase{ |
| + {"completed, then T failed", TargetInput{Pointer: head("v3"), Settings: UpdateSettings{Updates: UpdatesManual, InstallRequest: 2}, |
| + Build: known(true, &BuildState{LastExecuted: "v1", Resolved: &ResolvedRequest{ID: 2, Target: "v3"}, Completed: 2}), |
| + Installed: "v1", Failed: failedV3}, |
| + TargetDecision{Version: "v1", Reason: ReasonManual, Evidence: EvidenceRecord, Fail: 2}}, |
| + {"already recorded as failed: not again", TargetInput{Pointer: head("v3"), Settings: UpdateSettings{Updates: UpdatesManual, InstallRequest: 2}, |
| + Build: known(true, &BuildState{LastExecuted: "v1", Resolved: &ResolvedRequest{ID: 2, Target: "v3"}, Completed: 2, CompletedFailed: true}), |
| + Installed: "v1", Failed: failedV3}, |
| + TargetDecision{Version: "v1", Reason: ReasonManual, Evidence: EvidenceRecord}}, |
| + }) |
| +} |
| + |
| +// Astra I1 #9: rules 1-3 do not need a readable head. |
| +func TestIndependentTargetsNeedNoPointer(t *testing.T) { |
| + runTargetCases(t, []tcase{ |
| + {"pin, no pointer", TargetInput{Settings: UpdateSettings{Updates: UpdatesAuto, Pin: "v1"}}, |
| + TargetDecision{Version: "v1", Reason: ReasonPin, Evidence: EvidenceRecord}}, |
| + {"resolved request, no pointer", TargetInput{Settings: UpdateSettings{Updates: UpdatesManual, InstallRequest: 2}, |
| + Consumed: &Consumed{Resolved: &ResolvedRequest{ID: 2, Target: "v3"}}}, |
| + TargetDecision{Version: "v3", Reason: ReasonRequest, Evidence: EvidenceRecord}}, |
| + {"held recovery, no pointer", TargetInput{Settings: manual(), Build: known(true, &BuildState{LastExecuted: "v1"})}, |
| + TargetDecision{Version: "v1", Reason: ReasonManual, Evidence: EvidenceRecord}}, |
| + {"new held instance, no pointer: nothing yet", TargetInput{Settings: manual(), Build: known(false, nil)}, |
| + TargetDecision{Reason: ReasonNew}}, |
| + {"auto, no pointer: nothing", TargetInput{Settings: auto()}, TargetDecision{}}, |
| + }) |
| +} |
| + |
| +// Spec §8: a held sleeper with a pending request wakes onto T, which the |
| +// wake check reads from this same decision. |
| +func TestAPendingTOutranksLastExecuted(t *testing.T) { |
| + runTargetCases(t, []tcase{{"resolved T pending while last_executed is older", TargetInput{Pointer: head("v3"), |
| + Settings: UpdateSettings{Updates: UpdatesManual, InstallRequest: 2}, |
| + Consumed: &Consumed{Resolved: &ResolvedRequest{ID: 2, Target: "v3"}}, |
| + Build: known(true, &BuildState{LastExecuted: "v1", Completed: 1}), Installed: "v3"}, |
| + TargetDecision{Version: "v3", Reason: ReasonRequest, Evidence: EvidencePointer}}}) |
| +} |
diff --git a/internal/updatewords/words_approved_test.go b/internal/updatewords/words_approved_test.go| index 7e73ab107..627d8725d 100644 |
| --- a/internal/updatewords/words_approved_test.go |
| +++ b/internal/updatewords/words_approved_test.go |
| @@ -29,14 +29,17 @@ func loadApproved(t *testing.T, path string) approvedRecord { |
| return rec |
| } |
| -// The copy Paul approved lives in two records: plan 2's (2026-10-09, the |
| -// authorize strings) and plan 3's (2026-10-11, the settings.console.* |
| -// strings). Each record must name exactly its own IDs, each text must be what |
| -// Text returns byte for byte, each entry must name the file that holds it, and |
| -// together the two records must name exactly the IDs of All(), so a new draft |
| -// with no approved record fails. |
| +// The copy Paul approved lives in three records: plan 2's (2026-10-09, the |
| +// authorize strings), plan 3's (2026-10-11, the settings.console.* strings) |
| +// and plan 4's (the settings.box.* strings; the record is written only after |
| +// Paul approves them, so this test fails with the file missing until then). |
| +// Each record must name exactly its own IDs, each text must be what Text |
| +// returns byte for byte, each entry must name the file that holds it, and |
| +// together the three records must name exactly the IDs of All(), so a new |
| +// draft with no approved record fails. |
| func TestApprovedCopyIsApplied(t *testing.T) { |
| const console = "settings.console." |
| + const box = "settings.box." |
| records := []struct { |
| path string |
| file string |
| @@ -44,10 +47,13 @@ func TestApprovedCopyIsApplied(t *testing.T) { |
| }{ |
| {"../../docs/superpowers/plans/2026-10-07-instance-settings-2-copy.json", |
| "internal/updatewords/words_authorize.go", |
| - func(id string) bool { return !strings.HasPrefix(id, console) }}, |
| + func(id string) bool { return !strings.HasPrefix(id, console) && !strings.HasPrefix(id, box) }}, |
| {"../../docs/superpowers/plans/2026-10-07-instance-settings-3-copy.json", |
| "internal/updatewords/words_console.go", |
| func(id string) bool { return strings.HasPrefix(id, console) }}, |
| + {"../../docs/superpowers/plans/2026-10-07-instance-settings-4-copy.json", |
| + "internal/updatewords/words_box.go", |
| + func(id string) bool { return strings.HasPrefix(id, box) }}, |
| } |
| var union []string |
| docs := "" |
diff --git a/internal/updatewords/words_box.go b/internal/updatewords/words_box.go| new file mode 100644 |
| index 000000000..86934e875 |
| --- /dev/null |
| +++ b/internal/updatewords/words_box.go |
| @@ -0,0 +1,17 @@ |
| +package updatewords |
| + |
| +func init() { |
| + // TODO-COPY: draft, awaiting Paul's copy review (settings plan 4) |
| + add( |
| + Draft{ID: "settings.box.reason.adopt", Text: "following its channel", Context: "Why an instance runs the build it does: it takes its channel's builds as they arrive. Shown in carlos instances list and on the instance page."}, |
| + Draft{ID: "settings.box.reason.pin", Text: "pinned to %s", Context: "The instance is pinned. %s is the build."}, |
| + Draft{ID: "settings.box.reason.request", Text: "updating to %s (Update now)", Context: "An Update now request was resolved to this build and is being installed or waits for the next start. %s is the build."}, |
| + Draft{ID: "settings.box.reason.manual", Text: "manual updates: stays on %s", Context: "Updates are manual, so the instance stays on the build its data last ran with. %s is the build."}, |
| + Draft{ID: "settings.box.reason.major", Text: "held at major %d: stays on %s", Context: "The channel's newest build is another major version, so the instance stays put. Major number, build."}, |
| + Draft{ID: "settings.box.reason.unknown", Text: "build unknown: use Update now or set a pin", Context: "The box cannot tell which build last used this instance's data, so it starts nothing rather than guess. The member chooses."}, |
| + Draft{ID: "settings.box.reason.new", Text: "new instance: takes the channel's build", Context: "A held instance that has never run gets the channel's current build once."}, |
| + Draft{ID: "settings.box.reason.baking", Text: "Update now waits for %s to finish its safety delay on this box", Context: "An Update now request is not resolved yet because the newest build is still inside its bake window. %s is the build."}, |
| + Draft{ID: "settings.box.reason.failed", Text: "%s failed on this box, so it is not installed", Context: "The build the settings ask for failed here before, so the instance keeps what it has. %s is the build."}, |
| + Draft{ID: "settings.box.request-failed", Text: "Update now to %s failed: the instance stayed on its previous build", Context: "Shown once an Update now request completed as failed. %s is the build."}, |
| + ) |
| +} |