@intentius/behold 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/AGENTS.md +757 -26
  2. package/LICENSE +190 -0
  3. package/README.md +251 -187
  4. package/THIRD_PARTY.md +75 -0
  5. package/demos.json +80 -9
  6. package/dist/cli.js +5479 -1673
  7. package/example-argo-estate/package-lock.json +6 -6
  8. package/example-carve/app/package-lock.json +3 -3
  9. package/example-choudoufu-estate/.behold.json +8 -0
  10. package/example-choudoufu-estate/README.md +48 -0
  11. package/example-choudoufu-estate/monolith/behaviour.live.json +495 -0
  12. package/example-choudoufu-estate/monolith/carve.json +12 -0
  13. package/example-choudoufu-estate/monolith/main.tf +127 -0
  14. package/example-choudoufu-estate/scripts/choudoufu-down.sh +8 -0
  15. package/example-choudoufu-estate/scripts/choudoufu-up.sh +62 -0
  16. package/example-choudoufu-estate/team-a/behaviour.live.json +179 -0
  17. package/example-choudoufu-estate/team-a/main.tf +58 -0
  18. package/example-choudoufu-estate/team-b/behaviour.live.json +179 -0
  19. package/example-choudoufu-estate/team-b/main.tf +73 -0
  20. package/example-choudoufu-estate/team-c/behaviour.live.json +179 -0
  21. package/example-choudoufu-estate/team-c/main.tf +51 -0
  22. package/example-flux-estate/package-lock.json +6 -6
  23. package/example-k8s/package-lock.json +6 -6
  24. package/example-terraform-estate/README.md +109 -0
  25. package/example-terraform-estate/backends/backend.s3.tf +10 -0
  26. package/example-terraform-estate/envs/dev/README.md +5 -0
  27. package/example-terraform-estate/envs/prod/data.tf +18 -0
  28. package/example-terraform-estate/envs/prod/network.tf +80 -0
  29. package/example-terraform-estate/envs/prod/outputs.tf +13 -0
  30. package/example-terraform-estate/envs/prod/provider.tf +10 -0
  31. package/example-terraform-estate/envs/prod/services.tf +21 -0
  32. package/example-terraform-estate/envs/prod/storage.tf +31 -0
  33. package/example-terraform-estate/envs/prod/variables.tf +17 -0
  34. package/example-terraform-estate/envs/prod/versions.tf +10 -0
  35. package/example-terraform-estate/modules/service/main.tf +58 -0
  36. package/example-terraform-estate/modules/service/outputs.tf +7 -0
  37. package/example-terraform-estate/modules/service/variables.tf +16 -0
  38. package/example-terraform-estate/modules/service/versions.tf +10 -0
  39. package/example-terraform-estate/platform/iam.tf +40 -0
  40. package/example-terraform-estate/platform/kms.tf +16 -0
  41. package/example-terraform-estate/platform/outputs.tf +7 -0
  42. package/example-terraform-estate/platform/variables.tf +10 -0
  43. package/example-terraform-estate/platform/versions.tf +10 -0
  44. package/example-writes/package-lock.json +3 -3
  45. package/licenses/cncf-artwork-LICENSE.md +13 -0
  46. package/licenses/iTerm2-Color-Schemes-LICENSE +31 -0
  47. package/licenses/kubernetes-icons-LICENSE +201 -0
  48. package/package.json +25 -7
  49. package/web/app.js +1309 -41
  50. package/web/behaviour-scale.js +380 -0
  51. package/web/behaviour-scale.test.js +307 -0
  52. package/web/cross-env.js +58 -0
  53. package/web/cross-env.test.js +61 -0
  54. package/web/index.html +62 -2
  55. package/web/pending.js +67 -0
  56. package/web/pending.test.js +62 -0
  57. package/web/read-cost.js +49 -0
  58. package/web/read-cost.test.js +76 -0
  59. package/web/refresh-queue.js +26 -0
  60. package/web/refresh-queue.test.js +54 -0
