carlos / platform Public

Merge main into console-critique-and-ignore

Paul Campbell 4701225e7ee539b426dd5ea99a8cd496783b038c parent dac5587parent bdadb3f
22 files changed, +1201 −61
  • cli.go +12 −18
  • internal/console/bakecontrols_test.go +3 −3
  • internal/console/confirm.go +4 −4
  • internal/console/flow.go +229 −0
  • internal/console/flow_test.go +225 −0
  • internal/console/ladder.go +1 −1
  • internal/console/overview_test.go +63 −2
  • internal/console/server.go +18 −4
  • internal/console/server_test.go +1 −1
  • internal/console/templates/app_instances.html +2 −2
  • internal/console/templates/app_overview.html +11 −4
  • internal/console/templates/app_releases.html +3 −3
  • internal/console/templates/flow.html +42 −0
  • internal/console/templates/ladder.html +3 −3
  • internal/console/templates/layout.html +67 −0
  • internal/console/view.go +9 −9
  • internal/console/view_test.go +119 −0
  • internal/release/candidates.go +101 −0
  • internal/release/store.go +71 −7
  • internal/release/store_test.go +45 −0
  • internal/release/versions_test.go +95 −0
  • releases_local_test.go +77 −0
diff --git a/cli.go b/cli.go
index aeca97f16..64b460d6a 100644
--- a/cli.go
+++ b/cli.go
@@ -2198,19 +2198,21 @@ type releaseRow struct {
// screen of them.
const releasesCap = 20
-// localReleases is the direct-mode half of `carlos releases`: List the
-// release dirs, read each manifest, join the tags, newest first.
+// localReleases is the direct-mode half of `carlos releases`: pick the
+// releases worth reading, read those manifests, join the tags, newest first.
//
-// It is a local reimplementation of console.Versions rather than a call to
-// it, deliberately: the CLI does not import internal/console today — that
-// package carries templates, sessions, a registry and a whole HTTP server —
-// and twenty lines of List + GetManifest is a far smaller thing to own than
-// that dependency. A manifest that will not read is a row with Err set,
+// It is a local sibling of console.Versions rather than a call to it,
+// deliberately: the CLI does not import internal/console — that package
+// carries templates, sessions, a registry and a whole HTTP server. What the
+// two DO share is release.VersionCandidates, so both surfaces pick the same
+// releasesCap versions (by manifest mod time, when the store carries stamps
+// — a bare *S3Store or *DirStore, which is all openStore hands out) and read
+// nothing else; the two modes of this one command agree about what "the
+// latest release" is. A manifest that will not read is a row with Err set,
// exactly as it is on the console's app page and in runChannels above, not a
// failure of the whole listing.
func localReleases(ctx context.Context, s release.Store, acct, app string) ([]releaseRow, error) {
- prefix := release.ReleasesPrefix(acct, app)
- keys, err := s.List(ctx, prefix)
+ cands, _, err := release.VersionCandidates(ctx, s, acct, app, releasesCap)
if err != nil {
return nil, err
}
@@ -2222,16 +2224,8 @@ func localReleases(ctx context.Context, s release.Store, acct, app string) ([]re
for _, t := range tags {
byRelease[t.Release] = t.Version
}
- seen := map[string]bool{}
var out []releaseRow
- for _, k := range keys {
- // Every artifact of a release shares its dir, so the version is the
- // first segment after the prefix and each one is taken once.
- v, _, ok := strings.Cut(strings.TrimPrefix(k, prefix), "/")
- if !ok || v == "" || seen[v] {
- continue
- }
- seen[v] = true
+ for _, v := range cands {
m, err := release.GetManifest(ctx, s, acct, app, v)
if err != nil {
out = append(out, releaseRow{Version: v, Tag: byRelease[v], Err: err.Error()})
diff --git a/internal/console/bakecontrols_test.go b/internal/console/bakecontrols_test.go
index cab5d35d7..e86a9b363 100644
--- a/internal/console/bakecontrols_test.go
+++ b/internal/console/bakecontrols_test.go
@@ -278,7 +278,7 @@ func TestPromoteEnableBakeFlipsTheFlag(t *testing.T) {
// The flash tells the truth about both halves of what just happened: the
// flag went on in this action, so the safety-delay note is now accurate.
if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "Safety delay enabled.") ||
- !strings.Contains(body, "It goes live on each machine after the safety delay") {
+ !strings.Contains(body, "It goes live everywhere after the safety delay") {
t.Fatalf("enable_bake flash missing its two halves:\n%s", body)
}
}
@@ -333,7 +333,7 @@ func TestPromoteBakeNoteFollowsTheFlag(t *testing.T) {
if p := fx.channelVersion(t, "kass", "beta"); p.Version != "v3" {
t.Fatalf("beta = %+v, want v3", p)
}
- if _, _, body := get(t, c, fx.u("/apps/kass")); strings.Contains(body, "It goes live on each machine after the safety delay") {
+ if _, _, body := get(t, c, fx.u("/apps/kass")); strings.Contains(body, "It goes live everywhere after the safety delay") {
t.Fatalf("a flagless app was promised a safety delay:\n%s", body)
}
@@ -348,7 +348,7 @@ func TestPromoteBakeNoteFollowsTheFlag(t *testing.T) {
url.Values{"csrf": {csrf}, "version": {"v4"}, "channel": {"beta"}, "confirmed": {"1"}}); status != http.StatusSeeOther {
t.Fatalf("beta promote of v4 = %d:\n%s", status, body)
}
- if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "It goes live on each machine after the safety delay") {
+ if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "It goes live everywhere after the safety delay") {
t.Fatalf("a production app's held promotion did not mention the window:\n%s", body)
}
}
diff --git a/internal/console/confirm.go b/internal/console/confirm.go
index a165103f4..95ad7d7e3 100644
--- a/internal/console/confirm.go
+++ b/internal/console/confirm.go
@@ -134,7 +134,7 @@ func (s *Server) confirmPromote(w http.ResponseWriter, r *http.Request, addr, ra
flag, ferr := release.GetProduction(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app)
baking := ferr != nil || (flag != nil && flag.Production)
if hold := release.HoldFor(release.DefaultHolds, channel); hold > 0 && baking {
- cv.Lines = append(cv.Lines, "This app has the safety delay on: each machine waits "+holdText(hold)+" on "+channel+" before running "+version+".")
+ cv.Lines = append(cv.Lines, "This app has the safety delay on: each copy waits "+holdText(hold)+" on "+channel+" before running "+version+".")
}
if stable && ferr == nil && !baking {
cv.CheckboxName = "enable_bake"
@@ -157,7 +157,7 @@ func (s *Server) confirmRollback(w http.ResponseWriter, r *http.Request, addr, r
channel + " serves " + cur + " now.",
"Rolling back re-points it to " + target + ", the version before this one.",
},
- Note: "Rollbacks skip the safety delay — machines pick it up within a couple of minutes.",
+ Note: "Rollbacks skip the safety delay — it rolls out within a couple of minutes.",
Action: "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) + "/rollback",
// target pins what the card promised: the confirmed POST refuses if
// the channel's history has moved on since this was rendered.
@@ -192,13 +192,13 @@ func (s *Server) confirmFastTrack(w http.ResponseWriter, r *http.Request, addr,
cv.Title = "Skip the safety delay for " + cur.Version + " on " + channel + "?"
cv.Lines = append(cv.Lines,
channel+" holds "+cur.Version+", promoted "+ago(cur.PromotedAt)+", still in its safety delay.",
- "Skipping waives the rest of it: machines run "+cur.Version+" within a couple of minutes.")
+ "Skipping waives the rest of it: "+cur.Version+" rolls out within a couple of minutes.")
cv.Button = "Skip the delay for " + cur.Version
// Pin the version the card names — the waiver must not land on a
// build promoted after this was rendered.
cv.Fields = append(cv.Fields, [2]string{"version", cur.Version})
} else {
- cv.Lines = append(cv.Lines, "Waives the rest of "+channel+"'s safety delay: machines run its current version within a couple of minutes.")
+ cv.Lines = append(cv.Lines, "Waives the rest of "+channel+"'s safety delay: its current version rolls out within a couple of minutes.")
}
s.renderConfirm(w, r, addr, raw, acct, role, app, cv)
}
diff --git a/internal/console/flow.go b/internal/console/flow.go
new file mode 100644
index 000000000..c7c1caea0
--- /dev/null
+++ b/internal/console/flow.go
@@ -0,0 +1,229 @@
+// The app page's flow diagram: one picture answering "what is going on with
+// this app" — a version you shipped, the channels it moves through, the
+// addresses each channel feeds, and how each of those is doing.
+//
+// It exists because the ladder table answered the same question in prose that
+// only made sense to someone who already knew the answer (the 2026-08-09
+// clarity critique: recognition-over-recall 1/4, minimalism 1/4). A picture
+// of three boxes with an arrow between them says "promote moves it one step"
+// without a sentence explaining what promote means.
+//
+// It is assembled from the app's channel pointers and the SAME tenant-filtered
+// instance rows the Running tab renders — deliberately NOT from AppView.Routes
+// or the ladder, both of which carry routes stamped to other accounts (apps
+// are grouped by name across accounts; see assembleOverview's tenant note).
+// Reusing the rows means the diagram cannot draw an address the Running tab
+// does not, cannot disagree with it about health, and cannot leak a
+// neighbouring tenant's host into this account's picture.
+package console
+
+import (
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// FlowView is the whole diagram: the release line every app has, plus the
+// bands for channels that sit outside it.
+type FlowView struct {
+ // Show is false when the app has published nothing and serves nothing —
+ // three empty boxes teach less than the page's own empty state.
+ Show bool
+ // Shipped is the newest version in the account's bucket for this app,
+ // which is where the line starts: it is the one thing on the diagram the
+ // owner did directly.
+ Shipped string
+ // ShippedAt is the build time, parsed from the manifest's RFC3339 stamp;
+ // zero when there is none or it will not parse, which renders as no date
+ // rather than as a raw timestamp nobody can read at a glance.
+ ShippedAt time.Time
+ // Line is the release path — edge, beta, live — ALWAYS all three, even
+ // when the later ones hold nothing. A path drawn only as far as the app
+ // has travelled reads as a path with no further to go.
+ Line []FlowStage
+ // Tests are the canary/* channels: named, disposable, no delay.
+ Tests []FlowStage
+ // Extra is any other channel an address actually follows. Nothing should
+ // normally land here — it is the band that keeps the diagram honest, so
+ // a route on an off-ladder channel is drawn rather than silently omitted
+ // from a picture that presents itself as the whole story.
+ Extra []FlowStage
+}
+
+// FlowStage is one box on the diagram: a channel, what it holds, and the
+// addresses following it.
+type FlowStage struct {
+ // Name is the label on the box — the word an owner uses ("live"), not
+ // always the channel's name ("stable"); Channel is the real one.
+ Name string
+ Channel string
+ Version string
+ // PromotedAt is when this channel last moved; zero when it holds nothing.
+ PromotedAt time.Time
+ // Err is one line of bad news about this channel: a pointer that would
+ // not read, or a channel addresses follow that nothing was published to.
+ Err string
+ // Hold is the safety delay this channel imposes on arrival — "~24h" —
+ // and is empty both when the channel imposes none and when the app has
+ // the delay switched off. It is the general rule; BakedUntil is the
+ // live countdown for the version actually sitting here now.
+ Hold string
+ BakedUntil time.Time
+ FastTrack bool
+ // Carried is whether the version in the box before this one actually
+ // made it here. It is what the diagram draws as a solid arrow rather
+ // than a dashed one, and it is the whole reason the picture beats the
+ // table: "how far has my build got" becomes a thing you see rather than
+ // three version strings you compare by eye.
+ Carried bool
+ Addresses []FlowAddress
+}
+
+// FlowAddress is one address hanging off a stage, with its health.
+type FlowAddress struct {
+ Host string
+ Health string // healthWord's plain word, as the Running tab shows it
+ Version string // what this address reports it is running, "" unknown
+ // Tone is how the health word should read: ok, idle, wait or bad.
+ // Asleep is idle rather than bad on purpose — hibernating is the system
+ // working, and colouring it as a problem trains owners to ignore the
+ // colour that means an actual problem.
+ Tone string
+}
+
+// addressTone maps healthWord's vocabulary onto the four tones. An
+// unrecognised word falls to idle: a health word nobody taught this function
+// is missing information, and missing information must never render as
+// either reassurance or alarm.
+func addressTone(health string) string {
+ switch health {
+ case "Running", "Serving":
+ return "ok"
+ case "Setting up":
+ return "wait"
+ case "Not responding", "Failed":
+ return "bad"
+ default:
+ return "idle"
+ }
+}
+
+// flowLine is the release path, in the order versions travel it, with the
+// label each box carries. "live" rather than "stable" is deliberate and
+// matches the Versions tab's strip: stable is the channel's name, live is
+// what it means.
+var flowLine = []struct{ name, channel string }{
+ {"edge", "edge"},
+ {"beta", "beta"},
+ {"live", release.RungStable},
+}
+
+// assembleFlow folds the app's channels and its tenant-filtered instance rows
+// into the diagram. latest/latestAt are the newest shipped version and its
+// build time ("" when nothing has been shipped).
+func assembleFlow(view *AppView, rows []InstanceRow, latest, latestAt string) FlowView {
+ f := FlowView{Shipped: latest}
+ if t, err := time.Parse(time.RFC3339, latestAt); err == nil {
+ f.ShippedAt = t
+ }
+
+ // Rows arrive in route order and stay in it: the Running tab lists these
+ // same hosts in the same order, and two orderings of one list is exactly
+ // the kind of small friction that makes a picture feel untrustworthy.
+ byChannel := map[string][]FlowAddress{}
+ for _, r := range rows {
+ byChannel[r.Channel] = append(byChannel[r.Channel], FlowAddress{
+ Host: r.Host,
+ Health: r.HealthWord,
+ Version: r.Version,
+ Tone: addressTone(r.HealthWord),
+ })
+ }
+ chans := make(map[string]ChannelView, len(view.Channels))
+ for _, c := range view.Channels {
+ chans[c.Name] = c
+ }
+ // An unreadable production flag counts as ON, because that is what the
+ // boxes do with it (they keep baking) — the diagram must not tell an
+ // owner their delay is off while every box is still holding.
+ held := view.Production.On || view.Production.Err != ""
+
+ stage := func(name, channel string) FlowStage {
+ c := chans[channel]
+ s := FlowStage{
+ Name: name, Channel: channel,
+ Version: c.Version, PromotedAt: c.PromotedAt, Err: c.Err,
+ BakedUntil: c.BakedUntil, FastTrack: c.FastTrack,
+ Addresses: byChannel[channel],
+ }
+ if held {
+ if d := release.HoldFor(release.DefaultHolds, channel); d > 0 {
+ s.Hold = holdText(d)
+ }
+ }
+ // A channel with addresses but nothing ever published to it is the
+ // silent failure the ladder was built to surface: hosts serving
+ // nothing, waiting for a version that never came.
+ if s.Err == "" && s.Version == "" && len(s.Addresses) > 0 {
+ s.Err = "nothing published here yet — these addresses are waiting for a version"
+ }
+ return s
+ }
+
+ // The line, then the arrows between its boxes. A step counts as carried
+ // only when the box before it actually holds something: two empty boxes
+ // share a version string ("") without any version having travelled
+ // between them, and drawing that as a solid arrow would be the diagram's
+ // one chance to lie outright.
+ for _, ls := range flowLine {
+ f.Line = append(f.Line, stage(ls.name, ls.channel))
+ }
+ prev := latest
+ for i := range f.Line {
+ f.Line[i].Carried = prev != "" && f.Line[i].Version == prev
+ prev = f.Line[i].Version
+ }
+
+ // Canaries from their pointers first, then any canary channel an address
+ // follows that has no pointer at all.
+ drawn := map[string]bool{}
+ for _, ls := range flowLine {
+ drawn[ls.channel] = true
+ }
+ for _, c := range view.Channels {
+ if !strings.HasPrefix(c.Name, "canary/") || drawn[c.Name] {
+ continue
+ }
+ drawn[c.Name] = true
+ f.Tests = append(f.Tests, stage(strings.TrimPrefix(c.Name, "canary/"), c.Name))
+ }
+ for ch := range byChannel {
+ if drawn[ch] {
+ continue
+ }
+ drawn[ch] = true
+ if strings.HasPrefix(ch, "canary/") {
+ f.Tests = append(f.Tests, stage(strings.TrimPrefix(ch, "canary/"), ch))
+ continue
+ }
+ f.Extra = append(f.Extra, stage(ch, ch))
+ }
+ // Map iteration ordered nothing above, and a diagram that reshuffles
+ // between two refreshes of the same page is one nobody trusts.
+ sortStages := func(s []FlowStage) {
+ sort.Slice(s, func(a, b int) bool { return s[a].Channel < s[b].Channel })
+ }
+ sortStages(f.Tests)
+ sortStages(f.Extra)
+
+ for _, band := range [][]FlowStage{f.Line, f.Tests, f.Extra} {
+ for _, s := range band {
+ if s.Version != "" || len(s.Addresses) > 0 {
+ f.Show = true
+ }
+ }
+ }
+ return f
+}
diff --git a/internal/console/flow_test.go b/internal/console/flow_test.go
new file mode 100644
index 000000000..6ff027368
--- /dev/null
+++ b/internal/console/flow_test.go
@@ -0,0 +1,225 @@
+package console
+
+import "testing"
+
+// stageByChannel finds one stage across every band, so the tests can assert
+// about a channel without caring which band the assembler filed it under.
+func stageByChannel(f FlowView, channel string) *FlowStage {
+ for _, band := range [][]FlowStage{f.Line, f.Tests, f.Extra} {
+ for i := range band {
+ if band[i].Channel == channel {
+ return &band[i]
+ }
+ }
+ }
+ return nil
+}
+
+// The line is the app's release path, and it is drawn whole even when the
+// app has only reached the first rung: a pipeline with its later steps
+// missing reads as "there is no later step", which is the opposite of true.
+func TestFlowLineIsAlwaysTheWholePath(t *testing.T) {
+ v := &AppView{Channels: []ChannelView{{Name: "edge", Version: "a1"}}}
+ f := assembleFlow(v, nil, "a1", "")
+
+ if len(f.Line) != 3 {
+ t.Fatalf("line has %d stages, want edge/beta/live: %+v", len(f.Line), f.Line)
+ }
+ for i, want := range []string{"edge", "beta", "stable"} {
+ if f.Line[i].Channel != want {
+ t.Errorf("line[%d] is %q, want %q", i, f.Line[i].Channel, want)
+ }
+ }
+ if f.Line[2].Name != "live" {
+ t.Errorf("the stable stage is labelled %q, want the word owners use: live", f.Line[2].Name)
+ }
+ if f.Line[1].Version != "" {
+ t.Errorf("beta holds nothing but reports version %q", f.Line[1].Version)
+ }
+}
+
+// The whole point of the picture is that an address hangs off the channel
+// feeding it. An address filed under the wrong stage would draw a lie.
+func TestFlowAddressesAttachToTheirChannel(t *testing.T) {
+ v := &AppView{Channels: []ChannelView{
+ {Name: "stable", Version: "a1"},
+ {Name: "canary/rc", Version: "b2"},
+ }}
+ rows := []InstanceRow{
+ {Host: "kass.dev", Channel: "stable", Version: "a1", HealthWord: "Running"},
+ {Host: "www.kass.dev", Channel: "stable", Version: "a1", HealthWord: "Running"},
+ {Host: "rc.kass.dev", Channel: "canary/rc", Version: "b2", HealthWord: "Asleep"},
+ }
+ f := assembleFlow(v, rows, "b2", "")
+
+ live := stageByChannel(f, "stable")
+ if live == nil || len(live.Addresses) != 2 {
+ t.Fatalf("live stage should carry both stable hosts: %+v", live)
+ }
+ if live.Addresses[0].Host != "kass.dev" || live.Addresses[1].Host != "www.kass.dev" {
+ t.Errorf("live addresses out of order: %+v", live.Addresses)
+ }
+ rc := stageByChannel(f, "canary/rc")
+ if rc == nil || len(rc.Addresses) != 1 || rc.Addresses[0].Host != "rc.kass.dev" {
+ t.Fatalf("the canary host should hang off canary/rc: %+v", rc)
+ }
+ if rc.Name != "rc" {
+ t.Errorf("canary stage is labelled %q, want the short name rc", rc.Name)
+ }
+ if len(f.Tests) != 1 {
+ t.Errorf("canary/rc belongs in the test band, got %d test stages", len(f.Tests))
+ }
+}
+
+// A running address that the diagram does not draw is worse than no diagram:
+// the picture claims to be the whole story. Whatever channel a route follows,
+// its host must appear somewhere.
+func TestFlowDrawsEveryRunningAddress(t *testing.T) {
+ v := &AppView{Channels: []ChannelView{{Name: "stable", Version: "a1"}}}
+ rows := []InstanceRow{
+ {Host: "kass.dev", Channel: "stable", HealthWord: "Running"},
+ {Host: "rc.kass.dev", Channel: "canary/rc", HealthWord: "Running"},
+ {Host: "odd.kass.dev", Channel: "experiment", HealthWord: "Running"},
+ }
+ f := assembleFlow(v, rows, "a1", "")
+
+ for _, want := range []string{"kass.dev", "rc.kass.dev", "odd.kass.dev"} {
+ found := false
+ for _, band := range [][]FlowStage{f.Line, f.Tests, f.Extra} {
+ for _, s := range band {
+ for _, a := range s.Addresses {
+ if a.Host == want {
+ found = true
+ }
+ }
+ }
+ }
+ if !found {
+ t.Errorf("%s is running but appears nowhere in the diagram", want)
+ }
+ }
+ if len(f.Extra) != 1 || f.Extra[0].Channel != "experiment" {
+ t.Errorf("a route off the ladder needs its own band, got %+v", f.Extra)
+ }
+}
+
+// The safety delay is the single most confusing thing on the old ladder, so
+// the diagram states it — but only when it is actually switched on, and only
+// on the channels that hold.
+func TestFlowShowsTheSafetyDelayOnlyWhenItApplies(t *testing.T) {
+ chans := []ChannelView{{Name: "edge", Version: "a1"}, {Name: "beta", Version: "a1"}, {Name: "stable", Version: "a1"}}
+
+ on := assembleFlow(&AppView{Channels: chans, Production: ProductionView{On: true}}, nil, "a1", "")
+ if got := stageByChannel(on, "beta").Hold; got != "~24h" {
+ t.Errorf("beta hold is %q, want ~24h", got)
+ }
+ if got := stageByChannel(on, "stable").Hold; got != "~72h" {
+ t.Errorf("live hold is %q, want ~72h", got)
+ }
+ if got := stageByChannel(on, "edge").Hold; got != "" {
+ t.Errorf("edge holds nothing back but says %q", got)
+ }
+
+ off := assembleFlow(&AppView{Channels: chans}, nil, "a1", "")
+ for _, s := range off.Line {
+ if s.Hold != "" {
+ t.Errorf("safety delay is off, but %s advertises a %q wait", s.Channel, s.Hold)
+ }
+ }
+
+ // An unreadable flag is treated as on, because that is what the boxes do.
+ bad := assembleFlow(&AppView{Channels: chans, Production: ProductionView{Err: "unverifiable"}}, nil, "a1", "")
+ if stageByChannel(bad, "stable").Hold == "" {
+ t.Error("an unreadable production flag must still show the delay — boxes keep baking")
+ }
+}
+
+// Health is the third of the three questions the console exists to answer,
+// and the diagram is where it gets answered per address. Asleep must read as
+// idle, not as a problem: hibernating is the platform working.
+func TestFlowTonesAddressHealth(t *testing.T) {
+ rows := []InstanceRow{
+ {Host: "a.dev", Channel: "stable", HealthWord: "Running"},
+ {Host: "b.dev", Channel: "stable", HealthWord: "Asleep"},
+ {Host: "c.dev", Channel: "stable", HealthWord: "Not responding"},
+ {Host: "d.dev", Channel: "stable", HealthWord: "Failed"},
+ {Host: "e.dev", Channel: "stable", HealthWord: "Setting up"},
+ {Host: "f.dev", Channel: "stable", HealthWord: "Serving"},
+ {Host: "g.dev", Channel: "stable", HealthWord: "Something new"},
+ }
+ f := assembleFlow(&AppView{Channels: []ChannelView{{Name: "stable", Version: "a1"}}}, rows, "a1", "")
+
+ want := map[string]string{
+ "a.dev": "ok", "b.dev": "idle", "c.dev": "bad", "d.dev": "bad",
+ "e.dev": "wait", "f.dev": "ok",
+ // A word this function has never been taught is missing information:
+ // neither reassurance nor alarm.
+ "g.dev": "idle",
+ }
+ for _, a := range stageByChannel(f, "stable").Addresses {
+ if a.Tone != want[a.Host] {
+ t.Errorf("%s (%s): tone=%q, want %q", a.Host, a.Health, a.Tone, want[a.Host])
+ }
+ }
+}
+
+// The solid-vs-dashed arrow is the diagram's one claim beyond the words on
+// it: that a version really did travel this step. It must be exactly true.
+func TestFlowCarriesTheArrowOnlyWhereTheVersionTravelled(t *testing.T) {
+ // a1 shipped, promoted all the way to beta, not yet live.
+ f := assembleFlow(&AppView{Channels: []ChannelView{
+ {Name: "edge", Version: "a1"},
+ {Name: "beta", Version: "a1"},
+ {Name: "stable", Version: "old"},
+ }}, nil, "a1", "")
+ want := []bool{true, true, false}
+ for i, s := range f.Line {
+ if s.Carried != want[i] {
+ t.Errorf("%s: carried=%v, want %v", s.Channel, s.Carried, want[i])
+ }
+ }
+
+ // Two empty boxes in a row share the empty string, and no version has
+ // travelled between them — the arrow must stay dashed.
+ empty := assembleFlow(&AppView{Channels: []ChannelView{{Name: "edge", Version: "a1"}}}, nil, "a1", "")
+ if empty.Line[1].Carried || empty.Line[2].Carried {
+ t.Errorf("empty boxes must not chain a solid arrow: %+v", empty.Line)
+ }
+
+ // Nothing shipped at all: the very first arrow has nothing to carry.
+ none := assembleFlow(&AppView{Channels: []ChannelView{{Name: "edge", Version: "a1"}}}, nil, "", "")
+ if none.Line[0].Carried {
+ t.Error("no shipped version — the arrow into edge cannot be solid")
+ }
+}
+
+// A host following a channel nothing was ever published to is the failure the
+// ladder exists to make visible; the diagram must not quietly draw an empty
+// box instead.
+func TestFlowSaysWhenAChannelHasNothingPublished(t *testing.T) {
+ rows := []InstanceRow{{Host: "rc.kass.dev", Channel: "canary/rc", HealthWord: "Not responding"}}
+ f := assembleFlow(&AppView{}, rows, "", "")
+
+ rc := stageByChannel(f, "canary/rc")
+ if rc == nil {
+ t.Fatal("a host follows canary/rc — the stage must be drawn")
+ }
+ if rc.Err == "" {
+ t.Error("canary/rc has no version and no pointer: the stage must say so")
+ }
+}
+
+// Nothing shipped and nothing running is not a diagram — it is an empty
+// state, and the page has better words for that than three blank boxes.
+func TestFlowHidesItselfWhenThereIsNothingToDraw(t *testing.T) {
+ if f := assembleFlow(&AppView{}, nil, "", ""); f.Show {
+ t.Error("an app with no channels and no addresses should not draw a diagram")
+ }
+ if f := assembleFlow(&AppView{Channels: []ChannelView{{Name: "edge", Version: "a1"}}}, nil, "a1", ""); !f.Show {
+ t.Error("a shipped version on edge is worth drawing")
+ }
+ rows := []InstanceRow{{Host: "kass.dev", Channel: "stable", HealthWord: "Running"}}
+ if f := assembleFlow(&AppView{}, rows, "", ""); !f.Show {
+ t.Error("a running address is worth drawing even with no readable pointer")
+ }
+}
diff --git a/internal/console/ladder.go b/internal/console/ladder.go
index 67eff601d..535ed1f57 100644
--- a/internal/console/ladder.go
+++ b/internal/console/ladder.go
@@ -170,7 +170,7 @@ func assembleLadder(channels []ChannelView, routes []RouteView, now time.Time) [
}
entries[rung] = append(entries[rung], LadderChannel{
Channel: ch, Routes: rts,
- Err: "nothing has been published here yet — these machines are waiting for a version",
+ Err: "nothing has been published here yet — these addresses are waiting for a version",
})
}
out := make([]LadderRung, 0, 4)
diff --git a/internal/console/overview_test.go b/internal/console/overview_test.go
index 4ada7994e..cfc5175a3 100644
--- a/internal/console/overview_test.go
+++ b/internal/console/overview_test.go
@@ -347,8 +347,14 @@ func TestOverviewPageRendersTiles(t *testing.T) {
if !strings.Contains(body, "Needs attention") {
t.Fatalf("the headline verdict is missing:\n%s", body)
}
- if !strings.Contains(body, "Machines") {
- t.Fatalf("no Machines tile on the Overview:\n%s", body)
+ if !strings.Contains(body, "<h2>Running</h2>") {
+ t.Fatalf("no Running tile on the Overview:\n%s", body)
+ }
+ // "Machines" implied one computer per row when several rows routinely
+ // share a box, and its count was byte-for-byte the Web addresses count
+ // beside it. Both the word and the duplicate are meant to stay gone.
+ if strings.Contains(body, "Machines") || strings.Contains(body, "machines") {
+ t.Fatalf("the Overview still talks about machines:\n%s", body)
}
if strings.Contains(body, "stable pointer unreadable") {
t.Fatalf("stale tile copy still rendered:\n%s", body)
@@ -361,6 +367,61 @@ func TestOverviewPageRendersTiles(t *testing.T) {
}
}
+// flowSection returns just the diagram's markup. The console inlines its
+// whole stylesheet into every page, so `.flow-stage.is-carried` and friends
+// are present in the CSS on a page that draws no diagram at all — an
+// unscoped Contains would assert on the rules instead of the drawing.
+func flowSection(t *testing.T, page string) string {
+ t.Helper()
+ i := strings.Index(page, `<section class="flow"`)
+ if i < 0 {
+ t.Fatalf("no flow diagram on the page:\n%s", page)
+ }
+ j := strings.Index(page[i:], "</section>")
+ if j < 0 {
+ t.Fatalf("the flow section is never closed:\n%s", page[i:])
+ }
+ return page[i : i+j]
+}
+
+// The flow diagram is the Overview's answer to "what is going on" — the
+// release path drawn as a path, with each address hanging off the channel
+// that feeds it. These assertions cover what a reader has to be able to see;
+// flow_test.go covers what the drawing is allowed to claim.
+func TestOverviewDrawsTheFlow(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ _, _, page := get(t, c, fx.u("/apps/kass"))
+
+ // Scoped to the section, deliberately: the page carries its stylesheet
+ // inline, so every class name this test looks for also appears in the
+ // CSS above it, and a whole-body assertion would pass on the rules
+ // rather than on the drawing.
+ body := flowSection(t, page)
+ // The whole path, including the steps this app has not reached: a path
+ // drawn only as far as the app has travelled reads as a path with no
+ // further to go.
+ for _, label := range []string{"you shipped", "edge", "beta", "live"} {
+ if !strings.Contains(body, `<p class="flow-label">`+label+"</p>") {
+ t.Fatalf("the flow is missing its %q box:\n%s", label, body)
+ }
+ }
+ // The addresses hang off the diagram, each with the same health word the
+ // Running tab would give it — and as real links, since the point of an
+ // address is that you can go to it.
+ if !strings.Contains(body, `<a href="https://kass.carlosapp.dev">kass.carlosapp.dev</a>`) {
+ t.Fatalf("the flow does not show the app's address as a link:\n%s", body)
+ }
+ if !strings.Contains(body, `class="flow-addr flow-tone-bad"`) {
+ t.Fatalf("the fixture's silent host should be toned as needing attention:\n%s", body)
+ }
+ // The fixture ships v2 but only has v1 live, so nothing has travelled the
+ // whole path: no step may claim it has.
+ if strings.Contains(body, "is-carried") {
+ t.Fatalf("v2 is not live, so no step carried it:\n%s", body)
+ }
+}
+
// The Instances tab's one table, with the columns an owner reads across.
func TestOverviewInstancesPageRendersTable(t *testing.T) {
fx := newConsoleFixture(t)
diff --git a/internal/console/server.go b/internal/console/server.go
index 4a9f9226a..d249e3c39 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -516,6 +516,10 @@ type page struct {
// from reads the page does anyway.
Overview OverviewStats
+ // App Overview: the same reads drawn as the pipeline they describe
+ // (flow.go). Zero-valued (Show false) renders nothing at all.
+ Flow FlowView
+
// App Instances: the one table — routes joined with their readings.
InstanceRows []InstanceRow
@@ -1495,6 +1499,16 @@ func (s *Server) handleApp(w http.ResponseWriter, r *http.Request) {
p.Versions = versions
metrics := s.appResourceMetrics(r.Context(), p.Detail, acct)
p.Overview = assembleOverview(p.Detail, acct.Sqid, metrics, versions)
+ // The diagram is drawn from the SAME tenant-filtered rows the Running tab
+ // renders, so the two can never disagree about which addresses exist or
+ // how they are doing — see flow.go's package comment for why it is not
+ // built from Detail.Routes.
+ p.InstanceRows = assembleInstanceRows(p.Detail, acct, metrics)
+ var latest, latestAt string
+ if len(versions) > 0 {
+ latest, latestAt = versions[0].Version, versions[0].BuiltAt
+ }
+ p.Flow = assembleFlow(p.Detail, p.InstanceRows, latest, latestAt)
if events, err := s.store.Events(acct.ID, app, overviewActivityCap); err != nil {
log.Printf("console: events %s: %v", app, err)
} else {
@@ -1953,7 +1967,7 @@ func (s *Server) handlePromote(w http.ResponseWriter, r *http.Request) {
// checkbox was ticked, the production flag — in a single bump.
s.bumpSerial(ctx)
if baking && release.HoldFor(release.DefaultHolds, channel) > 0 {
- msg += " It goes live on each machine after the safety delay — rollbacks skip it."
+ msg += " It goes live everywhere after the safety delay — rollbacks skip it."
}
back("ok", msg)
}
@@ -2107,7 +2121,7 @@ func (s *Server) handleProduction(w http.ResponseWriter, r *http.Request) {
back("ok", "Safety delay enabled — new versions now wait on beta and stable before going live.")
} else {
s.audit(acct, app, addr, "production", "disabled the safety delay (production: off)")
- back("ok", "Safety delay turning off — takes effect on each machine after ~72h.")
+ back("ok", "Safety delay turning off — takes effect after ~72h.")
}
}
@@ -2195,7 +2209,7 @@ func (s *Server) handleFastTrack(w http.ResponseWriter, r *http.Request) {
// A waiver re-signs the pointer to say "adopt this now" — the one write
// whose entire point is that boxes act on it immediately.
s.bumpSerial(ctx)
- back("ok", "Skipped the delay for "+ver+" on "+channel+" — machines pick it up within a couple of minutes.")
+ back("ok", "Skipped the delay for "+ver+" on "+channel+" — it rolls out within a couple of minutes.")
}
// ---- canaries ---------------------------------------------------------------
@@ -2311,7 +2325,7 @@ func (s *Server) handleNewCanary(w http.ResponseWriter, r *http.Request) {
if enable != "" {
create = enable + " && " + create
}
- back("ok", "Created canary "+name+" — "+channel+" serves "+version+" the moment a machine follows it, no safety delay. Attach one: "+create)
+ back("ok", "Created canary "+name+" — "+channel+" serves "+version+" the moment an address follows it, no safety delay. Attach one: "+create)
}
// instanceDomainFor answers "where do this app's instance hosts live?" — the
diff --git a/internal/console/server_test.go b/internal/console/server_test.go
index 71ad872ed..b8ff01e2c 100644
--- a/internal/console/server_test.go
+++ b/internal/console/server_test.go
@@ -534,7 +534,7 @@ func TestOverviewIsALadderNotAnEverythingPage(t *testing.T) {
t.Fatalf("overview still renders %q", gone)
}
}
- for _, want := range []string{`class="tiles stat-tiles"`, "Machines", "Web addresses", "Versions"} {
+ for _, want := range []string{`class="tiles stat-tiles"`, "Running", "Web addresses", "Versions"} {
if !strings.Contains(body, want) {
t.Fatalf("overview missing %q", want)
}
diff --git a/internal/console/templates/app_instances.html b/internal/console/templates/app_instances.html
index 62f390e41..2607731c1 100644
--- a/internal/console/templates/app_instances.html
+++ b/internal/console/templates/app_instances.html
@@ -24,9 +24,9 @@
{{end}}
</table>
</div>
-<p class="small">Version is what each machine reports it is running — a dash means
+<p class="small">Version is what each copy reports it is running — a dash means
it hasn't reported yet. Charts cover the last 48 hours; the memory line is drawn
-against that machine's own peak.</p>
+against that copy's own peak.</p>
{{else}}
<p>Nothing running yet. Publish a version, then give it a web address:</p>
{{if .AttachEnable}}<pre><code>{{.AttachEnable}}
diff --git a/internal/console/templates/app_overview.html b/internal/console/templates/app_overview.html
index de5a45ea1..81689f40d 100644
--- a/internal/console/templates/app_overview.html
+++ b/internal/console/templates/app_overview.html
@@ -26,16 +26,23 @@
<a href="{{$.Nav}}/apps/{{$.AppName}}/releases">Versions</a> tab.</p>
{{end}}
</section>
+{{end}}
+{{template "flow" .}}
+{{with .Overview}}
<section class="tiles stat-tiles" aria-label="Vital statistics">
+ {{/* "Running" answers health, not headcount: the count this tile used to
+ show was byte-for-byte the Web addresses count beside it, under a name
+ ("Machines") that also implied one computer per row when several rows
+ routinely share a box. */}}
<a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/instances">
- <h2>Machines</h2>
- <p class="tile-value">{{.InstanceTotal}}</p>
- <p class="tile-detail">{{if eq .InstanceTotal 0}}none yet{{else if .MetricsUnavailable}}no data right now{{else if eq .InstanceExpected 0}}served by the platform{{else}}{{.InstanceReporting}} of {{.InstanceExpected}} processes responding{{if .InstanceAsleep}} · {{.InstanceAsleep}} asleep{{end}}{{end}}</p>
+ <h2>Running</h2>
+ <p class="tile-value">{{if eq .InstanceTotal 0}}—{{else if eq .InstanceExpected 0}}Served{{else if .MetricsUnavailable}}?{{else}}{{.InstanceReporting}} of {{.InstanceExpected}}{{end}}</p>
+ <p class="tile-detail">{{if eq .InstanceTotal 0}}nothing running yet{{else if eq .InstanceExpected 0}}static — the platform serves it, nothing to run{{else if .MetricsUnavailable}}no data right now{{else}}copies responding{{if .InstanceAsleep}} · {{.InstanceAsleep}} asleep{{end}}{{end}}</p>
</a>
<a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/instances">
<h2>Resources</h2>
<p class="tile-value">{{pct .CPUSum}} · {{bytesOrDash .MemSum}}</p>
- <p class="tile-detail">{{if .CPUSum}}CPU · memory, from the machines responding{{else if .MetricsUnavailable}}no data right now{{else}}no recent data{{end}}</p>
+ <p class="tile-detail">{{if .CPUSum}}CPU · memory, from the copies responding{{else if .MetricsUnavailable}}no data right now{{else}}no recent data{{end}}</p>
</a>
<a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/settings">
<h2>Web addresses</h2>
diff --git a/internal/console/templates/app_releases.html b/internal/console/templates/app_releases.html
index 589f324fa..6c8074365 100644
--- a/internal/console/templates/app_releases.html
+++ b/internal/console/templates/app_releases.html
@@ -110,7 +110,7 @@ carlos ship -app {{.Detail.App}} -kind static -version v1 &lt;site-dir&gt;</code
{{end}}
</table>
</div>
-<p class="small">Go-live times are estimates — each machine keeps its own clock. Rollbacks skip the delay.</p>
+<p class="small">Go-live times are estimates — each copy keeps its own clock. Rollbacks skip the delay.</p>
{{else}}<p class="small">No channels yet — ship a release, then promote it below to create the first one.</p>{{end}}
<h2>Canaries</h2>
@@ -152,10 +152,10 @@ carlos ship -app {{.Detail.App}} -kind static -version v1 &lt;site-dir&gt;</code
</select>
<button type="submit" class="btn-sm">Create canary</button>
</form>
-<p class="small">Creates <code>canary/&lt;name&gt;</code> — a test version serving the chosen build immediately, no delay. It gets its own URL from a machine that follows it:</p>
+<p class="small">Creates <code>canary/&lt;name&gt;</code> — a test version serving the chosen build immediately, no delay. It gets its own URL from an address that follows it:</p>
{{if .AttachEnable}}<pre><code>{{.AttachEnable}}
{{.AttachCmd}}</code></pre>
-<p class="small">This app has no machines yet, so the first line sets one up under this account's platform subdomain. It comes live within a couple of minutes.</p>
+<p class="small">This app has no addresses yet, so the first line sets one up under this account's platform subdomain. It comes live within a couple of minutes.</p>
{{else}}<pre><code>{{.AttachCmd}}</code></pre>
<p class="small">The address comes live within a couple of minutes.</p>
{{end}}
diff --git a/internal/console/templates/flow.html b/internal/console/templates/flow.html
new file mode 100644
index 000000000..0cdb1dac1
--- /dev/null
+++ b/internal/console/templates/flow.html
@@ -0,0 +1,42 @@
+{{define "flow"}}{{with .Flow}}{{if .Show}}
+<section class="flow" aria-label="How this app's versions reach the web">
+ <ol class="flow-line">
+ <li class="flow-stage flow-origin{{if .Shipped}} is-held{{end}}">
+ <p class="flow-label">you shipped</p>
+ <p class="flow-version">{{if .Shipped}}<code>{{.Shipped}}</code>{{else}}nothing yet{{end}}</p>
+ {{if not .ShippedAt.IsZero}}<p class="flow-note">{{ago .ShippedAt}}</p>{{end}}
+ </li>
+ {{range .Line}}{{template "flow_stage" .}}{{end}}
+ </ol>
+ {{if .Tests}}
+ <p class="flow-band">Test versions — no delay, and they can point anywhere.</p>
+ <ul class="flow-line flow-aside">{{range .Tests}}{{template "flow_stage" .}}{{end}}</ul>
+ {{end}}
+ {{if .Extra}}
+ <p class="flow-band">Following a channel outside the release path.</p>
+ <ul class="flow-line flow-aside">{{range .Extra}}{{template "flow_stage" .}}{{end}}</ul>
+ {{end}}
+ <p class="small flow-caption">A version moves one step at a time, left to right. Promoting is what moves it.</p>
+</section>
+{{end}}{{end}}{{end}}
+
+{{/* One box: a channel, what it holds, and the addresses it feeds. Used as an
+ <li> in both the ordered release path and the unordered bands below it. */}}
+{{define "flow_stage"}}
+<li class="flow-stage{{if .Version}} is-held{{end}}{{if .Carried}} is-carried{{end}}">
+ <p class="flow-label">{{.Name}}</p>
+ <p class="flow-version">{{if .Version}}<code>{{.Version}}</code>{{else}}nothing{{end}}</p>
+ {{if .Hold}}<p class="flow-note">{{if .FastTrack}}delay skipped{{else}}waits {{.Hold}} here{{end}}</p>{{end}}
+ {{if .Err}}<p class="flow-note error">{{.Err}}</p>{{end}}
+ {{if .Addresses}}
+ <ul class="flow-addrs">
+ {{range .Addresses}}
+ <li class="flow-addr flow-tone-{{.Tone}}">
+ <a href="https://{{.Host}}">{{.Host}}</a>
+ <span class="flow-health">{{if eq .Tone "bad"}}{{icon "triangle-alert"}}{{end}}{{.Health}}</span>
+ </li>
+ {{end}}
+ </ul>
+ {{end}}
+</li>
+{{end}}
diff --git a/internal/console/templates/ladder.html b/internal/console/templates/ladder.html
index 4a57c86be..5adf19d0f 100644
--- a/internal/console/templates/ladder.html
+++ b/internal/console/templates/ladder.html
@@ -1,7 +1,7 @@
{{define "ladder"}}
<h2>The ladder</h2>
<p class="small">Every version climbs the same steps — canary (test) → edge → beta → stable (live).
-Promoting moves it one step up; the machines following that step pick it up.</p>
+Promoting moves it one step up; whatever follows that step picks it up.</p>
{{with .Detail.Production}}
<div class="bake">
{{if .Err}}
@@ -16,7 +16,7 @@ Promoting moves it one step up; the machines following that step pick it up.</p>
{{end}}
</p>
{{else if .On}}
- <p class="small">Safety delay: <strong>on</strong> — new versions wait ~24h on beta and ~72h on live before machines pick them up.
+ <p class="small">Safety delay: <strong>on</strong> — new versions wait ~24h on beta and ~72h on live before they go live.
{{if and $.CanPromote $.IsOwner}}
<form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/production" class="inline">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
@@ -88,7 +88,7 @@ Promoting moves it one step up; the machines following that step pick it up.</p>
{{end}}
</ul>
{{else}}
- <p class="small">No machine follows this channel.</p>
+ <p class="small">Nothing follows this channel yet.</p>
{{end}}
</div>
{{end}}
diff --git a/internal/console/templates/layout.html b/internal/console/templates/layout.html
index 820d2c96a..99cba569c 100644
--- a/internal/console/templates/layout.html
+++ b/internal/console/templates/layout.html
@@ -189,6 +189,73 @@
.chip-up { background: var(--accent-soft); color: #2f7a4f; }
.chip-down { background: var(--accent-soft); color: var(--bad); }
.chips { display: flex; flex-wrap: wrap; margin: 0 0 1rem; }
+ /* The app flow diagram (flow.go): the release path drawn as the pipeline it
+ is, with the addresses each channel feeds hanging under it.
+
+ The one claim the drawing makes beyond its own words is the ARROW: solid
+ and accented where a version really did travel that step, dashed and grey
+ where it did not. That is what lets "how far has my build got" be seen
+ rather than worked out by comparing three version strings, so the two
+ states have to stay visually unmistakable — and distinguishable by more
+ than colour, hence solid vs dashed. */
+ .flow { margin: 0 0 1.75rem; }
+ .flow-line {
+ --flow-y: 0.52rem;
+ display: flex; flex-wrap: wrap; gap: 1.1rem 2.6rem;
+ list-style: none; margin: 0; padding: 0;
+ }
+ .flow-stage { position: relative; flex: 1 1 10rem; min-width: 9rem; }
+ .flow-stage + .flow-stage::before, .flow-stage + .flow-stage::after {
+ content: ""; position: absolute; pointer-events: none;
+ }
+ .flow-stage + .flow-stage::before {
+ left: -2.05rem; top: var(--flow-y); width: 1.3rem; border-top: 1px dashed var(--rule);
+ }
+ .flow-stage + .flow-stage::after {
+ left: -0.82rem; top: var(--flow-y); transform: translateY(-50%);
+ border: 0.28rem solid transparent; border-right: 0; border-left-color: var(--rule);
+ }
+ .flow-stage.is-carried::before { border-top-style: solid; border-top-color: var(--accent); }
+ .flow-stage.is-carried::after { border-left-color: var(--accent); }
+ .flow-label {
+ margin: 0; font-family: var(--mono); font-size: 0.74rem; text-transform: uppercase;
+ letter-spacing: 0.05em; color: var(--muted); font-weight: 600;
+ }
+ .flow-version { margin: 0.3rem 0 0; font-size: 0.95rem; }
+ /* An empty box states its emptiness in words, so it needs no fill of its
+ own — the missing version chip is the signal. */
+ .flow-stage:not(.is-held) .flow-version { color: var(--muted); }
+ .flow-note { margin: 0.25rem 0 0; font-size: 0.78rem; color: var(--muted); }
+ .flow-addrs {
+ list-style: none; margin: 0.6rem 0 0; padding: 0 0 0 0.7rem;
+ border-left: 1px solid var(--rule); display: grid; gap: 0.35rem;
+ }
+ .flow-addr { font-size: 0.82rem; line-height: 1.35; }
+ .flow-addr a { display: block; overflow-wrap: anywhere; }
+ .flow-health { display: inline-flex; align-items: center; gap: 0.2rem; color: var(--muted); }
+ .flow-health .icon { width: 0.9em; height: 0.9em; flex: none; }
+ .flow-tone-ok .flow-health { color: var(--good); }
+ .flow-tone-wait .flow-health { color: var(--warn, #a6790f); }
+ .flow-tone-bad .flow-health { color: var(--bad); font-weight: 600; }
+ .flow-band { margin: 1.4rem 0 0.7rem; font-size: 0.9rem; color: var(--muted); }
+ .flow-caption { margin: 1.1rem 0 0; }
+ /* Narrow screens: the same path, turned down the page. The connector turns
+ with it — an arrow still pointing the way the version travels. */
+ @media (max-width: 46rem) {
+ .flow-line { display: block; }
+ .flow-stage + .flow-stage { margin-top: 1.6rem; }
+ .flow-stage + .flow-stage::before {
+ left: 0.55rem; top: -1.2rem; width: 0; height: 0.85rem;
+ border-top: 0; border-left: 1px dashed var(--rule);
+ }
+ .flow-stage + .flow-stage::after {
+ left: 0.55rem; top: -0.34rem; transform: translateX(-50%);
+ border: 0.28rem solid transparent; border-bottom: 0; border-top-color: var(--rule);
+ border-left-color: transparent;
+ }
+ .flow-stage.is-carried::before { border-left-color: var(--accent); }
+ .flow-stage.is-carried::after { border-top-color: var(--accent); border-left-color: transparent; }
+ }
/* The release ladder: four rungs drawn top-down in the order the spec
draws them (canary first, stable at the foot), with the promotion
between two of them rendered as the step it is. list-style is off
diff --git a/internal/console/view.go b/internal/console/view.go
index 58075ce25..6b57bddcb 100644
--- a/internal/console/view.go
+++ b/internal/console/view.go
@@ -714,21 +714,21 @@ const tagsCap = 20
// apps/<app>/releases/ with each manifest's kind and build stamp — newest
// first by built_at (best effort: RFC3339 parses win, then version string),
// capped at versionsCap. Unreadable manifests degrade to a row error.
+//
+// Manifests are read only for the versionsCap versions that will render:
+// release.VersionCandidates ranks the listing by manifest mod time when the
+// store carries stamps (both real stores do), and this fetches just the
+// survivors — fetch-then-sort made this page O(every version ever shipped)
+// in sequential bucket GETs, ~8s at tito's 318. A plain Store (test fakes
+// only) still yields every version, fetched and sorted exactly as before.
func Versions(s release.Store, acct, app string) ([]VersionView, error) {
ctx := context.Background()
- prefix := release.ReleasesPrefix(acct, app)
- keys, err := s.List(ctx, prefix)
+ cands, _, err := release.VersionCandidates(ctx, s, acct, app, versionsCap)
if err != nil {
return nil, err
}
- seen := map[string]bool{}
var out []VersionView
- for _, k := range keys {
- v, _, ok := strings.Cut(strings.TrimPrefix(k, prefix), "/")
- if !ok || v == "" || seen[v] {
- continue
- }
- seen[v] = true
+ for _, v := range cands {
m, err := release.GetManifest(ctx, s, acct, app, v)
if err != nil {
out = append(out, VersionView{Version: v, Err: err.Error()})
diff --git a/internal/console/view_test.go b/internal/console/view_test.go
index 76b22f46a..97242954a 100644
--- a/internal/console/view_test.go
+++ b/internal/console/view_test.go
@@ -288,6 +288,125 @@ func TestVersionsDegradesBadManifest(t *testing.T) {
}
}
+// countingModStore is the production store shape in miniature — a bare
+// DirStore, ListMod and all (struct embedding, so the method survives where
+// deletableStore's interface embedding would drop it) — counting manifest
+// reads.
+type countingModStore struct {
+ *release.DirStore
+ gets int
+}
+
+func (c *countingModStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ c.gets++
+ return c.DirStore.Get(ctx, key)
+}
+
+// shipAt ships a real release (manifest and all — GetManifest's validation
+// gate must accept these rows) and pins every object's mod time so the
+// ranking key is under the test's control.
+func shipAt(t *testing.T, store release.Store, dir, v string, mod time.Time) {
+ t.Helper()
+ ctx := context.Background()
+ m := release.Manifest{
+ Schema: 1, App: "kass", Version: v, Kind: "binary", BuiltAt: mod.Format(time.RFC3339),
+ Artifacts: map[string]release.Artifact{"linux-amd64": {File: "kass"}},
+ }
+ if err := release.Ship(ctx, store, release.OpsAccount, m, map[string][]byte{"linux-amd64": []byte("binary " + v)}); err != nil {
+ t.Fatal(err)
+ }
+ for _, key := range []string{
+ release.ManifestKey(release.OpsAccount, "kass", v),
+ release.ArtifactKey(release.OpsAccount, "kass", v, "kass"),
+ } {
+ p := filepath.Join(dir, filepath.FromSlash(key))
+ if _, err := os.Stat(p); err != nil {
+ continue // artifact filename differs per Ship's layout; manifest is the one that must rank
+ }
+ if err := os.Chtimes(p, mod, mod); err != nil {
+ t.Fatal(err)
+ }
+ }
+}
+
+// TestVersionsBoundsManifestReads: when the store can rank keys by recency
+// (ModLister — both real stores), Versions reads only the manifests it will
+// render, and picks them by manifest MOD TIME, not by name — version names
+// here are numbered AGAINST recency (v01 shipped last), so a selection that
+// quietly ranked by name would return the wrong rows. Fetch-then-sort made
+// the app page O(every version ever shipped) in sequential bucket GETs —
+// tito's titogo reached 318 versions and ~8s per page render before this
+// was bounded.
+func TestVersionsBoundsManifestReads(t *testing.T) {
+ dir := t.TempDir()
+ store := &countingModStore{DirStore: release.NewDirStore(dir)}
+ base := time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC)
+ const shipped = versionsCap + 5
+ for i := 1; i <= shipped; i++ {
+ // v01 gets the NEWEST stamp, v25 the oldest.
+ shipAt(t, store, dir, fmt.Sprintf("v%02d", i), base.Add(time.Duration(shipped-i)*time.Minute))
+ }
+ store.gets = 0 // Ship reads during setup don't count
+ vs, err := Versions(store, release.OpsAccount, "kass")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(vs) != versionsCap {
+ t.Fatalf("len = %d, want versionsCap %d", len(vs), versionsCap)
+ }
+ // The newest versionsCap ships, newest first: v01..v20. v21..v25 (the
+ // oldest ships, despite their bigger numbers) are the ones cut.
+ for i := 0; i < versionsCap; i++ {
+ want := fmt.Sprintf("v%02d", i+1)
+ if vs[i].Version != want {
+ t.Fatalf("vs[%d] = %s, want %s (rank by mod time, not name)", i, vs[i].Version, want)
+ }
+ }
+ // Real rendered rows, not error rows: the fixture manifests must survive
+ // GetManifest's validation.
+ if vs[0].Kind != "binary" || vs[0].BuiltAt == "" || vs[0].Err != "" {
+ t.Fatalf("vs[0] = %+v, want a rendered release row", vs[0])
+ }
+ if store.gets != versionsCap {
+ t.Fatalf("read %d manifests for %d shipped versions, want %d — rank by mod time BEFORE reading", store.gets, shipped, versionsCap)
+ }
+}
+
+// TestVersionsRankedPathDegrades: the ranked path keeps the fallback path's
+// degradation contract — a junk manifest and a ship that died before its
+// manifest each cost one row (Err set, sorted last by their unparseable
+// built_at), never the listing.
+func TestVersionsRankedPathDegrades(t *testing.T) {
+ dir := t.TempDir()
+ store := &countingModStore{DirStore: release.NewDirStore(dir)}
+ ctx := context.Background()
+ base := time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC)
+ shipAt(t, store, dir, "good1", base)
+ shipAt(t, store, dir, "good2", base.Add(time.Hour))
+ if _, err := store.Put(ctx, release.ManifestKey(release.OpsAccount, "kass", "junk1"), []byte("junk"), ""); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := store.Put(ctx, release.ArtifactKey(release.OpsAccount, "kass", "dead1", "kass"), []byte("bin"), ""); err != nil {
+ t.Fatal(err)
+ }
+ vs, err := Versions(store, release.OpsAccount, "kass")
+ if err != nil {
+ t.Fatalf("Versions must not fail on bad rows: %v", err)
+ }
+ if len(vs) != 4 {
+ t.Fatalf("versions = %+v, want all 4 rows", vs)
+ }
+ // Healthy rows first (newest built_at first), degraded rows last.
+ if vs[0].Version != "good2" || vs[1].Version != "good1" {
+ t.Fatalf("healthy rows = %s, %s; want good2, good1", vs[0].Version, vs[1].Version)
+ }
+ for _, i := range []int{2, 3} {
+ if vs[i].Err == "" {
+ t.Fatalf("vs[%d] = %+v, want a degraded row", i, vs[i])
+ }
+ }
+}
+
func TestVersionsEmptyApp(t *testing.T) {
_, store, _, _ := buildFixtures(t)
vs, err := Versions(store, release.OpsAccount, "ghost")
diff --git a/internal/release/candidates.go b/internal/release/candidates.go
new file mode 100644
index 000000000..d95fb5ecf
--- /dev/null
+++ b/internal/release/candidates.go
@@ -0,0 +1,101 @@
+// Version-candidate selection: which of an app's shipped versions are worth
+// reading manifests for, decided from the listing alone. Shared by the
+// console's app page and `carlos releases` direct mode, so the two surfaces
+// agree about what "the latest releases" are.
+package release
+
+import (
+ "context"
+ "sort"
+ "strings"
+ "time"
+)
+
+// VersionCandidates lists the distinct versions under an app's releases
+// prefix, in the order manifests should be read.
+//
+// With a ModLister (both real stores), the answer is RANKED: the newest max
+// versions by each manifest.json object's own last-modified stamp, newest
+// first. The stamp is written at ship time, so it is a best-effort stand-in
+// for the manifest's built_at — best-effort meaning: S3 stamps are
+// second-granularity (same-second ships tie, broken deterministically by
+// version name descending, the final-sort tie-break every caller already
+// uses), and `accounts migrate` re-stamps the whole pre-migration backlog at
+// migration time. A caller then reads only the manifests it will render —
+// fetch-then-sort read every manifest ever shipped, and a CI-shipped app
+// (tito's titogo, 318 versions) took ~8s of sequential bucket GETs per page
+// render that way.
+//
+// A version with no manifest.json ranks by its newest object instead — an
+// in-flight ship (artifacts land before the manifest, Ship's write order)
+// briefly occupies a slot as the error row its fetch produces, and a ship
+// that died mid-write stays visible as one. Deliberate: the old sort-last
+// behavior silently hid an aborted ship forever once an app had max readable
+// versions, and an operator whose ship died wants the wreckage on the page,
+// not vanished.
+//
+// With a plain Store (test fakes; no production caller — cmd/console and the
+// CLI both hand over a bare *S3Store or *DirStore), the answer is every
+// version, unranked and uncapped, in listing order: the caller keeps its
+// fetch-everything path, byte-identical to the pre-ModLister behavior.
+func VersionCandidates(ctx context.Context, s Store, acct, app string, max int) (versions []string, ranked bool, err error) {
+ prefix := ReleasesPrefix(acct, app)
+ type cand struct {
+ version string
+ mod time.Time // manifest.json's stamp once seen; else newest object's
+ manifest bool
+ }
+ var cands []cand
+ index := map[string]int{}
+ note := func(key string, mod time.Time) {
+ v, rest, ok := strings.Cut(strings.TrimPrefix(key, prefix), "/")
+ if !ok || v == "" {
+ return
+ }
+ isManifest := rest == "manifest.json"
+ i, seen := index[v]
+ if !seen {
+ index[v] = len(cands)
+ cands = append(cands, cand{version: v, mod: mod, manifest: isManifest})
+ return
+ }
+ switch {
+ case isManifest:
+ cands[i].mod, cands[i].manifest = mod, true
+ case !cands[i].manifest && mod.After(cands[i].mod):
+ cands[i].mod = mod
+ }
+ }
+ ml, ranked := s.(ModLister)
+ if ranked {
+ keys, err := ml.ListMod(ctx, prefix)
+ if err != nil {
+ return nil, false, err
+ }
+ for _, km := range keys {
+ note(km.Key, km.Mod)
+ }
+ sort.SliceStable(cands, func(i, j int) bool {
+ if !cands[i].mod.Equal(cands[j].mod) {
+ return cands[i].mod.After(cands[j].mod)
+ }
+ return cands[i].version > cands[j].version
+ })
+ if len(cands) > max {
+ cands = cands[:max]
+ }
+ } else {
+ keys, err := s.List(ctx, prefix)
+ if err != nil {
+ return nil, false, err
+ }
+ for _, k := range keys {
+ note(k, time.Time{})
+ }
+ }
+ versions = make([]string, len(cands))
+ for i, c := range cands {
+ versions[i] = c.version
+ }
+ return versions, ranked, nil
+}
diff --git a/internal/release/store.go b/internal/release/store.go
index f672d74e4..ca6166cc1 100644
--- a/internal/release/store.go
+++ b/internal/release/store.go
@@ -19,6 +19,7 @@ import (
"sort"
"strings"
"sync"
+ "time"
"github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
@@ -53,6 +54,30 @@ type Store interface {
Delete(ctx context.Context, key string) error
}
+// KeyMod pairs a listed key with the object's last-modified stamp.
+type KeyMod struct {
+ Key string
+ Mod time.Time
+}
+
+// ModLister is the optional Store extension for listings that carry each
+// object's last-modified time — information both real stores already have in
+// hand (S3 returns it with every ListObjectsV2 entry; DirStore has the file
+// mtime) and List throws away. It exists so a caller ranking objects by
+// recency can decide WHICH bodies to read before reading any: the app page's
+// release list was O(every version ever shipped) in bucket GETs without it.
+// Optional rather than a Store method so the many test fakes that implement
+// Store stay valid; callers type-assert and fall back to List.
+type ModLister interface {
+ ListMod(ctx context.Context, prefix string) ([]KeyMod, error)
+}
+
+// Both real stores answer ListMod; only fakes fall back.
+var (
+ _ ModLister = (*DirStore)(nil)
+ _ ModLister = (*S3Store)(nil)
+)
+
// ---- DirStore ---------------------------------------------------------------
// DirStore keeps objects as files under a root directory — the test double
@@ -140,10 +165,22 @@ func (d *DirStore) Put(_ context.Context, key string, body []byte, ifMatch strin
return etagOf(body), nil
}
-func (d *DirStore) List(_ context.Context, prefix string) ([]string, error) {
+// List is ListMod minus the mtimes — one walk owns the prefix filter and the
+// .tmp exclusion, so the two listings cannot drift.
+func (d *DirStore) List(ctx context.Context, prefix string) ([]string, error) {
+ kms, err := d.ListMod(ctx, prefix)
+ out := make([]string, len(kms))
+ for i, km := range kms {
+ out[i] = km.Key
+ }
+ return out, err
+}
+
+// ListMod is the listing with each file's mtime alongside, key-sorted.
+func (d *DirStore) ListMod(_ context.Context, prefix string) ([]KeyMod, error) {
d.mu.Lock()
defer d.mu.Unlock()
- var out []string
+ var out []KeyMod
err := filepath.WalkDir(d.root, func(p string, de fs.DirEntry, err error) error {
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
@@ -159,12 +196,24 @@ func (d *DirStore) List(_ context.Context, prefix string) ([]string, error) {
return err
}
key := filepath.ToSlash(rel)
- if strings.HasPrefix(key, prefix) {
- out = append(out, key)
+ if !strings.HasPrefix(key, prefix) {
+ return nil
+ }
+ info, err := de.Info()
+ if err != nil {
+ // Vanished between readdir and stat — another process's delete
+ // racing this walk (in-process writers all hold d.mu). A file
+ // that is gone is not listed; it does not break the listing.
+ // Untestable deterministically, so pinned by comment alone.
+ if errors.Is(err, fs.ErrNotExist) {
+ return nil
+ }
+ return err
}
+ out = append(out, KeyMod{Key: key, Mod: info.ModTime()})
return nil
})
- sort.Strings(out)
+ sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key })
return out, err
}
@@ -326,8 +375,23 @@ func (s *S3Store) Put(ctx context.Context, key string, body []byte, ifMatch stri
return aws.ToString(out.ETag), nil
}
+// List is ListMod minus the stamps — one paginator loop serves both.
func (s *S3Store) List(ctx context.Context, prefix string) ([]string, error) {
- var out []string
+ kms, err := s.ListMod(ctx, prefix)
+ if err != nil {
+ return nil, err
+ }
+ out := make([]string, len(kms))
+ for i, km := range kms {
+ out[i] = km.Key
+ }
+ return out, nil
+}
+
+// ListMod is the listing keeping the LastModified S3 already sends with
+// every entry.
+func (s *S3Store) ListMod(ctx context.Context, prefix string) ([]KeyMod, error) {
+ var out []KeyMod
p := awss3.NewListObjectsV2Paginator(s.Client, &awss3.ListObjectsV2Input{
Bucket: &s.Bucket,
Prefix: &prefix,
@@ -338,7 +402,7 @@ func (s *S3Store) List(ctx context.Context, prefix string) ([]string, error) {
return nil, err
}
for _, o := range page.Contents {
- out = append(out, aws.ToString(o.Key))
+ out = append(out, KeyMod{Key: aws.ToString(o.Key), Mod: aws.ToTime(o.LastModified)})
}
}
return out, nil
diff --git a/internal/release/store_test.go b/internal/release/store_test.go
index a7a5f7726..417e6efeb 100644
--- a/internal/release/store_test.go
+++ b/internal/release/store_test.go
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"os"
+ "path/filepath"
"strings"
"testing"
"time"
@@ -172,6 +173,50 @@ func TestDirStoreListDirs(t *testing.T) {
}
}
+// TestDirStoreListMod: the mod-time-aware listing answers exactly like List
+// — same keys, same order, same prefix filter — with each key's mtime
+// alongside, so a caller can rank by recency without reading a single body.
+func TestDirStoreListMod(t *testing.T) {
+ dir := t.TempDir()
+ s := NewDirStore(dir)
+ ctx := context.Background()
+ older := time.Date(2026, 7, 1, 10, 0, 0, 0, time.UTC)
+ newer := time.Date(2026, 8, 2, 10, 0, 0, 0, time.UTC)
+ for k, mod := range map[string]time.Time{
+ "apps/bab/kass/releases/v1/manifest.json": older,
+ "apps/bab/kass/releases/v2/manifest.json": newer,
+ "apps/bab/kass/channels/stable.json": newer, // outside the prefix
+ } {
+ if _, err := s.Put(ctx, k, []byte(`{}`), ""); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chtimes(filepath.Join(dir, filepath.FromSlash(k)), mod, mod); err != nil {
+ t.Fatal(err)
+ }
+ }
+ got, err := s.ListMod(ctx, "apps/bab/kass/releases/")
+ if err != nil {
+ t.Fatalf("ListMod: %v", err)
+ }
+ want := []KeyMod{
+ {Key: "apps/bab/kass/releases/v1/manifest.json", Mod: older},
+ {Key: "apps/bab/kass/releases/v2/manifest.json", Mod: newer},
+ }
+ if len(got) != len(want) {
+ t.Fatalf("ListMod = %+v, want %+v", got, want)
+ }
+ for i := range want {
+ if got[i].Key != want[i].Key || !got[i].Mod.Equal(want[i].Mod) {
+ t.Fatalf("ListMod[%d] = %+v, want %+v", i, got[i], want[i])
+ }
+ }
+ // A missing prefix lists empty like an empty bucket, not an error.
+ empty, err := s.ListMod(ctx, "apps/nosuch/")
+ if err != nil || len(empty) != 0 {
+ t.Fatalf("ListMod of a missing prefix = %v, %v; want empty and no error", empty, err)
+ }
+}
+
// TestS3StoreLive exercises the production store against a real bucket —
// only when CARLOS_TEST_S3_BUCKET is set; the type otherwise just compiles.
func TestS3StoreLive(t *testing.T) {
diff --git a/internal/release/versions_test.go b/internal/release/versions_test.go
index ceb3c3437..267b6e41a 100644
--- a/internal/release/versions_test.go
+++ b/internal/release/versions_test.go
@@ -1,6 +1,7 @@
package release
import (
+ "context"
"encoding/json"
"errors"
"net"
@@ -344,3 +345,97 @@ func TestReadStagedManifestRefusesAnIdentityMismatch(t *testing.T) {
t.Fatal("a sibling naming a different version than its path was accepted")
}
}
+
+// modStamp writes key and pins its mtime — candidate ranking reads nothing
+// else.
+func modStamp(t *testing.T, s *DirStore, dir, key string, mod time.Time) {
+ t.Helper()
+ if _, err := s.Put(context.Background(), key, []byte(`x`), ""); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chtimes(filepath.Join(dir, filepath.FromSlash(key)), mod, mod); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// TestVersionCandidatesRanked: with a mod-aware store the candidates are the
+// newest max versions by each manifest object's own stamp — the mod order
+// here deliberately DISAGREES with version-name order, so a selection that
+// quietly ranked by name would fail — and a version whose ship never wrote a
+// manifest ranks by its newest artifact, so an aborted ship surfaces (as the
+// caller's error row) rather than vanishing.
+func TestVersionCandidatesRanked(t *testing.T) {
+ dir := t.TempDir()
+ s := NewDirStore(dir)
+ base := time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC)
+ for v, mod := range map[string]time.Time{
+ "aa": base.Add(3 * time.Hour), // newest ship, first lexically too
+ "zz": base.Add(2 * time.Hour), // second-newest despite sorting last by name
+ "cc": base.Add(1 * time.Hour),
+ "bb": base, // oldest — the one the cap must cut
+ } {
+ modStamp(t, s, dir, ManifestKey("bab", "kass", v), mod)
+ // An artifact NEWER than its own manifest must not outrank the
+ // manifest's stamp.
+ modStamp(t, s, dir, ArtifactKey("bab", "kass", v, "linux-arm64"), mod.Add(30*time.Minute))
+ }
+ // bb's ARTIFACT ends up newer than cc's MANIFEST: a ranking that took
+ // the max over all of a version's objects instead of letting the
+ // manifest's own stamp win would rank bb (100m) over cc (60m) and evict
+ // cc from the want below — this line is what pins the rule.
+ bbArtifact := filepath.Join(dir, filepath.FromSlash(ArtifactKey("bab", "kass", "bb", "linux-arm64")))
+ if err := os.Chtimes(bbArtifact, base.Add(100*time.Minute), base.Add(100*time.Minute)); err != nil {
+ t.Fatal(err)
+ }
+ // A ship that died before manifest.json: artifact only, newest of all.
+ modStamp(t, s, dir, ArtifactKey("bab", "kass", "dead1", "linux-arm64"), base.Add(4*time.Hour))
+
+ got, ranked, err := VersionCandidates(context.Background(), s, "bab", "kass", 4)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !ranked {
+ t.Fatal("DirStore is a ModLister — candidates must come back ranked")
+ }
+ want := []string{"dead1", "aa", "zz", "cc"}
+ if len(got) != len(want) {
+ t.Fatalf("candidates = %v, want %v", got, want)
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Fatalf("candidates = %v, want %v", got, want)
+ }
+ }
+}
+
+// plainStore hides ListMod behind the bare interface, the shape every test
+// fake has.
+type plainStore struct{ Store }
+
+// TestVersionCandidatesFallback: a store without ListMod answers every
+// version, unranked and uncapped, in listing order — the caller's
+// fetch-everything path, byte-identical to the pre-ModLister behavior.
+func TestVersionCandidatesFallback(t *testing.T) {
+ dir := t.TempDir()
+ s := NewDirStore(dir)
+ base := time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC)
+ for i, v := range []string{"v1", "v2", "v3"} {
+ modStamp(t, s, dir, ManifestKey("bab", "kass", v), base.Add(time.Duration(i)*time.Hour))
+ }
+ got, ranked, err := VersionCandidates(context.Background(), plainStore{s}, "bab", "kass", 2)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if ranked {
+ t.Fatal("a bare Store cannot rank")
+ }
+ want := []string{"v1", "v2", "v3"} // listing order, all of them — no cap
+ if len(got) != len(want) {
+ t.Fatalf("candidates = %v, want %v", got, want)
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Fatalf("candidates = %v, want %v", got, want)
+ }
+ }
+}
diff --git a/releases_local_test.go b/releases_local_test.go
new file mode 100644
index 000000000..556133f50
--- /dev/null
+++ b/releases_local_test.go
@@ -0,0 +1,77 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// manifestCountingStore is the production store shape in miniature — a bare
+// DirStore, ListMod and all (struct embedding keeps the method) — counting
+// manifest reads specifically, so the tag reads ListTags does don't muddy
+// the assertion.
+type manifestCountingStore struct {
+ *release.DirStore
+ manifestGets int
+}
+
+func (c *manifestCountingStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ if strings.HasSuffix(key, "/manifest.json") {
+ c.manifestGets++
+ }
+ return c.DirStore.Get(ctx, key)
+}
+
+// TestLocalReleasesBoundsManifestReads: `carlos releases` direct mode reads
+// only the manifests it will print, chosen by manifest mod time exactly as
+// the console does (release.VersionCandidates is shared) — version names
+// here are numbered AGAINST recency so a name-ranked selection fails, and a
+// fetch-everything regression fails the read count.
+func TestLocalReleasesBoundsManifestReads(t *testing.T) {
+ dir := t.TempDir()
+ store := &manifestCountingStore{DirStore: release.NewDirStore(dir)}
+ ctx := context.Background()
+ base := time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC)
+ const shipped = releasesCap + 5
+ for i := 1; i <= shipped; i++ {
+ v := fmt.Sprintf("v%02d", i)
+ mod := base.Add(time.Duration(shipped-i) * time.Minute) // v01 newest
+ m := release.Manifest{
+ Schema: 1, App: "kass", Version: v, Kind: "binary", BuiltAt: mod.Format(time.RFC3339),
+ Artifacts: map[string]release.Artifact{"linux-amd64": {File: "kass"}},
+ }
+ if err := release.Ship(ctx, store, release.OpsAccount, m, map[string][]byte{"linux-amd64": []byte("bin " + v)}); err != nil {
+ t.Fatal(err)
+ }
+ key := release.ManifestKey(release.OpsAccount, "kass", v)
+ if err := os.Chtimes(filepath.Join(dir, filepath.FromSlash(key)), mod, mod); err != nil {
+ t.Fatal(err)
+ }
+ }
+ store.manifestGets = 0 // Ship reads during setup don't count
+ rows, err := localReleases(ctx, store, release.OpsAccount, "kass")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(rows) != releasesCap {
+ t.Fatalf("len = %d, want releasesCap %d", len(rows), releasesCap)
+ }
+ for i := 0; i < releasesCap; i++ {
+ want := fmt.Sprintf("v%02d", i+1)
+ if rows[i].Version != want {
+ t.Fatalf("rows[%d] = %s, want %s (rank by mod time, not name)", i, rows[i].Version, want)
+ }
+ }
+ if rows[0].Kind != "binary" || rows[0].Err != "" {
+ t.Fatalf("rows[0] = %+v, want a rendered release row", rows[0])
+ }
+ if store.manifestGets != releasesCap {
+ t.Fatalf("read %d manifests for %d shipped versions, want %d", store.manifestGets, shipped, releasesCap)
+ }
+}