carlos / platform Public

zdd-6 task 12: the instance page shows installed, serving and the hold, with Retry

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev eede32ffc10f438c79227f71e2ec31d5a21a7264 parent 33d2d59
8 files changed, +290 −3
  • internal/console/browserretry.go +81 −0
  • internal/console/browserretry_test.go +116 −0
  • internal/console/claim_invariant_fixtures_test.go +24 −0
  • internal/console/instancepage.go +54 −0
  • internal/console/server.go +1 −0
  • internal/console/templates/app_instance.html +10 −1
  • internal/console/testdata/claim-routes.txt +1 −0
  • internal/provision/plan9_b8_census_test.go +3 −2
diff --git a/internal/console/browserretry.go b/internal/console/browserretry.go
new file mode 100644
index 000000000..b15053519
--- /dev/null
+++ b/internal/console/browserretry.go
@@ -0,0 +1,81 @@
+package console
+
+import (
+ "context"
+ "crypto/subtle"
+ "errors"
+ "log"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/provision"
+)
+
+// handleInstanceRetry is the instance page's Retry (spec §9.2): the browser
+// twin of `carlos instances retry`, shaped like the restart action: session,
+// CSRF, the byte-identical 404, a confirmation page, then the write. It
+// reads and writes through the CLI door's helpers (retryHostBuild,
+// requestHostRetry), so the two cannot disagree about what is held.
+func (s *Server) handleInstanceRetry(w http.ResponseWriter, r *http.Request) {
+ addr, raw, acct, role, ok := s.account(w, r)
+ if !ok {
+ return
+ }
+ if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
+ s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
+ return
+ }
+ app := r.PathValue("app")
+ host := strings.TrimSpace(r.PathValue("host"))
+ has, err := s.store.HasAppIn(acct.ID, app)
+ if err != nil {
+ s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read your apps. Please try again.")
+ return
+ }
+ if !has || !provision.ValidHostShape(host) || s.cfg.Releases == nil {
+ http.NotFound(w, r)
+ return
+ }
+ back := "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) + "/instances/" + url.PathEscape(host)
+ flash := func(kind, msg string) {
+ s.setFlash(w, kind, msg)
+ http.Redirect(w, r, back, http.StatusSeeOther)
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
+ defer cancel()
+ build, err := s.retryHostBuild(ctx, acct, app, host)
+ switch {
+ case errors.Is(err, errRetryNoRecord):
+ http.NotFound(w, r)
+ return
+ case errors.Is(err, errRetryNotHeld):
+ flash("err", strings.ReplaceAll(swapCopy.NotHeld, "{host}", host))
+ return
+ case err != nil:
+ log.Printf("console: browser retry %s/%s/%s: %v", acct.Sqid, app, host, err)
+ flash("err", swapCopy.RetryFailed)
+ return
+ }
+ fill := strings.NewReplacer("{host}", host, "{build}", build)
+ if !confirmed(r) {
+ s.renderConfirm(w, r, addr, raw, acct, role, app, &ConfirmView{
+ Action: r.URL.Path, Fields: [][2]string{{"csrf", raw}}, CancelURL: back,
+ Title: fill.Replace(swapCopy.RetryTitle), Lines: []string{fill.Replace(swapCopy.RetryLine)},
+ Button: swapCopy.RetryButton,
+ })
+ return
+ }
+ if !s.allowWrite(Principal{Kind: PrincipalUser, Address: addr}, time.Now()) {
+ flash("err", swapCopy.RetryLimited)
+ return
+ }
+ if _, err := s.requestHostRetry(ctx, acct.Sqid, app, host); err != nil {
+ log.Printf("console: browser retry %s/%s/%s: %v", acct.Sqid, app, host, err)
+ flash("err", swapCopy.RetryFailed)
+ return
+ }
+ s.auditActivity(acct, app, addr, Activity{Action: "retry", TargetType: "instance", Target: host, Detail: fill.Replace(swapCopy.Audit)})
+ flash("ok", fill.Replace(swapCopy.RetryDone))
+}
diff --git a/internal/console/browserretry_test.go b/internal/console/browserretry_test.go
new file mode 100644
index 000000000..fc6b61a00
--- /dev/null
+++ b/internal/console/browserretry_test.go
@@ -0,0 +1,116 @@
+package console
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/rolloutwords"
+ "github.com/carlosframework/platform/internal/status"
+)
+
+const heldHost = "web.kass.carlosapp.dev"
+
+func heldFixture(t *testing.T) *consoleFixture {
+ t.Helper()
+ fx := newConsoleFixture(t)
+ fx.addRoute(t, registry.Route{Host: heldHost, App: "kass", Account: fx.sqid, Kind: "instance", Status: "ready", Version: "v2", Channel: "stable"})
+ sibling(t, fx, "kass", heldHost, "stable")
+ writeHeldBox(t, fx, heldHost, "kass", "v2")
+ return fx
+}
+
+func TestInstancePageShowsInstalledServingAndTheHold(t *testing.T) {
+ fx := heldFixture(t)
+ c := signInPaul(t, fx)
+ code, _, body := get(t, c, fx.u("/apps/kass/instances/"+heldHost))
+ if code != http.StatusOK {
+ t.Fatalf("page = %d", code)
+ }
+ for _, want := range []string{
+ swapCopy.Installed, "<code>v2</code>", swapCopy.Serving, "<code>v1</code>", swapCopy.Hold,
+ fmt.Sprintf(swapCopy.HoldText, "v2", rolloutwords.Reason(status.ReasonHealth), "v1"),
+ fmt.Sprintf(swapCopy.PromoteRetry, "kass", "v2", "stable"),
+ `action="/a/` + fx.sqid + `/apps/kass/instances/` + heldHost + `/retry"`,
+ } {
+ if !strings.Contains(body, want) {
+ t.Errorf("page lacks %q", want)
+ }
+ }
+}
+
+func TestInstancePageWithoutAReportIsUnchanged(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ _, _, body := get(t, c, fx.u("/apps/kass/instances/kass.carlosapp.dev"))
+ if !strings.Contains(body, instanceCopy.Observed) || strings.Contains(body, swapCopy.Installed) {
+ t.Fatal("a host with no rollout report must show the page as before")
+ }
+}
+
+// A box that could not arm its scheduler reports only the installed build:
+// the page keeps Installed, shows no serving build, no hold and no Retry.
+func TestInstancePageOfAnUnavailableBoxShowsNoHoldAndNoRetry(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.addRoute(t, registry.Route{Host: heldHost, App: "kass", Account: fx.sqid, Kind: "instance", Status: "ready", Version: "v2", Channel: "stable"})
+ sibling(t, fx, "kass", heldHost, "stable")
+ box := status.CollectBox(nil, status.Probes{MachineID: "box1"}, time.Now())
+ box.Metrics = &status.BoxMetrics{
+ RouteVersions: map[string]string{heldHost: "v2"},
+ Rollout: &status.RolloutReport{Unavailable: "no-cgroup-delegation"},
+ }
+ if err := status.WriteBox(context.Background(), fx.rel, box); err != nil {
+ t.Fatal(err)
+ }
+ c := signInPaul(t, fx)
+ code, _, body := get(t, c, fx.u("/apps/kass/instances/"+heldHost))
+ if code != http.StatusOK {
+ t.Fatalf("page = %d", code)
+ }
+ if !strings.Contains(body, swapCopy.Installed) || !strings.Contains(body, "<code>v2</code>") {
+ t.Error("an unavailable box's page must keep the installed build")
+ }
+ for _, bad := range []string{swapCopy.Hold, swapCopy.RetryButton, "/retry\"", "carlos promote --app"} {
+ if strings.Contains(body, bad) {
+ t.Errorf("an unavailable box's page must not show %q", bad)
+ }
+ }
+}
+
+func TestBrowserRetryConfirmsThenWrites(t *testing.T) {
+ fx := heldFixture(t)
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+ endpoint := fx.u("/apps/kass/instances/" + heldHost + "/retry")
+ code, _, body := postForm(t, c, endpoint, url.Values{"csrf": {csrf}})
+ title := strings.NewReplacer("{host}", heldHost, "{build}", "v2").Replace(swapCopy.RetryTitle)
+ if code != http.StatusOK || !strings.Contains(body, title) {
+ t.Fatalf("confirm = %d, lacks %q", code, title)
+ }
+ code, loc, _ := postGated(t, fx, c, endpoint, url.Values{"csrf": {csrf}, "confirmed": {"1"}})
+ if code != http.StatusSeeOther || loc != fx.path("/apps/kass/instances/"+heldHost) {
+ t.Fatalf("retry = %d -> %q", code, loc)
+ }
+ if r := lastScoped(t, fx, "kass"); r.Scope.Kind != release.RestartScopeRetry || r.Scope.Value != heldHost {
+ t.Fatalf("stored = %+v", r)
+ }
+}
+
+func TestBrowserRetryRefusals(t *testing.T) {
+ fx := heldFixture(t)
+ c := signInPaul(t, fx)
+ if code, _, _ := postForm(t, c, fx.u("/apps/kass/instances/"+heldHost+"/retry"), url.Values{"csrf": {"wrong"}}); code != http.StatusForbidden {
+ t.Fatalf("bad csrf = %d", code)
+ }
+ csrf := sessionToken(t, fx, c)
+ code, _, body := postForm(t, c, fx.u("/apps/kass/instances/nope.kass.carlosapp.dev/retry"), url.Values{"csrf": {csrf}, "confirmed": {"1"}})
+ if code != http.StatusNotFound || body != "404 page not found\n" {
+ t.Fatalf("unknown host = %d %q", code, body)
+ }
+}
diff --git a/internal/console/claim_invariant_fixtures_test.go b/internal/console/claim_invariant_fixtures_test.go
index ec5ef687e..50c8df0ad 100644
--- a/internal/console/claim_invariant_fixtures_test.go
+++ b/internal/console/claim_invariant_fixtures_test.go
@@ -911,6 +911,30 @@ var claimFixtures = map[string]claimFixture{
apps: claimRefs("bab/kass"),
do: claimForm("/a/bab/apps/kass/instances/kass.carlosapp.dev/restart", "confirmed", "1"),
}},
+ "route POST /a/{sqid}/apps/{app}/instances/{host}/retry": {door: &claimDoor{
+ apps: claimRefs("bab/titogo"),
+ setup: func(t *testing.T, cx *claimCase) { cx.heldInstance(t, "web.go.tito.io") },
+ do: claimForm("/a/bab/apps/titogo/instances/web.go.tito.io/retry", "confirmed", "1"),
+ check: func(t *testing.T, cx *claimCase, open bool) string {
+ g, found, err := release.LoadRestartScopes(context.Background(), cx.rel, "bab", "titogo")
+ if err != nil {
+ return err.Error()
+ }
+ asked := false
+ if found {
+ for _, r := range g.Requests {
+ asked = asked || (r.Scope.Kind == release.RestartScopeRetry && r.Scope.Value == "web.go.tito.io")
+ }
+ }
+ switch {
+ case open && !asked:
+ return "the retry request was not written: the handler never reached the narrowed log"
+ case !open && asked:
+ return "a retry request was written while the app was closed under a foreign exclusive operation"
+ }
+ return ""
+ },
+ }},
"route POST /a/{sqid}/apps/{app}/keep": {door: &claimDoor{
apps: claimRefs("bab/tmp-claim-keep"),
setup: func(t *testing.T, cx *claimCase) { cx.temporary(t, "tmp-claim-keep") },
diff --git a/internal/console/instancepage.go b/internal/console/instancepage.go
index 7d3236754..412f7473d 100644
--- a/internal/console/instancepage.go
+++ b/internal/console/instancepage.go
@@ -3,11 +3,14 @@ package console
import (
"encoding/base64"
"encoding/json"
+ "fmt"
"net/http"
"net/url"
"sort"
+ "time"
"github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/rolloutwords"
"github.com/carlosframework/platform/internal/status"
)
@@ -21,6 +24,53 @@ type instanceDetail struct {
// reason, which can carry a path (R10). The approved fallback text
// when no box's report names this host.
Layout string
+ // Rollout is this host's rollout as its box last reported it (spec
+ // §9.2); nil when no fresh box reports one, and the page is as before.
+ // App and Channel name the `promote --retry` the hold suggests.
+ Rollout *rolloutView
+ App, Channel string
+}
+
+// HoldText is the Failure hold row (spec §9.2), or "" when nothing holds
+// this host back (and always for a box that could not arm its scheduler,
+// whose view carries no hold).
+func (d *instanceDetail) HoldText() string {
+ r := d.Rollout
+ if r == nil || r.Unavailable != "" || r.HeldBy == "" {
+ return ""
+ }
+ stays := r.Serving
+ if stays == "" {
+ stays = r.Good
+ }
+ return fmt.Sprintf(swapCopy.HoldText, r.HeldBy, rolloutwords.Reason(r.Reason), stays)
+}
+
+// PromoteRetryText names the app-wide retry (spec rev 14 §8.4) beside the
+// one-instance Retry button.
+func (d *instanceDetail) PromoteRetryText() string {
+ if d.HoldText() == "" {
+ return ""
+ }
+ return fmt.Sprintf(swapCopy.PromoteRetry, d.App, d.Rollout.HeldBy, d.Channel)
+}
+
+// RolloutNote explains a serving build that differs from the installed one
+// when no failure holds it: rolling out, or asleep until the next request.
+// Plan 5 reports Serving "" for an asleep instance, so asleep compares the
+// installed build with Good, the build it last ran. A box that could not arm
+// its scheduler reports neither, so it gets no note.
+func (d *instanceDetail) RolloutNote() string {
+ r := d.Rollout
+ switch {
+ case r == nil || r.Unavailable != "" || r.HeldBy != "":
+ return ""
+ case !r.Awake && r.Installed != r.Good:
+ return fmt.Sprintf(swapCopy.Asleep, r.Installed)
+ case r.Awake && r.Installed != r.Serving:
+ return fmt.Sprintf(swapCopy.Rolling, r.Installed, r.Serving)
+ }
+ return ""
}
// layoutUnknownCopy is console.instance.layout.unknown (approved copy):
@@ -178,10 +228,14 @@ func (s *Server) handleAppInstance(w http.ResponseWriter, r *http.Request) {
// helper the console's other box readers use) — nil Releases or a read
// error both leave the approved unknown text already set above, the
// same soft-degrade the alias lookup just below gives a broken registry.
+ detail.App, detail.Channel = p.AppName, selected.Channel
if s.cfg.Releases != nil {
if boxes, _, err := s.systemBoxes(r.Context(), false); err == nil {
detail.Layout = layoutWordFor(boxes, host)
}
+ // The rollout view comes from the same platform-box join the retry
+ // door reads (loadBoxJoin), not from systemBoxes.
+ detail.Rollout = loadBoxJoin(r.Context(), s.cfg.Releases, time.Now()).rollout(host, acct.Sqid, p.AppName)
}
if reg, err := openRegistry(s.cfg.Registry); err == nil {
if aliases, err := reg.ListAliases(); err == nil {
diff --git a/internal/console/server.go b/internal/console/server.go
index d9221584b..9decf7ebe 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -1347,6 +1347,7 @@ func NewServer(cfg Config, st *Store) (*Server, error) {
s.registerRoute("GET /a/{sqid}/apps/{app}/instances/{host}", s.handleAppInstance)
s.registerRoute("POST /a/{sqid}/apps/{app}/restart", s.handleAppRestart)
s.registerRoute("POST /a/{sqid}/apps/{app}/instances/{host}/restart", s.handleInstanceRestart)
+ s.registerRoute("POST /a/{sqid}/apps/{app}/instances/{host}/retry", s.handleInstanceRetry)
s.registerRoute("GET /a/{sqid}/apps/{app}/logs", s.handleAppLogs)
s.registerRoute("GET /a/{sqid}/apps/{app}/logs/data", s.handleAppLogsData)
// The Errors tab and its two setting doors (errorsview.go): the tab is
diff --git a/internal/console/templates/app_instance.html b/internal/console/templates/app_instance.html
index e5c4b97dc..bb68c62bb 100644
--- a/internal/console/templates/app_instance.html
+++ b/internal/console/templates/app_instance.html
@@ -15,12 +15,21 @@
<dt>{{$.InstanceCopy.LastReading}}</dt><dd>{{if .Row.Metrics.ObservedAt.IsZero}}—{{else}}<time title="{{fmtTime .Row.Metrics.ObservedAt}}">{{ago .Row.Metrics.ObservedAt}}</time>{{end}}</dd>
<dt>Channel</dt><dd><code>{{.Row.Channel}}</code></dd>
<dt>Layout</dt><dd>{{.Layout}}</dd>
- <dt>{{$.InstanceCopy.Observed}}</dt><dd>{{if .Row.Version}}<code>{{.Row.Version}}</code>{{else}}—{{end}}</dd>
+ {{if .Rollout}}<dt>{{$.SwapCopy.Installed}}</dt><dd><code>{{.Rollout.Installed}}</code></dd>
+ <dt>{{$.SwapCopy.Serving}}</dt><dd>{{if .Rollout.Serving}}<code>{{.Rollout.Serving}}</code>{{else}}—{{end}}</dd>
+ {{if .HoldText}}<dt>{{$.SwapCopy.Hold}}</dt><dd>{{.HoldText}}</dd>{{end}}
+ {{else}}<dt>{{$.InstanceCopy.Observed}}</dt><dd>{{if .Row.Version}}<code>{{.Row.Version}}</code>{{else}}—{{end}}</dd>{{end}}
<dt>{{$.InstanceCopy.Target}}</dt><dd>{{if .TargetErr}}Can't tell right now{{else if .TargetVersion}}<code>{{.TargetVersion}}</code>{{else}}—{{end}}</dd>
<dt>CPU</dt><dd>{{pct .Row.Metrics.CPUFrac}}{{if .Row.Metrics.SparkCPU}}<svg class="cellspark" aria-hidden="true" viewBox="0 0 240 40" preserveAspectRatio="none"><polyline fill="none" stroke="currentColor" points="{{.Row.Metrics.SparkCPU}}"/></svg>{{end}}</dd>
<dt>Memory</dt><dd>{{bytesOrDash .Row.Metrics.MemBytes}}{{if .Row.Metrics.SparkMem}}<svg class="cellspark" aria-hidden="true" viewBox="0 0 240 40" preserveAspectRatio="none"><polyline fill="none" stroke="currentColor" points="{{.Row.Metrics.SparkMem}}"/></svg>{{end}}</dd>
{{if .Aliases}}<dt>Web addresses</dt><dd>{{range .Aliases}}<div><a href="https://{{.}}">{{.}}</a></div>{{end}}</dd>{{end}}
</dl>
+{{with .PromoteRetryText}}<p class="small">{{.}}</p>{{end}}
+{{if .HoldText}}<form method="post" action="{{$.Base}}/apps/{{$.AppName}}/instances/{{.Row.Host}}/retry">
+ <input type="hidden" name="csrf" value="{{$.CSRF}}">
+ <button type="submit" class="btn-sm">{{$.SwapCopy.RetryButton}}</button>
+</form>{{end}}
+{{with .RolloutNote}}<p class="small">{{.}}</p>{{end}}
{{if eq .Row.HealthWord "No readings"}}<p class="small">{{$.InstanceCopy.NoReadings}}</p>{{end}}
{{if eq .Row.HealthWord "Asleep"}}<p class="small">wakes on the next request</p>{{end}}
{{if and .Row.Version .TargetVersion (not .TargetErr) (ne .Row.Version .TargetVersion)}}<p class="small">{{$.InstanceCopy.PendingVersion}}</p>{{end}}
diff --git a/internal/console/testdata/claim-routes.txt b/internal/console/testdata/claim-routes.txt
index f05179dc1..b54c2dc79 100644
--- a/internal/console/testdata/claim-routes.txt
+++ b/internal/console/testdata/claim-routes.txt
@@ -120,6 +120,7 @@ route POST /a/{sqid}/apps/{app}/errors/level
route POST /a/{sqid}/apps/{app}/fasttrack
route POST /a/{sqid}/apps/{app}/gate
route POST /a/{sqid}/apps/{app}/instances/{host}/restart
+route POST /a/{sqid}/apps/{app}/instances/{host}/retry
route POST /a/{sqid}/apps/{app}/keep
route POST /a/{sqid}/apps/{app}/move
route POST /a/{sqid}/apps/{app}/pending/{id}/confirm
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go
index 38319e017..05e671140 100644
--- a/internal/provision/plan9_b8_census_test.go
+++ b/internal/provision/plan9_b8_census_test.go
@@ -573,6 +573,7 @@ var plan9B8CensusInventory = []censusEntry{
// Registers instance and sidecar HTTP paths; record handlers use the reviewed provision writers.
{"internal/console/authcli.go", "Server.registerCLIRoutes", "key-site", "lit:instances,lit:sidecars", "eb6388aed42540177e6b1531fb6e79ed4d66127ec8774c8fb68858ffd0c79296"},
{"internal/console/browserrestart.go", "Server.handleBrowserRestart", "key-site", "lit:instances", "955d71813504f2cfec1e1f64817d5aa5a921aa38e1044c0e903d2cfbe4d073f4"},
+ {"internal/console/browserretry.go", "Server.handleInstanceRetry", "key-site", "lit:instances", "3da1e4c06eb91d1101461e78efaa58c39cf0d7d0ec9810317084fe51b0774a0d"},
{"internal/console/channelmove.go", "Server.handleAPIInstanceChannel", "record-writer", "key:UpdateRecord", "91a2b3157bde9af832e8d95b17d0c384910229982945818f8b52f62dafddb342"},
{"internal/console/channelmove.go", "Server.executePendingRouteChannel", "record-writer", "key:UpdateRecord", "b3b9a12213261a70b680ad5290957ebe7434eff633a73c2d80c75cc24d026d9a"},
{"internal/console/claim_alias.go", "claimAliasStore.Put", "nonrecord", "def:Put,sel:Put", "a21b7cce328ebad9955211657ca8353adc7c2495b065fe09909b93c85ea7defb"},
@@ -593,11 +594,11 @@ var plan9B8CensusInventory = []censusEntry{
{"internal/console/geoippublish.go", "geoipPublisher.ExpireIfDue", "nonrecord", "sel:Put", "a7d4f24a25c7bda40f18268d34a99203b58aa07bc10eda0db2306b54cb08302b"},
{"internal/console/hostreports.go", "putHostReport", "nonrecord", "sel:Put", "d8ba8c77b5f0ee5bcde5c11f4a608cf2bd2491c36b8cb9bf26753a230d485ad4"},
{"internal/console/infrapass.go", "Server.putInventory", "nonrecord", "sel:Put", "c5d930ec178ee206d25194a2b265738ac42c6f00bd9b73626e4d46d769d370ad"},
- {"internal/console/instancepage.go", "Server.handleAppInstance", "key-site", "lit:instances", "51073a3eb95b98982de74259fc99dd0aa375c8c1504213c7dd5ef58682daec8e"},
+ {"internal/console/instancepage.go", "Server.handleAppInstance", "key-site", "lit:instances", "95147b261febf9a40976bef10c6a55b8c328ef0412745d991b3372e2a7d3be10"},
{"internal/console/relayops.go", "Server.relayAcquire", "nonrecord", "sel:Put", "3c757e1c92f1c0ce41c3b5f00aa1397ce5a0c4faabc2f176b23355cf5a41f5fb"},
{"internal/console/relayops.go", "Server.relayPublishOnce", "nonrecord", "sel:Put", "783a3c20337914e08f92be783f6b48ab14288437a8043c0d5bf29f281a7a9b1d"},
{"internal/console/relayops.go", "Server.relayRotate", "nonrecord", "sel:Put", "58e0090a4990e2d5e793cec2ec7bcbfb3626b4043307f64484a55766099e1434"},
- {"internal/console/server.go", "NewServer", "key-site", "lit:instances", "78de5c1959d047aa5025fc51a7e37a568d29257384dc022a0ea020448fa6dd9e"},
+ {"internal/console/server.go", "NewServer", "key-site", "lit:instances", "87f202afe1e346f072109f6b19cc3ce8bc68b7a392ad1c9cdb329951e5ab1cfd"},
{"internal/console/server.go", "Server.PublishMovesConsole", "nonrecord", "sel:PutReplace", "b228397d413769f93209645db9ac6bd4a47cc9db31f06c8699cb540cffea9b5a"},
{"internal/console/server.go", "Server.handleAppInstances", "key-site", "lit:instances", "7368e00fa1bf20365b61361815db216441b7fd21b3d330349dc09170045d45b7"},
{"internal/console/server.go", "Server.handleAppActivity", "key-site", "lit:instances", "265425be83a63bf2fbcd88e1b75600933cf1a2e8b7fa9cc90046fdd329db9786"},