@@ -0,0 +1,58 @@
1
+ // #234's last remainder — the cross-environment half of "is prod running what
2
+ // staging tested", the pure half (tested the way web/release.js and
3
+ // web/run-playhead.js are — no DOM). The payload is /api/components/compare's
4
+ // `{env, to, comparisons}`, each comparison chant's own CrossEnvComparison:
5
+ // `same` decided on the COMPARABLE identity (inputDigest ?? digest per side —
6
+ // a pinned helm deploy's bytes legitimately differ per cluster), `comparedOn`
7
+ // present when the input side spoke, digests always visible.
8
+
9
+ /** Tone vocabulary matches the component palette: good / warn / neutral. */
10
+ export function comparisonRow(c) {
11
+ const missingA = !c.digestA;
12
+ const missingB = !c.digestB;
13
+ if (missingA && missingB) {
14
+ return { component: c.component, tone: "neutral", verdict: "unrecorded", detail: `no release recorded in ${c.envA} or ${c.envB}` };
15
+ }
16
+ if (missingA || missingB) {
17
+ const where = missingA ? c.envA : c.envB;
18
+ return { component: c.component, tone: "neutral", verdict: "unrecorded", detail: `no release recorded in ${where}` };
19
+ }
20
+ if (c.same) {
21
+ // The honest wording: matching inputs with differing bytes is still
22
+ // "same" — that is chant#1243's contract, and saying "same bytes" here
23
+ // would claim more than the ledger does.
24
+ const inputDecided = !!c.comparedOn && c.comparedOn.a === c.comparedOn.b && c.digestA !== c.digestB;
25
+ return {
26
+ component: c.component,
27
+ tone: "good",
28
+ verdict: inputDecided ? "same inputs" : "same",
29
+ detail: inputDecided
30
+ ? `built from the same inputs (${short(c.comparedOn.a)}); rendered bytes differ per cluster, as pinned deploys do`
31
+ : `same digest in both (${short(c.digestA)})`,
32
+ };
33
+ }
34
+ return {
35
+ component: c.component,
36
+ tone: "warn",
37
+ verdict: "differs",
38
+ detail: `${c.envA}: ${short(c.digestA)} · ${c.envB}: ${short(c.digestB)}`,
39
+ };
40
+ }
41
+
42
+ /** One line for the section header: the counts, worst-tone-first. */
43
+ export function comparisonSummary(payload) {
44
+ const rows = (payload.comparisons || []).map(comparisonRow);
45
+ if (!rows.length) return { text: `no components recorded in ${payload.env} or ${payload.to}`, tone: "neutral" };
46
+ const differs = rows.filter((r) => r.tone === "warn").length;
47
+ const unrecorded = rows.filter((r) => r.tone === "neutral").length;
48
+ const same = rows.length - differs - unrecorded;
49
+ if (differs) return { text: `${differs} of ${rows.length} differ from ${payload.to}`, tone: "warn" };
50
+ if (same === rows.length) return { text: `all ${rows.length} running what ${payload.to} tested`, tone: "good" };
51
+ return { text: `${same} same as ${payload.to}, ${unrecorded} unrecorded`, tone: "neutral" };
52
+ }
53
+
54
+ function short(digest) {
55
+ if (!digest) return "—";
56
+ const hex = digest.startsWith("sha256:") ? digest.slice(7) : digest;
57
+ return hex.slice(0, 12);
58
+ }
@@ -0,0 +1,61 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { comparisonRow, comparisonSummary } from "./cross-env.js";
3
+
4
+ const cmp = (over) => ({ component: "web", envA: "prod", envB: "staging", digestA: "sha256:aaaa1111bbbb2222", digestB: "sha256:aaaa1111bbbb2222", same: true, ...over });
5
+
6
+ describe("comparisonRow (#234 — is prod running what staging tested)", () => {
7
+ it("same digest in both reads good, with the digest named", () => {
8
+ const r = comparisonRow(cmp({}));
9
+ expect(r).toMatchObject({ tone: "good", verdict: "same" });
10
+ expect(r.detail).toContain("aaaa1111bbbb");
11
+ });
12
+
13
+ it("matching inputs with differing bytes says SAME INPUTS — never claims same bytes (pinned helm, chant#1243)", () => {
14
+ const r = comparisonRow(cmp({
15
+ digestA: "sha256:prod-bytes",
16
+ digestB: "sha256:staging-bytes",
17
+ same: true,
18
+ comparedOn: { a: "sha256:inputs", b: "sha256:inputs" },
19
+ }));
20
+ expect(r.tone).toBe("good");
21
+ expect(r.verdict).toBe("same inputs");
22
+ expect(r.detail).toContain("rendered bytes differ per cluster");
23
+ });
24
+
25
+ it("differing identities read warn, both digests visible", () => {
26
+ const r = comparisonRow(cmp({ digestB: "sha256:other", same: false }));
27
+ expect(r).toMatchObject({ tone: "warn", verdict: "differs" });
28
+ expect(r.detail).toContain("prod");
29
+ expect(r.detail).toContain("staging");
30
+ });
31
+
32
+ it("a missing side is UNRECORDED (neutral) and names where — never painted as drift", () => {
33
+ const r = comparisonRow(cmp({ digestB: undefined, same: false }));
34
+ expect(r).toMatchObject({ tone: "neutral", verdict: "unrecorded" });
35
+ expect(r.detail).toContain("staging");
36
+ expect(comparisonRow(cmp({ digestA: undefined, digestB: undefined, same: false })).detail).toContain("prod or staging");
37
+ });
38
+ });
39
+
40
+ describe("comparisonSummary", () => {
41
+ const payload = (comparisons) => ({ env: "prod", to: "staging", comparisons });
42
+
43
+ it("worst tone first: any differ makes the line warn with the count", () => {
44
+ const s = comparisonSummary(payload([cmp({}), cmp({ component: "api", digestB: "sha256:x", same: false })]));
45
+ expect(s).toEqual({ text: "1 of 2 differ from staging", tone: "warn" });
46
+ });
47
+
48
+ it("all same is the good line the question wants answered", () => {
49
+ const s = comparisonSummary(payload([cmp({}), cmp({ component: "api" })]));
50
+ expect(s).toEqual({ text: "all 2 running what staging tested", tone: "good" });
51
+ });
52
+
53
+ it("unrecorded components temper the claim instead of hiding in either count", () => {
54
+ const s = comparisonSummary(payload([cmp({}), cmp({ component: "api", digestB: undefined, same: false })]));
55
+ expect(s).toEqual({ text: "1 same as staging, 1 unrecorded", tone: "neutral" });
56
+ });
57
+
58
+ it("an empty ledger pair says so", () => {
59
+ expect(comparisonSummary(payload([]))).toEqual({ text: "no components recorded in prod or staging", tone: "neutral" });
60
+ });
61
+ });
package/web/index.html CHANGED
@@ -4,6 +4,14 @@
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>behold</title>
7
+ <!-- #393: "nothing in the browser console" includes the 404 every page
8
+ load has always fetched for itself. behold ships no icon file and its
9
+ static export copies only web/, so the mark is a data URI rather than a
10
+ route or an asset: no request, and the exported page keeps it. -->
11
+ <link
12
+ rel="icon"
13
+ href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='6' fill='none' stroke='%23a6adc8' stroke-width='1.6'/%3E%3Ccircle cx='8' cy='8' r='2.2' fill='%23a6adc8'/%3E%3C/svg%3E"
14
+ />
7
15
  <style>
