A status page that names the worker that gave up A static export in Bun and TypeScript
Six reports in, six pages out: only two healthy workers go green; a gave_up worker, a recovering one or a degraded ensemble never do, and a report that contradicts itself stops the build.
Lab download. Starter is the tree you edit. Worked answer is a separate file; open it after the starter passes. Bun 1.3 or newer. package.json has no dependencies to install. All lab requirements.
A status page has one job that matters more than looking nice: it must never be more optimistic than the facts behind it. This lesson builds a small page that shows one card per worker. It names the worker that restarted or gave up, and it refuses to show green when any worker gave up. If the data it was given contradicts itself, the build stops and no page is written.
What is real and what is simulated. The facts come from a supervisor decision kernel: a program that reads a recorded plan of worker events and decides what to restart. Nothing spawns live processes, there is no cluster, and no browser or screen reader was run against the page. What is real is the data format, the rules the page enforces, the TypeScript code, and the build and its checks (all run with Bun 1.3 in a clean container with no network). The Kubernetes mapping is prose only. The Gleam path in section 4 is a hypothetical sketch and was not run.
1. Concept
This section defines the one-for-one rule, the per-worker restart cap and the ensemble state, then explains why the page re-checks its input and why it is a static export.
The setup: two workers, one supervisor
Imagine a service made of two independent workers under one supervisor. api handles requests. scorer is a separate sidecar-style worker that produces scores for them. (It stands in for an independent scoring component; there is no model and no training here.) The supervisor watches both. When a worker exits, the supervisor decides what to do.
The supervisor's rule, called one-for-one, is the default in Erlang/OTP's supervisor behaviour: "If a child process terminates, only that process is restarted." The alternative, one_for_all, terminates and restarts every child when one fails. Joe Armstrong's thesis explains why one-for-one fits here. In an "or" supervision tree the supervised activities are assumed to be independent, so the failure of one has no consequence for the others. Restarting a healthy sibling because its neighbour crashed turns one failure into two outages.
The supervisor also keeps a restart cap per worker. This lesson's policy allows max_restarts = 3. After three restarts, a further exit makes that worker gave_up. Erlang has the same idea: its restart intensity stops the supervisor after too many restarts in a period. The cap here is per worker, so one worker giving up says nothing about the other.
The text the page reads
The supervisor's isolate report command prints this, one key: value pair per line:
strategy: one_for_one
max_restarts: 3
worker.api.state: healthy|recovering|gave_up
worker.api.restarts: N
worker.scorer.state: healthy|recovering|gave_up
worker.scorer.restarts: N
siblings_restarted: N
ensemble: healthy|recovering|degraded
A worker is healthy if it was never restarted, recovering if it has been restarted but not capped out, and gave_up at the cap. The ensemble line is the rule this page exists to protect: it is healthy only when every worker is healthy, recovering when at least one is recovering and none gave up, and degraded when any worker gave up. The ensemble is never healthy after any worker gave up.
Why the page must not just trust the report
A page that copies ensemble: healthy onto a green banner is only as honest as the file it was given. Files get hand-edited, truncated, or produced by a buggy tool. So the page applies two defences:
- Parse strictly and cross-check. The parser rejects any report whose own fields disagree. Examples:
ensemble: healthywhile a worker isgave_up;gave_upwith fewer restarts than the cap;siblings_restartedabove 0 underone_for_one; orstrategy: one_for_all. A rejected report is a build error, not a page. - Re-derive green. The page does not read "green" from a field. It computes it from the worker states, and green means the ensemble is healthy and every worker is healthy and no sibling was restarted.
Why a static export, and why no server
This is a content page. The build reads report files once and writes HTML. Nothing on the page changes after the build, so there is no data to fetch, nothing for a server to do, and no client JavaScript to ship. A server would be one more moving part between the report and the reader, and it could answer with something other than the report. The cost is stated up front: a static page shows the report as of build time, not live state. If you need live state you need a different design, and that is out of scope.
Bun's Bun.file(...).text() reads a file's contents as a string and Bun.write writes one. For directory listing, Bun's documentation points you to its node:fs implementation, which is what the build uses (readdirSync, mkdirSync).
The Kubernetes mapping (read-only prose)
One-for-one is the local stand-in for how Kubernetes treats a failed container. The Pod Lifecycle documentation says: "If one of the containers in the Pod fails, then Kubernetes may try to restart that specific container." Sidecar containers "can restart independently of the main application container." The cluster analog of one_for_all is an explicit opt-in, RestartAllContainers, which terminates and restarts the entire Pod in place.
One level up, the ReplicaSet documentation says its purpose "is to maintain a stable set of replica Pods running at any given time." It adds: "Think of it similarly to a process supervisor, only it supervises multiple Pods across multiple nodes instead of individual processes on a single node. A ReplicaSet delegates local container restarts to some agent on the node such as Kubelet." In plain terms, a ReplicaSet replacing a dead Pod does not take unrelated Deployments with it. The Certified Kubernetes Administrator curriculum (v1.37, Workloads and Scheduling) lists this under self-healing application deployments. Nothing here calls a Kubernetes API.
2. Worked example
The worked example takes one real report where api gave up, follows it through the parser, the green rule and the build, and ends with the page the build wrote.
The report for an api that gave up
Take the report in which api has exited past its cap while scorer was only started. The values are:
strategy: one_for_one
max_restarts: 3
worker.api.state: gave_up
worker.api.restarts: 3
worker.scorer.state: healthy
worker.scorer.restarts: 0
siblings_restarted: 0
ensemble: degraded
The parser
status.ts pulls out each field, insisting on exactly one line per key:
function field(text: string, key: string): string {
const hits = text.split("\n").filter((l) => l.startsWith(`${key}: `));
if (hits.length !== 1) throw new Error(`report needs exactly one "${key}:" line, found ${hits.length}`);
return hits[0].slice(key.length + 2).trim();
}
Duplicate or missing lines throw rather than pick one silently. count and oneOf build on field, accepting only whole numbers and the listed states.
Then comes the part that makes a forged file fail. ensembleFor says what the worker states demand, and parseReport compares that with what the report claims:
export function ensembleFor(workers: Worker[]): EnsembleState {
if (workers.some((w) => w.state === "gave_up")) return "degraded";
if (workers.some((w) => w.state === "recovering")) return "recovering";
return "healthy";
}
const want = ensembleFor(r.workers);
if (r.ensemble !== want) {
const gave = r.workers.filter((w) => w.state === "gave_up").map((w) => w.name);
const why = gave.length ? `${gave.join(" and ")} gave up` : `worker states are ${r.workers.map((w) => w.state).join("/")}`;
throw new Error(`${why}, so ensemble must be ${want}, report says ${r.ensemble}`);
}
The other cross-checks sit just above it in parseReport:
restartsmay not exceedmax_restarts.healthyrequires 0 restarts.recoveringrequires at least 1.gave_uprequires exactlymax_restarts.siblings_restartedmust be 0, because the strategy is one-for-one.
The strategy itself is parsed with oneOf(..., ["one_for_one"]), so a one_for_all report is rejected as an unknown value before any other check.
Hand-edit that report to say ensemble: healthy and the build refuses it. This is the recorded output from the lab run (the forged file is forged.report):
$ bun build.ts fixtures dist
build error: forged.report: api gave up, so ensemble must be degraded, report says healthy
build failed: 1 report(s) disagree with themselves
The check also confirms that no forged.html is written. The lab run exercised seven more disagreeing reports against parseReport, and each one threw with a message that names the broken rule:
gave_up with ensemble recovering: threw: api gave up, so ensemble must be degraded, report says recovering
gave_up with 2 of 3 restarts: threw: api: state gave_up but restarts is 2, not max_restarts 3
healthy with restarts: threw: scorer: state healthy but restarts is 1
restarts above the cap: threw: api: restarts 5 is more than max_restarts 3
siblings restarted under one_for_one: threw: strategy one_for_one but siblings_restarted is 1
strategy one_for_all: threw: strategy has unknown value: one_for_all
restarts not a number: threw: worker.api.restarts is not a whole number: three
Green is derived
export function isGreen(r: Report): boolean {
return r.ensemble === "healthy" && r.workers.every((w) => w.state === "healthy") && r.siblingsRestarted === 0;
}
The parser already guarantees these three conditions agree. isGreen is a second, independent lock: if a later edit loosens the parser, the banner still cannot go green after a give-up.
The build
build.ts has no server and emits no script tags. It reads every *.report file in a directory, parses each, and writes one HTML file per report. Usage, from its header: bun build.ts <fixtures-dir> <out-dir>. A bad report is counted, logged, and skipped; after all files are processed, the build exits with code 1 if any were bad. So one forged file cannot hide behind good ones, and the failing file is named.
The banner picks its class from isGreen, so ok is reachable only when green:
const cls = isGreen(r) ? "ok" : r.ensemble === "degraded" ? "error" : "warn"; // ok only when green
const live = cls === "error" ? "assertive" : cls === "warn" ? "polite" : "off";
const role = cls === "error" ? "alert" : "status";
The live-region choice follows MDN's guidance on aria-live. polite notifies without interrupting, assertive presents immediately, and MDN says not to use assertive "unless the interruption is imperative." The page reserves it for the degraded state, where a worker has stopped being restarted. The W3C alert pattern describes an alert as a brief, important message that attracts attention "without interrupting the user's task". On a static page this mostly affects how the markup is announced if the page is ever updated or injected. No screen reader was run here, so this is markup that follows the guidance, not a tested experience.
The real page
For the report above, the build produced this body:
<p class="banner error" role="alert" aria-live="assertive" data-ensemble="degraded">Degraded: api gave up after 3 of 3 restarts; scorer was not restarted.</p>
<article class="card error" data-worker="api" data-state="gave_up"><h2>api</h2><p data-field="state">gave_up</p><p data-field="restarts">3 of 3 restarts used</p></article>
<article class="card ok" data-worker="scorer" data-state="healthy"><h2>scorer</h2><p data-field="state">healthy</p><p data-field="restarts">0 of 3 restarts used</p></article>
<p data-field="siblings">Siblings restarted: 0 (strategy one_for_one)</p>
Three things to notice. The banner names the worker that gave up, in words as well as colour. Only the api card is error; scorer stays ok, which is the one-for-one rule made visible. And the last line states Siblings restarted: 0.
The headline function sorts any worker that gave up to the front of the sentence. In a mixed report (api recovering after 1 restart, scorer gave_up) the banner reads: Degraded: scorer gave up after 3 of 3 restarts; api is recovering (1 of 3 restarts used). The api card is warn, the scorer card is error, and the page's data-green attribute is false. Degraded beats recovering: the worst fact leads.
The other fixtures
The fixtures are real isolate report output, from the supervisor tool's own reference build, plus one hand-edited forgery. The cases are:
- Scorer crashed once:
scorerrecovering with 1 restart,apihealthy, ensemblerecovering. - Scorer gave up: after attempts 1, 2 and 3 a
gave_upaction line,scorergave_upwith 3 restarts,apihealthy with 0, ensembledegraded. - Mixed: as described above.
- Forged:
apigave_upwithensemble: healthy.
The supervisor's action ledger for the scorer-gave-up case, for reference, names worker, attempt, max and siblings_touched on every decision, and siblings_touched is [] throughout:
{"event":"restarting","worker":"scorer","attempt":1,"max":3,"siblings_touched":[]}
{"event":"restarting","worker":"scorer","attempt":2,"max":3,"siblings_touched":[]}
{"event":"restarting","worker":"scorer","attempt":3,"max":3,"siblings_touched":[]}
{"event":"gave_up","worker":"scorer","attempt":3,"max":3,"siblings_touched":[]}
3. Lab
The lab gives you a starter page generator with two planted bugs and a checker that tells you which behaviour is still missing.
Layout
The lab directory holds starter/ and reference/. Each has status.ts, build.ts, status.css and package.json. To run it, copy a tree (starter/ or reference/) to a scratch directory, put check.sh beside it, and run sh check.sh; it needs only Bun and no network. The script writes the fixtures itself, builds each tree in the oven/bun:1.3-debian container with no network, and prints one RESULT line per check.
The starter's bugs
The starter has two defects:
isGreenalways returnstrue.parseReporttrusts every field and checks nothing.
So every page comes out green, including after a worker gave up, and the forged report builds without complaint.
What to do
Make the starter behave like the reference without copying it blindly. Work through these in order:
- Make
isGreenderive green from the report, so a gave-up worker cannot produce a green banner. - Add the per-worker consistency checks to
parseReport, then the ensemble cross-check againstensembleFor. - Confirm that
strategy: one_for_allandsiblings_restartedabove 0 are refused. - Check that the failing file is named in the error and that the other reports still build.
The gate
The reference passes all eight checks. The starter fails five:
runner: docker image=oven/bun:1.3-debian
## reference (rc=0, PASS)
RESULT static_build PASS
RESULT healthy_page_green PASS
RESULT never_green_after_gave_up PASS
RESULT only_failed_worker_card_is_error PASS
RESULT recovering_is_warning_not_green PASS
RESULT degraded_beats_recovering PASS
RESULT forged_report_fails_build PASS
RESULT disagreeing_numbers_fail_build PASS
## starter (rc=1, FAIL)
RESULT static_build PASS
RESULT healthy_page_green PASS
RESULT never_green_after_gave_up FAIL
RESULT only_failed_worker_card_is_error PASS
RESULT recovering_is_warning_not_green FAIL
RESULT degraded_beats_recovering FAIL
RESULT forged_report_fails_build FAIL
RESULT disagreeing_numbers_fail_build FAIL
VERDICT: GATE PASS
VERDICT: GATE PASS means the gate itself worked: the reference passed and the starter was caught. It does not mean the starter passed.
Look at the three checks the starter passes. A check that passes against broken code is weak evidence. static_build only proves that six pages are written. healthy_page_green is expected to pass even with isGreen always true, because the healthy case really is green. only_failed_worker_card_is_error also passes on the starter, so it only proves each card reflects its own worker. It says nothing about the banner. Only the five failing checks exercise the banner and the build refusal.
Stretch exercises
WORKERSis a two-element constant. Add a third worker name and trace which functions and fixtures must change. Is the one-for-onesiblingsline still enough?- The report says
ensemble: recoveringbut both workers arehealthy. Which check catches it? RunparseReporton such a file; the reference throwsworker states are healthy/healthy, so ensemble must be healthy, report says recovering. - A page that is correct at build time can be wrong an hour later. What would you have to add to make staleness visible without adding client JavaScript? (Hint: a build timestamp is static text.)
Honest limits
- No browser or screen reader was run; the markup follows the cited guidance and the checks inspect generated HTML.
- The page shows the report as of build time, not live state.
- The supervisor output came from a fixture plan, not live processes.
- The Kubernetes mapping is prose; no cluster was used.
4. Other paths
The same decision, ignoring a report that contradicts itself, looks different in Gleam with Lustre and in Erlang/OTP itself.
Gleam with Lustre (hypothetical sketch, not run)
Lustre's documentation says it can be used "as a HTML templating library, without using its runtime features, by passing elements to functions like to_string_tree or to_document_string." That matches a static export: no runtime in the page. text makes a text node, and none renders nothing, which suits conditional pieces. The sketch below shows the shape of a card and banner. It has not been compiled or run, and the report parsing is omitted.
// Hypothetical sketch, not run. Assumes a Report already parsed and cross-checked.
import gleam/int
import lustre/attribute
import lustre/element
import lustre/element/html
fn card(name: String, state: String, restarts: Int, max: Int) -> element.Element(msg) {
let cls = case state {
"gave_up" -> "card error"
"recovering" -> "card warn"
_ -> "card ok"
}
html.article([attribute.class(cls)], [
html.h2([], [element.text(name)]),
html.p([], [element.text(state)]),
html.p([], [element.text(int.to_string(restarts) <> " of " <> int.to_string(max) <> " restarts used")]),
])
}
The design point carries over from TypeScript: put the cross-check in the parser, so an inconsistent report cannot reach the template. Gleam's custom types make it natural to make the illegal state unrepresentable, for instance with a Report type that can only be built by a function returning Result. In this sketch that is a design suggestion, not verified code.
Erlang/OTP, the original
The same decision is a single line of supervisor configuration there. Erlang's one_for_one restarts only the child that terminated; one_for_all restarts all of them; rest_for_one restarts the failed child and those started after it. Armstrong's thesis example {one_for_one,5,1000} allows 5 restarts in 1000 seconds before the supervisor itself fails. The lesson's cap is a count, with no time window, which is a simplification.
5. Citations
- Joe Armstrong, Making reliable distributed systems in the presence of software errors, PhD thesis, KTH Royal Institute of Technology, 2003. Section 5.2.3, "And/or supervision hierarchies": an OR supervisor restarts the child that died, and the activities are assumed independent so one child's failure has no consequence for the others. Chapter 6:
{one_for_one,5,1000}builds an "or" tree because the supervised activities are considered unrelated; more than 5 restarts in 1000 seconds and the supervisor itself fails. - Erlang/OTP, Supervisor Behaviour, system documentation (documentation labelled v29.1.2, fetched 2026-11-08):
one_for_one("If a child process terminates, only that process is restarted."),one_for_all,rest_for_one, and maximum restart intensity. - Kubernetes documentation, "Pod Lifecycle" (fetched 2026-11-08): restarting the specific failed container, sidecar containers restarting independently, and the
RestartAllContainersopt-in. - Kubernetes documentation, "ReplicaSet" (fetched 2026-11-08): maintaining a stable set of replica Pods, and the supervisor analogy with local container restarts delegated to the kubelet.
- Certified Kubernetes Administrator (CKA) Curriculum v1.37, Workloads and Scheduling: "Understand the primitives used to create robust, self-healing, application deployments."
- Bun documentation, "File I/O" (fetched 2026-11-05):
Bun.fileandBun.write;node:fsfor operations such asmkdirandreaddir;await foo.text()returns the contents as a string. - Lustre documentation,
lustre/element, v5.7.1 (fetched 2026-11-05): HTML templating withto_string_treeandto_document_string;text;none. - MDN Web Docs, "aria-live" (fetched 2026-11-07):
polite,assertiveandoff, and the caution againstassertiveunless the interruption is imperative. - W3C, WAI-ARIA Authoring Practices Guide, "Alert Pattern" (fetched 2026-11-07).