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>
3 files changed,
+376
−0
internal/console/INSTANCES.md+60 −0internal/console/instances.go+153 −0internal/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) |
| + } |
| +} |