8
16
  /* Pre-paint fallback (#229). These are the DEFAULT theme's tokens —
9
17
  Catppuccin Mocha run through theme.js's tokensFor() — not a palette of
@@ -155,6 +163,11 @@
155
163
  cursor: progress; }
156
164
  #loading-overlay[hidden] { display: none; }
157
165
  #loading-overlay #loading-msg { font: var(--t-body)/1.4 var(--font-mono); color: var(--muted); }
166
+ /* #421: what the last read cost, for the person waiting on this one.
167
+ Quieter than the message above it — an estimate, never a promise. */
168
+ #loading-overlay #loading-cost { font: var(--t-caption)/1.4 var(--font-mono); color: var(--muted);
169
+ opacity: 0.75; }
170
+ #loading-overlay #loading-cost:empty { display: none; }
158
171
  #loading-overlay .spinner { width: 34px; height: 34px; border-radius: 50%;
159
172
  border: 3px solid var(--line); border-top-color: var(--focus);
160
173
  animation: spin 0.7s linear infinite; }
@@ -313,6 +326,31 @@
313
326
  #panel .grow { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
314
327
  #panel .tag { color: var(--muted); font: var(--t-caption)/1.5 var(--font-mono); white-space: nowrap; }
315
328
 
329
+ /* The provenance badge (#401, M4 of #397): who stated this figure, to
330
+ what tolerance, and whether it is modeled from list prices or
331
+ validated against a bill. Boxed rather than plain muted text, because
332
+ it rides BESIDE a number and has to read as a label on that number
333
+ rather than as more of it — and it sits on every figure the behaviour
334
+ overlay shows, in the panel and in the inspect pane alike, so it is
335
+ styled for both. `help` cursor: the hover carries "modeled, not
336
+ billed", which is the sentence #397 turns on. */
337
+ .behaviour-badge { color: var(--muted); font: var(--t-caption)/1.4 var(--font-mono);
338
+ border: 1px solid var(--line); border-radius: var(--r-ctl); padding: 0 5px;
339
+ white-space: nowrap; cursor: help; flex: none; }
340
+ #panel .behaviour-total { display: flex; flex-direction: column; gap: 1px; padding: 3px 0;
341
+ font: var(--t-caption)/1.5 var(--font-mono); }
342
+ #panel .behaviour-ramp { margin: 4px 0 2px; }
343
+ /* A card nothing priced keeps the drift overlay's neutral fill (#399) —
344
+ and says so, faintly, so the grey is legible as "not looked at" rather
345
+ than as the low end of the scale. */
346
+ #graph [data-unpriced="1"] { opacity: 0.82; }
347
+ /* #429: a member with no live half, on an overlay. Distinct from the
348
+ neutral fill it would otherwise be indistinguishable from — a dashed
349
+ edge says "nothing observed this" where neutral says "looked, could
350
+ not see". */
351
+ #graph [data-no-live] rect { stroke-dasharray: 2 4; }
352
+ #graph [data-no-live] { opacity: 0.75; }
353
+
316
354
  /* Substrate readiness (M5, #54) — one row per substrate on the panel's
317
355
  Substrates tab: status dot + name + state, with its actions (bring up /
318
356
  reset / run pipeline) visible inline again. #73 moved those into ⌘K;
@@ -424,6 +462,13 @@
424
462
  here", not "something is wrong". */
