diff --git a/AGENTS.md b/AGENTS.md index 016e020..ecd7253 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -100,6 +100,109 @@ preview-locked, and one load runs at a time (409 otherwise). web/app.js drives the same viewBox the wheel, the drag and "⤢ fit" drive. Nothing here fetches, so it works in a static export too. +### The behaviour overlay (#398, M1 of #397) + +What the estate costs and where it runs out of headroom, on `/api/overlay` +only. **behold never calls a behavioural engine**, never holds its key, and +produces no figure of its own — it renders what a chant lexicon handed it, the +way it already renders drift and the carve score. The one arithmetic it does is +addition, and the result is named `sum` so nobody can read it as a bill. + +The contract's source of truth is the comment titled "The behaviour block, +proposed" on INTENTIUS/behold#398 (chant#2356 is the writing half). Field names +here match it exactly; `src/behaviour.ts` is the implementation and the +validator. + +Per entity, `attrs._behaviour` on the overlay IR node — the channel `_status`, +`_release` and `_carve` already ride: `{at: {traffic}, cost: {perHour, +currency}, headroom: {cpu?, latency?}, errorRate, resilience: {failure, verdict: +survives|degrades|fails, note?}, rightSize?: {suggestion, reason?}, provenance: +{engine, version, tolerance, basis: modeled|validated}}`. An entity nothing +priced carries **no `_behaviour` key at all**, never a zeroed block, and a +missing headroom axis is absent rather than 0. The block stays on the node and +there is deliberately no top-level `behaviour: {}` map: the SPA already walks +`ir.nodes` reading `attrs._status`, and a colour-by-cost mode is that same walk, +with no join. + +Two sources, in this order: + +1. `attrs._behaviour` on the nodes, as `chant graph --live --overlay` painted + them, with `meta._behaviour` beside them for the graph-level half. +2. `behaviour..json` at a member's root — `{meta, entities: {
: + block}}` — read **only** when no node of that member carries the attr, and + **only on the overlay** (`/api/graph` is the source graph; a prediction + about a live account has no business on it). The file's keys are the + member's own addresses; behold prefixes them to reach the composed id + `/
`. + +`meta.behaviour` carries the graph-level half: `engine`, `version`, `at`, the +engine's own `total` when it stated one, else behold's `sum` and the same per +box under `boxes[]` (`{perHour, currency, priced, unpriced}`). Mixed +currencies produce no sum and a diagnostic — behold converts no currency. A +malformed block is dropped whole with its reason in `diagnostics`, never +half-rendered. A member with neither source gets `{absent: "…"}` naming both +places behold looked; that is an absence, not a refusal, because nothing was +configured. + +`meta.behaviour.refusal` is `{reason, remedy}` in the lexicon's own words, +printed as it came, and it is present **instead of** everything above: a +refusal emits no entity block at all and strips any that had arrived. The drift +overlay is untouched either way — nothing in this pass reads or writes +`_status`. `?logical=1` projects a different picture and carries no behaviour +block in M1. + +### Colour by drift, cost or headroom (#399, M2) + +The SPA colours the graph by one of three modes. The mode is **client-side +state** — `web/app.js`'s `colourMode`, persisted under `behold.colourBy` the way +the theme is. It is deliberately NOT in `LENS_PARAMS`/`canonicalKey`: a lens +param means "a different snapshot comes back", and a mode change changes no +fetch at all. The block is already on every priced card, so a switch is a +repaint of the SVG on screen. + +`drift` keeps the categorical fill and #393's vocabulary legend, unchanged. The +two behaviour modes replace the card's FILL only, so the drift bar underneath +keeps saying what chant observed. `cost` is a sequential scale over +`cost.perHour` between the estate's own min and max; `headroom` is absolute over +0..1 and reads **the lower of the axes present**. An entity with no block draws +the drift overlay's neutral (`pinTokensFor().neutralFill`) and is marked +`data-unpriced`, never the zero end of the scale — "nothing priced this" and +"this is free" are opposite claims. + +Both ramps are derived from the active palette (`rampFor` in +`web/behaviour-scale.js`), the way #229 derives the chrome: cost is a single hue +off the accent walked in lightness away from the background — expensive is not a +verdict behold gets to paint — and headroom rides the three status hues the +drift overlay already anchors, degraded → foreign → managed. + +One legend per mode, in the Model tab. The Scope tab carries a totals row per +box and per estate for the active mode: `cost` quotes `meta.behaviour.boxes[key]` +and `.sum`, or `meta.behaviour.total` when the ENGINE stated one (labelled +"engine total"); `headroom` has no server-side aggregate — no engine states one — +so the SPA takes the scope's min and median itself and the row says "computed". +Every row carries `n priced · m unpriced`. In drift mode there is no row. + +Everything either mode decides is pure in `web/behaviour-scale.js`, with +`web/behaviour-scale.test.js` beside it; the SPA owns the fetches and the SVG. + +### Provenance on every figure, and the refusal (#401, M4) + +Every figure the SPA shows — the totals rows, the legend, and the inspect pane's +`behaviour` section — carries `{engine} {version} · {tolerance} · {basis}` +beside it, never once per page. `modeled` is spelled out on hover as "modeled, +not billed": #397's one prohibition is a prediction presented as a bill, and the +badge is where that is prevented. Provenance is per ENTITY in #398's contract, so +a badge over a set (a box, the estate) says `mixed` on any field the set +disagrees on rather than picking one. + +`meta.behaviour.refusal` disables both behaviour modes in the View tab and in +⌘K — visibly, with the lexicon's reason and remedy as their tooltip — and prints +those words verbatim where the legend would be. The drift overlay keeps +rendering; it never depended on an engine. `meta.behaviour.absent` disables the +same two modes with the absent line as their tooltip and prints **nothing**: +nothing was configured, so nothing refused. `meta.behaviour.diagnostics` render +in the Model tab under the legend. + ## The carve loop (Terraform → chant, #230) `behold carve ` serves a `chant carve advise --json` peelability diff --git a/docs/src/assets/behaviour/absent-modes.png b/docs/src/assets/behaviour/absent-modes.png new file mode 100644 index 0000000..3e91782 Binary files /dev/null and b/docs/src/assets/behaviour/absent-modes.png differ diff --git a/docs/src/assets/behaviour/graph-cost.png b/docs/src/assets/behaviour/graph-cost.png new file mode 100644 index 0000000..49d8bfc Binary files /dev/null and b/docs/src/assets/behaviour/graph-cost.png differ diff --git a/docs/src/assets/behaviour/graph-drift.png b/docs/src/assets/behaviour/graph-drift.png new file mode 100644 index 0000000..0a6ebef Binary files /dev/null and b/docs/src/assets/behaviour/graph-drift.png differ diff --git a/docs/src/assets/behaviour/graph-headroom.png b/docs/src/assets/behaviour/graph-headroom.png new file mode 100644 index 0000000..7a85521 Binary files /dev/null and b/docs/src/assets/behaviour/graph-headroom.png differ diff --git a/docs/src/assets/behaviour/inspect-badge.png b/docs/src/assets/behaviour/inspect-badge.png new file mode 100644 index 0000000..39f8349 Binary files /dev/null and b/docs/src/assets/behaviour/inspect-badge.png differ diff --git a/docs/src/assets/behaviour/refusal-legend.png b/docs/src/assets/behaviour/refusal-legend.png new file mode 100644 index 0000000..10b7b9c Binary files /dev/null and b/docs/src/assets/behaviour/refusal-legend.png differ diff --git a/docs/src/assets/behaviour/scope-cost-totals.png b/docs/src/assets/behaviour/scope-cost-totals.png new file mode 100644 index 0000000..1a8d805 Binary files /dev/null and b/docs/src/assets/behaviour/scope-cost-totals.png differ diff --git a/example-choudoufu-estate/monolith/behaviour.live.json b/example-choudoufu-estate/monolith/behaviour.live.json new file mode 100644 index 0000000..372f564 --- /dev/null +++ b/example-choudoufu-estate/monolith/behaviour.live.json @@ -0,0 +1,495 @@ +{ + "meta": { + "engine": "behold-fixture", + "version": "0", + "at": { + "traffic": "100 rps, p50" + } + }, + "entities": { + "aws_iam_role.team_a": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0004, + "currency": "USD" + }, + "headroom": { + "cpu": 0.9, + "latency": 0.85 + }, + "errorRate": 0.001, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_iam_role_policy.team_a_inline": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0002, + "currency": "USD" + }, + "headroom": { + "cpu": 0.84, + "latency": 0.76 + }, + "errorRate": 0.001, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_iam_policy.team_a": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0002, + "currency": "USD" + }, + "headroom": { + "cpu": 0.74 + }, + "errorRate": 0.001, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_iam_role_policy_attachment.team_a": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0001, + "currency": "USD" + }, + "headroom": { + "cpu": 0.72, + "latency": 0.58 + }, + "errorRate": 0.001, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_cloudwatch_log_group.team_a_0": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0416, + "currency": "USD" + }, + "headroom": { + "cpu": 0.66, + "latency": 0.49 + }, + "errorRate": 0.0004, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_cloudwatch_log_group.team_a_1": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0312, + "currency": "USD" + }, + "headroom": { + "cpu": 0.6, + "latency": 0.85 + }, + "errorRate": 0.0004, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_cloudwatch_log_group.team_a_2": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0208, + "currency": "USD" + }, + "headroom": { + "cpu": 0.54, + "latency": 0.76 + }, + "errorRate": 0.0004, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_iam_role.team_b": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0004, + "currency": "USD" + }, + "headroom": { + "cpu": 0.9, + "latency": 0.67 + }, + "errorRate": 0.001, + "resilience": { + "failure": "one zone lost", + "verdict": "degrades", + "note": "the trust policy names a zone-scoped service principal, so the assume path is re-established rather than kept" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_iam_role_policy.team_b_inline": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0002, + "currency": "USD" + }, + "headroom": { + "cpu": 0.84, + "latency": 0.58 + }, + "errorRate": 0.001, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_iam_policy.team_b": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0002, + "currency": "USD" + }, + "headroom": { + "cpu": 0.78, + "latency": 0.49 + }, + "errorRate": 0.001, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_iam_role_policy_attachment.team_b": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0001, + "currency": "USD" + }, + "headroom": { + "cpu": 0.72, + "latency": 0.85 + }, + "errorRate": 0.001, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_cloudwatch_log_group.team_b_0": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0416, + "currency": "USD" + }, + "headroom": { + "cpu": 0.66, + "latency": 0.76 + }, + "errorRate": 0.0004, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_cloudwatch_log_group.team_b_1": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0312, + "currency": "USD" + }, + "headroom": { + "cpu": 0.6, + "latency": 0.67 + }, + "errorRate": 0.0004, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + }, + "rightSize": { + "suggestion": "retention 1 day -> 7 days", + "reason": "the group is read for a week after every deploy and re-ingested when it is not" + } + }, + "aws_cloudwatch_log_group.team_b_2": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0208, + "currency": "USD" + }, + "headroom": { + "cpu": 0.54, + "latency": 0.58 + }, + "errorRate": 0.0004, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_iam_role.team_c": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0004, + "currency": "USD" + }, + "headroom": { + "cpu": 0.9, + "latency": 0.49 + }, + "errorRate": 0.001, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_iam_role_policy.team_c_inline": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0002, + "currency": "USD" + }, + "headroom": { + "cpu": 0.84, + "latency": 0.85 + }, + "errorRate": 0.001, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_iam_policy.team_c": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0002, + "currency": "USD" + }, + "headroom": { + "cpu": 0.78, + "latency": 0.76 + }, + "errorRate": 0.001, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_cloudwatch_log_group.team_c_0": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0416, + "currency": "USD" + }, + "headroom": { + "cpu": 0.66, + "latency": 0.58 + }, + "errorRate": 0.0004, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_cloudwatch_log_group.team_c_1": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0312, + "currency": "USD" + }, + "headroom": { + "cpu": 0.6, + "latency": 0.49 + }, + "errorRate": 0.0004, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_cloudwatch_log_group.team_c_2": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0208, + "currency": "USD" + }, + "headroom": { + "cpu": 0.54, + "latency": 0.85 + }, + "errorRate": 0.0004, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + } + } +} diff --git a/example-choudoufu-estate/team-a/behaviour.live.json b/example-choudoufu-estate/team-a/behaviour.live.json new file mode 100644 index 0000000..d11452f --- /dev/null +++ b/example-choudoufu-estate/team-a/behaviour.live.json @@ -0,0 +1,179 @@ +{ + "meta": { + "engine": "behold-fixture", + "version": "0", + "at": { + "traffic": "100 rps, p50" + } + }, + "entities": { + "aws_iam_role.team_a": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0004, + "currency": "USD" + }, + "headroom": { + "cpu": 0.9, + "latency": 0.85 + }, + "errorRate": 0.001, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_iam_role_policy.team_a_inline": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0002, + "currency": "USD" + }, + "headroom": { + "cpu": 0.84, + "latency": 0.76 + }, + "errorRate": 0.001, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_iam_policy.team_a": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0002, + "currency": "USD" + }, + "headroom": { + "cpu": 0.78, + "latency": 0.67 + }, + "errorRate": 0.001, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_iam_role_policy_attachment.team_a": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0001, + "currency": "USD" + }, + "headroom": { + "cpu": 0.72, + "latency": 0.58 + }, + "errorRate": 0.001, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_cloudwatch_log_group.team_a_0": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0416, + "currency": "USD" + }, + "headroom": { + "cpu": 0.66, + "latency": 0.49 + }, + "errorRate": 0.0004, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_cloudwatch_log_group.team_a_1": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0312, + "currency": "USD" + }, + "headroom": { + "cpu": 0.6, + "latency": 0.85 + }, + "errorRate": 0.0004, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_cloudwatch_log_group.team_a_2": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0208, + "currency": "USD" + }, + "headroom": { + "cpu": 0.54, + "latency": 0.76 + }, + "errorRate": 0.0004, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + } + } +} diff --git a/example-choudoufu-estate/team-b/behaviour.live.json b/example-choudoufu-estate/team-b/behaviour.live.json new file mode 100644 index 0000000..c60868a --- /dev/null +++ b/example-choudoufu-estate/team-b/behaviour.live.json @@ -0,0 +1,179 @@ +{ + "meta": { + "engine": "behold-fixture", + "version": "0", + "at": { + "traffic": "100 rps, p50" + } + }, + "entities": { + "aws_iam_role.team_b": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0004, + "currency": "USD" + }, + "headroom": { + "cpu": 0.9, + "latency": 0.85 + }, + "errorRate": 0.001, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_iam_role_policy.team_b_inline": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0002, + "currency": "USD" + }, + "headroom": { + "cpu": 0.84, + "latency": 0.76 + }, + "errorRate": 0.001, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_iam_policy.team_b": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0002, + "currency": "USD" + }, + "headroom": { + "cpu": 0.78, + "latency": 0.67 + }, + "errorRate": 0.001, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_iam_role_policy_attachment.team_b": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0001, + "currency": "USD" + }, + "headroom": { + "cpu": 0.72, + "latency": 0.58 + }, + "errorRate": 0.001, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_cloudwatch_log_group.team_b_0": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0416, + "currency": "USD" + }, + "headroom": { + "cpu": 0.66, + "latency": 0.49 + }, + "errorRate": 0.0004, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_cloudwatch_log_group.team_b_1": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0312, + "currency": "USD" + }, + "headroom": { + "cpu": 0.6, + "latency": 0.85 + }, + "errorRate": 0.0004, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_cloudwatch_log_group.team_b_2": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0208, + "currency": "USD" + }, + "headroom": { + "cpu": 0.54, + "latency": 0.76 + }, + "errorRate": 0.0004, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + } + } +} diff --git a/example-choudoufu-estate/team-c/behaviour.live.json b/example-choudoufu-estate/team-c/behaviour.live.json new file mode 100644 index 0000000..f8b74c9 --- /dev/null +++ b/example-choudoufu-estate/team-c/behaviour.live.json @@ -0,0 +1,179 @@ +{ + "meta": { + "engine": "behold-fixture", + "version": "0", + "at": { + "traffic": "100 rps, p50" + } + }, + "entities": { + "aws_iam_role.team_c": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0004, + "currency": "USD" + }, + "headroom": { + "cpu": 0.9, + "latency": 0.85 + }, + "errorRate": 0.001, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_iam_role_policy.team_c_inline": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0002, + "currency": "USD" + }, + "headroom": { + "cpu": 0.84, + "latency": 0.76 + }, + "errorRate": 0.001, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_iam_policy.team_c": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0002, + "currency": "USD" + }, + "headroom": { + "cpu": 0.78, + "latency": 0.67 + }, + "errorRate": 0.001, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_iam_role_policy_attachment.team_c": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0001, + "currency": "USD" + }, + "headroom": { + "cpu": 0.72, + "latency": 0.58 + }, + "errorRate": 0.001, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_cloudwatch_log_group.team_c_0": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0416, + "currency": "USD" + }, + "headroom": { + "cpu": 0.66, + "latency": 0.49 + }, + "errorRate": 0.0004, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_cloudwatch_log_group.team_c_1": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0312, + "currency": "USD" + }, + "headroom": { + "cpu": 0.6, + "latency": 0.85 + }, + "errorRate": 0.0004, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_cloudwatch_log_group.team_c_2": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0208, + "currency": "USD" + }, + "headroom": { + "cpu": 0.54, + "latency": 0.76 + }, + "errorRate": 0.0004, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + } + } +} diff --git a/smoke/stub.mjs b/smoke/stub.mjs index 733a532..516fab2 100644 --- a/smoke/stub.mjs +++ b/smoke/stub.mjs @@ -560,7 +560,113 @@ const NON_CHANT_IR = { edges: [], }; -export function startStub(port, { carve = false, nonChant = false, choudoufu = false } = {}) { +// --------------------------------------------------------------------------- +// #399 M2 / #401 M4 of #397: an estate carrying the behaviour block. +// +// Two boxes, five cards, four of them priced and one deliberately not — the +// unpriced card is the whole of #399's own acceptance test ("a node with no +// behaviour block draws in a neutral colour … never a zero that could be +// misread as free or empty"), and it has to be a card the smoke can point at. +// Field names are #398's, exactly, and the figures are the ones the checks in +// ui-smoke.mjs assert to the digit: +// +// monolith/api 0.0416/h headroom min 0.41 +// monolith/db 0.2000/h headroom min 0.10 (the estate's tightest) +// monolith/quiet — no block at all — +// team-a/queue 0.0100/h headroom min 0.95 (latency only: a missing +// axis is ABSENT, not 0) +// team-a/edge 0.5000/h headroom min 0.55 +// +// `meta.behaviour` is what src/behaviour.ts's `attachBehaviour` really emits +// for that set: the sum, the same per box, and one diagnostic. Three variants +// — priced, refused, absent — because #401's refusal and M1's absence are two +// different states with two different UIs and both have to be drivable. +// --------------------------------------------------------------------------- +const BEH_PROV = { engine: "acme-sim", version: "1.4.2", tolerance: "±15%", basis: "modeled" }; +export const BEH_BADGE = "acme-sim 1.4.2 · ±15% · modeled"; +export const BEH_AT = { traffic: "100 rps, p50" }; +const behBlock = (perHour, headroom) => ({ + at: BEH_AT, + cost: { perHour, currency: "USD" }, + headroom, + errorRate: 0.001, + resilience: { failure: "one zone lost", verdict: "survives", note: "two of three zones carry the load" }, + rightSize: { suggestion: "t3.small", reason: "cpu never clears 40% at this level" }, + provenance: BEH_PROV, +}); +export const BEH_CARDS = [ + { id: "monolith/api", box: "monolith", kind: "aws_ecs_service", status: "good", block: behBlock(0.0416, { cpu: 0.62, latency: 0.41 }) }, + { id: "monolith/db", box: "monolith", kind: "aws_rds_instance", status: "good", block: behBlock(0.2, { cpu: 0.1, latency: 0.8 }) }, + { id: "monolith/quiet", box: "monolith", kind: "aws_iam_role", status: "neutral", block: null }, + { id: "team-a/queue", box: "team-a", kind: "aws_sqs_queue", status: "good", block: behBlock(0.01, { latency: 0.95 }) }, + { id: "team-a/edge", box: "team-a", kind: "aws_cloudfront_distribution", status: "warn", block: behBlock(0.5, { cpu: 0.55, latency: 0.6 }) }, +]; +export const BEH_REFUSAL = { + reason: "the behavioural engine acme-sim is configured for this lexicon but did not answer.", + remedy: "Set CHANT_BEHAVIOUR_ENGINE_URL, or unset CHANT_BEHAVIOUR_ENGINE to render drift alone.", +}; +export const BEH_ABSENT = + "no behaviour block: no node carries attrs._behaviour (what `chant graph --live --overlay` would paint from the lexicon's engine), and no report document at monolith/behaviour.live.json, team-a/behaviour.live.json"; +export const BEH_DIAGNOSTIC = "dropped the behaviour block on monolith/legacy: cost.perHour is not a finite number"; +const BEH_SVG = ` + + + + monolith + + team-a +${BEH_CARDS.map((c, i) => nodeSvg(c.id, i < 3 ? 40 + i * 190 : 100 + i * 190, c.status, c.id.split("/")[1])).join("")} +`; +const BEH_VOCABULARY = { of: "choudoufu", labels: { good: "bound", warn: "unowned", accent: "pending", neutral: "not observed", runtime: "runtime child" } }; +const BEH_PROJECT = { + projectDir: "/estates/behaviour-estate", + projectDirs: ["/estates/behaviour-estate/monolith", "/estates/behaviour-estate/team-a"], + recents: [], + environments: ["live"], + lexicons: [], + currentEnv: "live", + targets: [], + memberKinds: ["choudoufu", "choudoufu"], +}; +/** The overlay meta for one of the three behaviour states. Exported so the + * smoke asserts against the same numbers the stub serves rather than a second + * copy of them that can drift. */ +export function behaviourMeta(variant) { + if (variant === "refused") return { refusal: BEH_REFUSAL }; + if (variant === "absent") return { absent: BEH_ABSENT }; + return { + engine: BEH_PROV.engine, + version: BEH_PROV.version, + at: BEH_AT, + sum: { perHour: 0.7516, currency: "USD", priced: 4, unpriced: 1 }, + boxes: { + monolith: { perHour: 0.2416, currency: "USD", priced: 2, unpriced: 1 }, + "team-a": { perHour: 0.51, currency: "USD", priced: 2, unpriced: 0 }, + }, + diagnostics: [BEH_DIAGNOSTIC], + }; +} +function behaviourIr(variant) { + return { + nodes: BEH_CARDS.map((c) => ({ + id: c.id, + kind: c.kind, + lexicon: "choudoufu", + attrs: { + _status: c.status, + estate: c.box, + // A refusal emits no entity block at all and strips any that arrived + // (src/behaviour.ts); an absence never had one. Either way the node the + // SPA sees carries nothing, which is what makes the modes undrawable. + ...(c.block && variant === "priced" ? { _behaviour: c.block } : {}), + }, + })), + edges: [], + groups: { byStack: { monolith: BEH_CARDS.filter((c) => c.box === "monolith").map((c) => c.id), "team-a": BEH_CARDS.filter((c) => c.box === "team-a").map((c) => c.id) } }, + }; +} + +export function startStub(port, { carve = false, nonChant = false, choudoufu = false, behaviour = null } = {}) { // #228: the hand-layout sidecar, in memory instead of `.behold/layout.json` // — the SAME wire contract src/server.ts serves (lens-keyed deltas, a // `writable` flag on the read), so the smoke drives the client's whole sync @@ -668,6 +774,48 @@ export function startStub(port, { carve = false, nonChant = false, choudoufu = f if (path === "/api/ops") return json({ ops: [], adoptLexicons: [], autoSync: "off" }); if (path === "/api/layout") return json({ lens: url.searchParams.get("lens"), writable: false, reason: "a carve report isn't a project", deltas: {} }); } + if (behaviour) { + const json = (body) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify(body)); + }; + if (path === "/api/project") return json(BEH_PROJECT); + // The block rides the OVERLAY only — src/behaviour.ts is explicit that a + // prediction about a live account has no business on the source graph — + // so `/api/graph` answers the same cards with no `meta.behaviour` at all, + // and the modes have to disable themselves there too. + if (path === "/api/graph") { + return json({ + ir: behaviourIr("absent"), + svg: BEH_SVG, + meta: { projectDir: BEH_PROJECT.projectDir, env: null, tier: null, target: null, estate: 2, mode: "graph" }, + }); + } + if (path === "/api/overlay") { + return json({ + ir: behaviourIr(behaviour), + svg: BEH_SVG, + meta: { + projectDir: BEH_PROJECT.projectDir, + env: "live", + tier: null, + target: null, + estate: 2, + mode: "overlay", + vocabulary: BEH_VOCABULARY, + behaviour: behaviourMeta(behaviour), + }, + }); + } + if (path === "/api/diff") return json({ env: "live", nodes: {} }); + if (path === "/api/substrates") return json({ substrates: [] }); + if (path === "/api/resources") return json({ byComponent: {} }); + if (path === "/api/ci") return json({ stages: [], jobs: [], forge: null }); + if (path === "/api/ops") return json({ ops: [], adoptLexicons: [], autoSync: "off" }); + if (path === "/api/history") return json({ commits: [] }); + if (path === "/api/demos") return json({ demos: [] }); + if (path === "/api/layout") return json({ lens: url.searchParams.get("lens"), writable: false, reason: "a stub", deltas: {} }); + } if (choudoufu) { const json = (body) => { res.writeHead(200, { "content-type": "application/json" }); diff --git a/smoke/ui-smoke.mjs b/smoke/ui-smoke.mjs index 874fd4d..6358d76 100644 --- a/smoke/ui-smoke.mjs +++ b/smoke/ui-smoke.mjs @@ -11,7 +11,7 @@ import { chromium } from "playwright"; import { mkdirSync, readFileSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; -import { startStub, JSON_FIXTURE, BOX, EDGE_VIA, PAL_FAR, PAL_MEMBER } from "./stub.mjs"; +import { startStub, JSON_FIXTURE, BOX, EDGE_VIA, PAL_FAR, PAL_MEMBER, BEH_BADGE, BEH_REFUSAL, BEH_ABSENT, BEH_DIAGNOSTIC } from "./stub.mjs"; import { THEMES, DEFAULT_THEME } from "../web/themes.js"; import { tokensFor, pinTokensFor, colorForCategory, setTheme, hexToOklch, contrast } from "../web/theme.js"; import { helmIconFor, PLATE_FILL } from "../src/icon-packs.ts"; @@ -1206,6 +1206,247 @@ try { chdfServer.close(); } + // ---- #399 M2 / #401 M4 of #397: colour by drift, cost or headroom -------- + // Three stubs of the same estate — priced, refused, absent — because those + // are three different UIs (a scale, the lexicon's refusal, and a pair of + // disabled controls that print nothing) and only the first draws a scale. + const behNeutral = pinTokensFor(THEMES[DEFAULT_THEME]).neutralFill.toLowerCase(); + const modeBtn = (p, mode) => p.locator(`#panel-zoom button:text-is("${mode}")`); + + { + const behServer = await startStub(PORT + 4, { behaviour: "priced" }); + const behPage = await browser.newPage({ viewport: { width: 1400, height: 900 } }); + const behErrors = []; + behPage.on("pageerror", (e) => behErrors.push(String(e))); + try { + await behPage.goto(`http://localhost:${PORT + 4}/`); + await behPage.waitForSelector('#graph svg [data-node-id="monolith/api"]', { timeout: 20000 }); + await behPage.click('#panel-tabs button[data-tab="view"]'); + await behPage.waitForTimeout(100); + + // Drift first: the mode the SPA has always been in, and the legend #393 + // gave it. Nothing about M2 may change either. + check("drift is the mode a fresh SPA lands in", await modeBtn(behPage, "drift").evaluate((b) => b.classList.contains("active"))); + await behPage.click('#panel-tabs button[data-tab="model"]'); + await behPage.waitForTimeout(100); + const driftLegend = await behPage.locator("#tab-model").innerText(); + check("the drift legend is still #393's vocabulary legend", /drift · live/i.test(driftLegend) && driftLegend.includes("bound") && driftLegend.includes("unowned")); + const driftFill = (await behPage.getAttribute('[data-node-id="monolith/quiet"] [data-cat="fill"]', "fill")).toLowerCase(); + check("…and in drift the unpriced card wears its category hue like every other card", driftFill !== behNeutral); + check("…with no unpriced marks on the canvas at all", (await behPage.locator("#graph [data-unpriced]").count()) === 0); + + // ---- cost ----------------------------------------------------------- + await behPage.click('#panel-tabs button[data-tab="view"]'); + await behPage.waitForTimeout(50); + await modeBtn(behPage, "cost").click(); + await behPage.waitForTimeout(150); + + const fills = {}; + for (const id of ["monolith/api", "monolith/db", "monolith/quiet", "team-a/queue", "team-a/edge"]) { + fills[id] = (await behPage.getAttribute(`[data-node-id="${id}"] [data-cat="fill"]`, "fill")).toLowerCase(); + } + // #399's own acceptance test, on the canvas: the card with no block is + // the drift overlay's neutral, and NOT the colour the cheapest priced + // card got — which is exactly what "never a zero that could be misread + // as free" forbids. + check("an unpriced card draws the drift overlay's neutral in cost mode", fills["monolith/quiet"] === behNeutral); + check("…and not the zero end of the scale", fills["monolith/quiet"] !== fills["team-a/queue"]); + check("…while every priced card takes a colour off the scale", ["monolith/api", "monolith/db", "team-a/queue", "team-a/edge"].every((id) => fills[id] !== behNeutral)); + check("the cheapest and the dearest card are not the same colour", fills["team-a/queue"] !== fills["team-a/edge"]); + check("the unpriced card is marked as such, not merely coloured", (await behPage.locator('#graph [data-unpriced="1"]').count()) === 1); + check("the status strip says which colour the graph is in", (await behPage.locator("#statusbar").innerText()).includes("colour: cost")); + + await behPage.click('#panel-tabs button[data-tab="model"]'); + await behPage.waitForTimeout(100); + const costLegend = await behPage.locator("#tab-model").innerText(); + check("the cost legend labels both ends of the estate's own range", costLegend.includes("0.0100 USD/h") && costLegend.includes("0.5000 USD/h")); + check("…and counts the priced against the unpriced", costLegend.includes("4 priced · 1 unpriced")); + check("…and says what the neutral cards are", costLegend.includes("unpriced — no behaviour block")); + check("…and is the cost legend, not the drift one", /cost · live/i.test(costLegend) && !costLegend.includes("bound")); + check("M1's diagnostics show under the legend", costLegend.includes(BEH_DIAGNOSTIC)); + check("the legend's figures carry the provenance badge", costLegend.includes(BEH_BADGE)); + + await behPage.click('#panel-tabs button[data-tab="scope"]'); + await behPage.waitForTimeout(100); + const costScope = await behPage.locator("#tab-scope").innerText(); + // The server's own sums, quoted — per box and per estate — each with its + // priced/unpriced split and its badge. + check("the scope panel totals the boxes in cost mode", costScope.includes("0.2416 USD/h · 2 priced · 1 unpriced") && costScope.includes("0.5100 USD/h · 2 priced · 0 unpriced")); + check("…and the estate", costScope.includes("0.7516 USD/h · 4 priced · 1 unpriced")); + check("…naming which box each figure is for", /monolith\n0\.2416 USD\/h/.test(costScope) && /estate\n0\.7516 USD\/h/.test(costScope)); + check("…with a badge on every figure", (await behPage.locator("#tab-scope .behaviour-badge").count()) === 3); + const totalBadge = behPage.locator("#tab-scope .behaviour-badge").first(); + check("the badge reads {engine} {version} · {tolerance} · {basis}", (await totalBadge.innerText()) === BEH_BADGE); + check("…and spells `modeled` out as `modeled, not billed` on hover", (await totalBadge.getAttribute("title")).includes("modeled, not billed")); + + // ---- headroom ------------------------------------------------------- + await behPage.click('#panel-tabs button[data-tab="view"]'); + await behPage.waitForTimeout(50); + await modeBtn(behPage, "headroom").click(); + await behPage.waitForTimeout(150); + const hrQuiet = (await behPage.getAttribute('[data-node-id="monolith/quiet"] [data-cat="fill"]', "fill")).toLowerCase(); + check("the neutral-not-zero rule holds in headroom mode too", hrQuiet === behNeutral); + await behPage.click('#panel-tabs button[data-tab="model"]'); + await behPage.waitForTimeout(100); + const hrLegend = await behPage.locator("#tab-model").innerText(); + check("the headroom legend runs 0% free to 100% free, absolutely", hrLegend.includes("0% free") && hrLegend.includes("100% free")); + check("…and says it colours on the lower axis present", hrLegend.includes("the lower of the axes the engine reported")); + check("…and is its own legend, not cost's", /headroom · live/i.test(hrLegend) && !hrLegend.includes("USD/h")); + await behPage.click('#panel-tabs button[data-tab="scope"]'); + await behPage.waitForTimeout(100); + const hrScope = await behPage.locator("#tab-scope").innerText(); + // No engine states a headroom aggregate, so this row is behold's own + // arithmetic — and has to say so on the row. + check("the headroom totals are the estate's worst and middle card", hrScope.includes("min 10% free · median 48% free · 4 priced · 1 unpriced · computed")); + check("…and the box's own", hrScope.includes("min 10% free · median 26% free · 2 priced · 1 unpriced · computed")); + check("…labelled as computed by behold, not quoted from an engine", hrScope.includes("no engine states a headroom aggregate")); + + // ---- the inspect pane's behaviour section (#401) --------------------- + // dispatchEvent, not click: the floating panel is over this corner of the + // canvas by now (the Scope tab just grew a totals row), and the gesture + // under test is the card's own handler, not where the panel happens to sit. + await behPage.locator('[data-node-id="monolith/api"]').dispatchEvent("click"); + await behPage.waitForTimeout(200); + const pane = behPage.locator("#inspect-body"); + const paneText = await pane.innerText(); + check("a priced card's inspect pane has a behaviour section", /behaviour/i.test(paneText)); + check("…with the cost in the engine's own currency", paneText.includes("0.0416 USD/h")); + check("…both headroom axes, and which one the colour reads", paneText.includes("cpu 62% free · latency 41% free") && paneText.includes("colours on 41% free")); + check("…the error rate at the level the engine named", paneText.includes("0.10% of requests at 100 rps, p50")); + check("…the resilience verdict, its named failure and its note", paneText.includes("survives — one zone lost") && paneText.includes("two of three zones carry the load")); + check("…and the right-size suggestion", paneText.includes("t3.small")); + const paneBadges = pane.locator(".behaviour-badge"); + check("every figure in the section carries its own badge, not one per pane", (await paneBadges.count()) === 5); + check("…reading the entity's own provenance", (await paneBadges.first().innerText()) === BEH_BADGE); + // An unpriced card has no section at all — an empty one would imply the + // engine answered and said nothing. + await behPage.locator('[data-node-id="monolith/quiet"]').dispatchEvent("click"); + await behPage.waitForTimeout(200); + check("an unpriced card gets no behaviour section at all", !/behaviour/i.test(await pane.innerText())); + + // ---- the modes off the overlay -------------------------------------- + // `/api/graph` carries no behaviour block at all — a prediction about a + // live account has no business on the source graph (src/behaviour.ts) — + // so both modes have to disable themselves there, and drift must not. + await behPage.click('#panel-tabs button[data-tab="scope"]'); + await behPage.waitForTimeout(100); + await behPage.locator('#tab-scope button:text-is("(source)")').click(); + await behPage.waitForSelector('#graph svg [data-node-id="monolith/api"]', { timeout: 20000 }); + await behPage.click('#panel-tabs button[data-tab="view"]'); + await behPage.waitForTimeout(200); + for (const m of ["cost", "headroom"]) check(`the ${m} mode is disabled on the source graph`, await modeBtn(behPage, m).isDisabled()); + check("…with the reason on it", (await modeBtn(behPage, "cost").getAttribute("title")).includes("pick an environment")); + check("drift is never disabled", !(await modeBtn(behPage, "drift").isDisabled())); + check("…and the graph falls back to drift rather than painting an empty scale", !(await behPage.locator("#statusbar").innerText()).includes("colour:")); + await behPage.click('#panel-tabs button[data-tab="scope"]'); + await behPage.waitForTimeout(100); + await behPage.locator('#tab-scope button:text-is("live")').click(); + await behPage.waitForSelector('#graph svg [data-node-id="monolith/api"]', { timeout: 20000 }); + await behPage.waitForTimeout(300); + check("…and the pick comes back the moment the overlay can serve it again", (await behPage.locator("#statusbar").innerText()).includes("colour: headroom")); + + // The pick survives a reload the way the theme does — and it is not a + // lens param, so the fetch that comes back is the same one. + await behPage.reload(); + await behPage.waitForSelector('#graph svg [data-node-id="monolith/api"]', { timeout: 20000 }); + await behPage.waitForTimeout(200); + check("the picked mode survives a reload", (await behPage.locator("#statusbar").innerText()).includes("colour: headroom")); + + await behPage.click('#panel-tabs button[data-tab="view"]'); + await behPage.waitForTimeout(50); + await behPage.screenshot({ path: join(SHOTS, "12-behaviour-headroom.png") }); + check("no console errors across all three colour modes", behErrors.length === 0); + if (behErrors.length) console.error("behaviour page errors:", behErrors); + } finally { + await behPage.close(); + behServer.close(); + } + } + + // ---- #401: the refusal, in place of the modes ---------------------------- + { + const refServer = await startStub(PORT + 5, { behaviour: "refused" }); + const refPage = await browser.newPage({ viewport: { width: 1400, height: 900 } }); + const refErrors = []; + refPage.on("pageerror", (e) => refErrors.push(String(e))); + try { + await refPage.goto(`http://localhost:${PORT + 5}/`); + await refPage.waitForSelector('#graph svg [data-node-id="monolith/api"]', { timeout: 20000 }); + await refPage.click('#panel-tabs button[data-tab="view"]'); + await refPage.waitForTimeout(150); + + for (const mode of ["cost", "headroom"]) { + check(`the ${mode} mode is disabled on a refusal`, await modeBtn(refPage, mode).isDisabled()); + const title = await modeBtn(refPage, mode).getAttribute("title"); + check(`…with the refusal's reason on it`, title.includes(BEH_REFUSAL.reason)); + check(`…and its remedy`, title.includes(BEH_REFUSAL.remedy)); + } + check("drift is never disabled — it never depended on an engine", !(await modeBtn(refPage, "drift").isDisabled())); + check("…and the drift overlay is still painted", (await refPage.locator("#meta").innerText()).includes("bound")); + + await refPage.click('#panel-tabs button[data-tab="model"]'); + await refPage.waitForTimeout(100); + const refLegend = await refPage.locator("#tab-model").innerText(); + check("the refusal's reason is printed where the legend is, verbatim", refLegend.includes(BEH_REFUSAL.reason)); + check("…and its remedy with it", refLegend.includes(BEH_REFUSAL.remedy)); + check("…and no figure is invented in its place", !/USD\/h|% free/.test(refLegend)); + await refPage.click('#panel-tabs button[data-tab="scope"]'); + await refPage.waitForTimeout(100); + check("no totals row on a refusal", !/totals/i.test(await refPage.locator("#tab-scope").innerText())); + + // ⌘K lists the blocked modes with their reason rather than hiding them. + await refPage.keyboard.press("Meta+k"); + await refPage.waitForTimeout(100); + await refPage.fill("#pal-input", "colour by"); + await refPage.waitForTimeout(100); + const palRows = await refPage.locator("#pal-list > *").allInnerTexts(); + check("⌘K lists all three colour modes", palRows.length === 3); + check("…and says on the blocked ones why", palRows.some((r) => r.startsWith("colour by: cost") && r.includes(BEH_REFUSAL.reason))); + await refPage.keyboard.press("Escape"); + await refPage.waitForTimeout(100); + + await refPage.click('#panel-tabs button[data-tab="model"]'); + await refPage.waitForTimeout(100); + await refPage.screenshot({ path: join(SHOTS, "13-behaviour-refusal.png") }); + check("no console errors on a refusal", refErrors.length === 0); + if (refErrors.length) console.error("refusal page errors:", refErrors); + } finally { + await refPage.close(); + refServer.close(); + } + } + + // ---- M1's absence: disabled the same way, but nothing printed ------------ + { + const absServer = await startStub(PORT + 6, { behaviour: "absent" }); + const absPage = await browser.newPage({ viewport: { width: 1400, height: 900 } }); + const absErrors = []; + absPage.on("pageerror", (e) => absErrors.push(String(e))); + try { + await absPage.goto(`http://localhost:${PORT + 6}/`); + await absPage.waitForSelector('#graph svg [data-node-id="monolith/api"]', { timeout: 20000 }); + await absPage.click('#panel-tabs button[data-tab="view"]'); + await absPage.waitForTimeout(150); + for (const mode of ["cost", "headroom"]) { + check(`the ${mode} mode is disabled on an absence`, await modeBtn(absPage, mode).isDisabled()); + check(`…with the absent line as its tooltip`, (await modeBtn(absPage, mode).getAttribute("title")) === BEH_ABSENT); + } + await absPage.click('#panel-tabs button[data-tab="model"]'); + await absPage.waitForTimeout(100); + const absLegend = await absPage.locator("#tab-model").innerText(); + // Nothing was configured, so nothing refused: the legend says nothing at + // all, and the drift legend is untouched. + check("nothing is printed in the legend on an absence", !/refus/i.test(absLegend) && !absLegend.includes(BEH_ABSENT)); + check("…and the drift legend is exactly what it was", absLegend.includes("bound") && absLegend.includes("unowned")); + await absPage.screenshot({ path: join(SHOTS, "14-behaviour-absent.png") }); + check("no console errors on an absence", absErrors.length === 0); + if (absErrors.length) console.error("absent page errors:", absErrors); + } finally { + await absPage.close(); + absServer.close(); + } + } + check("no page errors", pageErrors.length === 0); if (pageErrors.length) console.error("page errors:", pageErrors); } finally { diff --git a/src/__fixtures__/behaviour-report-choudoufu-monolith.json b/src/__fixtures__/behaviour-report-choudoufu-monolith.json new file mode 100644 index 0000000..372f564 --- /dev/null +++ b/src/__fixtures__/behaviour-report-choudoufu-monolith.json @@ -0,0 +1,495 @@ +{ + "meta": { + "engine": "behold-fixture", + "version": "0", + "at": { + "traffic": "100 rps, p50" + } + }, + "entities": { + "aws_iam_role.team_a": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0004, + "currency": "USD" + }, + "headroom": { + "cpu": 0.9, + "latency": 0.85 + }, + "errorRate": 0.001, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_iam_role_policy.team_a_inline": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0002, + "currency": "USD" + }, + "headroom": { + "cpu": 0.84, + "latency": 0.76 + }, + "errorRate": 0.001, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_iam_policy.team_a": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0002, + "currency": "USD" + }, + "headroom": { + "cpu": 0.74 + }, + "errorRate": 0.001, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_iam_role_policy_attachment.team_a": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0001, + "currency": "USD" + }, + "headroom": { + "cpu": 0.72, + "latency": 0.58 + }, + "errorRate": 0.001, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_cloudwatch_log_group.team_a_0": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0416, + "currency": "USD" + }, + "headroom": { + "cpu": 0.66, + "latency": 0.49 + }, + "errorRate": 0.0004, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_cloudwatch_log_group.team_a_1": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0312, + "currency": "USD" + }, + "headroom": { + "cpu": 0.6, + "latency": 0.85 + }, + "errorRate": 0.0004, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_cloudwatch_log_group.team_a_2": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0208, + "currency": "USD" + }, + "headroom": { + "cpu": 0.54, + "latency": 0.76 + }, + "errorRate": 0.0004, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_iam_role.team_b": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0004, + "currency": "USD" + }, + "headroom": { + "cpu": 0.9, + "latency": 0.67 + }, + "errorRate": 0.001, + "resilience": { + "failure": "one zone lost", + "verdict": "degrades", + "note": "the trust policy names a zone-scoped service principal, so the assume path is re-established rather than kept" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_iam_role_policy.team_b_inline": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0002, + "currency": "USD" + }, + "headroom": { + "cpu": 0.84, + "latency": 0.58 + }, + "errorRate": 0.001, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_iam_policy.team_b": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0002, + "currency": "USD" + }, + "headroom": { + "cpu": 0.78, + "latency": 0.49 + }, + "errorRate": 0.001, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_iam_role_policy_attachment.team_b": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0001, + "currency": "USD" + }, + "headroom": { + "cpu": 0.72, + "latency": 0.85 + }, + "errorRate": 0.001, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_cloudwatch_log_group.team_b_0": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0416, + "currency": "USD" + }, + "headroom": { + "cpu": 0.66, + "latency": 0.76 + }, + "errorRate": 0.0004, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_cloudwatch_log_group.team_b_1": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0312, + "currency": "USD" + }, + "headroom": { + "cpu": 0.6, + "latency": 0.67 + }, + "errorRate": 0.0004, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + }, + "rightSize": { + "suggestion": "retention 1 day -> 7 days", + "reason": "the group is read for a week after every deploy and re-ingested when it is not" + } + }, + "aws_cloudwatch_log_group.team_b_2": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0208, + "currency": "USD" + }, + "headroom": { + "cpu": 0.54, + "latency": 0.58 + }, + "errorRate": 0.0004, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_iam_role.team_c": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0004, + "currency": "USD" + }, + "headroom": { + "cpu": 0.9, + "latency": 0.49 + }, + "errorRate": 0.001, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_iam_role_policy.team_c_inline": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0002, + "currency": "USD" + }, + "headroom": { + "cpu": 0.84, + "latency": 0.85 + }, + "errorRate": 0.001, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_iam_policy.team_c": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0002, + "currency": "USD" + }, + "headroom": { + "cpu": 0.78, + "latency": 0.76 + }, + "errorRate": 0.001, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_cloudwatch_log_group.team_c_0": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0416, + "currency": "USD" + }, + "headroom": { + "cpu": 0.66, + "latency": 0.58 + }, + "errorRate": 0.0004, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_cloudwatch_log_group.team_c_1": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0312, + "currency": "USD" + }, + "headroom": { + "cpu": 0.6, + "latency": 0.49 + }, + "errorRate": 0.0004, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_cloudwatch_log_group.team_c_2": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0208, + "currency": "USD" + }, + "headroom": { + "cpu": 0.54, + "latency": 0.85 + }, + "errorRate": 0.0004, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + } + } +} diff --git a/src/__fixtures__/behaviour-report-malformed.json b/src/__fixtures__/behaviour-report-malformed.json new file mode 100644 index 0000000..458a7a0 --- /dev/null +++ b/src/__fixtures__/behaviour-report-malformed.json @@ -0,0 +1,150 @@ +{ + "meta": { + "engine": "behold-fixture", + "version": "0", + "at": { + "traffic": "100 rps, p50" + } + }, + "entities": { + "aws_iam_role.team_a": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0004, + "currency": "USD" + }, + "headroom": { + "cpu": 0.9, + "latency": 0.85 + }, + "errorRate": 0.001, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_iam_role_policy.team_a_inline": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0002, + "currency": "USD" + }, + "headroom": { + "cpu": 0.84, + "latency": 0.76 + }, + "errorRate": 0.001, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "basis": "modeled" + } + }, + "aws_iam_policy.team_a": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0002, + "currency": "USD" + }, + "headroom": { + "cpu": 0.78, + "latency": 0.67 + }, + "errorRate": 0.001, + "resilience": { + "failure": "one zone lost", + "verdict": "probably fine" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_iam_role_policy_attachment.team_a": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0001, + "currency": "USD" + }, + "headroom": {}, + "errorRate": 0.001, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_cloudwatch_log_group.team_a_0": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0416, + "currency": "USD" + }, + "headroom": { + "cpu": 0.66, + "latency": 0.49 + }, + "errorRate": 4, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_cloudwatch_log_group.team_a_1": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0312 + }, + "headroom": { + "cpu": 0.6, + "latency": 0.85 + }, + "errorRate": 0.0004, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + } + } +} diff --git a/src/__fixtures__/behaviour-report-refused.json b/src/__fixtures__/behaviour-report-refused.json new file mode 100644 index 0000000..e588cf2 --- /dev/null +++ b/src/__fixtures__/behaviour-report-refused.json @@ -0,0 +1,58 @@ +{ + "meta": { + "refusal": { + "reason": "no behavioural engine is configured for this estate: BEHAVIOUR_ENGINE_URL is unset and the lexicon declares no engine block", + "remedy": "set BEHAVIOUR_ENGINE_URL and BEHAVIOUR_ENGINE_TOKEN in the environment the lexicon runs in, then re-read the overlay" + } + }, + "entities": { + "aws_iam_role.team_a": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0004, + "currency": "USD" + }, + "headroom": { + "cpu": 0.9, + "latency": 0.85 + }, + "errorRate": 0.001, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + }, + "aws_cloudwatch_log_group.team_a_0": { + "at": { + "traffic": "100 rps, p50" + }, + "cost": { + "perHour": 0.0416, + "currency": "USD" + }, + "headroom": { + "cpu": 0.66, + "latency": 0.49 + }, + "errorRate": 0.0004, + "resilience": { + "failure": "one zone lost", + "verdict": "survives" + }, + "provenance": { + "engine": "behold-fixture", + "version": "0", + "tolerance": "fixture", + "basis": "modeled" + } + } + } +} diff --git a/src/behaviour.test.ts b/src/behaviour.test.ts new file mode 100644 index 0000000..9d45b38 --- /dev/null +++ b/src/behaviour.test.ts @@ -0,0 +1,459 @@ +import { describe, it, expect, afterAll } from "vitest"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createApp } from "./server.ts"; +import { Broadcaster } from "./events.ts"; +import { FrameBuffer } from "./frames.ts"; +import { OpRunner } from "./op-runner.ts"; +import { attachBehaviour, validateBehaviourBlock, validateBehaviourMeta, type BehaviourBlock, type BehaviourMeta } from "./behaviour.ts"; + +/** + * The behaviour overlay (#398, M1 of #397) over a two-member choudoufu estate + * on disk, with the choudoufu spawn answered from recorded documents — no + * binary, no cloud, the pattern src/choudoufu-route.test.ts established. + * + * ## Where the numbers come from + * + * Nowhere. Every figure in this file is a RECORDED DOCUMENT, and its + * provenance says so out loud: `engine: "behold-fixture"`, `version: "0"`, + * `tolerance: "fixture"`. No behavioural engine exists yet (#397 keeps the + * engine abstract on purpose, and chant #2356 is the half that will write + * these for real), so the documents below were WRITTEN, not observed — + * generated once against the bundled `example-choudoufu-estate`'s own declared + * addresses so the join is real even though the pricing is not. That labelling + * is the only way a figure may appear in this build: behold computes nothing + * but the sums, and the sums are named `sum`. + * + * - `behaviour-report-choudoufu-monolith.json` — the monolith's 21 declared + * addresses, 20 of them priced. `aws_iam_role_policy_attachment.team_c` is + * deliberately absent, so the card renders with no behaviour key at all and + * the `unpriced` counter has something to count. One `rightSize` + * (`aws_cloudwatch_log_group.team_b_1`), one `resilience.verdict: + * "degrades"` (`aws_iam_role.team_b`), one single-axis headroom + * (`aws_iam_policy.team_a`, cpu only) — the three optional shapes the + * contract allows, each present exactly once. A copy of this same file is + * committed at `example-choudoufu-estate/monolith/behaviour.live.json` so + * the demo's overlay carries the block; the three team estates carry their + * own seven-address reports beside it. + * - `behaviour-report-refused.json` — the same engine refusing, with two + * VALID entity blocks still in the document, so the refusal has something + * to strip rather than merely nothing to add. + * - `behaviour-report-malformed.json` — one sound block and five that each + * break a different required field (no `provenance.tolerance`, a verdict + * outside the three words, an empty `headroom`, an `errorRate` of 4, a cost + * with no currency), so a drop is per entity and its reason is specific. + * + * The live half — the roster, the listing and the plan the cards are drawn and + * coloured from — is the choudoufu-live fixture set recorded from a real + * `floci` run; src/choudoufu-live.test.ts's header records that provenance. + */ +const HERE = dirname(fileURLToPath(import.meta.url)); +const raw = (name: string): string => readFileSync(join(HERE, "__fixtures__", name), "utf8"); + +const made: string[] = []; +afterAll(() => made.forEach((d) => rmSync(d, { recursive: true, force: true }))); + +/** The estate on disk: the monolith's 21 addresses and team-a's seven. */ +function estate(reports: Record = {}): { root: string; mono: string; teamA: string } { + const root = mkdtempSync(join(tmpdir(), "behold-behaviour-")); + made.push(root); + const write = (rel: string, content: string) => { + mkdirSync(dirname(join(root, rel)), { recursive: true }); + writeFileSync(join(root, rel), content); + }; + write("mono/main.tf", 'terraform {\n live {\n estate = "tlmig-sample-monolith"\n }\n}\n'); + write("team-a/main.tf", 'terraform {\n live {\n estate = "tlmig-sample-team-a"\n }\n}\n'); + for (const [rel, content] of Object.entries(reports)) write(rel, content); + return { root, mono: join(root, "mono"), teamA: join(root, "team-a") }; +} + +/** The spawn seam — live-check/live-ls/live-plan per member, from the recorded + * documents. Identical to choudoufu-route.test.ts's, minus live-mv: nothing + * here moves anything. */ +const fakeChoudoufu = async (args: string[], cwd: string) => { + const verb = args[0]; + const inMono = cwd.endsWith("mono"); + if (verb === "live-check") return { code: 0, stderr: "", stdout: inMono ? raw("choudoufu-live-check-monolith.json") : raw("choudoufu-live-check-team-a.json") }; + if (verb === "live-ls") return { code: 0, stderr: "", stdout: inMono ? raw("choudoufu-live-ls-monolith.json") : raw("choudoufu-live-ls-team-a-after-split.json") }; + if (verb === "live-plan") return { code: 0, stderr: "", stdout: inMono ? raw("choudoufu-live-plan-monolith-clean.json") : raw("choudoufu-live-plan-team-a-after-split.json") }; + return { code: 2, stderr: `unexpected ${verb}`, stdout: "" }; +}; + +function served(reports: Record = {}) { + const { mono, teamA } = estate(reports); + const broadcaster = new Broadcaster(); + const app = createApp( + { projectDir: mono, projectDirs: [mono, teamA], port: 0, choudoufu: { run: fakeChoudoufu } }, + broadcaster, + new FrameBuffer(), + new OpRunner({ projectDir: mono, broadcaster, onDone: () => {} }), + ); + return { app, mono, teamA }; +} + +interface OverlayNode { + id: string; + attrs?: Record & { _status?: string; _behaviour?: BehaviourBlock }; +} +interface OverlayBody { + ir: { nodes: OverlayNode[] }; + svg: string; + meta: { behaviour: BehaviourMeta } & Record; +} + +const overlay = async (app: { request: (p: string) => Promise | Response }): Promise => + (await (await app.request("/api/overlay?env=live")).json()) as OverlayBody; + +/** Every entity that carries a block, id → cost per hour. */ +const priced = (body: OverlayBody): Record => + Object.fromEntries(body.ir.nodes.filter((n) => n.attrs?._behaviour).map((n) => [n.id, n.attrs!._behaviour!.cost.perHour])); + +/** The drift overlay, untouched by any of this. */ +const drift = (body: OverlayBody): Record => + Object.fromEntries(body.ir.nodes.map((n) => [n.id, n.attrs?._status])); + +const MONOLITH = "mono/behaviour.live.json"; +const TEAM_A = "team-a/behaviour.live.json"; + +describe("the behaviour block on /api/overlay (#398)", () => { + // 1. The golden: the payload the fixture estate produces, pinned. + it("joins the report document onto the composed ids, and sums per box and per estate", async () => { + const { app } = served({ [MONOLITH]: raw("behaviour-report-choudoufu-monolith.json") }); + const body = await overlay(app); + + // The join: the file's keys are the member's OWN addresses; behold + // prefixes them to reach the composed id. + const blocks = priced(body); + expect(Object.keys(blocks).sort()).toEqual([ + "mono/aws_cloudwatch_log_group.team_a_0", + "mono/aws_cloudwatch_log_group.team_a_1", + "mono/aws_cloudwatch_log_group.team_a_2", + "mono/aws_cloudwatch_log_group.team_b_0", + "mono/aws_cloudwatch_log_group.team_b_1", + "mono/aws_cloudwatch_log_group.team_b_2", + "mono/aws_cloudwatch_log_group.team_c_0", + "mono/aws_cloudwatch_log_group.team_c_1", + "mono/aws_cloudwatch_log_group.team_c_2", + "mono/aws_iam_policy.team_a", + "mono/aws_iam_policy.team_b", + "mono/aws_iam_policy.team_c", + "mono/aws_iam_role.team_a", + "mono/aws_iam_role.team_b", + "mono/aws_iam_role.team_c", + "mono/aws_iam_role_policy.team_a_inline", + "mono/aws_iam_role_policy.team_b_inline", + "mono/aws_iam_role_policy.team_c_inline", + "mono/aws_iam_role_policy_attachment.team_a", + "mono/aws_iam_role_policy_attachment.team_b", + ]); + + // The one address the document deliberately did not price carries NO + // behaviour key — not a zeroed block. #398's whole acceptance criterion. + const unpriced = body.ir.nodes.find((n) => n.id === "mono/aws_iam_role_policy_attachment.team_c"); + expect(unpriced).toBeDefined(); + expect(unpriced!.attrs).not.toHaveProperty("_behaviour"); + + // One block, in full — the contract's field names, byte for byte. + expect(body.ir.nodes.find((n) => n.id === "mono/aws_cloudwatch_log_group.team_b_1")!.attrs!._behaviour).toEqual({ + at: { traffic: "100 rps, p50" }, + cost: { perHour: 0.0312, currency: "USD" }, + headroom: { cpu: 0.6, latency: 0.67 }, + errorRate: 0.0004, + resilience: { failure: "one zone lost", verdict: "survives" }, + rightSize: { + suggestion: "retention 1 day -> 7 days", + reason: "the group is read for a week after every deploy and re-ingested when it is not", + }, + provenance: { engine: "behold-fixture", version: "0", tolerance: "fixture", basis: "modeled" }, + }); + + // The optional shapes, each exactly once. + expect(body.ir.nodes.find((n) => n.id === "mono/aws_iam_role.team_b")!.attrs!._behaviour!.resilience.verdict).toBe("degrades"); + // A missing axis is ABSENT, never 0. + expect(body.ir.nodes.find((n) => n.id === "mono/aws_iam_policy.team_a")!.attrs!._behaviour!.headroom).toEqual({ cpu: 0.74 }); + + // The engine stated no total, so behold sums and says it is a sum. + // 20 priced of the monolith's 21; team-a supplied nothing at all. + expect(body.meta.behaviour).toEqual({ + engine: "behold-fixture", + version: "0", + at: { traffic: "100 rps, p50" }, + sum: { perHour: 0.2834, currency: "USD", priced: 20, unpriced: 8 }, + boxes: { mono: { perHour: 0.2834, currency: "USD", priced: 20, unpriced: 1 } }, + diagnostics: ["no behaviour source for team-a (no attrs._behaviour, no behaviour.live.json)"], + }); + }); + + // 2. Nothing invented in its place. + it("renders byte-identically to today with the file removed — only meta.behaviour is added", async () => { + const withFile = await overlay(served({ [MONOLITH]: raw("behaviour-report-choudoufu-monolith.json") }).app); + const without = await overlay(served().app); + + // The picture is the same picture. + expect(without.svg).toBe(withFile.svg); + expect(drift(without)).toEqual(drift(withFile)); + expect(without.ir.nodes.map((n) => n.id)).toEqual(withFile.ir.nodes.map((n) => n.id)); + + // Not one node carries a behaviour key, zeroed or otherwise, and the IRs + // are identical once the one added attr is taken back off. + expect(priced(without)).toEqual({}); + const strip = (b: OverlayBody) => + JSON.stringify(b.ir.nodes.map((n) => ({ ...n, attrs: Object.fromEntries(Object.entries(n.attrs ?? {}).filter(([k]) => k !== "_behaviour")) }))); + expect(strip(without)).toBe(strip(withFile)); + + // What it gained instead: one line naming both places it looked. + expect(without.meta.behaviour).toEqual({ + absent: + "no behaviour block: no node carries attrs._behaviour (what `chant graph --live --overlay` would paint from the lexicon's engine), and no report document at mono/behaviour.live.json, team-a/behaviour.live.json", + }); + // An absence is not a refusal: nothing was configured, so nothing refused. + expect(without.meta.behaviour.refusal).toBeUndefined(); + + // And the rest of the meta is what it always was. (`projectDir` is each + // server's own temp copy, so it is the one key that legitimately differs.) + const rest = (b: OverlayBody) => JSON.stringify({ ...b.meta, behaviour: undefined, projectDir: undefined }); + expect(rest(without)).toBe(rest(withFile)); + }); + + // 3. The refusal. + it("a refusal strips every block, prints the lexicon's words verbatim, and leaves drift alone", async () => { + const coloured = await overlay(served({ [MONOLITH]: raw("behaviour-report-choudoufu-monolith.json") }).app); + // Both documents present: the monolith prices its estate, team-a refuses. + // The estate's answer is ONE answer, so the monolith's blocks go too. + const { app } = served({ + [MONOLITH]: raw("behaviour-report-choudoufu-monolith.json"), + [TEAM_A]: raw("behaviour-report-refused.json"), + }); + const body = await overlay(app); + + expect(priced(body)).toEqual({}); + expect(body.meta.behaviour).toEqual({ + refusal: { + reason: "no behavioural engine is configured for this estate: BEHAVIOUR_ENGINE_URL is unset and the lexicon declares no engine block", + remedy: "set BEHAVIOUR_ENGINE_URL and BEHAVIOUR_ENGINE_TOKEN in the environment the lexicon runs in, then re-read the overlay", + }, + }); + // No engine, no sum, no total — a refusal is present INSTEAD of them. + expect(body.meta.behaviour.sum).toBeUndefined(); + expect(body.meta.behaviour.engine).toBeUndefined(); + // The drift overlay keeps rendering, unchanged. + expect(drift(body)).toEqual(drift(coloured)); + expect(body.svg).toBe(coloured.svg); + }); + + // 4. The malformed drop. + it("drops a malformed block per entity with a reason, and renders the sound one", async () => { + const { app } = served({ [TEAM_A]: raw("behaviour-report-malformed.json") }); + const body = await overlay(app); + + // One block in the document survived validation; five did not. + expect(priced(body)).toEqual({ "team-a/aws_iam_role.team_a": 0.0004 }); + for (const id of [ + "team-a/aws_iam_role_policy.team_a_inline", + "team-a/aws_iam_policy.team_a", + "team-a/aws_iam_role_policy_attachment.team_a", + "team-a/aws_cloudwatch_log_group.team_a_0", + "team-a/aws_cloudwatch_log_group.team_a_1", + ]) { + expect(body.ir.nodes.find((n) => n.id === id)!.attrs).not.toHaveProperty("_behaviour"); + } + // Never partially rendered: each drop names the entity and the field. + expect(body.meta.behaviour.diagnostics).toEqual([ + "dropped the behaviour block on team-a/aws_iam_role_policy.team_a_inline: provenance.tolerance missing — a figure without a stated tolerance is not a prediction", + 'dropped the behaviour block on team-a/aws_iam_policy.team_a: resilience.verdict "probably fine" is not survives/degrades/fails', + "dropped the behaviour block on team-a/aws_iam_role_policy_attachment.team_a: headroom carries neither cpu nor latency", + "dropped the behaviour block on team-a/aws_cloudwatch_log_group.team_a_0: errorRate is not a fraction 0..1", + "dropped the behaviour block on team-a/aws_cloudwatch_log_group.team_a_1: cost.currency missing", + "no behaviour source for mono (no attrs._behaviour, no behaviour.live.json)", + ]); + expect(body.meta.behaviour.sum).toEqual({ perHour: 0.0004, currency: "USD", priced: 1, unpriced: 27 }); + }); + + // 6. The advertisement (#398 item 6). + it("is advertised on /api", async () => { + const { app } = served(); + const api = (await (await app.request("/api")).json()) as { routes: Array<{ path: string; desc: string }> }; + const route = api.routes.find((r) => r.path === "/api/overlay")!; + expect(route.desc).toContain("attrs._behaviour"); + expect(route.desc).toContain("meta.behaviour"); + }); +}); + +// --------------------------------------------------------------------------- +// 5. The arithmetic, on the pass itself — the one thing behold computes. +// --------------------------------------------------------------------------- + +const block = (perHour: number, currency = "USD"): BehaviourBlock => ({ + at: { traffic: "100 rps, p50" }, + cost: { perHour, currency }, + headroom: { cpu: 0.5 }, + errorRate: 0.001, + resilience: { failure: "one zone lost", verdict: "survives" }, + provenance: { engine: "behold-fixture", version: "0", tolerance: "fixture", basis: "modeled" }, +}); + +const irOf = (nodes: Array<{ id: string; block?: BehaviourBlock }>) => ({ + nodes: nodes.map((n) => ({ id: n.id, attrs: { _status: "good", ...(n.block ? { _behaviour: n.block } : {}) } })), + groups: { + byStack: { + mono: nodes.filter((n) => n.id.startsWith("mono/")).map((n) => n.id), + "team-a": nodes.filter((n) => n.id.startsWith("team-a/")).map((n) => n.id), + }, + }, +}); + +const members = [ + { name: "mono", dir: "/nowhere/mono" }, + { name: "team-a", dir: "/nowhere/team-a" }, +]; +/** No file exists anywhere: these cases are all source 1, the node attr. */ +const noFiles = () => undefined; + +describe("the sums (#398 item 4)", () => { + it("adds per box and per estate, counts priced and unpriced, and never calls the total a total", () => { + const ir = irOf([ + { id: "mono/a", block: block(0.0416) }, + { id: "mono/b", block: block(0.0312) }, + { id: "mono/c" }, + { id: "team-a/a", block: block(0.0208) }, + ]); + const meta = attachBehaviour(ir, members, "live", noFiles); + expect(meta.sum).toEqual({ perHour: 0.0936, currency: "USD", priced: 3, unpriced: 1 }); + expect(meta.boxes).toEqual({ + mono: { perHour: 0.0728, currency: "USD", priced: 2, unpriced: 1 }, + "team-a": { perHour: 0.0208, currency: "USD", priced: 1, unpriced: 0 }, + }); + // `total` is the ENGINE's word and this engine stated none. + expect(meta.total).toBeUndefined(); + }); + + it("shows the engine's own total instead of a sum when the engine states one", () => { + const ir = irOf([ + { id: "mono/a", block: block(0.0416) }, + { id: "mono/b", block: block(0.0312) }, + ]); + const engineMeta = { engine: "behold-fixture", version: "0", at: { traffic: "100 rps, p50" }, total: { perHour: 12.31, currency: "USD" } }; + const meta = attachBehaviour(ir, [{ name: "mono", dir: "/nowhere/mono", meta: engineMeta }], "live", noFiles); + expect(meta.total).toEqual({ perHour: 12.31, currency: "USD" }); + expect(meta.sum).toBeUndefined(); + expect(meta.boxes).toBeUndefined(); + }); + + it("refuses to sum mixed currencies, and says why rather than converting", () => { + const ir = irOf([ + { id: "mono/a", block: block(0.0416, "USD") }, + { id: "mono/b", block: block(0.0312, "EUR") }, + { id: "team-a/a", block: block(0.0208, "USD") }, + ]); + const meta = attachBehaviour(ir, members, "live", noFiles); + expect(meta.sum).toBeUndefined(); + expect(meta.diagnostics).toEqual([ + "no estate sum: the figures are in EUR and USD and behold converts no currency", + "no sum for box mono: the figures are in EUR and USD and behold converts no currency", + ]); + // The box whose figures agree still sums — the mixture is per scope. + expect(meta.boxes).toEqual({ "team-a": { perHour: 0.0208, currency: "USD", priced: 1, unpriced: 0 } }); + }); + + it("rounds the addition back to six decimals rather than shipping a float tail", () => { + const ir = irOf(Array.from({ length: 21 }, (_, i) => ({ id: `mono/${i}`, block: block(0.0416) }))); + // 0.0416 * 21 in binary floating point is 0.8735999999999999. + expect(attachBehaviour(ir, [members[0]], "live", noFiles).sum!.perHour).toBe(0.8736); + }); +}); + +describe("the source priority (#398 item 3)", () => { + it("never reads the document for a member whose nodes already carry the attr", () => { + const looked: string[] = []; + const ir = irOf([{ id: "mono/a", block: block(0.0416) }, { id: "team-a/a" }]); + const meta = attachBehaviour(ir, members, "live", (p) => { + looked.push(p); + return undefined; + }); + // The monolith painted its own; only the silent member was looked up. + expect(looked).toEqual(["/nowhere/team-a/behaviour.live.json"]); + expect(meta.sum).toEqual({ perHour: 0.0416, currency: "USD", priced: 1, unpriced: 1 }); + }); + + it("names the environment in the file it looks for", () => { + const looked: string[] = []; + attachBehaviour(irOf([{ id: "mono/a" }]), [members[0]], "staging", (p) => { + looked.push(p); + return undefined; + }); + expect(looked).toEqual(["/nowhere/mono/behaviour.staging.json"]); + }); + + it("reports a document key that names no entity rather than minting a card", () => { + const ir = irOf([{ id: "mono/a" }]); + const doc = JSON.stringify({ meta: { engine: "e", version: "1", at: { traffic: "t" } }, entities: { ghost: block(1) } }); + const meta = attachBehaviour(ir, [members[0]], "live", () => doc); + expect(ir.nodes.map((n) => n.id)).toEqual(["mono/a"]); + expect(meta.diagnostics).toContain("mono: behaviour.live.json prices ghost, which this overlay has no entity for"); + }); + + it("drops an unreadable document with its reason — an absence, not a refusal", () => { + const meta = attachBehaviour(irOf([{ id: "mono/a" }]), [members[0]], "live", () => "{not json"); + // Nothing was priced, so the answer is still the absent line — with the + // breakage named beside it rather than swallowed. + expect(meta.absent).toContain("no report document at mono/behaviour.live.json"); + expect(meta.refusal).toBeUndefined(); + expect(meta.diagnostics?.[0]).toContain("mono: behaviour.live.json is not JSON"); + }); + + it("names two engines rather than pretending one priced the estate", () => { + const other = { ...block(0.02), provenance: { engine: "other-sim", version: "2.0", tolerance: "±20%", basis: "validated" as const } }; + const ir = irOf([{ id: "mono/a", block: block(0.0416) }, { id: "team-a/a", block: other }]); + const meta = attachBehaviour(ir, [ + { name: "mono", dir: "/nowhere/mono", meta: { engine: "behold-fixture", version: "0", at: { traffic: "100 rps, p50" } } }, + { name: "team-a", dir: "/nowhere/team-a", meta: { engine: "other-sim", version: "2.0", at: { traffic: "100 rps, p50" } } }, + ], "live", noFiles); + expect(meta.diagnostics).toContain("priced by 2 engines (behold-fixture 0, other-sim 2.0) — every figure carries its own provenance"); + // Every figure still carries its own badge, which is why the summary can + // be a summary. + expect((ir.nodes[1].attrs as { _behaviour: BehaviourBlock })._behaviour.provenance.engine).toBe("other-sim"); + }); +}); + +describe("the validator (#398 item 1)", () => { + const ok = block(0.01); + + it("accepts the contract's shape and rebuilds it, dropping whatever an engine appended", () => { + const v = validateBehaviourBlock({ ...ok, futureField: "not in the contract" }); + expect(v.ok).toBe(true); + expect(v.ok && v.value).not.toHaveProperty("futureField"); + }); + + it("names the missing field", () => { + const reason = (b: unknown): string => { + const v = validateBehaviourBlock(b); + return v.ok ? "accepted" : v.reason; + }; + expect(reason({ ...ok, at: {} })).toContain("at.traffic missing"); + expect(reason({ ...ok, cost: { perHour: "0.04", currency: "USD" } })).toContain("cost.perHour"); + expect(reason({ ...ok, cost: { perHour: -1, currency: "USD" } })).toContain("negative"); + expect(reason({ ...ok, headroom: { cpu: 1.4 } })).toContain("headroom.cpu is not a fraction"); + expect(reason({ ...ok, resilience: { verdict: "survives" } })).toContain("resilience.failure missing"); + expect(reason({ ...ok, rightSize: { reason: "why" } })).toContain("rightSize present without a suggestion"); + expect(reason({ ...ok, provenance: { ...ok.provenance, basis: "guessed" } })).toContain("is not modeled/validated"); + expect(reason(null)).toBe("not an object"); + }); + + it("keeps an axis the engine omitted omitted, and a note it wrote", () => { + const v = validateBehaviourBlock({ ...ok, headroom: { latency: 0.4 }, resilience: { failure: "one zone lost", verdict: "degrades", note: "re-established" } }); + expect(v.ok && v.value.headroom).toEqual({ latency: 0.4 }); + expect(v.ok && v.value.resilience).toEqual({ failure: "one zone lost", verdict: "degrades", note: "re-established" }); + }); + + it("takes a refusal on its own terms and requires a way forward", () => { + expect(validateBehaviourMeta({ refusal: { reason: "unreachable", remedy: "set X" } })).toEqual({ + ok: true, + value: { refusal: { reason: "unreachable", remedy: "set X" } }, + }); + const v = validateBehaviourMeta({ refusal: { reason: "unreachable" } }); + expect(v.ok).toBe(false); + expect(!v.ok && v.reason).toContain("refusal.remedy missing"); + }); +}); diff --git a/src/behaviour.ts b/src/behaviour.ts new file mode 100644 index 0000000..a74f8ca --- /dev/null +++ b/src/behaviour.ts @@ -0,0 +1,579 @@ +/** + * The behaviour overlay (#398, M1 of #397) — what an estate costs and where it + * runs out of headroom, as a fact per entity on `/api/overlay`. + * + * The contract is not behold's. It is pinned in the "The behaviour block, + * proposed" comment on INTENTIUS/behold#398 (chant #2356 is the other half), + * and this module implements it byte for byte in field names. A behavioural + * engine — the lexicon's, never behold's — states, at a traffic level it names + * itself, a cost per hour, the headroom left on cpu and latency, an expected + * error rate, a resilience verdict under a named failure, an optional + * right-size hint, and the provenance of all of it. + * + * The invariant #397 exists to protect: **behold never calls an engine**, + * never holds its key, and produces no figure of its own. It reads what the + * lexicon handed it, validates the shape, and does exactly one piece of + * arithmetic — a sum of engine figures, labelled as a sum (`meta.behaviour. + * sum`, never `total`, which is the word reserved for a figure the engine + * itself stated). A missing engine is a refusal printed in the engine's own + * words; it is never a locally faked number. + * + * ## The two sources, and their priority + * + * 1. **`attrs._behaviour` on an overlay IR node** — the channel every other + * live fact rides on (`_status`, `_release`, `_carve`, `_renderDrift`; + * src/overlay.ts documents the family). `chant graph --live --overlay` + * puts it there, keyed by the entity id the drift overlay already keys. + * Graph-level facts ride beside it on `meta._behaviour`. + * 2. **`behaviour..json` at a member's root** — the same shapes as a + * document, `{ meta, entities: {
: block } }`. Read ONLY when no + * node in that member's slice carries `_behaviour`, and only on the + * overlay: `/api/graph` is the source graph and a prediction of a live + * account has no business on it. Entity keys are the member's own + * addresses, as a lexicon writes them; behold prefixes them with the + * member name to reach the composed id (`/
`). + * + * A member with neither draws no behaviour modes at all, and the meta says so + * in one line naming both places it looked. That is an absence, not a refusal: + * nothing was configured, so nothing refused. + * + * ## Why the block stays on the node + * + * #398 leaves the payload shape to the implementation: the block on the node + * as today, or a second top-level `behaviour: { [id]: block }` map beside the + * IR. It stays on the node, and the map is deliberately NOT emitted. + * + * The SPA walks `ir.nodes` once per render and reads `attrs._status` off each + * node to colour it (web/app.js). A colour-by-cost mode is that same walk + * reading `attrs._behaviour.cost.perHour` — no join, no second index, no way + * for the two to fall out of step. A top-level map would make every node + * lookup a join against a second structure keyed by an id the node already + * carries, which is a join the drift overlay never needed and the reason + * `_status` is a node attr in the first place. The estate scale a colour-by + * mode needs (min/max over the entities that carry a figure) comes out of the + * same single pass. + * + * So: one channel, one reader, whichever source filled it. `meta.behaviour` is + * the graph-level half — engine, version, the traffic level, the sums, the + * refusal, the diagnostics — and it is the only thing the response gains + * beside the node attr. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +// --------------------------------------------------------------------------- +// The shapes. Field names are the #398 comment's, exactly. +// --------------------------------------------------------------------------- + +/** The stated traffic level a prediction is for — a string the engine names, + * echoed verbatim. behold never picks a default one; there isn't one. */ +export interface BehaviourAt { + traffic: string; +} + +export interface BehaviourCost { + perHour: number; + currency: string; +} + +/** Fraction of capacity still free, 0..1. A missing axis is ABSENT, not 0 — + * "the engine did not report cpu" and "there is no cpu left" are opposite + * claims and must not render alike. */ +export interface BehaviourHeadroom { + cpu?: number; + latency?: number; +} + +export type BehaviourVerdict = "survives" | "degrades" | "fails"; + +export interface BehaviourResilience { + failure: string; + verdict: BehaviourVerdict; + note?: string; +} + +export interface BehaviourRightSize { + suggestion: string; + reason?: string; +} + +/** List prices versus a bill. `modeled` is the badge's default word. */ +export type BehaviourBasis = "modeled" | "validated"; + +/** Per ENTITY, not per report: one estate can be priced by two engines. */ +export interface BehaviourProvenance { + engine: string; + version: string; + /** The engine's own stated tolerance, echoed verbatim ("±15%"). */ + tolerance: string; + basis: BehaviourBasis; +} + +/** One entity's block. Everything except `rightSize`, `resilience.note` and + * either `headroom` axis is required when the block is present at all. */ +export interface BehaviourBlock { + at: BehaviourAt; + cost: BehaviourCost; + headroom: BehaviourHeadroom; + errorRate: number; + resilience: BehaviourResilience; + rightSize?: BehaviourRightSize; + provenance: BehaviourProvenance; +} + +/** Present INSTEAD of engine/version/at/total when the engine is configured + * but unreachable, or not configured at all. The words are the lexicon's, in + * its own house style; behold prints them as they came. */ +export interface BehaviourRefusal { + reason: string; + remedy: string; +} + +/** `meta._behaviour` on the overlay IR / `meta` in the report document. */ +export interface BehaviourReportMeta { + engine?: string; + version?: string; + at?: BehaviourAt; + /** An estate total the ENGINE stated. When present it outranks behold's own + * sum, and behold emits no sum at all. */ + total?: BehaviourCost; + refusal?: BehaviourRefusal; +} + +/** `{ meta, entities }` — the lexicon's written report, and every fixture. */ +export interface BehaviourReport { + meta: BehaviourReportMeta; + entities: Record; +} + +/** behold's own arithmetic over engine figures, labelled as arithmetic. */ +export interface BehaviourSum { + perHour: number; + currency: string; + /** Entities in scope that carried a cost figure. */ + priced: number; + /** Entities in scope that carried none. */ + unpriced: number; +} + +/** `meta.behaviour` on the `/api/overlay` payload. */ +export interface BehaviourMeta { + engine?: string; + version?: string; + at?: BehaviourAt; + /** The engine's own estate total, when it stated one. */ + total?: BehaviourCost; + refusal?: BehaviourRefusal; + /** One line naming the two places behold looked and found nothing. Present + * INSTEAD of everything else; not a refusal — nothing was configured. */ + absent?: string; + /** behold's sum over the entity figures, when the engine stated no total. */ + sum?: BehaviourSum; + /** The same sum per boundary box (`groups.byStack` — a member of an + * estate), keyed by the box's own key. */ + boxes?: Record; + /** Everything behold dropped or could not reconcile, in the order it hit + * them: a malformed block, two engines on one estate, mixed currencies. */ + diagnostics?: string[]; +} + +// --------------------------------------------------------------------------- +// Validation. A malformed block is DROPPED with a diagnostic, never partially +// rendered — half a block on a card is a figure without its provenance, which +// is the one thing #397 says may never appear. +// --------------------------------------------------------------------------- + +type Rec = Record; +const rec = (v: unknown): Rec | undefined => (v && typeof v === "object" && !Array.isArray(v) ? (v as Rec) : undefined); +const str = (v: unknown): string | undefined => (typeof v === "string" && v.trim().length > 0 ? v : undefined); +const num = (v: unknown): number | undefined => (typeof v === "number" && Number.isFinite(v) ? v : undefined); +/** A fraction the contract bounds at 0..1. Out of range is malformed, not + * clamped: clamping would invent a figure, which is the whole prohibition. */ +const frac = (v: unknown): number | undefined => { + const n = num(v); + return n !== undefined && n >= 0 && n <= 1 ? n : undefined; +}; + +export type Validated = { ok: true; value: T } | { ok: false; reason: string }; + +const bad = (reason: string): { ok: false; reason: string } => ({ ok: false, reason }); + +/** + * One entity block, validated against the #398 contract and REBUILT to it. + * + * Rebuilt rather than passed through: the block reaches a card and a badge, so + * what the SPA sees should be exactly the contract's fields and nothing an + * engine happened to append. A future field is a contract change, made here, + * not a key that leaks through unvalidated. + */ +export function validateBehaviourBlock(v: unknown): Validated { + const b = rec(v); + if (!b) return bad("not an object"); + + const traffic = str(rec(b.at)?.traffic); + if (!traffic) return bad("at.traffic missing — the engine must name the traffic level it priced"); + + const cost = rec(b.cost); + const perHour = num(cost?.perHour); + const currency = str(cost?.currency); + if (perHour === undefined) return bad("cost.perHour is not a finite number"); + if (perHour < 0) return bad("cost.perHour is negative"); + if (!currency) return bad("cost.currency missing"); + + const headroom = rec(b.headroom); + if (!headroom) return bad("headroom missing"); + const cpu = headroom.cpu === undefined ? undefined : frac(headroom.cpu); + const latency = headroom.latency === undefined ? undefined : frac(headroom.latency); + if (headroom.cpu !== undefined && cpu === undefined) return bad("headroom.cpu is not a fraction 0..1"); + if (headroom.latency !== undefined && latency === undefined) return bad("headroom.latency is not a fraction 0..1"); + if (cpu === undefined && latency === undefined) return bad("headroom carries neither cpu nor latency"); + + const errorRate = frac(b.errorRate); + if (errorRate === undefined) return bad("errorRate is not a fraction 0..1"); + + const res = rec(b.resilience); + const failure = str(res?.failure); + const verdict = res?.verdict; + if (!failure) return bad("resilience.failure missing — a verdict with no named failure says nothing"); + if (verdict !== "survives" && verdict !== "degrades" && verdict !== "fails") { + return bad(`resilience.verdict ${JSON.stringify(verdict)} is not survives/degrades/fails`); + } + const note = str(res?.note); + + let rightSize: BehaviourRightSize | undefined; + if (b.rightSize !== undefined) { + const rs = rec(b.rightSize); + const suggestion = str(rs?.suggestion); + if (!suggestion) return bad("rightSize present without a suggestion"); + const reason = str(rs?.reason); + rightSize = { suggestion, ...(reason ? { reason } : {}) }; + } + + const prov = rec(b.provenance); + const engine = str(prov?.engine); + const version = str(prov?.version); + const tolerance = str(prov?.tolerance); + const basis = prov?.basis; + if (!engine) return bad("provenance.engine missing"); + if (!version) return bad("provenance.version missing"); + if (!tolerance) return bad("provenance.tolerance missing — a figure without a stated tolerance is not a prediction"); + if (basis !== "modeled" && basis !== "validated") return bad(`provenance.basis ${JSON.stringify(basis)} is not modeled/validated`); + + return { + ok: true, + value: { + at: { traffic }, + cost: { perHour, currency }, + headroom: { ...(cpu !== undefined ? { cpu } : {}), ...(latency !== undefined ? { latency } : {}) }, + errorRate, + resilience: { failure, verdict, ...(note ? { note } : {}) }, + ...(rightSize ? { rightSize } : {}), + provenance: { engine, version, tolerance, basis }, + }, + }; +} + +/** The graph-level half. A refusal is validated on its own terms and short- + * circuits everything else — that is what "present INSTEAD of" means. */ +export function validateBehaviourMeta(v: unknown): Validated { + const m = rec(v); + if (!m) return bad("meta is not an object"); + + if (m.refusal !== undefined) { + const r = rec(m.refusal); + const reason = str(r?.reason); + const remedy = str(r?.remedy); + if (!reason) return bad("refusal.reason missing"); + if (!remedy) return bad("refusal.remedy missing — a refusal that names no way forward is a dead end"); + return { ok: true, value: { refusal: { reason, remedy } } }; + } + + const engine = str(m.engine); + const version = str(m.version); + if (!engine) return bad("meta.engine missing"); + if (!version) return bad("meta.version missing"); + const traffic = str(rec(m.at)?.traffic); + if (!traffic) return bad("meta.at.traffic missing"); + + let total: BehaviourCost | undefined; + if (m.total !== undefined) { + const t = rec(m.total); + const perHour = num(t?.perHour); + const currency = str(t?.currency); + if (perHour === undefined || !currency) return bad("meta.total is not {perHour, currency}"); + total = { perHour, currency }; + } + return { ok: true, value: { engine, version, at: { traffic }, ...(total ? { total } : {}) } }; +} + +/** The whole document, shape-checked before either half is read. */ +export function validateBehaviourReport(v: unknown): Validated<{ meta: BehaviourReportMeta; entities: Record }> { + const d = rec(v); + if (!d) return bad("not an object"); + const meta = validateBehaviourMeta(d.meta); + if (!meta.ok) return bad(meta.reason); + const entities = rec(d.entities) ?? {}; + if (d.entities !== undefined && !rec(d.entities)) return bad("entities is not an object"); + return { ok: true, value: { meta: meta.value, entities } }; +} + +// --------------------------------------------------------------------------- +// The document on disk. +// --------------------------------------------------------------------------- + +/** The report document a member's root may carry for an environment. One + * name, derived from the env the overlay was asked for — so a `live` overlay + * never reads a `staging` prediction. */ +export const behaviourFileName = (env: string): string => `behaviour.${env}.json`; + +/** How a file is read. Injected so tests (and a future non-fs source) do not + * need a temp directory, exactly as `pathAlignment`'s `isDir` is injected. */ +export type ReadFile = (path: string) => string | undefined; + +const realRead: ReadFile = (path) => { + try { + return readFileSync(path, "utf8"); + } catch { + return undefined; // absent is an ordinary state, not an error + } +}; + +// --------------------------------------------------------------------------- +// The pass. +// --------------------------------------------------------------------------- + +interface Node { + id: string; + attrs?: Record; +} + +interface Ir { + nodes: Node[]; + groups: { byStack?: Record }; +} + +/** One served member, as the pass needs to see it. */ +export interface BehaviourMember { + /** The name `composeStacks` namespaces this member's ids under, and the + * prefix an entity address in its report document gets. Undefined on a + * single-project serve, where node ids are the member's own addresses. */ + name?: string; + /** The member's root — where `behaviour..json` would sit. */ + dir: string; + /** `meta._behaviour` as this member's own overlay read carried it. chant's + * graph JSON may carry keys `GraphIR` does not declare, and composition + * keeps nothing above nodes/edges/groups, so it is captured per member + * before composition and handed here. */ + meta?: unknown; +} + +/** How a member's blocks arrived, for the diagnostics and the absent line. */ +type MemberSource = "attrs" | "document" | "none"; + +const BEHAVIOUR_ATTR = "_behaviour"; + +/** The node ids belonging to a member: composition namespaces every id + * `/`, so a member's slice of a composed IR is exactly that + * prefix (the same reading `withoutJoinedMembers` takes in src/estate.ts). */ +const sliceOf = (nodes: Node[], name: string | undefined): Node[] => + name === undefined ? nodes : nodes.filter((n) => n.id.startsWith(`${name}/`)); + +/** Sums carry engine figures through addition and nothing else. Binary + * floating point makes `0.0416 * 21` end in a tail of digits nobody stated, + * so the sum is rounded back to six decimals — finer than any currency, and + * enough that the arithmetic reads as the addition it is rather than as a + * figure of behold's own with a spurious precision. */ +const round6 = (n: number): number => Math.round(n * 1e6) / 1e6; + +/** The sum over a set of nodes: engine figures added, everything else counted. + * Mixed currencies produce no sum at all — converting is the engine's job and + * behold has no rate to convert with. */ +function sumOver(nodes: Node[], onMixed: (currencies: string[]) => void): BehaviourSum | undefined { + const currencies = new Set(); + let perHour = 0; + let priced = 0; + let unpriced = 0; + for (const n of nodes) { + const b = n.attrs?.[BEHAVIOUR_ATTR] as BehaviourBlock | undefined; + if (!b) { + unpriced++; + continue; + } + priced++; + perHour += b.cost.perHour; + currencies.add(b.cost.currency); + } + if (priced === 0) return undefined; + if (currencies.size > 1) { + onMixed([...currencies].sort()); + return undefined; + } + return { perHour: round6(perHour), currency: [...currencies][0], priced, unpriced }; +} + +/** + * Read the behaviour block onto every entity that has one, and answer the + * graph-level `meta.behaviour`. + * + * Mutates `ir.nodes` — a validated block replaces whatever `attrs._behaviour` + * held, a malformed one is deleted, and a refusal deletes every one of them. + * Returns the meta the route puts on the payload; always returns something, + * because "nothing here is priced, and here is where I looked" is itself the + * answer to the question the overlay asks. + */ +export function attachBehaviour( + ir: Ir, + members: readonly BehaviourMember[], + env: string, + readFile: ReadFile = realRead, +): BehaviourMeta { + const diagnostics: string[] = []; + const metas: { name: string; meta: BehaviourReportMeta }[] = []; + const sources = new Map(); + const looked: string[] = []; + + for (const m of members) { + const label = m.name ?? "the project"; + const slice = sliceOf(ir.nodes, m.name); + const inline = slice.filter((n) => n.attrs?.[BEHAVIOUR_ATTR] !== undefined); + + if (inline.length > 0) { + // Source 1 wins outright: a member whose lexicon painted the nodes is + // never second-guessed by a file someone left in its root. + sources.set(label, "attrs"); + for (const n of inline) { + const v = validateBehaviourBlock(n.attrs![BEHAVIOUR_ATTR]); + if (v.ok) n.attrs![BEHAVIOUR_ATTR] = v.value; + else { + delete n.attrs![BEHAVIOUR_ATTR]; + diagnostics.push(`dropped the behaviour block on ${n.id}: ${v.reason}`); + } + } + if (m.meta !== undefined) { + const vm = validateBehaviourMeta(m.meta); + if (vm.ok) metas.push({ name: label, meta: vm.value }); + else diagnostics.push(`${label}: dropped meta._behaviour — ${vm.reason}`); + } + continue; + } + + // Source 2, and only now: no node of this member carries the attr. + const file = behaviourFileName(env); + looked.push(`${label}/${file}`); + const raw = readFile(join(m.dir, file)); + if (raw === undefined) { + sources.set(label, "none"); + continue; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + sources.set(label, "none"); + diagnostics.push(`${label}: ${file} is not JSON — ${err instanceof Error ? err.message : String(err)}`); + continue; + } + const doc = validateBehaviourReport(parsed); + if (!doc.ok) { + sources.set(label, "none"); + diagnostics.push(`${label}: ${file} is not a behaviour report — ${doc.reason}`); + continue; + } + sources.set(label, "document"); + metas.push({ name: label, meta: doc.value.meta }); + // Join by entity id. The file's keys are the member's OWN addresses, the + // way a lexicon writes them; the composed id is `/
`, so + // behold adds the prefix rather than asking a lexicon to know what it was + // composed as. A key naming no node is reported, never minted as a card. + const byId = new Map(slice.map((n) => [n.id, n])); + for (const [address, block] of Object.entries(doc.value.entities)) { + const id = m.name === undefined ? address : `${m.name}/${address}`; + const node = byId.get(id); + if (!node) { + diagnostics.push(`${label}: ${file} prices ${address}, which this overlay has no entity for`); + continue; + } + const v = validateBehaviourBlock(block); + if (!v.ok) { + diagnostics.push(`dropped the behaviour block on ${id}: ${v.reason}`); + continue; + } + node.attrs = { ...node.attrs, [BEHAVIOUR_ATTR]: v.value }; + } + } + + // A refusal drops every behaviour-derived figure and colour — including any + // that another member happily supplied, because the estate's answer is one + // answer. The drift overlay is untouched: nothing above or below this line + // reads or writes `_status`. + const refused = metas.find((m) => m.meta.refusal); + if (refused) { + for (const n of ir.nodes) if (n.attrs?.[BEHAVIOUR_ATTR] !== undefined) delete n.attrs[BEHAVIOUR_ATTR]; + const others = metas.filter((m) => m.meta.refusal && m !== refused).map((m) => m.name); + return { + refusal: refused.meta.refusal!, + ...(others.length ? { diagnostics: [`${others.join(", ")} refused too, in their own words`] } : {}), + }; + } + + // Absent means nothing arrived from EITHER source: no graph-level statement + // and not one entity block. A lexicon that paints nodes without stating a + // `meta._behaviour` has still priced the estate — the badge on each figure is + // that entity's own provenance, which is where the contract puts it. + const anyBlock = ir.nodes.some((n) => n.attrs?.[BEHAVIOUR_ATTR] !== undefined); + if (metas.length === 0 && !anyBlock) { + const where = looked.length ? looked.join(", ") : `${behaviourFileName(env)} at each member's root`; + return { + absent: `no behaviour block: no node carries attrs._behaviour (what \`chant graph --live --overlay\` would paint from the lexicon's engine), and no report document at ${where}`, + ...(diagnostics.length ? { diagnostics } : {}), + }; + } + + // Provenance is per entity, so the graph-level engine is a summary of what + // priced this estate, not an authority over it. When members disagree the + // first is reported and the disagreement is named — every figure still + // carries its own badge, which is the reason the contract puts provenance on + // the entity in the first place. + const first = metas[0]?.meta ?? {}; + const engines = [...new Set(metas.map((m) => `${m.meta.engine} ${m.meta.version}`))]; + if (engines.length > 1) diagnostics.push(`priced by ${engines.length} engines (${engines.join(", ")}) — every figure carries its own provenance`); + const trafficLevels = [...new Set(metas.map((m) => m.meta.at?.traffic).filter((t): t is string => !!t))]; + if (trafficLevels.length > 1) diagnostics.push(`priced at ${trafficLevels.length} traffic levels (${trafficLevels.join("; ")}) — the sums add figures stated at different levels`); + + const meta: BehaviourMeta = { + ...(first.engine ? { engine: first.engine } : {}), + ...(first.version ? { version: first.version } : {}), + ...(first.at ? { at: first.at } : {}), + }; + + // `total` is the ENGINE's word for an estate figure and only ever holds an + // engine's own number; `sum` is behold's addition, named so nobody can mistake + // one for the other. When the engine stated a total, behold adds nothing. + const engineTotal = metas.map((m) => m.meta.total).find((t): t is BehaviourCost => !!t); + if (engineTotal) { + meta.total = engineTotal; + if (metas.filter((m) => m.meta.total).length > 1) diagnostics.push("two members stated an estate total; the first is shown"); + } else { + const sum = sumOver(ir.nodes, (cs) => diagnostics.push(`no estate sum: the figures are in ${cs.join(" and ")} and behold converts no currency`)); + if (sum) meta.sum = sum; + const byStack = ir.groups.byStack; + if (byStack) { + const boxes: Record = {}; + for (const [boxKey, ids] of Object.entries(byStack)) { + const inBox = new Set(ids); + const boxSum = sumOver( + ir.nodes.filter((n) => inBox.has(n.id)), + (cs) => diagnostics.push(`no sum for box ${boxKey}: the figures are in ${cs.join(" and ")} and behold converts no currency`), + ); + if (boxSum) boxes[boxKey] = boxSum; + } + if (Object.keys(boxes).length) meta.boxes = boxes; + } + } + + const quiet = [...sources].filter(([, s]) => s === "none").map(([n]) => n); + if (quiet.length) diagnostics.push(`no behaviour source for ${quiet.join(", ")} (no attrs._behaviour, no ${behaviourFileName(env)})`); + + if (diagnostics.length) meta.diagnostics = diagnostics; + return meta; +} diff --git a/src/choudoufu-refs.ts b/src/choudoufu-refs.ts index 177076e..975a04c 100644 --- a/src/choudoufu-refs.ts +++ b/src/choudoufu-refs.ts @@ -301,13 +301,29 @@ export async function readEstateLexicon(dir: string, opts: GraphOptions = {}): P * behold already has; the note says the reader is absent, and a reader that * broke says nothing louder than an empty edge set). */ +/** The test seam for the lexicon read (#334). `readEstateLexicon` spawns a + * real chant over a scratch project — three to four seconds on a laptop, and + * right under vitest's default five-second budget on a CI runner, which is how + * a route test that only wanted the roster timed out. `createApp` sets this + * beside `setChoudoufuRunner`: a fake choudoufu means no lexicon spawn either + * (`null`), unless the test hands in a reader of its own, which is how a + * route test asks for edges off a recorded lexicon IR. `undefined` outside a + * test: the real read. */ +let lexiconReadOverride: LexiconRead | null | undefined; +export function setEstateLexiconRead(read: LexiconRead | null | undefined): void { + lexiconReadOverride = read; +} + export async function addIntraEstateEdges( ir: GraphIR, dir: string, state: TerraformReaderState = terraformReaderState(), - read: LexiconRead = readEstateLexicon, + read: LexiconRead = lexiconReadOverride === undefined ? readEstateLexicon : lexiconReadOverride ?? (async () => ({ nodes: [], edges: [] }) as unknown as GraphIR), ): Promise { - if (state.refusal || ir.nodes.length === 0) return ir; + // A reader a test handed in is its own source of truth: the lexicon's + // resolvability on this machine says nothing about it (CI has no lexicon). + const handed = lexiconReadOverride !== undefined && lexiconReadOverride !== null; + if ((state.refusal && !handed) || ir.nodes.length === 0) return ir; let lexicon: GraphIR; try { lexicon = await read(dir, {}); diff --git a/src/choudoufu-route.test.ts b/src/choudoufu-route.test.ts index 30269a8..599370f 100644 --- a/src/choudoufu-route.test.ts +++ b/src/choudoufu-route.test.ts @@ -130,6 +130,30 @@ describe("GET /api/choudoufu/moves (#371)", () => { expect(body.meta.vocabulary.note).toBeUndefined(); }); + // #334: a fake choudoufu means no lexicon spawn — the overlay read used to + // shell a real chant for the estate's references and sat right under the + // five-second budget on a CI runner. A test that wants edges hands in a + // reader over a recorded lexicon IR instead. + it("spawns no lexicon read under the fake runner, and joins the edges a test's own reader hands in", async () => { + const { mono, teamA } = estate(); + const broadcaster = new Broadcaster(); + const lexicon = async () => ({ + nodes: [], + edges: [{ from: "estate/aws_iam_role.team_a", to: "estate/aws_iam_policy.team_a", kind: "ref", viaAttr: "policy_arn" }], + }) as never; + const app = createApp( + { projectDir: mono, projectDirs: [mono, teamA], port: 0, choudoufu: { run: fakeChoudoufu([]), lexicon } }, + broadcaster, + new FrameBuffer(), + new OpRunner({ projectDir: mono, broadcaster, onDone: () => {} }), + ); + const started = Date.now(); + const body = (await (await app.request("/api/graph")).json()) as { ir: { edges: Array<{ from: string; to: string }> } }; + expect(Date.now() - started).toBeLessThan(3000); // no chant spawned for the join + // Joined on the member's own addresses, then composed under its short name. + expect(body.ir.edges).toContainEqual({ from: "mono/aws_iam_role.team_a", to: "mono/aws_iam_policy.team_a", kind: "ref", viaAttr: "policy_arn", inferred: true }); + }); + it("is advertised on /api, and /api/project lists the plans found", async () => { const { app } = served(); const api = (await (await app.request("/api")).json()) as { routes: Array<{ path: string }> }; diff --git a/src/estate.ts b/src/estate.ts index 81c0a43..3da96a6 100644 --- a/src/estate.ts +++ b/src/estate.ts @@ -138,6 +138,18 @@ export interface EstateOverlayResult { * declared rather than the member itself — see `estateNamespaceScopes`. * Sorted by name; empty on an estate with no such binding. */ joined: { name: string; namespace: string }[]; + /** + * Each member's own graph-level `meta`, keyed by the name composition + * namespaced it under — captured before `composeStacks`, which keeps + * nothing above `nodes`/`edges`/`groups`. + * + * `GraphIR` declares no `meta`, and that is the point: a lexicon states + * graph-level facts there (`meta._behaviour`, #398) and chant's graph JSON + * carries them through `runChantJson` verbatim. Read as `unknown` and + * validated by whoever claims a key — this module composes, it does not + * interpret. Absent for a member whose read carried none. + */ + memberMeta: Record; } /** A short human reason from a failure. ChantCliError's message leads with @@ -431,6 +443,7 @@ export async function composeEstateOverlay( const dropped: { name: string; reason: string }[] = []; const joined: { name: string; namespace: string }[] = []; const stacks: ({ name: string; ir: GraphIR } | undefined)[] = new Array(projectDirs.length); + const memberMeta: Record = {}; // #221: where the estate itself says a member's objects run. Resolved before // the live pass because it is an argument TO that pass. const scopes = await estateNamespaceScopes(projectDirs, opts); @@ -439,7 +452,12 @@ export async function composeEstateOverlay( const namespace = scopes.get(dir); try { const live = { ...opts, live: true, overlay: true, ...(namespace ? { namespace } : {}) }; - stacks[i] = { name, ir: namespaceRuntimeOwners(name, classify(await memberLive(dir, live))) }; + const read = await memberLive(dir, live); + // Before composition, while the member's own read is still whole — see + // `memberMeta` on the result. + const own = (read as { meta?: unknown }).meta; + if (own !== undefined) memberMeta[name] = own; + stacks[i] = { name, ir: namespaceRuntimeOwners(name, classify(read)) }; // Reported only for a read that actually happened — an unobserved // member was not read anywhere, joined namespace or not. if (namespace) joined.push({ name, namespace }); @@ -468,5 +486,6 @@ export async function composeEstateOverlay( // Concurrent reads finish in whatever order the clusters answer; the note // this feeds should read the same twice. joined: joined.sort((a, b) => a.name.localeCompare(b.name)), + memberMeta, }; } diff --git a/src/server.ts b/src/server.ts index bb9326e..0b4425b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -132,6 +132,7 @@ import { pickAutoSyncOps, splitForgeRouted, suspendedByRollback, type AutoSyncMo import { sourceCommits, openRollbackBranches } from "./history.ts"; import { composeEstate, composeEstateOverlay, estateMembers, withoutJoinedMembers } from "./estate.ts"; import { statusVocabulary } from "./status-vocabulary.ts"; +import { attachBehaviour, type BehaviourMember } from "./behaviour.ts"; import { addEstateMemberEdges } from "./estate-edges.ts"; import { addChoudoufuReferenceEdges, liveCheckToIr, readLiveCheck, setChoudoufuRunner, setChoudoufuSpawnEnv } from "./choudoufu-member.ts"; import { @@ -144,7 +145,7 @@ import { type TerraformElision, } from "./terraform-lens.ts"; import { choudoufuDiffNodes, readChoudoufuLive, type Runner as ChoudoufuRunner } from "./choudoufu-live.ts"; -import { choudoufuLexiconNote } from "./choudoufu-refs.ts"; +import { choudoufuLexiconNote, setEstateLexiconRead, type LexiconRead } from "./choudoufu-refs.ts"; import { discoverCarvePlans, moveMembers, moveReceipt, movesPayload, readCarvePlan, type MoveMorphMoveInput } from "./choudoufu-moves.ts"; import { memberKindOf, memberKindSpec, servesAsEstate } from "./member-kind.ts"; import { TerraformReadError, discoverTerraformRoots, terraformRootsNote, terraformRootsNoteShort } from "./terraform-member.ts"; @@ -231,7 +232,12 @@ export interface ServerOptions { * route test can answer `live-mv -dry-run` from a recorded document with * no binary. Never widens the write surface: the only `live-mv` spelling * that reaches it is `dryRunArgs`' (src/choudoufu-moves.ts). */ - choudoufu?: { run: ChoudoufuRunner }; + choudoufu?: { + run: ChoudoufuRunner; + /** #334: a test that wants intra-estate edges hands in a reader over a + * recorded lexicon IR; absent, a fake choudoufu means no lexicon spawn. */ + lexicon?: LexiconRead; + }; port: number; } @@ -899,6 +905,7 @@ export function createApp( // A test's fake choudoufu answers every read, the member via's included // (src/choudoufu-member.ts `setChoudoufuRunner`); undefined in production. setChoudoufuRunner(cfg.choudoufu?.run); + setEstateLexiconRead(cfg.choudoufu ? (cfg.choudoufu.lexicon ?? null) : undefined); // Carve mode (#252) claims /api/graph, /api/project and friends before the // project-shaped handlers are registered — see carveRoutes. @@ -1790,7 +1797,11 @@ export function createApp( }, ] : []), - { method: "GET", path: "/api/overlay", desc: "live drift overlay for ?env= — same shape/params as /api/graph, plus runtime=1" }, + { + method: "GET", + path: "/api/overlay", + desc: "live drift overlay for ?env= — same shape/params as /api/graph, plus runtime=1; each priced entity carries attrs._behaviour (cost/headroom/errorRate/resilience/rightSize/provenance, #398) and meta.behaviour holds the engine, its sums, a refusal or an absent line", + }, { method: "GET", path: "/api/layout", desc: "hand-layout sidecar (.behold/layout.json): ?lens= → {lens, deltas, writable}; no lens → every lens" }, { method: "POST", path: "/api/layout", desc: "store one lens's deltas: JSON body {lens, deltas: {: {dx,dy,dw,dh}}} (the only file behold writes in your project)" }, { method: "GET", path: "/api/diff", desc: "per-node live diff for ?env= — {env, nodes: {: {observed, diff, health, fieldDrift}}}" }, @@ -2691,6 +2702,18 @@ export function createApp( // the same boxes the single-project runtime view draws, which is the // point of asking for the tier. Every other estate view keeps // `byStack`. + // #398: the behaviour block, from each member's own nodes or, when a + // member painted none, from the report document at its root. Before + // the collapse below, so the sums add the estate's real entities + // rather than the one card a collapsed box leaves behind, and after + // every edge/paint pass, so nothing downstream can invent a figure. + // The overlay only: `/api/graph` is the source graph and a prediction + // about a live account has no business on it (src/behaviour.ts). + const behaviour = attachBehaviour( + ir, + estateMembers(cfg.projectDirs).map((m): BehaviourMember => ({ name: m.name, dir: m.dir, meta: est.memberMeta[m.name] })), + env, + ); // #393 C: the same collapse lens and the same count badges the source // graph carries — one flag, both routes, or the palette command would // undo itself the moment an env was picked. @@ -2730,7 +2753,7 @@ export function createApp( return c.json({ ir, svg, - meta: { projectDir: cfg.projectDir, env, mode: "overlay", estate: est.total, vocabulary, ...(note ? { note } : {}) }, + meta: { projectDir: cfg.projectDir, env, mode: "overlay", estate: est.total, vocabulary, behaviour, ...(note ? { note } : {}) }, }); } // #261: `runtime` forces detail 3 exactly as `logical` does, and for the @@ -2742,7 +2765,13 @@ export function createApp( // Reclassify wiring/examples so they don't read as "pending" over a done // deploy (see reclassifyOverlay): Parameters take their deployed // component's status, src/examples/ nodes go neutral + `_byo`. - let ir = joinCarvedSources(reclassifyOverlay(await graphIr(cfg.projectDir, opts)), (await carveStatesFor(cfg.projectDir)).values(), cfg.projectDir).ir; + const read = await graphIr(cfg.projectDir, opts); + // #398: the lexicon's graph-level behaviour statement, taken off the + // read itself — `GraphIR` declares no `meta`, and the passes below have + // no reason to carry one (see src/behaviour.ts and estate.ts's + // `memberMeta`, which captures the same fact per member). + const readMeta = (read as { meta?: unknown }).meta; + let ir = joinCarvedSources(reclassifyOverlay(read), (await carveStatesFor(cfg.projectDir)).values(), cfg.projectDir).ir; const boundContext = await boundK8sContext(env); // The `cluster/` build root merges in — the estates declare their k3d // cluster there, outside sourceDir (see clusterRootGraphIr) — painted @@ -2864,6 +2893,9 @@ export function createApp( compositeEdgesAttached = 0; } } + // #398, the single-project half: one member, so an entity key in the + // report document IS the node id — nothing to prefix. + const behaviour = attachBehaviour(ir, [{ dir: cfg.projectDir, meta: readMeta }], env); // `boxes: "byContainer"` (#86) is a no-op unless attachRuntimeContainment // populated it above — same "harmless when absent" contract as byStack. const { svg } = renderGraph(ir, { boxes: "byContainer", radial: new URL(c.req.url).searchParams.get("radial") === "1" }); @@ -2890,7 +2922,7 @@ export function createApp( // that never had the attrs to derive edges from in the first place. const zoomNotes = notesFor(zoom, ir, compositeEdgesAttached, undefined, opts.detail ?? 2); const note = [tierNote, nsNote, zoomNotes].filter(Boolean).join(" · "); - return c.json({ ir, svg, meta: { projectDir: cfg.projectDir, env, mode: "overlay", vocabulary, ...(note ? { note } : {}) } }); + return c.json({ ir, svg, meta: { projectDir: cfg.projectDir, env, mode: "overlay", vocabulary, behaviour, ...(note ? { note } : {}) } }); } catch (err) { // #72: the same structured {error, code, remedy} the other read routes // return — this is in fact where a picked tier's creds gate USUALLY diff --git a/vitest.config.ts b/vitest.config.ts index e4b5430..2d05bc1 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -14,5 +14,12 @@ export default defineConfig({ test: { include: ["src/**/*.test.ts", "web/**/*.test.js"], reporters: ["default", ["json", { outputFile: ".vitest-last.json" }]], + // #334: the route tests that spawn a real chant (`carve status` over a + // project, a lexicon read) have a fixed cost the runner sets — under a + // second here, 4.7 s on a GitHub runner, 5.008 s the day PR #405 failed + // its first run against the 5 s default. A spawn is not a unit test's + // budget. Twenty seconds still catches a hang; it stops a slow runner + // counting as a flake. + testTimeout: 20_000, }, }); diff --git a/web/app.js b/web/app.js index 244d2af..49ea1ed 100644 --- a/web/app.js +++ b/web/app.js @@ -8,7 +8,31 @@ // floating control panel's chrome (panel.js — drag/snap/collapse/tabs, persisted // position), and the theme picker into the panel's View-tab slot (a stable element // renderPanelView never rewrites, so the select mounts once and survives re-renders). -import { initTheme, mountThemePicker, readableOn, colorForCategory, onThemeChange, getTokens } from "./theme.js"; +import { initTheme, mountThemePicker, readableOn, colorForCategory, onThemeChange, getTokens, getTheme, pinTokensFor } from "./theme.js"; +// #399 M2 / #401 M4 of #397: the colour-by modes' arithmetic and the +// provenance badge's wording — every decision the behaviour overlay makes, +// pure and unit-tested beside the file. This module owns the fetches, the SVG +// and the panels; nothing below states a figure of its own. +import { + COLOUR_MODES, + badgeFor, + blockOf, + countFigures, + countsText, + domainFor, + effectiveMode, + figureOf, + fillFor, + fmtCost, + fmtHeadroom, + headroomOf, + modeAvailability, + rampFor, + rampGradient, + scaleEnds, + totalsFor, + totalsText, +} from "./behaviour-scale.js"; import { addPanelTab, initPanel, setPanelTab, togglePanelCollapsed, isPanelCollapsed } from "./panel.js"; // #254: the carve walkthrough's stepper — everything it DECIDES is a pure // function in there; this file owns the fetches, the graph selection, and the @@ -66,16 +90,115 @@ mountThemePicker(document.getElementById("panel-theme")); // labels/icons get readable ink (black/white) on the category fill. Re-runs on theme switch, // since the categorical hues come from the active theme's palette. let lastGraphIr = null; + +// --- colour by drift, cost or headroom (#399, M2 of #397) ------------------- +// +// The mode is CLIENT-SIDE state, deliberately. It changes no fetch: the server +// hands the same overlay whichever mode is picked — `attrs._behaviour` rides +// every priced card already (src/behaviour.ts explains why the block stays on +// the node), so a mode switch is a repaint of the SVG that is already on +// screen, with no round trip and nothing for a slow chant read to hold up. It +// is therefore NOT in `LENS_PARAMS`/`canonicalKey`: a lens param there means "a +// different snapshot comes back", and adding one would give a static export +// three identical bundles of the same graph under three keys. +// +// It survives a reload the way the theme does, and for the same reason — the +// colour you left the graph in is part of how you read it. +const COLOUR_STORE_KEY = "behold.colourBy"; +let colourMode = "drift"; +try { + const saved = localStorage.getItem(COLOUR_STORE_KEY); + if (COLOUR_MODES.includes(saved)) colourMode = saved; +} catch { + /* private mode */ +} + +const COLOUR_MODE_TITLE = { + drift: "Colour each card by what chant observed live — the overlay behold has always drawn", + cost: "Colour each card by the engine's cost per hour, low to high across this estate. An entity nothing priced draws neutral, never the cheap end.", + headroom: "Colour each card by how far it is from saturation — the LOWER of the axes the engine reported. An entity nothing priced draws neutral.", +}; + +/** `meta.behaviour` off the last render, or null. */ +function behaviourMeta() { + return (lastMeta && lastMeta.behaviour) || null; +} + +/** The mode actually painted: the pick, unless this graph cannot serve it (no + * overlay, a refusal, an absence), in which case drift — which never depended + * on an engine. The PICK is kept either way, so switching back to a live env + * lands where you left off. */ +function activeColourMode() { + return effectiveMode(colourMode, lastMeta); +} + +function setColourMode(mode) { + colourMode = mode; + try { + localStorage.setItem(COLOUR_STORE_KEY, mode); + } catch { + /* private mode */ + } + // No load(): the mode changes nothing the server would answer differently, + // so the SVG already on screen is repainted in place. renderStatusbar() is + // the one entry point that redraws the strip AND all three panel tabs, which + // is exactly the surface a mode switch moves. + recolorNodesByCategory(); + renderStatusbar(); +} + +/** + * What each card is painted, in the active mode. + * + * Drift keeps the categorical hue #62 gives a card (the drift state stays on + * the bar and the stroke, where pinhole puts it). The two behaviour modes + * replace the FILL with the mode's scale, so the drift bar underneath is + * untouched and both facts stay on the card at once. + * + * The scale's domain is taken over every entity in the graph that carries a + * figure — the same single walk of `ir.nodes` M1's design note promised, with + * no join against a second index. + */ +function colourPlan(graphIr) { + const mode = activeColourMode(); + const tokens = getTokens(); + const kindOf = new Map(graphIr.nodes.map((n) => [n.id, n.kind || n.lexicon || "node"])); + if (mode === "drift" || !tokens) { + return { mode, fillOf: (id) => ({ fill: colorForCategory(kindOf.get(id)), unpriced: false }) }; + } + const theme = getTheme(); + // The drift overlay's own neutral card ground — the colour a card wears when + // chant could not read it. #399: an entity with no block draws THIS, never + // the zero end of the scale, which would say "free" or "saturated" about an + // entity nothing priced. + const neutralFill = theme ? pinTokensFor(theme).neutralFill : tokens.neutral; + const ramp = rampFor(mode, tokens); + const domain = domainFor( + mode, + graphIr.nodes.map((n) => figureOf(mode, blockOf(n))), + ); + const figures = new Map(graphIr.nodes.map((n) => [n.id, figureOf(mode, blockOf(n))])); + return { mode, domain, ramp, fillOf: (id) => fillFor(figures.get(id), domain, ramp, neutralFill) }; +} + function recolorNodesByCategory(ir) { if (ir) lastGraphIr = ir; const graphIr = ir || lastGraphIr; const svg = document.querySelector("#graph svg"); if (!svg || !graphIr) return; const kindOf = new Map(graphIr.nodes.map((n) => [n.id, n.kind || n.lexicon || "node"])); + const plan = colourPlan(graphIr); for (const g of svg.querySelectorAll("[data-node-id]")) { - const kind = kindOf.get(g.getAttribute("data-node-id")); + const id = g.getAttribute("data-node-id"); + const kind = kindOf.get(id); if (!kind) continue; - const cat = colorForCategory(kind), ink = readableOn(cat); + const paint = plan.fillOf(id); + const cat = paint.fill, ink = readableOn(cat); + // #399: an unpriced card is marked, not merely coloured — a hex cannot be + // read back out into "the engine did not price this", and the legend's + // unpriced count has to agree with what is on the canvas. + if (plan.mode !== "drift" && paint.unpriced) g.setAttribute("data-unpriced", "1"); + else g.removeAttribute("data-unpriced"); // Classify each element ONCE by the pinhole token it rode on (data-cat role), then apply // the role's colour on every pass. This is what makes it recolour on theme switch: after // the first pass the fill is a hex (not a --pin-* var), so we must key off the marker, not @@ -403,6 +526,37 @@ function renderDriftSection(section, r) { pr("rendered", `${p.renderedAt} · helm ${p.helmVersion} · chant ${p.chantVersion}`); } +const RESILIENCE_WORD = { survives: "survives", degrades: "degrades", fails: "fails" }; + +/** + * The `behaviour` inspect section (#401, M4 of #397) — one entity's whole + * block, as rows, with the provenance badge beside every figure. + * + * Every row here is the engine's claim, echoed. The currency is the engine's + * (behold converts none), the traffic level is the string the engine named + * (there is no default one), a headroom axis the engine did not report is + * simply not a row, and `rightSize` appears only when the engine suggested + * something. The badge on each figure says who stated it, to what tolerance, + * and whether it was modeled from list prices or validated against a bill. + */ +function renderBehaviourSection(section, b) { + if (!b) return; + const meta = behaviourMeta(); + const one = [b]; + const add = section("behaviour"); + add("at", b.at.traffic); + add("cost", figureWithBadge(fmtCost(b.cost.perHour, b.cost.currency), one, meta)); + const axes = []; + if (typeof b.headroom.cpu === "number") axes.push(`cpu ${fmtHeadroom(b.headroom.cpu)}`); + if (typeof b.headroom.latency === "number") axes.push(`latency ${fmtHeadroom(b.headroom.latency)}`); + const worst = headroomOf(b); + add("headroom", figureWithBadge(`${axes.join(" · ")} — colours on ${fmtHeadroom(worst)}`, one, meta)); + add("error rate", figureWithBadge(`${(b.errorRate * 100).toFixed(2)}% of requests at ${b.at.traffic}`, one, meta)); + add("resilience", figureWithBadge(`${RESILIENCE_WORD[b.resilience.verdict] || b.resilience.verdict} — ${b.resilience.failure}`, one, meta)); + if (b.resilience.note) add("resilience note", b.resilience.note); + if (b.rightSize) add("right-size", figureWithBadge(b.rightSize.suggestion + (b.rightSize.reason ? ` — ${b.rightSize.reason}` : ""), one, meta)); +} + function inspect(node) { const panel = document.getElementById("inspect-body"); panel.innerHTML = "

inspect

"; @@ -527,6 +681,13 @@ function inspect(node) { }); } + // #401: the behaviour block, field by field, with a badge on every figure. + // The colour on the card carries one number at a time (whichever mode is + // picked) and carries it as a hue; this is where the engine's whole + // statement about this entity is legible — and where a figure without its + // provenance would be visible as the contract violation it is. + renderBehaviourSection(section, blockOf(node)); + // Render diff (#146's deferred half): what `chant helm diff // --live --json` said about this chart's PINNED render. Never rely on the // colour alone (#57's accessibility note) — and here that matters twice @@ -1166,6 +1327,28 @@ function renderPanelView() { }), ); } + // #399: the three-state colour switch, beside the zoom — the other thing + // this tab is for is "what am I looking at", and colour is half that answer. + // Sits below the stops because it is not a granularity: every zoom paints in + // whichever of the three you picked. + zoom.appendChild(panelHeading("colour by")); + for (const mode of COLOUR_MODES) { + const gate = mode === "drift" ? { available: true, reason: "" } : modeAvailability(lastMeta, mode); + const b = panelOpt( + mode, + mode === activeColourMode(), + () => { + if (!gate.available) return; + setColourMode(mode); + }, + gate.available ? COLOUR_MODE_TITLE[mode] : gate.reason, + ); + // #401: disabled VISIBLY, with the lexicon's own reason on it. A control + // greyed out is where a person asks "why not", so that is where the answer + // goes — never hidden, which would leave the question unasked. + if (!gate.available) b.disabled = true; + zoom.appendChild(b); + } // Radial toggle — entity zooms only, same gate the ⌘K entry has // (components/logical/ops all lay themselves out: waves / nested arch boxes / // phase boxes). @@ -1415,6 +1598,66 @@ function renderChoudoufuMoves(host, info) { } } +/** + * The totals, per box and per estate, in the active mode (#399). + * + * Sits in the Scope tab because Scope is already the tab that says what this + * estate IS — its members, its moves, its environments — and a per-box figure + * is a fact about a member, not about the picture. + * + * In `drift` mode there is no row at all. Drift counts states; a sum of states + * is not a thing, and the Model tab's legend already carries the counts. + * + * Where the figure comes from is on the row, every time. `cost` quotes the + * server: `meta.behaviour.boxes[]` per box and `.sum` for the estate, both + * additions of engine figures — or `meta.behaviour.total`, the engine's own + * estate figure, which outranks the sum and is labelled "engine total" so the + * two can never be confused. `headroom` has no server-side aggregate, because + * no engine states one and adding fractions of capacity would mean nothing, so + * the SPA takes the worst and middle card of the scope itself and the row says + * "computed". + */ +function renderBehaviourTotals(host) { + const mode = activeColourMode(); + if (mode === "drift") return; + const b = behaviourMeta(); + const ir = lastGraphIr; + if (!b || !ir) return; + const byId = new Map(ir.nodes.map((n) => [n.id, n])); + const boxes = (ir.groups && ir.groups.byStack) || {}; + host.appendChild(panelHeading(`${mode} totals`)); + + const row = (label, blocks, sum, engineTotal, title) => { + const totals = totalsFor(mode, blocks, sum, engineTotal); + if (!totals) return; + // Stacked, not a name/value row: the panel is 260px and one of these + // lines is "0.5669 USD/h · 41 priced · 4 unpriced" — side by side, the + // figure takes the width and the box's name ellipses away to nothing, + // which is the one part of the row that says WHAT is being totalled. + const r = document.createElement("div"); + r.className = "behaviour-total"; + const name = document.createElement("div"); + name.className = "grow"; + name.style.fontWeight = "600"; + name.textContent = label; + if (title) name.title = title; + r.appendChild(name); + r.appendChild(figureWithBadge(totalsText(totals), blocks.filter(Boolean), b)); + host.appendChild(r); + }; + + for (const [key, ids] of Object.entries(boxes).sort(([a], [c]) => a.localeCompare(c))) { + const blocks = ids.map((id) => blockOf(byId.get(id))); + row(key, blocks, (b.boxes || {})[key], undefined, `${ids.length} entities in this box`); + } + const all = ir.nodes.map((n) => blockOf(n)); + // `total` is the engine's word and only ever holds an engine's own figure; + // `sum` is behold's addition. Passing both lets totalsFor apply #398's + // precedence in one place rather than at each call site. + row(b.total ? "estate · engine total" : "estate", all, b.sum, b.total, b.total ? "the engine stated this estate total itself; behold added nothing" : "behold's sum over the engine's per-entity figures"); + if (mode === "headroom") host.appendChild(panelMuted("min and median computed by behold over the engine's per-entity figures — no engine states a headroom aggregate.")); +} + function renderPanelScope() { const host = document.getElementById("tab-scope"); if (!host) return; @@ -1446,6 +1689,10 @@ function renderPanelScope() { // one — and it renders identically in carve mode and on an ordinary project // serve, because /api/project publishes one shape for both. renderCarveState(host, info.carve && info.carve.state); + // #399: what the estate costs, or how much room it has left, per member box + // and for the whole estate — right under the member list the figures are + // about, and above the moves that would change them. + renderBehaviourTotals(host); // #371: a choudoufu member's move plan — the handoff lines with copy // buttons, and the morph page. Read-only: behold never runs live-mv. renderChoudoufuMoves(host, info.choudoufu); @@ -1609,6 +1856,103 @@ function renderPanelScope() { const DRIFT_STATUS_VAR = { good: "var(--managed)", warn: "var(--foreign)", accent: "var(--pending)", neutral: "var(--muted)", runtime: "var(--runtime)" }; const COMPONENT_STATUS_VAR = { good: "var(--managed)", accent: "var(--pending)", warn: "var(--degraded)", neutral: "var(--muted)" }; +/** + * The provenance badge (#401, M4 of #397) — `{engine} {version} · {tolerance} + * · {basis}`, beside a figure rather than once per page. + * + * #397's one prohibition is a prediction presented as a bill, and this element + * is where that is prevented: the word `modeled` is on the badge and the + * sentence "modeled, not billed" is on its hover, on every figure that carries + * one. `blocks` is the entities the figure was derived from — one for an + * inspect row, a whole box or estate for a totals row — because #398 puts + * provenance on the ENTITY, so a badge over a set can only say what the set + * agrees on (badgeFor says `mixed` where it does not). + */ +function badgeEl(blocks, meta) { + const b = badgeFor(blocks, meta); + if (!b) return null; + const el = document.createElement("span"); + el.className = "tag behaviour-badge"; + el.textContent = b.text; + el.title = b.title; + return el; +} + +/** A figure and its badge on one line — the shape every behaviour figure the + * SPA shows takes, so none of them can end up without one. */ +function figureWithBadge(text, blocks, meta) { + const wrap = document.createElement("span"); + wrap.style.cssText = "display:flex;gap:6px;align-items:baseline;flex-wrap:wrap;min-width:0"; + const val = document.createElement("span"); + val.textContent = text; + wrap.appendChild(val); + const badge = badgeEl(blocks, meta); + if (badge) wrap.appendChild(badge); + return wrap; +} + +/** + * One mode's legend (#399: one legend per mode). + * + * `drift` is not here — it keeps #393's vocabulary legend, the estate's own + * words for the four states, which is the whole point of that issue. The two + * behaviour modes get a ramp with its two ends labelled, the priced/unpriced + * split, the neutral swatch that says what an unpriced card is drawn in, and + * the badge for the figures the ramp spans. + */ +function renderBehaviourLegend(host, mode) { + const b = behaviourMeta(); + const ir = lastGraphIr; + if (!b || !ir) return; + const blocks = ir.nodes.map((n) => blockOf(n)); + const values = ir.nodes.map((n) => figureOf(mode, blockOf(n))); + const domain = domainFor(mode, values); + const tokens = getTokens(); + const ramp = rampFor(mode, tokens); + const currency = (blocks.find((x) => x && x.cost) || { cost: {} }).cost.currency || ""; + const heading = panelHeading(`${mode} · ${lastMeta.env}`); + heading.title = COLOUR_MODE_TITLE[mode]; + host.appendChild(heading); + + const bar = document.createElement("div"); + bar.className = "behaviour-ramp"; + bar.style.cssText = `height:10px;border-radius:5px;border:1px solid var(--line);background:${rampGradient(ramp)}`; + host.appendChild(bar); + const ends = scaleEnds(mode, domain, currency); + if (ends) { + const row = document.createElement("div"); + row.className = "count-row"; + const lo = document.createElement("span"); + lo.className = "grow"; + lo.textContent = ends.low; + const hi = document.createElement("span"); + hi.className = "tag"; + hi.textContent = ends.high; + row.append(lo, hi); + host.appendChild(row); + } + + const counts = countFigures(mode, blocks); + const priced = blocks.filter((x) => x && figureOf(mode, x) !== undefined); + const line = document.createElement("div"); + line.className = "count-row"; + const txt = document.createElement("span"); + txt.className = "grow"; + txt.textContent = countsText(counts); + line.appendChild(txt); + const badge = badgeEl(priced, b); + if (badge) line.appendChild(badge); + host.appendChild(line); + + // The neutral, spelled out. Without this row the grey cards read as a third + // band of the scale rather than as "nothing priced these". + const theme = getTheme(); + host.appendChild( + panelDotRow(theme ? pinTokensFor(theme).neutralFill : "var(--muted)", "unpriced — no behaviour block", String(counts.unpriced)), + ); + if (mode === "headroom") host.appendChild(panelMuted("the lower of the axes the engine reported; a missing axis is absent, not zero")); +} + function panelDotRow(color, main, tag, onClick) { const row = document.createElement("div"); row.className = onClick ? "node-row" : "count-row"; @@ -1683,6 +2027,11 @@ function renderPanelModel() { const s = n.attrs && n.attrs._status; host.appendChild(panelDotRow(COMPONENT_STATUS_VAR[s] || "var(--muted)", n.id, APPLY_STATUS_TAG[s] || "", () => selectNode(n.id))); } + } else if (drift && activeColourMode() !== "drift") { + // #399: one legend per mode. The drift legend below is #393's vocabulary + // legend and stays exactly as it was — this branch is the two behaviour + // modes, whose legend is a scale rather than a set of states. + renderBehaviourLegend(host, activeColourMode()); } else if (drift) { // #393 item 8: the legend proper. The heading carries the mixed-estate // sentence as its tooltip — the one case where the words on these rows are @@ -1720,6 +2069,45 @@ function renderPanelModel() { host.appendChild(actButton("→ Scope", () => setPanelTab("scope"))); } } + renderBehaviourRefusal(host); + renderBehaviourDiagnostics(host); +} + +/** + * The refusal, where the legend would be (#401). + * + * The words are the lexicon's, in its own house style — an env-var chain and + * the variable it wants — and behold prints them as they came. It does not + * paraphrase them, does not soften them, and above all does not substitute a + * number: "a missing or unreachable engine means no overlay and a refusal that + * names why, never a locally faked number" is #397's rule, and this is the one + * surface where a person finds out it fired. + * + * An ABSENCE prints nothing here. Nothing was configured, so nothing refused — + * and a panel that announced every estate with no behavioural engine would be + * shouting about a feature nobody asked for. The absent line is on the disabled + * modes' tooltips instead, where the question actually gets asked. + */ +function renderBehaviourRefusal(host) { + const b = behaviourMeta(); + if (!b || !b.refusal) return; + host.appendChild(panelHeading("behaviour — refused")); + const reason = panelMuted(b.refusal.reason); + reason.style.color = "var(--degraded)"; + host.appendChild(reason); + host.appendChild(panelMuted(b.refusal.remedy)); + host.appendChild(panelMuted("the drift overlay above is unaffected — it never depended on an engine.")); +} + +/** M1's diagnostics (`meta.behaviour.diagnostics`): every block behold dropped + * and every thing it could not reconcile, under the legend where the figures + * they are about are. They were reaching the wire and nothing was reading + * them. */ +function renderBehaviourDiagnostics(host) { + const b = behaviourMeta(); + if (!b || !b.diagnostics || !b.diagnostics.length) return; + host.appendChild(panelHeading(`behaviour diagnostics · ${b.diagnostics.length}`)); + for (const d of b.diagnostics) host.appendChild(panelMuted(d)); } // --------------------------------------------------------------------------- @@ -2081,6 +2469,10 @@ function renderStatusbar() { // Pure state — the strip echoes the axes whose controls live on the floating // panel and in ⌘K, so the current view stays legible with the panel collapsed. const parts = [`zoom: ${zoomValue()}`, view.env ? `env: ${view.env}` : "env: (source)"]; + // #399: which colour the graph is in is state, not a control, so it belongs + // on the strip the same way the zoom and the env do. Only when it is not + // drift — drift is what the strip has always implied. + if (activeColourMode() !== "drift") parts.push(`colour: ${activeColourMode()}`); if (view.stack) parts.push(`stack: ${view.stack}`); if (axes.tier) parts.push(`tier: ${axes.tier}`); if (view.radial && !view.components && !view.logical && !view.ops) parts.push("radial"); @@ -4724,6 +5116,21 @@ function paletteCommands() { for (const [label, v] of availableZooms()) { c.push([label + (v === zoomValue() ? " ✓" : ""), () => { applyZoom(v); load(); }]); } + // #399: the colour-by modes, with the same ✓ every other lens row carries. + // A disabled mode is LISTED with its reason rather than hidden — same rule + // #254's blocked carve steps follow, and the same reason: "why can't I do + // that" is a question the palette should be able to answer. + for (const mode of COLOUR_MODES) { + const gate = mode === "drift" ? { available: true, reason: "" } : modeAvailability(lastMeta, mode); + const why = gate.available ? "" : ` — ${gate.reason}`; + c.push([ + `colour by: ${mode}${mode === activeColourMode() ? " ✓" : ""}${why}`, + () => { + if (!gate.available) return showToast(gate.reason, false); + setColourMode(mode); + }, + ]); + } // Radial toggle — entity zooms only, same gate the removed checkbox had // (components/logical/ops all lay themselves out: waves / nested arch boxes / // phase boxes). diff --git a/web/behaviour-scale.js b/web/behaviour-scale.js new file mode 100644 index 0000000..3462828 --- /dev/null +++ b/web/behaviour-scale.js @@ -0,0 +1,380 @@ +// The colour-by modes' arithmetic (#399 M2, #401 M4 of #397) — everything the +// behaviour overlay DECIDES, with no DOM and no fetch in it, so it can be unit +// tested beside the file the way web/theme.js is. +// +// The one rule the whole module exists to keep: behold states no figure of its +// own. Every number below arrives from a `attrs._behaviour` block a lexicon's +// engine wrote (src/behaviour.ts validates it against #398's contract), and +// what happens here is positioning on an axis, a min, a median, and a count. +// Where a figure IS computed here rather than read — the headroom min and +// median a box's totals row shows — the label says "computed", because +// `meta.behaviour` carries only cost sums and the engine stated no headroom +// aggregate for behold to quote. +import { hexToOklch, oklchToHex } from "./theme.js"; + +const clamp = (x, lo, hi) => (x < lo ? lo : x > hi ? hi : x); + +/** The block a node carries, or undefined. One reader, so a rename is one edit. */ +export function blockOf(node) { + return (node && node.attrs && node.attrs._behaviour) || undefined; +} + +/** + * Headroom, as the mode paints it: **the lower of the axes present**. + * + * A missing axis is absent, never 0 (#398) — "the engine did not report cpu" + * and "there is no cpu left" are opposite claims. So a block with only + * `latency` is read on latency alone, and a block whose `headroom` somehow + * carries neither is not a headroom figure at all (the M1 validator already + * refuses one, so this returns undefined rather than inventing a floor). + */ +export function headroomOf(block) { + if (!block || !block.headroom) return undefined; + const axes = [block.headroom.cpu, block.headroom.latency].filter((v) => typeof v === "number"); + return axes.length ? Math.min(...axes) : undefined; +} + +/** The figure the active mode reads off one block. `drift` reads none. */ +export function figureOf(mode, block) { + if (mode === "cost") return block && block.cost ? block.cost.perHour : undefined; + if (mode === "headroom") return headroomOf(block); + return undefined; +} + +/** + * The domain a mode's scale spans, over the entities that carry a figure. + * + * `cost` has no absolute range — a per-hour figure means nothing until you know + * what the rest of the estate costs — so its domain is the estate's own min and + * max. `headroom` is already a fraction of capacity, so its domain is fixed at + * 0..1: half free is half free whether the estate is one box or forty, and + * stretching it to the observed range would make a comfortable estate's best + * card read as its worst. + */ +export function domainFor(mode, values) { + const xs = values.filter((v) => typeof v === "number" && Number.isFinite(v)); + if (mode === "headroom") return { min: 0, max: 1, n: xs.length, absolute: true }; + if (!xs.length) return null; + return { min: Math.min(...xs), max: Math.max(...xs), n: xs.length, absolute: false }; +} + +/** Where a figure sits on its domain, 0..1. A flat domain (every entity the + * same price) puts everything mid-scale rather than at an end — an end would + * claim a spread the figures do not have. */ +export function positionOf(value, domain) { + if (!domain || typeof value !== "number" || !Number.isFinite(value)) return undefined; + if (domain.max === domain.min) return 0.5; + return clamp((value - domain.min) / (domain.max - domain.min), 0, 1); +} + +/** + * A colour at `t` on a ramp of anchor colours, interpolated in OKLCH so the + * steps read as even brightness rather than as even RGB arithmetic — the same + * space theme.js already does its perceptual work in. + */ +export function rampColor(t, stops) { + if (!stops.length) return "#888888"; + if (stops.length === 1) return stops[0]; + const x = clamp(t, 0, 1) * (stops.length - 1); + const i = Math.min(Math.floor(x), stops.length - 2); + const f = x - i; + const a = hexToOklch(stops[i]); + const b = hexToOklch(stops[i + 1]); + // Hue takes the short way round the wheel, or a green→red ramp would swing + // backwards through blue on a palette whose two anchors straddle 0°. + let dH = b.H - a.H; + if (dH > 180) dH -= 360; + if (dH < -180) dH += 360; + return oklchToHex({ L: a.L + (b.L - a.L) * f, C: a.C + (b.C - a.C) * f, H: (a.H + dH * f + 360) % 360 }); +} + +/** + * The two modes' ramps, DERIVED from the active theme the way #229 derives the + * chrome — no mode owns a fixed colour, and all 552 palettes have to come out + * of here with a scale you can read low from high. + * + * `cost` is a SINGLE-HUE ramp on the accent's own hue and chroma, walked in + * lightness away from the theme's background: quiet and close to the ground at + * the cheap end, full strength at the expensive one. Deliberately not a + * green-to-red ramp — expensive is not a verdict, and behold has no standing to + * paint an estate's bill as a fault. Magnitude is all the engine stated, so + * magnitude is all the colour says. + * + * Anchored to the BACKGROUND's lightness rather than to the accent's own, for + * the reason #229 pushes `--active` off the panel: a palette whose blue slot + * happens to sit where a fixed offset would land (Hot Dog Stand's, on a red + * ground) otherwise derives two ends you cannot tell apart. Walking outward + * from the ground gives every one of the 552 palettes a spread, and it is the + * spread, not the offset, that the scale is for. + * + * `headroom` IS a good-to-bad axis — #399 says so in as many words — so it + * rides the three status hues the drift overlay already anchors across every + * palette: degraded at no headroom, foreign in the middle, managed at plenty. + * The card's drift bar keeps its own colour underneath, so the two never occupy + * the same pixel. Where a palette paints red and green alike (Retro, Hot Dog + * Stand's mustard), the drift overlay reads alike there too — that is the + * palette's own choice and not a thing this scale may overrule. + */ +export function rampFor(mode, tokens) { + if (mode === "cost") { + const { C, H } = hexToOklch(tokens.pending); + const ground = hexToOklch(tokens.bg).L; + const away = ground < 0.5 ? 1 : -1; + // How far there is to walk before the walk runs out of sRGB. A ground + // sitting almost exactly mid-lightness (Grass) has barely half the room a + // near-black one does, so the span is what's actually available rather than + // a constant that silently clips at one end. + const room = away > 0 ? 0.95 - ground : ground - 0.05; + const span = Math.min(0.62, room); + return [atLightness(ground + away * span * 0.32, C * 0.35, H), atLightness(ground + away * span, C, H)]; + } + if (mode === "headroom") return [tokens.degraded, tokens.foreign, tokens.managed]; + return []; +} + +/** + * The colour at a requested OKLCH lightness, with chroma eased off until the + * result actually LANDS there in sRGB. + * + * Without this a saturated hue near either end of the range clips on the way + * out, and the ramp's two ends come back closer together than they were asked + * to be — which on one palette (Grass, whose background sits within a + * thousandth of mid-lightness) collapsed the scale to two shades of the same + * navy. Lightness is the thing the eye reads a sequential scale by, so + * lightness is the thing that is honoured and chroma is the thing that gives. + */ +function atLightness(L, C, H) { + const want = clamp(L, 0.05, 0.95); + let c = C; + let out = oklchToHex({ L: want, C: c, H }); + for (let i = 0; i < 10 && Math.abs(hexToOklch(out).L - want) > 0.01; i++) { + c *= 0.75; + out = oklchToHex({ L: want, C: c, H }); + } + return out; +} + +/** + * The fill one card draws in a colour-by mode. + * + * An entity with no block draws the drift overlay's own neutral — pinhole's + * `neutralFill`, the ground a card rides when chant could not read it — and + * NEVER the zero end of the scale. The zero end of a cost ramp says "free" and + * the zero end of a headroom ramp says "saturated"; both are claims about an + * entity nothing priced. `unpriced` comes back beside the colour so a caller + * can mark the card rather than infer the state back out of a hex. + */ +export function fillFor(value, domain, ramp, neutralFill) { + const t = positionOf(value, domain); + if (t === undefined) return { fill: neutralFill, unpriced: true }; + return { fill: rampColor(t, ramp), unpriced: false }; +} + +/** How many entities in a set carry a figure for this mode, and how many don't. */ +export function countFigures(mode, blocks) { + let priced = 0; + let unpriced = 0; + for (const b of blocks) { + if (figureOf(mode, b) === undefined) unpriced++; + else priced++; + } + return { priced, unpriced }; +} + +/** The middle value of a sorted set — the even case averages the two middles, + * which is the ordinary definition and the only place this module produces a + * number no engine stated. Its caller labels it "computed" for that reason. */ +export function median(values) { + const xs = values.filter((v) => typeof v === "number" && Number.isFinite(v)).sort((a, b) => a - b); + if (!xs.length) return undefined; + const i = xs.length >> 1; + return xs.length % 2 ? xs[i] : (xs[i - 1] + xs[i]) / 2; +} + +/** A per-hour cost, at the precision a cost that small deserves. */ +export function fmtCost(perHour, currency) { + if (typeof perHour !== "number" || !Number.isFinite(perHour)) return "—"; + const digits = Math.abs(perHour) >= 1 ? 2 : 4; + return `${perHour.toFixed(digits)} ${currency}/h`; +} + +/** A headroom fraction as the percentage of capacity still free. */ +export function fmtHeadroom(v) { + return typeof v === "number" && Number.isFinite(v) ? `${Math.round(v * 100)}% free` : "—"; +} + +/** + * The totals row for one scope (a box, or the whole estate), in the active + * mode's own terms. + * + * `cost` quotes arithmetic the server already did — `meta.behaviour.sum` or + * `.boxes[key]`, both of which are additions of engine figures — and, when the + * ENGINE stated an estate total, quotes that instead and says whose it is. + * That is the #398 precedence rule: `total` is the engine's word, `sum` is + * behold's, and the engine outranks the arithmetic. + * + * `headroom` has no server-side aggregate at all, because no engine stated one + * and adding fractions of capacity would be meaningless. The SPA takes the + * worst card and the middle card of the scope and says, in the row, that it + * computed them. + * + * `drift` gets no row: the drift overlay counts states, and a count of states + * is what the Model tab's legend has always shown. + */ +export function totalsFor(mode, blocks, sum, engineTotal) { + if (mode === "drift") return null; + const counts = countFigures(mode, blocks); + if (mode === "cost") { + // The engine's own total wins, and says so. Otherwise the server's sum; + // otherwise nothing to quote — never a figure computed here, because a + // currency behold cannot convert is exactly why the sum was withheld. + if (engineTotal) { + return { + mode, + source: "engine total", + value: fmtCost(engineTotal.perHour, engineTotal.currency), + counts, + computed: false, + }; + } + if (!sum) return { mode, source: "sum", value: "—", counts, computed: false, missing: true }; + return { + mode, + source: "sum", + value: fmtCost(sum.perHour, sum.currency), + counts: { priced: sum.priced, unpriced: sum.unpriced }, + computed: false, + }; + } + const xs = blocks.map((b) => headroomOf(b)).filter((v) => v !== undefined); + if (!xs.length) return { mode, source: "computed", value: "—", counts, computed: true, missing: true }; + return { + mode, + source: "computed", + value: `min ${fmtHeadroom(Math.min(...xs))} · median ${fmtHeadroom(median(xs))}`, + counts, + computed: true, + }; +} + +/** The `n priced · m unpriced` tail every totals row and every legend carries. */ +export function countsText(counts) { + return `${counts.priced} priced · ${counts.unpriced} unpriced`; +} + +/** The whole totals row as one line of text — what the smoke asserts, and what + * a narrow panel row renders. */ +export function totalsText(totals) { + if (!totals) return ""; + return `${totals.value} · ${countsText(totals.counts)}${totals.computed ? " · computed" : ""}`; +} + +/** + * The provenance badge (#401): `{engine} {version} · {tolerance} · {basis}`, + * beside every figure rather than once per page. + * + * `provenance` is per ENTITY in #398's contract, because one estate can be + * priced by two engines — so a badge over a SET of entities can only say what + * that set agrees on. Where they disagree the badge says `mixed` on the field + * that disagrees rather than picking one, and the tooltip names the engines. + * A badge over one entity is just that entity's own four fields. + * + * `modeled` is spelled out on hover as "modeled, not billed": the whole of #397 + * turns on a prediction never being presented as a bill, and the badge is where + * that sentence has to live. + */ +export function badgeFor(blocks, meta) { + const provs = blocks.map((b) => b && b.provenance).filter(Boolean); + if (!provs.length) { + // No entity provenance in scope: the graph-level engine, when the lexicon + // stated one, is all there is to show — and it carries no tolerance or + // basis, because #398 puts those on the entity. + if (meta && meta.engine) return { text: `${meta.engine} ${meta.version}`, title: engineTitle(meta), engines: [`${meta.engine} ${meta.version}`] }; + return null; + } + const one = (pick) => { + const vs = [...new Set(provs.map(pick))]; + return vs.length === 1 ? vs[0] : "mixed"; + }; + const engines = [...new Set(provs.map((p) => `${p.engine} ${p.version}`))].sort(); + const enginePart = engines.length === 1 ? engines[0] : `${engines.length} engines`; + const tolerance = one((p) => p.tolerance); + const basis = one((p) => p.basis); + const parts = []; + if (engines.length === 1) parts.push(`${provs[0].engine} ${provs[0].version}`); + else parts.push(enginePart); + parts.push(tolerance); + parts.push(basis); + const bases = [...new Set(provs.map((p) => p.basis))]; + const title = + (engines.length === 1 ? `Stated by ${engines[0]}` : `Stated by ${engines.join(", ")}`) + + `, tolerance ${tolerance === "mixed" ? [...new Set(provs.map((p) => p.tolerance))].join(" / ") : tolerance}. ` + + (bases.length === 1 + ? bases[0] === "modeled" + ? "modeled, not billed — a prediction from list prices, never a bill." + : "validated against a bill." + : "mixed basis — some figures are modeled, not billed; some are validated against a bill.") + + (meta && meta.at && meta.at.traffic ? ` At ${meta.at.traffic}.` : provs[0] && blocks[0] && blocks[0].at ? ` At ${blocks[0].at.traffic}.` : ""); + return { text: parts.join(" · "), title, engines }; +} + +function engineTitle(meta) { + return `Stated by ${meta.engine} ${meta.version}.${meta.at && meta.at.traffic ? ` At ${meta.at.traffic}.` : ""} Provenance rides each figure; this scope carries none.`; +} + +/** + * Which colour-by modes this graph can offer, and why not. + * + * A refusal or an absence disables both behaviour modes and hands back the + * lexicon's own sentence for the tooltip — the disabled control is where a + * person asks "why not", so that is where the answer goes. `drift` is never + * disabled: it does not depend on an engine, which is the whole reason #401 + * says the drift overlay keeps rendering through a refusal. + */ +export function modeAvailability(meta, mode) { + const b = (meta && meta.behaviour) || null; + const overlay = !!b; + if (!overlay) { + return { available: false, reason: "the behaviour overlay is only on a live overlay — pick an environment on the Scope tab", refusal: null }; + } + if (b.refusal) { + return { available: false, reason: `${b.refusal.reason} ${b.refusal.remedy}`, refusal: b.refusal }; + } + if (b.absent) return { available: false, reason: b.absent, refusal: null }; + return { available: true, reason: "", refusal: null }; +} + +/** The mode the SPA may actually paint in, given what the overlay carries. + * A picked mode outlives an overlay that cannot serve it — switch envs back + * and the pick is still there — but it never paints a scale over figures that + * are not on the page. */ +export function effectiveMode(picked, meta) { + if (picked === "drift") return "drift"; + return modeAvailability(meta, picked).available ? picked : "drift"; +} + +export const COLOUR_MODES = ["drift", "cost", "headroom"]; + +/** The legend's own words for a mode. */ +export const MODE_LABEL = { drift: "drift", cost: "cost", headroom: "headroom" }; + +/** The two ends of a mode's ramp, in words — the legend's axis labels. */ +export function scaleEnds(mode, domain, currency = "") { + if (mode === "cost") { + if (!domain) return null; + return { low: fmtCost(domain.min, currency), high: fmtCost(domain.max, currency) }; + } + if (mode === "headroom") return { low: "0% free", high: "100% free" }; + return null; +} + +/** A colour-mix-free CSS gradient over a ramp — sampled, so the stops are the + * same OKLCH walk the cards took rather than the browser's own sRGB blend. */ +export function rampGradient(ramp, steps = 12) { + const stops = []; + for (let i = 0; i < steps; i++) { + const t = i / (steps - 1); + stops.push(`${rampColor(t, ramp)} ${(t * 100).toFixed(1)}%`); + } + return `linear-gradient(90deg, ${stops.join(", ")})`; +} diff --git a/web/behaviour-scale.test.js b/web/behaviour-scale.test.js new file mode 100644 index 0000000..4e04add --- /dev/null +++ b/web/behaviour-scale.test.js @@ -0,0 +1,307 @@ +// #399 M2 / #401 M4 of #397 — the colour-by modes' arithmetic, without a +// browser. The smoke drives the same decisions through the real SPA; this pins +// the rules that are easy to get subtly wrong and impossible to see in a +// screenshot: an unpriced card never landing at the zero end of a scale, a +// headroom axis the engine did not report never reading as a saturated one, +// the engine's own total outranking behold's sum, and a badge over two engines +// refusing to pick one of them. +import { describe, it, expect } from "vitest"; +import { THEMES } from "./themes.js"; +import { tokensFor, hexToOklch } from "./theme.js"; +import { + badgeFor, + blockOf, + countFigures, + countsText, + domainFor, + effectiveMode, + figureOf, + fillFor, + fmtCost, + fmtHeadroom, + headroomOf, + median, + modeAvailability, + positionOf, + rampColor, + rampFor, + scaleEnds, + totalsFor, + totalsText, +} from "./behaviour-scale.js"; + +const prov = (over = {}) => ({ engine: "acme-sim", version: "1.4.2", tolerance: "±15%", basis: "modeled", ...over }); +const block = (over = {}) => ({ + at: { traffic: "100 rps, p50" }, + cost: { perHour: 0.05, currency: "USD" }, + headroom: { cpu: 0.6, latency: 0.4 }, + errorRate: 0.001, + resilience: { failure: "one zone lost", verdict: "survives" }, + provenance: prov(), + ...over, +}); + +describe("reading a figure off a block", () => { + it("takes headroom as the LOWER of the axes present", () => { + expect(headroomOf(block({ headroom: { cpu: 0.6, latency: 0.4 } }))).toBe(0.4); + expect(headroomOf(block({ headroom: { cpu: 0.2, latency: 0.9 } }))).toBe(0.2); + }); + + it("reads a block with one axis on that axis alone — a missing axis is absent, not zero", () => { + // The whole point of #398's "a missing axis is absent, not 0": a block that + // reports only latency must not read as a card with no CPU left. + expect(headroomOf(block({ headroom: { latency: 0.8 } }))).toBe(0.8); + expect(headroomOf(block({ headroom: { cpu: 0.8 } }))).toBe(0.8); + }); + + it("has no figure at all for an entity with no block", () => { + expect(blockOf({ id: "a", attrs: {} })).toBeUndefined(); + expect(figureOf("cost", undefined)).toBeUndefined(); + expect(figureOf("headroom", undefined)).toBeUndefined(); + expect(figureOf("drift", block())).toBeUndefined(); + }); +}); + +describe("the domain each mode spans", () => { + it("stretches cost over the estate's own min and max", () => { + const d = domainFor("cost", [0.01, 0.5, 0.25]); + expect(d).toMatchObject({ min: 0.01, max: 0.5, n: 3, absolute: false }); + }); + + it("keeps headroom absolute at 0..1, so a comfortable estate's best card is not painted as its worst", () => { + const d = domainFor("headroom", [0.7, 0.8, 0.9]); + expect(d).toMatchObject({ min: 0, max: 1, absolute: true }); + // 0.7 free is high on the axis, not the bottom of a 0.7..0.9 stretch. + expect(positionOf(0.7, d)).toBeCloseTo(0.7, 6); + }); + + it("has no cost domain when nothing in scope carries a price", () => { + expect(domainFor("cost", [])).toBeNull(); + expect(positionOf(0.5, null)).toBeUndefined(); + }); + + it("puts a flat domain mid-scale rather than at an end", () => { + expect(positionOf(4, domainFor("cost", [4, 4, 4]))).toBe(0.5); + }); +}); + +describe("the neutral-not-zero rule (#399's own acceptance test)", () => { + const tokens = tokensFor(THEMES.Argonaut || Object.values(THEMES)[0]); + const NEUTRAL = "#3a3a3a"; + + it("draws an entity with no block in the drift overlay's neutral, never the zero end of the cost scale", () => { + const ramp = rampFor("cost", tokens); + const domain = domainFor("cost", [0.01, 0.5]); + const unpriced = fillFor(undefined, domain, ramp, NEUTRAL); + const cheapest = fillFor(0.01, domain, ramp, NEUTRAL); + expect(unpriced).toEqual({ fill: NEUTRAL, unpriced: true }); + expect(cheapest.unpriced).toBe(false); + expect(cheapest.fill).not.toBe(NEUTRAL); + // The distinction the rule exists for: "nothing priced this" must not + // render as "this is the cheapest thing in the estate". + expect(unpriced.fill).not.toBe(cheapest.fill); + }); + + it("does the same in headroom mode, where the zero end would read as saturated", () => { + const ramp = rampFor("headroom", tokens); + const domain = domainFor("headroom", [0.4]); + expect(fillFor(undefined, domain, ramp, NEUTRAL)).toEqual({ fill: NEUTRAL, unpriced: true }); + expect(fillFor(0, domain, ramp, NEUTRAL).fill).not.toBe(NEUTRAL); + }); + + it("counts the unpriced rather than dropping them", () => { + const blocks = [block(), undefined, block({ cost: { perHour: 1, currency: "USD" } })]; + expect(countFigures("cost", blocks)).toEqual({ priced: 2, unpriced: 1 }); + expect(countsText(countFigures("cost", blocks))).toBe("2 priced · 1 unpriced"); + }); +}); + +describe("the ramps, derived from the theme", () => { + it("no mode owns a fixed colour — every ramp stop comes out of the active palette", () => { + for (const name of ["Argonaut", "Builtin Solarized Light", "Hot Dog Stand"]) { + const th = THEMES[name]; + if (!th) continue; + const t = tokensFor(th); + for (const mode of ["cost", "headroom"]) { + for (const stop of rampFor(mode, t)) expect(stop).toMatch(/^#[0-9a-f]{6}$/i); + } + } + }); + + it("gives every one of the 552 palettes a cost ramp you can tell low from high", () => { + const bad = []; + for (const th of Object.values(THEMES)) { + const ramp = rampFor("cost", tokensFor(th)); + const low = rampColor(0, ramp); + const high = rampColor(1, ramp); + // The two ends have to differ perceptibly in lightness, or the scale is + // one flat colour and says nothing at all. + if (Math.abs(hexToOklch(low).L - hexToOklch(high).L) < 0.1) bad.push(th.name); + } + expect(bad).toEqual([]); + }); + + it("takes the headroom ramp's three stops from the drift overlay's own anchors, in every palette", () => { + // Not a separation assertion. A handful of palettes (Retro, Hot Dog Stand + // (Mustard)) paint slots 1 and 2 the same colour, so their red and their + // green ARE one colour — the drift legend reads alike on them too. That is + // the palette's choice; what this scale owes is that its ends are those + // anchors and nothing of its own invention. + const bad = []; + for (const th of Object.values(THEMES)) { + const t = tokensFor(th); + const ramp = rampFor("headroom", t); + if (rampColor(0, ramp) !== t.degraded || rampColor(1, ramp) !== t.managed || ramp[1] !== t.foreign) bad.push(th.name); + } + expect(bad).toEqual([]); + }); + + it("walks a hue the short way round, so a green-to-red ramp never swings through blue", () => { + // Two stops straddling 0°: red at ~29°, a green at ~145°. Midway must land + // in the yellows between them, not on the far side of the wheel. + const mid = hexToOklch(rampColor(0.5, ["#d02020", "#20b020"])); + expect(mid.H).toBeGreaterThan(29); + expect(mid.H).toBeLessThan(145); + }); +}); + +describe("the totals row", () => { + const sum = { perHour: 0.5669, currency: "USD", priced: 41, unpriced: 4 }; + + it("is absent in drift mode — drift counts states, and the legend already does", () => { + expect(totalsFor("drift", [block()], sum, undefined)).toBeNull(); + }); + + it("quotes the server's sum of engine figures, with its own priced/unpriced counts", () => { + const t = totalsFor("cost", [block()], sum, undefined); + expect(t.source).toBe("sum"); + expect(t.computed).toBe(false); + expect(totalsText(t)).toBe("0.5669 USD/h · 41 priced · 4 unpriced"); + }); + + it("lets the ENGINE's own total outrank behold's sum, and says whose it is", () => { + const t = totalsFor("cost", [block(), block()], sum, { perHour: 12.31, currency: "USD" }); + expect(t.source).toBe("engine total"); + expect(t.value).toBe("12.31 USD/h"); + }); + + it("says nothing rather than adding figures the server withheld (mixed currencies)", () => { + const t = totalsFor("cost", [block()], undefined, undefined); + expect(t.missing).toBe(true); + expect(t.value).toBe("—"); + }); + + it("computes headroom itself — no engine states an aggregate — and labels it computed", () => { + const blocks = [ + block({ headroom: { cpu: 0.2, latency: 0.9 } }), + block({ headroom: { cpu: 0.6, latency: 0.7 } }), + block({ headroom: { cpu: 0.9, latency: 0.8 } }), + undefined, + ]; + const t = totalsFor("headroom", blocks, sum, undefined); + expect(t.computed).toBe(true); + expect(t.value).toBe("min 20% free · median 60% free"); + expect(totalsText(t)).toBe("min 20% free · median 60% free · 3 priced · 1 unpriced · computed"); + }); + + it("takes the ordinary median of an even set", () => { + expect(median([0.1, 0.3, 0.5, 0.7])).toBeCloseTo(0.4, 6); + expect(median([])).toBeUndefined(); + }); + + it("prints a small per-hour figure at four decimals and a large one at two", () => { + expect(fmtCost(0.0416, "USD")).toBe("0.0416 USD/h"); + expect(fmtCost(12.3, "EUR")).toBe("12.30 EUR/h"); + expect(fmtHeadroom(0.415)).toBe("42% free"); + }); + + it("labels the cost scale's ends with the estate's own extremes", () => { + expect(scaleEnds("cost", domainFor("cost", [0.01, 0.5]), "USD")).toEqual({ low: "0.0100 USD/h", high: "0.5000 USD/h" }); + expect(scaleEnds("headroom", domainFor("headroom", [0.5]))).toEqual({ low: "0% free", high: "100% free" }); + }); +}); + +describe("the provenance badge (#401)", () => { + it("reads {engine} {version} · {tolerance} · {basis} off one entity's own block", () => { + const b = badgeFor([block()], { engine: "acme-sim", version: "1.4.2", at: { traffic: "100 rps, p50" } }); + expect(b.text).toBe("acme-sim 1.4.2 · ±15% · modeled"); + }); + + it("spells `modeled` out as `modeled, not billed` on hover — the sentence #397 turns on", () => { + const b = badgeFor([block()], null); + expect(b.title).toContain("modeled, not billed"); + expect(b.title).toContain("100 rps, p50"); + }); + + it("says `validated against a bill` for a figure that was", () => { + const b = badgeFor([block({ provenance: prov({ basis: "validated" }) })], null); + expect(b.text).toContain("validated"); + expect(b.title).toContain("validated against a bill"); + }); + + it("refuses to pick one engine when a scope was priced by two", () => { + const b = badgeFor([block(), block({ provenance: prov({ engine: "other-sim", version: "2.0", tolerance: "±5%", basis: "validated" }) })], null); + expect(b.text).toBe("2 engines · mixed · mixed"); + expect(b.engines).toEqual(["acme-sim 1.4.2", "other-sim 2.0"]); + expect(b.title).toContain("acme-sim 1.4.2, other-sim 2.0"); + }); + + it("falls back to the graph-level engine when no entity in scope carries provenance", () => { + const b = badgeFor([], { engine: "acme-sim", version: "1.4.2" }); + expect(b.text).toBe("acme-sim 1.4.2"); + expect(b.title).toContain("Provenance rides each figure"); + }); + + it("has nothing to badge when there is neither", () => { + expect(badgeFor([], null)).toBeNull(); + }); +}); + +describe("which modes a graph can offer", () => { + it("offers nothing off the overlay at all — there is no behaviour block on the source graph", () => { + const a = modeAvailability({ mode: "graph" }, "cost"); + expect(a.available).toBe(false); + expect(a.reason).toContain("pick an environment"); + }); + + it("keeps a picked mode alive when the overlay can serve it", () => { + const meta = { behaviour: { engine: "acme-sim", version: "1.4.2", sum: { perHour: 1, currency: "USD", priced: 2, unpriced: 0 } } }; + expect(effectiveMode("cost", meta)).toBe("cost"); + expect(effectiveMode("headroom", meta)).toBe("headroom"); + }); + + it("falls back to drift rather than painting a scale over figures nobody sent — the PICK survives, so a live env that IS priced comes back in cost", () => { + expect(effectiveMode("cost", { behaviour: { absent: "…" } })).toBe("drift"); + expect(effectiveMode("drift", null)).toBe("drift"); + }); +}); + +describe("what a refusal and an absence do to the modes (#401)", () => { + const refusal = { reason: "no behavioural engine is configured for this lexicon.", remedy: "Set CHANT_BEHAVIOUR_ENGINE." }; + + it("disables both behaviour modes on a refusal and hands back the lexicon's own words", () => { + // The reason a disabled control carries is the LEXICON's sentence, not a + // generic "unavailable" — that is the whole of #401's refusal half. + const meta = { behaviour: { refusal } }; + for (const mode of ["cost", "headroom"]) { + const a = modeAvailability(meta, mode); + expect(a.available).toBe(false); + expect(a.reason).toContain("no behavioural engine is configured"); + expect(a.reason).toContain("Set CHANT_BEHAVIOUR_ENGINE."); + expect(a.refusal).toEqual(refusal); + } + }); + + it("keeps drift painting through a refusal — drift never depended on an engine", () => { + expect(effectiveMode("drift", { behaviour: { refusal } })).toBe("drift"); + expect(effectiveMode("cost", { behaviour: { refusal } })).toBe("drift"); + }); + + it("disables the modes on an absence too, with the absent line as the reason and no refusal to print", () => { + const absent = "no behaviour block: no node carries attrs._behaviour, and no report document at monolith/behaviour.live.json"; + const a = modeAvailability({ behaviour: { absent } }, "cost"); + expect(a.available).toBe(false); + expect(a.reason).toBe(absent); + expect(a.refusal).toBeNull(); + }); +}); diff --git a/web/index.html b/web/index.html index 68ceaa7..4173cdd 100644 --- a/web/index.html +++ b/web/index.html @@ -321,6 +321,25 @@ #panel .grow { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } #panel .tag { color: var(--muted); font: var(--t-caption)/1.5 var(--font-mono); white-space: nowrap; } + /* The provenance badge (#401, M4 of #397): who stated this figure, to + what tolerance, and whether it is modeled from list prices or + validated against a bill. Boxed rather than plain muted text, because + it rides BESIDE a number and has to read as a label on that number + rather than as more of it — and it sits on every figure the behaviour + overlay shows, in the panel and in the inspect pane alike, so it is + styled for both. `help` cursor: the hover carries "modeled, not + billed", which is the sentence #397 turns on. */ + .behaviour-badge { color: var(--muted); font: var(--t-caption)/1.4 var(--font-mono); + border: 1px solid var(--line); border-radius: var(--r-ctl); padding: 0 5px; + white-space: nowrap; cursor: help; flex: none; } + #panel .behaviour-total { display: flex; flex-direction: column; gap: 1px; padding: 3px 0; + font: var(--t-caption)/1.5 var(--font-mono); } + #panel .behaviour-ramp { margin: 4px 0 2px; } + /* A card nothing priced keeps the drift overlay's neutral fill (#399) — + and says so, faintly, so the grey is legible as "not looked at" rather + than as the low end of the scale. */ + #graph [data-unpriced="1"] { opacity: 0.82; } + /* Substrate readiness (M5, #54) — one row per substrate on the panel's Substrates tab: status dot + name + state, with its actions (bring up / reset / run pipeline) visible inline again. #73 moved those into ⌘K;