Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions cmd/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -178,13 +184,19 @@ 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 + ")"
}
if environment.BaseDomain != nil && *environment.BaseDomain != "" {
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")
}
Expand Down
23 changes: 23 additions & 0 deletions cmd/plan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
11 changes: 11 additions & 0 deletions internal/client/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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.
Expand Down
Loading