@intentius/behold 0.18.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 (38) hide show
  1. package/AGENTS.md +287 -71
  2. package/README.md +234 -190
  3. package/demos.json +15 -7
  4. package/dist/cli.js +11881 -11193
  5. package/example-argo-estate/package-lock.json +6 -6
  6. package/example-carve/app/package-lock.json +3 -3
  7. package/example-flux-estate/package-lock.json +6 -6
  8. package/example-k8s/package-lock.json +6 -6
  9. package/example-terraform-estate/README.md +109 -0
  10. package/example-terraform-estate/backends/backend.s3.tf +10 -0
  11. package/example-terraform-estate/envs/dev/README.md +5 -0
  12. package/example-terraform-estate/envs/prod/data.tf +18 -0
  13. package/example-terraform-estate/envs/prod/network.tf +80 -0
  14. package/example-terraform-estate/envs/prod/outputs.tf +13 -0
  15. package/example-terraform-estate/envs/prod/provider.tf +10 -0
  16. package/example-terraform-estate/envs/prod/services.tf +21 -0
  17. package/example-terraform-estate/envs/prod/storage.tf +31 -0
  18. package/example-terraform-estate/envs/prod/variables.tf +17 -0
  19. package/example-terraform-estate/envs/prod/versions.tf +10 -0
  20. package/example-terraform-estate/modules/service/main.tf +58 -0
  21. package/example-terraform-estate/modules/service/outputs.tf +7 -0
  22. package/example-terraform-estate/modules/service/variables.tf +16 -0
  23. package/example-terraform-estate/modules/service/versions.tf +10 -0
  24. package/example-terraform-estate/platform/iam.tf +40 -0
  25. package/example-terraform-estate/platform/kms.tf +16 -0
  26. package/example-terraform-estate/platform/outputs.tf +7 -0
  27. package/example-terraform-estate/platform/variables.tf +10 -0
  28. package/example-terraform-estate/platform/versions.tf +10 -0
  29. package/example-writes/package-lock.json +3 -3
  30. package/package.json +2 -1
  31. package/web/app.js +385 -26
  32. package/web/index.html +23 -1
  33. package/web/pending.js +67 -0
  34. package/web/pending.test.js +62 -0
  35. package/web/read-cost.js +49 -0
  36. package/web/read-cost.test.js +76 -0
  37. package/web/refresh-queue.js +26 -0
  38. package/web/refresh-queue.test.js +54 -0
package/web/index.html CHANGED
@@ -163,6 +163,11 @@
163
163
  cursor: progress; }
164
164
  #loading-overlay[hidden] { display: none; }
165
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; }
166
171
  #loading-overlay .spinner { width: 34px; height: 34px; border-radius: 50%;
167
172
  border: 3px solid var(--line); border-top-color: var(--focus);
168
173
  animation: spin 0.7s linear infinite; }
@@ -339,6 +344,12 @@
339
344
  and says so, faintly, so the grey is legible as "not looked at" rather
340
345
  than as the low end of the scale. */
341
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; }
342
353
 
343
354
  /* Substrate readiness (M5, #54) — one row per substrate on the panel's
344
355
  Substrates tab: status dot + name + state, with its actions (bring up /
@@ -554,12 +565,23 @@
554
565
  colour so the selected row stays readable on --pending. */
555
566
  #pal-list .row .sub { font-size: var(--t-caption); opacity: 0.66; margin-top: 1px; }
556
567
  #pal-list .empty { padding: 8px 11px; color: var(--muted); font-size: var(--t-body); }
557
- </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>
558
579
  </head>
559
580
  <body>
560
581
  <div id="loading-overlay" hidden>
561
582
  <div class="spinner"></div>
562
583
  <span id="loading-msg">loading…</span>
584
+ <span id="loading-cost"></span>
563
585
  </div>
564
586
  <div id="app">
565
587
  <div id="graph"></div>
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
+ });