carlos / platform Public

console: app-internal instance visibility — the /.carlos/instances contract

Apps MAY serve GET /.carlos/instances (JSON array of {slug,status,region,
detail}; contract in INSTANCES.md) on their public host. The console polls
the first ready, non-static, non-hibernatable route — hibernatable hosts
are never polled, so drawing a table can never wake an app — with a 1.5s
timeout, a 64KiB/100-row cap, and a 30s per-host cache covering failures
too. Every failure mode folds to a silent not-reporting state.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 3e9ae1c983af2386f4628632cda9c30684d422a1 parent e22284c
3 files changed, +376 −0
  • internal/console/INSTANCES.md +60 −0
  • internal/console/instances.go +153 −0
  • internal/console/instances_test.go +163 −0
diff --git a/internal/console/INSTANCES.md b/internal/console/INSTANCES.md
new file mode 100644
index 000000000..fed6fa28b
--- /dev/null
+++ b/internal/console/INSTANCES.md
@@ -0,0 +1,60 @@
+# The instance-report contract (`/.carlos/instances`)
+
+Some CARLOS apps are internally plural: one route on the platform, many
+instances inside the app (Kass and its training instances, titogo and its
+event routers). The platform's registry sees the route; only the app knows
+its insides. This contract lets an app volunteer that knowledge to the
+console. It is entirely optional — an app that doesn't implement it renders
+in the console exactly as before.
+
+## What the app exposes
+
+```
+GET /.carlos/instances
+```
+
+on its **public host** (the same host the platform routes to it). Response:
+
+- Status `200`, body a **JSON array** of instance objects:
+
+```json
+[
+ {"slug": "riders-2026", "status": "ready", "region": "eu-west-1"},
+ {"slug": "spring-camp", "status": "ready", "region": "eu-west-1"},
+ {"slug": "trial-run", "status": "archived", "detail": "read-only since June"}
+]
+```
+
+- `slug` (string, **required**): the instance's stable short name. Entries
+ without a slug are dropped.
+- `status` (string, recommended): one short word — the console renders it
+ verbatim. Use the platform's route-status vocabulary where it fits
+ (`ready`, `starting`, `failed`) plus whatever the app means (`archived`,
+ `migrating`).
+- `region` (string, optional): where the instance's data lives.
+- `detail` (string, optional): one short human sentence.
+- Unknown fields are ignored; the contract may grow additively.
+
+Limits: at most **100 instances** and **64 KiB** of body are read; beyond
+either, the report is treated as absent.
+
+The endpoint must serve **non-secret operational metadata only**. It is
+unauthenticated by design (the console calls through the public host, like
+any visitor), so anything an app would not put on a status page does not
+belong in it. Apps that want the console table implement the endpoint; apps
+that don't, 404 — both are correct behavior.
+
+## What the console does
+
+- Polls the app's **first ready, non-static route that has not opted into
+ hibernation** (routes sort by host). Hibernatable hosts are never polled:
+ a poll could wake them, and the console must never wake an app to draw a
+ table.
+- Uses a **1.5 s timeout** and treats anything but a fast, valid `200` —
+ connection refused, timeout, non-200, junk JSON, oversized body — as
+ "not reporting". Not reporting is silent: the app page shows the ordinary
+ route-level view, nothing more.
+- **Caches per host for 30 s** (successes and failures alike), so page
+ reloads don't drum on tenants.
+- Renders the instance count and a slug/status/region/detail table in an
+ "Instances" section on the app page.
diff --git a/internal/console/instances.go b/internal/console/instances.go
new file mode 100644
index 000000000..4a7d700c8
--- /dev/null
+++ b/internal/console/instances.go
@@ -0,0 +1,153 @@
+// App-internal instance visibility: apps MAY expose GET /.carlos/instances
+// on their public host (the contract is INSTANCES.md, next to this file) and
+// the console renders what they report. The fetch is deliberately meek — a
+// short timeout, a small size cap, a brief cache — because an app that is
+// slow, asleep, or silent must cost the app page nothing but this section.
+package console
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "strings"
+ "sync"
+ "time"
+)
+
+const (
+ // InstancesPath is the well-known path of the instance-report contract.
+ InstancesPath = "/.carlos/instances"
+ // instancesTimeout bounds one report fetch: past it the app is simply
+ // "not reporting" — the page never hangs on a slow tenant.
+ instancesTimeout = 1500 * time.Millisecond
+ // instancesTTL is how long a fetched report (or a failure) is reused
+ // before the host is asked again.
+ instancesTTL = 30 * time.Second
+ // instancesMaxBody caps the response body read; bigger is "not reporting".
+ instancesMaxBody = 64 << 10
+ // instancesMaxCount caps how many instances one report may list.
+ instancesMaxCount = 100
+)
+
+// Instance is one row of an app's self-report. Slug is required; everything
+// else is optional color. Unknown JSON fields are ignored.
+type Instance struct {
+ Slug string `json:"slug"`
+ Status string `json:"status"`
+ Region string `json:"region"`
+ Detail string `json:"detail"`
+}
+
+// InstanceReport is what the app page renders: which host was asked and what
+// it said. Reporting is false when the host was polled but didn't answer the
+// contract (no endpoint, an error, a timeout, junk) — the page then renders
+// exactly as it did before this feature existed.
+type InstanceReport struct {
+ Host string
+ Instances []Instance
+ Reporting bool
+}
+
+// instanceFetcher polls apps for their reports through their public hosts,
+// caching per host so page reloads don't drum on tenants.
+type instanceFetcher struct {
+ client *http.Client
+ scheme string
+ timeout time.Duration
+
+ mu sync.Mutex
+ cache map[string]instanceCacheEntry
+}
+
+type instanceCacheEntry struct {
+ report InstanceReport
+ fetched time.Time
+}
+
+func newInstanceFetcher(client *http.Client, scheme string) *instanceFetcher {
+ if client == nil {
+ client = &http.Client{Timeout: instancesTimeout}
+ }
+ if scheme == "" {
+ scheme = "https"
+ }
+ return &instanceFetcher{
+ client: client,
+ scheme: scheme,
+ timeout: instancesTimeout,
+ cache: map[string]instanceCacheEntry{},
+ }
+}
+
+// Report picks the app's reporting host and fetches (or reuses) its report.
+// The host is the first ready, non-static route that has NOT opted into
+// hibernation — polling a hibernatable host could wake it, and the console
+// must never wake an app just to draw a table. nil means no route is
+// pollable, which renders the same as "not reporting".
+func (f *instanceFetcher) Report(ctx context.Context, routes []RouteView) *InstanceReport {
+ for _, rt := range routes {
+ if rt.Host == "" || rt.Kind == "static" || rt.Status != "ready" || rt.Hibernate {
+ continue
+ }
+ rep := f.get(ctx, rt.Host)
+ return &rep
+ }
+ return nil
+}
+
+func (f *instanceFetcher) get(ctx context.Context, host string) InstanceReport {
+ now := time.Now()
+ f.mu.Lock()
+ if e, ok := f.cache[host]; ok && now.Sub(e.fetched) < instancesTTL {
+ f.mu.Unlock()
+ return e.report
+ }
+ f.mu.Unlock()
+ rep := f.fetch(ctx, host)
+ f.mu.Lock()
+ f.cache[host] = instanceCacheEntry{report: rep, fetched: now}
+ f.mu.Unlock()
+ return rep
+}
+
+// fetch asks one host once. Every failure mode — connect error, non-200,
+// oversized body, invalid JSON — folds to Reporting=false; the contract is
+// opt-in and silence is a fine answer.
+func (f *instanceFetcher) fetch(ctx context.Context, host string) InstanceReport {
+ rep := InstanceReport{Host: host}
+ ctx, cancel := context.WithTimeout(ctx, f.timeout)
+ defer cancel()
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, f.scheme+"://"+host+InstancesPath, nil)
+ if err != nil {
+ return rep
+ }
+ res, err := f.client.Do(req)
+ if err != nil {
+ return rep
+ }
+ defer res.Body.Close()
+ if res.StatusCode != http.StatusOK {
+ return rep
+ }
+ body, err := io.ReadAll(io.LimitReader(res.Body, instancesMaxBody+1))
+ if err != nil || len(body) > instancesMaxBody {
+ return rep
+ }
+ var raw []Instance
+ if err := json.Unmarshal(body, &raw); err != nil {
+ return rep
+ }
+ var out []Instance
+ for _, in := range raw {
+ if strings.TrimSpace(in.Slug) == "" {
+ continue // slug is the one required field; slugless rows are noise
+ }
+ out = append(out, in)
+ if len(out) == instancesMaxCount {
+ break
+ }
+ }
+ rep.Instances, rep.Reporting = out, true
+ return rep
+}
diff --git a/internal/console/instances_test.go b/internal/console/instances_test.go
new file mode 100644
index 000000000..8dce3583f
--- /dev/null
+++ b/internal/console/instances_test.go
@@ -0,0 +1,163 @@
+package console
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+// instanceApp is a fake app serving the /.carlos/instances contract. Its
+// httptest host doubles as the route host (the fetcher is pointed at scheme
+// "http" in tests).
+type instanceApp struct {
+ srv *httptest.Server
+ hits atomic.Int64
+ body string
+ code int
+ wait time.Duration
+}
+
+func newInstanceApp(t *testing.T, body string) *instanceApp {
+ t.Helper()
+ a := &instanceApp{body: body, code: http.StatusOK}
+ a.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != InstancesPath {
+ http.NotFound(w, r)
+ return
+ }
+ a.hits.Add(1)
+ if a.wait > 0 {
+ time.Sleep(a.wait)
+ }
+ w.WriteHeader(a.code)
+ w.Write([]byte(a.body))
+ }))
+ t.Cleanup(a.srv.Close)
+ return a
+}
+
+func (a *instanceApp) host(t *testing.T) string {
+ t.Helper()
+ u, err := url.Parse(a.srv.URL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return u.Host
+}
+
+const kassReport = `[
+ {"slug":"riders-2026","status":"ready","region":"eu-west-1"},
+ {"slug":"spring-camp","status":"ready","region":"eu-west-1"},
+ {"slug":"trial-run","status":"archived","detail":"read-only since June"}
+]`
+
+func readyRoute(host string) RouteView {
+ return RouteView{Host: host, Kind: "instance", Status: "ready"}
+}
+
+func TestInstancesFetchAndCache(t *testing.T) {
+ app := newInstanceApp(t, kassReport)
+ f := newInstanceFetcher(nil, "http")
+ rep := f.Report(context.Background(), []RouteView{readyRoute(app.host(t))})
+ if rep == nil || !rep.Reporting {
+ t.Fatalf("report = %+v, want reporting", rep)
+ }
+ if len(rep.Instances) != 3 {
+ t.Fatalf("instances = %+v, want 3", rep.Instances)
+ }
+ if in := rep.Instances[0]; in.Slug != "riders-2026" || in.Status != "ready" || in.Region != "eu-west-1" {
+ t.Fatalf("first instance = %+v", in)
+ }
+ if in := rep.Instances[2]; in.Detail != "read-only since June" {
+ t.Fatalf("third instance = %+v", in)
+ }
+ // A second report within the TTL is served from cache — one hit total.
+ f.Report(context.Background(), []RouteView{readyRoute(app.host(t))})
+ if n := app.hits.Load(); n != 1 {
+ t.Fatalf("app got %d hits, want 1 (cached)", n)
+ }
+}
+
+func TestInstancesNotReporting(t *testing.T) {
+ for name, app := range map[string]*instanceApp{
+ "404": func() *instanceApp { a := newInstanceApp(t, "nope"); a.code = 404; return a }(),
+ "junk": newInstanceApp(t, "not json"),
+ "nonarray": newInstanceApp(t, `{"slug":"x"}`),
+ "oversized": newInstanceApp(t, `[{"slug":"`+strings.Repeat("x", instancesMaxBody)+`"}]`),
+ } {
+ f := newInstanceFetcher(nil, "http")
+ rep := f.Report(context.Background(), []RouteView{readyRoute(app.host(t))})
+ if rep == nil || rep.Reporting {
+ t.Fatalf("%s: report = %+v, want polled but not reporting", name, rep)
+ }
+ }
+ // A host that isn't even listening: same graceful silence.
+ f := newInstanceFetcher(nil, "http")
+ rep := f.Report(context.Background(), []RouteView{readyRoute("127.0.0.1:1")})
+ if rep == nil || rep.Reporting {
+ t.Fatalf("dead host: report = %+v, want not reporting", rep)
+ }
+ // And the failure is cached too — no re-poll inside the TTL.
+ app := newInstanceApp(t, "junk")
+ f2 := newInstanceFetcher(nil, "http")
+ f2.Report(context.Background(), []RouteView{readyRoute(app.host(t))})
+ f2.Report(context.Background(), []RouteView{readyRoute(app.host(t))})
+ if n := app.hits.Load(); n != 1 {
+ t.Fatalf("failing app got %d hits, want 1 (failure cached)", n)
+ }
+}
+
+func TestInstancesSlugRequired(t *testing.T) {
+ app := newInstanceApp(t, `[{"slug":"good"},{"status":"ready"},{"slug":" "}]`)
+ f := newInstanceFetcher(nil, "http")
+ rep := f.Report(context.Background(), []RouteView{readyRoute(app.host(t))})
+ if !rep.Reporting || len(rep.Instances) != 1 || rep.Instances[0].Slug != "good" {
+ t.Fatalf("report = %+v, want only the slugged row", rep)
+ }
+}
+
+func TestInstancesTimeoutDoesNotHang(t *testing.T) {
+ app := newInstanceApp(t, kassReport)
+ app.wait = 500 * time.Millisecond
+ f := newInstanceFetcher(nil, "http")
+ f.timeout = 50 * time.Millisecond
+ start := time.Now()
+ rep := f.Report(context.Background(), []RouteView{readyRoute(app.host(t))})
+ if rep == nil || rep.Reporting {
+ t.Fatalf("slow app report = %+v, want not reporting", rep)
+ }
+ if e := time.Since(start); e > 300*time.Millisecond {
+ t.Fatalf("report took %v — the page hung on a slow app", e)
+ }
+}
+
+func TestInstancesNeverPollsHibernatableOrStatic(t *testing.T) {
+ app := newInstanceApp(t, kassReport)
+ f := newInstanceFetcher(nil, "http")
+ routes := []RouteView{
+ {Host: app.host(t), Kind: "instance", Status: "ready", Hibernate: true},
+ {Host: app.host(t), Kind: "static", Status: "ready"},
+ {Host: app.host(t), Kind: "instance", Status: "pending"},
+ }
+ if rep := f.Report(context.Background(), routes); rep != nil {
+ t.Fatalf("report = %+v, want nil (nothing pollable)", rep)
+ }
+ if n := app.hits.Load(); n != 0 {
+ t.Fatalf("app got %d hits — a hibernatable/static/unready host was polled", n)
+ }
+ // The first pollable route wins; later routes are left alone.
+ other := newInstanceApp(t, `[]`)
+ routes = append(routes, readyRoute(app.host(t)), readyRoute(other.host(t)))
+ rep := f.Report(context.Background(), routes)
+ if rep == nil || rep.Host != app.host(t) {
+ t.Fatalf("report = %+v, want the first ready host", rep)
+ }
+ if n := other.hits.Load(); n != 0 {
+ t.Fatalf("second host got %d hits, want 0", n)
+ }
+}