425
463
  #graph [data-node-id].operator-home rect:first-of-type { stroke: var(--pending); stroke-width: 2; }
426
464
 
465
+ /* #404: a bound choudoufu card the plan would change in place. A DASHED
466
+ --degraded edge, deliberately not a fill: the fill is the ownership
467
+ verdict and attribute drift never moves it, so the two signals have to
468
+ live in different channels. Dashed rather than solid so it reads apart
469
+ from .carved's and .operator-home's solid strokes at a glance. */
470
+ #graph [data-node-id].plan-drifted rect:first-of-type { stroke: var(--degraded); stroke-width: 2; stroke-dasharray: 4 3; }
471
+
427
472
  /* The step the playhead sits on, on the graph itself — the ops lens marks
428
473
  it `_playhead` and the card wears a dashed edge, so "here" reads
429
474
  differently from "this one is done". */
@@ -515,13 +560,28 @@
515
560
  font: var(--t-body)/1.5 var(--font-mono);
516
561
  cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
517
562
  #pal-list .row.sel { background: var(--pending); color: var(--pal-sel-fg, var(--fg)); }
563
+ /* A node row's second line (#393): the member and the kind, which is what
564
+ tells two same-named addresses in two members apart. Inherits the row's
565
+ colour so the selected row stays readable on --pending. */
566
+ #pal-list .row .sub { font-size: var(--t-caption); opacity: 0.66; margin-top: 1px; }
518
567
  #pal-list .empty { padding: 8px 11px; color: var(--muted); font-size: var(--t-body); }
519
- </style>
568
+
569
+ /* #422: a card whose member is still being read. Dashed and dimmed, never
570
+ filled — the fill is the status channel and this card has no status yet,
571
+ so colouring it would claim an observation behold has not made. */
572
+ #graph [data-pending-read] { opacity: 0.55; }
573
+ #graph [data-pending-read] rect { stroke-dasharray: 4 3; }
574
+ @media (prefers-reduced-motion: no-preference) {
575
+ #graph [data-pending-read] { animation: pending-breathe 2.4s ease-in-out infinite; }
576
+ @keyframes pending-breathe { 0%, 100% { opacity: 0.45; } 50% { opacity: 0.68; } }
577
+ }
578
+ </style>
520
579
  </head>
521
580
  <body>
522
581
  <div id="loading-overlay" hidden>
523
582
  <div class="spinner"></div>
524
583
  <span id="loading-msg">loading…</span>
584
+ <span id="loading-cost"></span>
525
585
  </div>
526
586
  <div id="app">
527
587
  <div id="graph"></div>
