From 4e2acc1bb9c1699e868d57d5292c408b06c7afab Mon Sep 17 00:00:00 2001 From: Mark Shine Date: Wed, 2 Sep 2026 16:22:50 +0200 Subject: [PATCH] tael plan says when the runtime is not answering (ankra-y2us8) The plan's Runtime line read the environment's status, which a dedicated machine keeps at ready while its agent is gone. The backend's workspace status now carries the runtime view the web banner reads; the line says "not answering" and the detail sentence follows. An older backend sends nothing, which reads as before. --- cmd/plan.go | 12 ++++++++++++ cmd/plan_test.go | 23 +++++++++++++++++++++++ internal/client/plan.go | 11 +++++++++++ 3 files changed, 46 insertions(+) diff --git a/cmd/plan.go b/cmd/plan.go index f13686b..81fa194 100644 --- a/cmd/plan.go +++ b/cmd/plan.go @@ -165,6 +165,12 @@ func withThousands(value int64) string { return builder.String() } +// runtimeNotAnswering says the backend reports the runtime as not +// answering right now; an older backend says nothing, which reads as fine. +func runtimeNotAnswering(status *client.WorkspaceStatus) bool { + return status.Runtime != nil && !status.Runtime.Reachable +} + // renderPlan prints the plan and what the workspace holds, the runtime, // and the coupon in force. func renderPlan(status *client.WorkspaceStatus, grant *client.CouponGrant) string { @@ -178,6 +184,9 @@ func renderPlan(status *client.WorkspaceStatus, grant *client.CouponGrant) strin fmt.Fprintf(&builder, "Holding: %s\n", strings.Join(holding, " · ")) if environment := status.Environment; environment != nil { runtime := valueOrDash(environment.Status) + if runtimeNotAnswering(status) { + runtime = "not answering" + } if environment.Tier != nil && *environment.Tier != "" { runtime += " (" + *environment.Tier + ")" } @@ -185,6 +194,9 @@ func renderPlan(status *client.WorkspaceStatus, grant *client.CouponGrant) strin runtime += " · addresses under " + *environment.BaseDomain } fmt.Fprintf(&builder, "Runtime: %s\n", runtime) + if runtimeNotAnswering(status) && status.Runtime.Detail != "" { + fmt.Fprintf(&builder, " %s\n", status.Runtime.Detail) + } if environment.PendingRuntime != nil && *environment.PendingRuntime != "" { builder.WriteString(" A dedicated runtime is on its way; apps move over when it is ready.\n") } diff --git a/cmd/plan_test.go b/cmd/plan_test.go index ed8fc72..34aec1c 100644 --- a/cmd/plan_test.go +++ b/cmd/plan_test.go @@ -101,3 +101,26 @@ func TestCouponShowsTheAppliedCoupon(t *testing.T) { t.Fatalf("tael coupon on an older API = %q, %v", output, olderError) } } + +// A dedicated runtime whose agent is gone keeps its environment at ready; +// the plan says it is not answering, and since when, ahead of the rest. +func TestPlanSaysWhenTheRuntimeIsNotAnswering(t *testing.T) { + server, _ := newAPIServer(t, + route{http.MethodGet, "/api/v1/status", http.StatusOK, `{"workspace_status":"ready","plan":"launch","apps_total":1,"apps_live":0,"open_incidents":0,"needs_you":0,"runtime_status":"ready",` + + `"environment":{"status":"ready","tier":"starter","kind":"hetzner","base_domain":"codestaple.tael.site","pending_cluster":null,"upgrade_error":null},` + + `"runtime":{"reachable":false,"unreachable_since":"2026-08-30T13:35:54Z","last_answered_at":"2026-08-30T13:35:54Z","detail":"The runtime has not answered since 13:35 on 30 August. Your apps keep serving; deploys and installs wait."}}`}, + route{http.MethodGet, "/api/v1/workspace/coupon", http.StatusOK, `{"grant":null}`}, + ) + output, planError := runCommand(t, server, "plan") + if planError != nil { + t.Fatalf("tael plan: %v", planError) + } + mustContain(t, output, + "Runtime: not answering (starter) · addresses under codestaple.tael.site\n", + " The runtime has not answered since 13:35 on 30 August. Your apps keep serving; deploys and installs wait.\n", + ) + if strings.Contains(output, "Runtime: ready") { + t.Fatalf("the environment's ready must not stand in for the runtime: %q", output) + } + mustSpeakTael(t, output) +} diff --git a/internal/client/plan.go b/internal/client/plan.go index 24483be..f9f08f8 100644 --- a/internal/client/plan.go +++ b/internal/client/plan.go @@ -18,6 +18,15 @@ type WorkspaceEnvironment struct { UpgradeError *string `json:"upgrade_error"` } +// WorkspaceRuntime is whether the runtime is answering right now — the +// view the web app's banner reads — as distinct from what the environment +// is: a dedicated machine reads ready while its agent is gone. +type WorkspaceRuntime struct { + Reachable bool `json:"reachable"` + UnreachableSince *string `json:"unreachable_since"` + Detail string `json:"detail"` +} + type WorkspaceStatus struct { WorkspaceStatus string `json:"workspace_status"` Plan string `json:"plan"` @@ -27,6 +36,8 @@ type WorkspaceStatus struct { NeedsYou int `json:"needs_you"` RuntimeStatus *string `json:"runtime_status"` Environment *WorkspaceEnvironment `json:"environment"` + // Runtime is absent on an older backend, which reads as answering. + Runtime *WorkspaceRuntime `json:"runtime"` } // CouponGrant is what a redeemed coupon put in place, in plain terms.