@@ -577,7 +637,7 @@
577
637
  <!-- Command palette (⌘K / Ctrl+K, #73) — the spicypath FG-036 shape
578
638
  (../spicypath/src/index.html): input + filtered list, driven entirely
579
639
  by app.js's paletteCommands()/palRender()/openPalette()/closePalette(). -->
580
- <div id="palette"><div class="box"><input id="pal-input" placeholder="type a command… (Esc to close)" autocomplete="off"><div id="pal-list"></div></div></div>
640
+ <div id="palette"><div class="box"><input id="pal-input" placeholder="type a command, or an address… (Esc to close)" autocomplete="off"><div id="pal-list"></div></div></div>
581
641
  <script type="module" src="./app.js"></script>
582
642
  </body>
583
643
  </html>
package/web/pending.js ADDED
@@ -0,0 +1,67 @@
1
+ // The picture while it is still arriving (#422, M3 of #419).
2
+ //
3
+ // An estate whose slowest member takes two minutes showed nothing for two
4
+ // minutes, including the members that answered in three seconds. The server's
5
+ // `?progressive=1` answers at once with every member composed from its SOURCE,
6
+ // each node carrying `_pendingRead`, and then broadcasts one `member` frame per
7
+ // live read as it settles. This module is the pure half of applying those.
8
+ //
9
+ // Pending is its own claim. `_unobserved` means behold looked and could not
10
+ // see; neutral means the plan did not mention it; this means the read has not
11
+ // finished. A viewer that blurs them is the thing behold exists not to be, so
12
+ // the pending mark is a separate attr and a separate treatment, never a fifth
13
+ // status colour — pinhole's painter takes a closed set and silently falls back
14
+ // to neutral for anything else, which is "unobserved", the exact wrong answer.
15
+
16
+ /**
17
+ * Fold one `member` frame into the graph IR on screen.
18
+ *
19
+ * Returns the ids whose status actually moved, so the caller repaints those and
20
+ * leaves the rest of the SVG alone — the node set never changes here, which is
21
+ * what keeps dagre out of it and stops cards jumping under the reader.
22
+ *
23
+ * Mutates `ir` in place, because it IS the IR the renderer holds.
24
+ */
25
+ export function applyMemberFrame(ir, frame) {
26
+ if (!ir || !frame || !frame.member || !frame.statuses) return [];
27
+ const moved = [];
28
+ for (const node of ir.nodes || []) {
29
+ const next = frame.statuses[node.id];
30
+ if (next === undefined) continue;
31
+ const attrs = node.attrs || (node.attrs = {});
32
+ const was = attrs._status;
33
+ delete attrs._pendingRead;
34
+ attrs._status = next;
35
+ // A member whose live read failed comes back with its reason, and that is
36
+ // a different card from one that simply finished: unobserved, not pending
37
+ // and not absent.
38
+ if (frame.unobserved) attrs._unobserved = frame.unobserved;
39
+ else delete attrs._unobserved;
40
+ if (was !== next) moved.push(node.id);
41
+ }
42
+ return moved;
43
+ }
44
+
45
+ /** Which members the picture is still waiting on, after a frame lands. */
46
+ export function stillPending(pending, frame) {
47
+ if (!Array.isArray(pending)) return [];
48
+ if (!frame || !frame.member) return [...pending];
49
+ return pending.filter((m) => m !== frame.member);
50
+ }
51
+
52
+ /**
53
+ * The line for the reader while the estate arrives.
54
+ *
55
+ * Null once nothing is pending — the caller clears rather than printing a
56
+ * finished-sounding sentence, because the ordinary status line already says
57
+ * what the estate is.
58
+ */
59
+ export function pendingLine(pending, total) {
60
+ if (!Array.isArray(pending) || pending.length === 0) return null;
61
+ const done = Math.max(0, (total ?? pending.length) - pending.length);
62
+ const scope = total ? `${done} of ${total} members read` : `${pending.length} members still reading`;
63
+ // Named, not counted, while the list is short enough to read: "waiting on
64
+ // team-a" is actionable and "3 pending" is not.
65
+ const who = pending.length <= 3 ? ` — waiting on ${pending.join(", ")}` : "";
66
+ return `${scope}${who}`;
67
+ }
@@ -0,0 +1,62 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { applyMemberFrame, pendingLine, stillPending } from "./pending.js";
3
+
4
+ const ir = () => ({
5
+ nodes: [
6
+ { id: "team-a/bucket", attrs: { _pendingRead: true } },
7
+ { id: "team-a/role", attrs: { _pendingRead: true } },
8
+ { id: "team-b/bucket", attrs: { _pendingRead: true } },
9
+ ],
10
+ });
11
+
12
+ describe("applyMemberFrame (#422)", () => {
13
+ it("clears pending and paints only the member that landed", () => {
14
+ const graph = ir();
15
+ const moved = applyMemberFrame(graph, { member: "team-a", statuses: { "team-a/bucket": "good", "team-a/role": "warn" } });
16
+
17
+ expect(moved.sort()).toEqual(["team-a/bucket", "team-a/role"]);
18
+ expect(graph.nodes[0].attrs).toEqual({ _status: "good" });
19
+ expect(graph.nodes[1].attrs).toEqual({ _status: "warn" });
20
+ // The member that has not answered is untouched, and still says so.
21
+ expect(graph.nodes[2].attrs).toEqual({ _pendingRead: true });
22
+ });
23
+
24
+ it("carries a failed member's reason, which is not the same as pending", () => {
25
+ const graph = ir();
26
+ applyMemberFrame(graph, { member: "team-b", unobserved: "no credentials", statuses: { "team-b/bucket": "neutral" } });
27
+ // Unobserved is "I looked and could not see"; pending was "I have not
28
+ // finished looking". The card must stop claiming the second one.
29
+ expect(graph.nodes[2].attrs).toEqual({ _status: "neutral", _unobserved: "no credentials" });
30
+ });
31
+
32
+ it("reports nothing moved when the status is what it already was", () => {
33
+ const graph = { nodes: [{ id: "a", attrs: { _status: "good" } }] };
34
+ expect(applyMemberFrame(graph, { member: "m", statuses: { a: "good" } })).toEqual([]);
35
+ expect(graph.nodes[0].attrs._status).toBe("good");
36
+ });
37
+
38
+ it("ignores a malformed frame rather than throwing over the picture", () => {
39
+ expect(applyMemberFrame(ir(), null)).toEqual([]);
40
+ expect(applyMemberFrame(ir(), { member: "x" })).toEqual([]);
41
+ expect(applyMemberFrame(null, { member: "x", statuses: {} })).toEqual([]);
42
+ });
43
+ });
44
+
45
+ describe("what is still arriving", () => {
46
+ it("drops the member that landed", () => {
47
+ expect(stillPending(["a", "b", "c"], { member: "b", statuses: {} })).toEqual(["a", "c"]);
48
+ });
49
+
50
+ it("names them while the list is short enough to read", () => {
51
+ expect(pendingLine(["team-a", "team-b"], 5)).toBe("3 of 5 members read — waiting on team-a, team-b");
52
+ });
53
+
54
+ it("counts them once naming would be a wall of text", () => {
55
+ expect(pendingLine(["a", "b", "c", "d"], 11)).toBe("7 of 11 members read");
56
+ });
57
+
58
+ it("says nothing once nothing is pending", () => {
59
+ expect(pendingLine([], 5)).toBeNull();
60
+ expect(pendingLine(undefined, 5)).toBeNull();
61
+ });
62
+ });
@@ -0,0 +1,49 @@
1
+ // What the last read cost, as one line (#421, M2 of #419).
2
+ //
3
+ // The person staring at the loading scrim has exactly one question — is this
4
+ // three seconds or three minutes — and behold has never answered it. #420's
5
+ // ledger knows; this turns it into a sentence.
6
+ //
7
+ // The number reported is the SLOWEST recent member read, not the sum. An estate
8
+ // read fans out under a shared budget (src/estate.ts) and finishes when its last
9
+ // member does, so the slowest member is what the wall clock actually follows.
10
+ // Summing would report a number no one ever waits for.
11
+
12
+ /** Whole-second durations, because nobody waiting cares about milliseconds. */
13
+ export function humanMs(ms) {
14
+ if (!Number.isFinite(ms) || ms < 0) return null;
15
+ const s = Math.round(ms / 1000);
16
+ if (s < 1) return "under a second";
17
+ if (s < 60) return `${s}s`;
18
+ const m = Math.floor(s / 60);
19
+ const rest = s % 60;
20
+ return rest ? `${m}m ${rest}s` : `${m}m`;
21
+ }
22
+
23
+ /**
24
+ * One line for the scrim, or null when there is nothing honest to say.
25
+ *
26
+ * Null rather than a zero or a guess: this is the same rule the rest of behold
27
+ * holds to about absent figures. A fresh server with no reads behind it says
28
+ * nothing, and preview mode — which gets the totals and not the samples — says
29
+ * nothing too, because "slowest member" is a claim about members.
30
+ */
31
+ export function readCostLine(reads) {
32
+ const recent = reads && Array.isArray(reads.recent) ? reads.recent : null;
33
+ if (!recent || recent.length === 0) return null;
34
+ const slowest = recent.reduce((worst, r) => (r && r.runningMs > worst.runningMs ? r : worst), recent[0]);
35
+ const took = humanMs(slowest.runningMs);
36
+ if (!took) return null;
37
+ const members = new Set(recent.map((r) => r.dir)).size;
38
+ const who = [slowest.dir, slowest.what].filter(Boolean).join(" ");
39
+ const scope = members === 1 ? "1 member" : `${members} members`;
40
+ // A member the ledger cannot see is named rather than dropped. Only chant
41
+ // reads pass ReadScheduler; a choudoufu member spawns its own binary, so on a
42
+ // mixed estate the slowest member may be one of the ones missing here. Saying
43
+ // "slowest 3s" while a 60s member went unmeasured is the claim behold refuses
44
+ // to make anywhere else, and the clause is what keeps this line honest.
45
+ const blind = Number.isInteger(reads.unmeasured) && reads.unmeasured > 0
46
+ ? ` · ${reads.unmeasured} not measured`
47
+ : "";
48
+ return `last read: ${scope}, slowest ${took}${who ? ` (${who})` : ""}${blind}`;
49
+ }
@@ -0,0 +1,76 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { humanMs, readCostLine } from "./read-cost.js";
3
+
4
+ const sample = (over = {}) => ({ dir: "monolith", what: "graph --live", live: true, queuedMs: 0, runningMs: 1000, outcome: "completed", ...over });
5
+
6
+ describe("humanMs", () => {
7
+ it("rounds to whole seconds and names a sub-second read rather than showing 0s", () => {
8
+ expect(humanMs(400)).toBe("under a second");
9
+ expect(humanMs(1499)).toBe("1s");
10
+ expect(humanMs(59_400)).toBe("59s");
11
+ });
12
+
13
+ it("breaks a minute out, and drops a zero remainder", () => {
14
+ expect(humanMs(60_000)).toBe("1m");
15
+ expect(humanMs(147_000)).toBe("2m 27s");
16
+ expect(humanMs(120_000)).toBe("2m");
17
+ });
18
+
19
+ it("refuses a number it cannot render", () => {
20
+ expect(humanMs(NaN)).toBeNull();
21
+ expect(humanMs(-1)).toBeNull();
22
+ expect(humanMs(undefined)).toBeNull();
23
+ });
24
+ });
25
+
26
+ describe("readCostLine (#421)", () => {
27
+ it("reports the slowest member, because that is what the wall clock follows", () => {
28
+ // Summing these would say 2m 30s, a number nobody ever waits for: the reads
29
+ // overlap under the shared budget and the estate is done when the last is.
30
+ const reads = { recent: [
31
+ sample({ dir: "monolith", runningMs: 3_000 }),
32
+ sample({ dir: "team-a", runningMs: 147_000 }),
33
+ sample({ dir: "team-b", runningMs: 500 }),
34
+ ] };
35
+ expect(readCostLine(reads)).toBe("last read: 3 members, slowest 2m 27s (team-a graph --live)");
36
+ });
37
+
38
+ it("counts members rather than reads — a member read twice is one member", () => {
39
+ const reads = { recent: [
40
+ sample({ dir: "monolith", what: "graph", runningMs: 900 }),
41
+ sample({ dir: "monolith", what: "graph --live", runningMs: 4_000 }),
42
+ ] };
43
+ expect(readCostLine(reads)).toBe("last read: 1 member, slowest 4s (monolith graph --live)");
44
+ });
45
+
46
+ it("says nothing rather than zero when there is nothing behind it", () => {
47
+ expect(readCostLine(null)).toBeNull();
48
+ expect(readCostLine({})).toBeNull();
49
+ expect(readCostLine({ recent: [] })).toBeNull();
50
+ });
51
+
52
+ it("says nothing in preview mode, where the totals arrive without the samples", () => {
53
+ // "Slowest member" is a claim about members, and preview mode deliberately
54
+ // withholds them (src/server.ts). Totals alone cannot answer it.
55
+ expect(readCostLine({ runs: 12, live: { runs: 6, runningMs: 900_000 } })).toBeNull();
56
+ });
57
+ });
58
+
59
+ describe("the members the ledger cannot see (#421)", () => {
60
+ it("names them rather than reporting a slowest it did not measure", () => {
61
+ // A choudoufu member spawns its own binary and never reaches ReadScheduler,
62
+ // so "slowest 3s" beside an unmeasured 60s member would be a false claim.
63
+ const line = readCostLine({ unmeasured: 2, recent: [sample({ dir: "app", runningMs: 3000 })] });
64
+ expect(line).toBe("last read: 1 member, slowest 3s (app graph --live) · 2 not measured");
65
+ });
66
+
67
+ it("says nothing extra when every member was measured", () => {
68
+ const line = readCostLine({ unmeasured: 0, recent: [sample({ dir: "app", runningMs: 3000 })] });
69
+ expect(line).toBe("last read: 1 member, slowest 3s (app graph --live)");
70
+ });
71
+
72
+ it("says nothing extra when the field is absent, as an older payload has it", () => {
73
+ const line = readCostLine({ recent: [sample({ dir: "app", runningMs: 3000 })] });
74
+ expect(line).toBe("last read: 1 member, slowest 3s (app graph --live)");
75
+ });
76
+ });
@@ -0,0 +1,26 @@
1
+ // One active refresh and one pending refresh, regardless of how many events
2
+ // arrive. All callers wait for the latest requested view to finish loading.
3
+ export function createRefreshQueue(run) {
4
+ let active;
5
+ let pending;
6
+ let revision = 0;
7
+ return (options = {}) => {
8
+ revision++;
9
+ pending = { ...options, quiet: pending ? !!pending.quiet && !!options.quiet : !!options.quiet };
10
+ if (!active) {
11
+ active = Promise.resolve().then(async () => {
12
+ try {
13
+ while (pending) {
14
+ const next = pending;
15
+ pending = undefined;
16
+ const current = revision;
17
+ await run(next, () => current === revision);
18
+ }
19
+ } finally {
20
+ active = undefined;
21
+ }
22
+ });
23
+ }
24
+ return active;
25
+ };
26
+ }
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createRefreshQueue } from "./refresh-queue.js";
3
+
4
+ function deferred() {
5
+ let resolve;
6
+ const promise = new Promise((done) => { resolve = done; });
7
+ return { promise, resolve };
8
+ }
9
+
10
+ describe("browser refresh queue", () => {
11
+ it("a slow read plus an event burst makes one follow-up, never overlapping reads", async () => {
12
+ const first = deferred(), second = deferred();
13
+ let active = 0, peak = 0;
14
+ const calls = [], current = [];
15
+ const load = createRefreshQueue(async (opts, isCurrent) => {
16
+ peak = Math.max(peak, ++active);
17
+ calls.push(opts); current.push(isCurrent);
18
+ await (calls.length === 1 ? first : second).promise;
19
+ active--;
20
+ });
21
+ const initial = load();
22
+ await Promise.resolve();
23
+ const burst = Array.from({ length: 20 }, () => load({ quiet: true }));
24
+ load({ quiet: false }); load({ quiet: true });
25
+ expect(calls).toHaveLength(1);
26
+ expect(current[0]()).toBe(false); // suppress the superseded response
27
+ first.resolve();
28
+ await Promise.resolve(); await Promise.resolve();
29
+ expect(calls).toHaveLength(2);
30
+ expect(calls[1].quiet).toBe(false); // foreground request wins
31
+ expect(current[1]()).toBe(true);
32
+ second.resolve();
33
+ await Promise.all([initial, ...burst]);
34
+ expect(peak).toBe(1);
35
+ expect(calls).toHaveLength(2);
36
+ });
37
+
38
+ it("one change event makes exactly one read", async () => {
39
+ let calls = 0;
40
+ const load = createRefreshQueue(async () => { calls++; });
41
+ await load();
42
+ expect(calls).toBe(1);
43
+ await load(); // a later notification remains fresh
44
+ expect(calls).toBe(2);
45
+ });
46
+
47
+ it("a failed load does not wedge future refreshes", async () => {
48
+ let calls = 0;
49
+ const load = createRefreshQueue(async () => { if (++calls === 1) throw new Error("offline"); });
50
+ await expect(load()).rejects.toThrow("offline");
51
+ await load();
52
+ expect(calls).toBe(2);
53
+ });
54
+ });