@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.
- package/AGENTS.md +287 -71
- package/README.md +234 -190
- package/demos.json +15 -7
- package/dist/cli.js +11881 -11193
- package/example-argo-estate/package-lock.json +6 -6
- package/example-carve/app/package-lock.json +3 -3
- package/example-flux-estate/package-lock.json +6 -6
- package/example-k8s/package-lock.json +6 -6
- package/example-terraform-estate/README.md +109 -0
- package/example-terraform-estate/backends/backend.s3.tf +10 -0
- package/example-terraform-estate/envs/dev/README.md +5 -0
- package/example-terraform-estate/envs/prod/data.tf +18 -0
- package/example-terraform-estate/envs/prod/network.tf +80 -0
- package/example-terraform-estate/envs/prod/outputs.tf +13 -0
- package/example-terraform-estate/envs/prod/provider.tf +10 -0
- package/example-terraform-estate/envs/prod/services.tf +21 -0
- package/example-terraform-estate/envs/prod/storage.tf +31 -0
- package/example-terraform-estate/envs/prod/variables.tf +17 -0
- package/example-terraform-estate/envs/prod/versions.tf +10 -0
- package/example-terraform-estate/modules/service/main.tf +58 -0
- package/example-terraform-estate/modules/service/outputs.tf +7 -0
- package/example-terraform-estate/modules/service/variables.tf +16 -0
- package/example-terraform-estate/modules/service/versions.tf +10 -0
- package/example-terraform-estate/platform/iam.tf +40 -0
- package/example-terraform-estate/platform/kms.tf +16 -0
- package/example-terraform-estate/platform/outputs.tf +7 -0
- package/example-terraform-estate/platform/variables.tf +10 -0
- package/example-terraform-estate/platform/versions.tf +10 -0
- package/example-writes/package-lock.json +3 -3
- package/package.json +2 -1
- package/web/app.js +385 -26
- package/web/index.html +23 -1
- package/web/pending.js +67 -0
- package/web/pending.test.js +62 -0
- package/web/read-cost.js +49 -0
- package/web/read-cost.test.js +76 -0
- package/web/refresh-queue.js +26 -0
- package/web/refresh-queue.test.js +54 -0
package/web/app.js
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
// floating control panel's chrome (panel.js — drag/snap/collapse/tabs, persisted
|
|
9
9
|
// position), and the theme picker into the panel's View-tab slot (a stable element
|
|
10
10
|
// renderPanelView never rewrites, so the select mounts once and survives re-renders).
|
|
11
|
+
import { createRefreshQueue } from "./refresh-queue.js";
|
|
12
|
+
import { readCostLine } from "./read-cost.js";
|
|
13
|
+
import { applyMemberFrame, pendingLine, stillPending } from "./pending.js";
|
|
11
14
|
import { initTheme, mountThemePicker, readableOn, colorForCategory, onThemeChange, getTokens, getTheme, pinTokensFor } from "./theme.js";
|
|
12
15
|
// #399 M2 / #401 M4 of #397: the colour-by modes' arithmetic and the
|
|
13
16
|
// provenance badge's wording — every decision the behaviour overlay makes,
|
|
@@ -199,6 +202,12 @@ function recolorNodesByCategory(ir) {
|
|
|
199
202
|
// unpriced count has to agree with what is on the canvas.
|
|
200
203
|
if (plan.mode !== "drift" && paint.unpriced) g.setAttribute("data-unpriced", "1");
|
|
201
204
|
else g.removeAttribute("data-unpriced");
|
|
205
|
+
// #429: and the card an overlay never observed because its member has no
|
|
206
|
+
// live half. Only on an overlay: on the source graph NO card has a status
|
|
207
|
+
// and the mark would be on every one of them, saying nothing.
|
|
208
|
+
const onOverlay = !!(lastMeta && lastMeta.env);
|
|
209
|
+
const node = graphIr.nodes.find((n) => n.id === id);
|
|
210
|
+
g.toggleAttribute("data-no-live", onOverlay && !!node && !(node.attrs && node.attrs._status));
|
|
202
211
|
// Classify each element ONCE by the pinhole token it rode on (data-cat role), then apply
|
|
203
212
|
// the role's colour on every pass. This is what makes it recolour on theme switch: after
|
|
204
213
|
// the first pass the fill is a hex (not a --pin-* var), so we must key off the marker, not
|
|
@@ -282,11 +291,11 @@ if (staticMode) {
|
|
|
282
291
|
|
|
283
292
|
/** Canonical key for a read URL — path + the lens params (whitelisted, sorted)
|
|
284
293
|
* that select a distinct snapshot. MUST match src/export.ts `canonicalKey`. */
|
|
285
|
-
const LENS_PARAMS = ["collapse", "components", "detail", "env", "logical", "ops", "radial", "runtime", "tier"];
|
|
294
|
+
const LENS_PARAMS = ["collapse", "components", "detail", "env", "logical", "ops", "radial", "runtime", "stacks", "tier"];
|
|
286
295
|
function canonicalKey(path, params) {
|
|
287
296
|
// Components + logical + ops views ignore detail/radial — drop them so they
|
|
288
297
|
// match the single captured snapshot (MUST match src/export.ts).
|
|
289
|
-
const flat = params.get("components") === "1" || params.get("logical") === "1" || params.get("ops") === "1";
|
|
298
|
+
const flat = params.get("components") === "1" || params.get("logical") === "1" || params.get("ops") === "1" || params.get("stacks") === "1";
|
|
290
299
|
const q = LENS_PARAMS.filter((k) => params.has(k) && !(flat && (k === "detail" || k === "radial")))
|
|
291
300
|
.map((k) => `${k}=${params.get(k)}`)
|
|
292
301
|
.join("&");
|
|
@@ -329,6 +338,29 @@ function vocabularyNote() {
|
|
|
329
338
|
const v = lastMeta && lastMeta.vocabulary;
|
|
330
339
|
return (v && v.note) || "";
|
|
331
340
|
}
|
|
341
|
+
// #396 item 5: the inspect pane's LIVE section prints `ownership` raw, and
|
|
342
|
+
// `ownership` is a word two different readers write into the same field.
|
|
343
|
+
// chant's live reader answers it from the resource's own marker — `owned` /
|
|
344
|
+
// `foreign`, which is chant's contract for that field (chant graph-ir.d.ts).
|
|
345
|
+
// src/choudoufu-live.ts has no marker to read: it writes the same two words for
|
|
346
|
+
// choudoufu's `bound[]` and `unowned[]`, so an unowned card read `status:
|
|
347
|
+
// unowned` and, two rows below it, `ownership: foreign` — chant's word for
|
|
348
|
+
// choudoufu's state, the last of the four #393 item 8 did not reach.
|
|
349
|
+
//
|
|
350
|
+
// So this row joins the legend, the counts and the status row in being rendered
|
|
351
|
+
// through the estate's own vocabulary rather than printed. Translated ONLY when
|
|
352
|
+
// the estate's members are all of a kind that has its own words for these
|
|
353
|
+
// colours (`vocabulary.of`): on a chant estate, and on a mixed one — which
|
|
354
|
+
// keeps chant's words on the legend by design and says so in its tooltip — the
|
|
355
|
+
// value is printed exactly as it is today, which is the point. The mapping is
|
|
356
|
+
// value → the colour that value means → that colour's word, so it needs no
|
|
357
|
+
// table of its own beyond the two words chant's field can hold.
|
|
358
|
+
const OWNERSHIP_STATUS = { owned: "good", foreign: "warn" };
|
|
359
|
+
function ownershipLabel(value) {
|
|
360
|
+
const v = lastMeta && lastMeta.vocabulary;
|
|
361
|
+
if (!v || !v.labels || !v.of || v.of === "chant" || v.of === "mixed") return value;
|
|
362
|
+
return v.labels[OWNERSHIP_STATUS[value]] || value;
|
|
363
|
+
}
|
|
332
364
|
// #404: whether a plan was read for this overlay, and how many cards it would
|
|
333
365
|
// change. `{read: false}` and `{read: true, drifted: 0}` are DIFFERENT answers
|
|
334
366
|
// — "nobody looked" versus "looked, nothing drifted" — and the legend must not
|
|
@@ -708,7 +740,7 @@ function inspect(node) {
|
|
|
708
740
|
if (node.physicalId || node.ownership) {
|
|
709
741
|
const live = section("live");
|
|
710
742
|
if (node.physicalId) live("physical id", node.physicalId);
|
|
711
|
-
if (node.ownership) live("ownership", node.ownership);
|
|
743
|
+
if (node.ownership) live("ownership", ownershipLabel(node.ownership)); // #396 item 5: the estate's own word for it
|
|
712
744
|
} else if (liveStatus) {
|
|
713
745
|
// The colour alone doesn't carry chant's verdict or its reasoning — spell
|
|
714
746
|
// both out here (never rely on the node's colour alone, #57 accessibility
|
|
@@ -994,7 +1026,7 @@ function renderObserved(panel, o, health, healthDetail) {
|
|
|
994
1026
|
if (o.type) add("type", o.type);
|
|
995
1027
|
if (o.status) add("status", o.status, HEALTH_COLOR[health] || undefined);
|
|
996
1028
|
if (o.physicalId) add("physical id", o.physicalId);
|
|
997
|
-
if (o.ownership) add("ownership", o.ownership);
|
|
1029
|
+
if (o.ownership) add("ownership", ownershipLabel(o.ownership)); // #396 item 5: same, off /api/diff
|
|
998
1030
|
if (o.lastUpdated) add("last updated", o.lastUpdated);
|
|
999
1031
|
// What the object's own controller says is wrong with it (#86, chant#1401).
|
|
1000
1032
|
// Placed with health and status rather than among the attributes below,
|
|
@@ -1259,6 +1291,13 @@ const ZOOM_OPTS = [
|
|
|
1259
1291
|
// same chant source), so it sits last, and only appears when the served
|
|
1260
1292
|
// estate has actually emitted Ops — `opsAvailable`, from /api/project.
|
|
1261
1293
|
["zoom: ops", "ops"],
|
|
1294
|
+
// The apply order (#426): the cross-stack ordering chant already computes at
|
|
1295
|
+
// synthesis — which lexicon partition must apply before which. A different
|
|
1296
|
+
// axis again, like `ops`: neither coarser nor finer than a resource, and the
|
|
1297
|
+
// only stop whose edges are about TIME rather than reference. Single-project
|
|
1298
|
+
// only and gated on `stacksCapable`, because two members' `aws` stacks are
|
|
1299
|
+
// not one stack.
|
|
1300
|
+
["zoom: apply order", "stacks"],
|
|
1262
1301
|
];
|
|
1263
1302
|
const ZOOM_DETAIL = { composites: 1, resources: 2, attributes: 3, runtime: 3 };
|
|
1264
1303
|
/** How many emitted Ops the served estate has (0 = the stop doesn't exist).
|
|
@@ -1271,18 +1310,28 @@ let opsAvailable = 0;
|
|
|
1271
1310
|
* Kubernetes tier that could only ever answer "nothing below the declaration
|
|
1272
1311
|
* boundary". */
|
|
1273
1312
|
let runtimeCapable = false;
|
|
1313
|
+
/** #422: the members whose live read has not landed yet, off the progressive
|
|
1314
|
+
* overlay's `meta.pending`. Empty on every non-progressive load, which is every
|
|
1315
|
+
* single-project serve and every static snapshot. */
|
|
1316
|
+
let pendingMembers = [];
|
|
1317
|
+
let pendingTotal = 0;
|
|
1318
|
+
/** #426: whether the apply-order stop is worth offering — /api/project's
|
|
1319
|
+
* `stacksCapable`, which is false for an estate and for a single-lexicon
|
|
1320
|
+
* project, where the answer could only ever be one box. */
|
|
1321
|
+
let stacksCapable = false;
|
|
1274
1322
|
/** The zoom stops this project can actually offer — `runtime` needs an env AND
|
|
1275
1323
|
* a member whose substrate has an owner chain, `ops` needs emitted op.json
|
|
1276
1324
|
* files. Both the panel and ⌘K read this, so a stop never appears in one
|
|
1277
1325
|
* surface and not the other. */
|
|
1278
1326
|
function availableZooms() {
|
|
1279
|
-
return ZOOM_OPTS.filter(([, z]) => (z === "runtime" ? Boolean(view.env) && runtimeCapable : z === "ops" ? opsAvailable > 0 : true));
|
|
1327
|
+
return ZOOM_OPTS.filter(([, z]) => (z === "runtime" ? Boolean(view.env) && runtimeCapable : z === "ops" ? opsAvailable > 0 : z === "stacks" ? stacksCapable : true));
|
|
1280
1328
|
}
|
|
1281
1329
|
/** Current zoom value from (components, logical, ops, detail). */
|
|
1282
1330
|
function zoomValue() {
|
|
1283
1331
|
if (view.components) return "components";
|
|
1284
1332
|
if (view.logical) return "logical";
|
|
1285
1333
|
if (view.ops) return "ops";
|
|
1334
|
+
if (view.stacks) return "stacks";
|
|
1286
1335
|
if (view.runtime) return "runtime";
|
|
1287
1336
|
return { 1: "composites", 2: "resources", 3: "attributes" }[view.detail] ?? "resources";
|
|
1288
1337
|
}
|
|
@@ -1291,8 +1340,9 @@ function applyZoom(z) {
|
|
|
1291
1340
|
view.components = z === "components";
|
|
1292
1341
|
view.logical = z === "logical";
|
|
1293
1342
|
view.ops = z === "ops";
|
|
1343
|
+
view.stacks = z === "stacks";
|
|
1294
1344
|
view.runtime = z === "runtime";
|
|
1295
|
-
if (z !== "components" && z !== "logical" && z !== "ops") view.detail = ZOOM_DETAIL[z] ?? 2;
|
|
1345
|
+
if (z !== "components" && z !== "logical" && z !== "ops" && z !== "stacks") view.detail = ZOOM_DETAIL[z] ?? 2;
|
|
1296
1346
|
}
|
|
1297
1347
|
|
|
1298
1348
|
// --- Floating control panel content ---------------------------------------
|
|
@@ -1539,12 +1589,21 @@ function renderCarveState(host, state) {
|
|
|
1539
1589
|
*/
|
|
1540
1590
|
function copyableRow(add, key, text, title) {
|
|
1541
1591
|
const wrap = document.createElement("span");
|
|
1542
|
-
|
|
1592
|
+
// `center`, not `baseline`: an `overflow: hidden` flex item takes its bottom
|
|
1593
|
+
// margin edge as its baseline, so the clipped line below would have dragged
|
|
1594
|
+
// the copy button down with it.
|
|
1595
|
+
wrap.style.cssText = "display:flex;gap:6px;align-items:center;min-width:0";
|
|
1543
1596
|
const line = document.createElement("code");
|
|
1544
1597
|
line.className = "grow";
|
|
1545
|
-
line.
|
|
1598
|
+
// #396 item 7c: ONE line, clipped. `tofu-estate=… tofu-address=…` wrapped
|
|
1599
|
+
// onto three lines in the 260px pane and pushed everything the card had to
|
|
1600
|
+
// say off the bottom — for a string nobody reads off the screen anyway,
|
|
1601
|
+
// because the copy button beside it is how it gets used. The whole of it is
|
|
1602
|
+
// on this row's tooltip, on the button's, and in the clipboard.
|
|
1603
|
+
line.style.cssText = "flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis";
|
|
1546
1604
|
line.textContent = text;
|
|
1547
|
-
|
|
1605
|
+
line.title = text;
|
|
1606
|
+
const copy = actButton("copy", () => copyToClipboard(text, copy), title ? `${title}:\n${text}` : text);
|
|
1548
1607
|
wrap.append(line, copy);
|
|
1549
1608
|
add(key, wrap);
|
|
1550
1609
|
}
|
|
@@ -2073,6 +2132,23 @@ function renderPanelModel() {
|
|
|
2073
2132
|
// bound and is already counted on the row above. The row appears only when
|
|
2074
2133
|
// a plan was actually read; where it was not, the legend says so in words
|
|
2075
2134
|
// and prints no number, because "0 drifted" would be a claim nobody made.
|
|
2135
|
+
// #429: a card with NO `_status` at all. pinhole's painter falls back to
|
|
2136
|
+
// `neutral` for an unrecognised value, `undefined` included, so a member
|
|
2137
|
+
// with no live half — a Terraform root, whose `live`/`overlay`/`env` are
|
|
2138
|
+
// stripped in src/terraform-member.ts — paints exactly like a card behold
|
|
2139
|
+
// looked at and could not see. It is in no legend bucket either, so the
|
|
2140
|
+
// colour claimed 37 unobserved cards on an estate whose legend counted 2.
|
|
2141
|
+
//
|
|
2142
|
+
// Named rather than folded into `unobserved`, because they are different
|
|
2143
|
+
// claims: "I looked and could not see" against "there is nothing here to
|
|
2144
|
+
// look at". Same argument #399 makes for an unpriced card, and the same
|
|
2145
|
+
// answer — mark it, do not merely colour it.
|
|
2146
|
+
const noLive = ir.nodes.filter((n) => !(n.attrs && n.attrs._status)).length;
|
|
2147
|
+
if (noLive) {
|
|
2148
|
+
const row = panelDotRow("var(--muted)", "no live half", String(noLive));
|
|
2149
|
+
row.title = "These members declare no live half, so nothing observed them — which is not the same as unobserved, and not the same as absent.";
|
|
2150
|
+
host.appendChild(row);
|
|
2151
|
+
}
|
|
2076
2152
|
const dm = driftMeta();
|
|
2077
2153
|
if (dm && dm.read) {
|
|
2078
2154
|
host.appendChild(panelDotRow("var(--degraded)", "drifted (the plan would change it)", String(dm.drifted)));
|
|
@@ -2462,6 +2538,36 @@ function markOperatorCards(ir) {
|
|
|
2462
2538
|
* estates this can appear on the corner is free; the guard below still skips a
|
|
2463
2539
|
* card that already carries one of those, rather than drawing over it.
|
|
2464
2540
|
*/
|
|
2541
|
+
/**
|
|
2542
|
+
* The cards whose member is still being read (#422).
|
|
2543
|
+
*
|
|
2544
|
+
* The same post-render stamp `markCarvedCards`/`markDriftedCards` use, and for
|
|
2545
|
+
* the same reason: pinhole's SVG arrives as a string and is replaced on every
|
|
2546
|
+
* render, so a mark is re-applied here rather than asked for at layout time.
|
|
2547
|
+
*
|
|
2548
|
+
* A dashed outline and a muted corner rather than a fill, because the fill is
|
|
2549
|
+
* the status channel and this card HAS no status yet — painting one would be
|
|
2550
|
+
* the claim the whole milestone exists to avoid. `_pendingRead` rides its own
|
|
2551
|
+
* attr for the same reason: a fifth `_status` value falls back to neutral in
|
|
2552
|
+
* pinhole's painter, which reads as "unobserved", which is a different and
|
|
2553
|
+
* wrong thing.
|
|
2554
|
+
*/
|
|
2555
|
+
function markPendingCards(ir) {
|
|
2556
|
+
const svg = document.querySelector("#graph svg");
|
|
2557
|
+
if (!svg) return;
|
|
2558
|
+
for (const n of (ir && ir.nodes) || []) {
|
|
2559
|
+
const g = svg.querySelector('[data-node-id="' + CSS.escape(n.id) + '"]');
|
|
2560
|
+
if (!g) continue;
|
|
2561
|
+
const pending = !!(n.attrs && n.attrs._pendingRead);
|
|
2562
|
+
g.toggleAttribute("data-pending-read", pending);
|
|
2563
|
+
if (pending && !g.querySelector("title")) {
|
|
2564
|
+
const t = document.createElementNS("http://www.w3.org/2000/svg", "title");
|
|
2565
|
+
t.textContent = "still reading this member — not yet observed";
|
|
2566
|
+
g.appendChild(t);
|
|
2567
|
+
}
|
|
2568
|
+
}
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2465
2571
|
function markDriftedCards(ir) {
|
|
2466
2572
|
const svg = document.querySelector("#graph svg");
|
|
2467
2573
|
if (!svg) return;
|
|
@@ -2559,6 +2665,11 @@ function renderStatusbar() {
|
|
|
2559
2665
|
if (view.stack) parts.push(`stack: ${view.stack}`);
|
|
2560
2666
|
if (axes.tier) parts.push(`tier: ${axes.tier}`);
|
|
2561
2667
|
if (view.radial && !view.components && !view.logical && !view.ops) parts.push("radial");
|
|
2668
|
+
// #422: what the picture is still waiting on, while it is waiting. Ahead of
|
|
2669
|
+
// the zoom note, because a half-read estate is the more urgent fact about
|
|
2670
|
+
// what is on screen.
|
|
2671
|
+
const stillReading = pendingLine(pendingMembers, pendingTotal);
|
|
2672
|
+
if (stillReading) parts.push(stillReading);
|
|
2562
2673
|
el.textContent = parts.join(" · ");
|
|
2563
2674
|
// #131: why this level rendered empty, or as the one below it. The server
|
|
2564
2675
|
// decides (src/zoom-notes.ts) — the SPA never infers it, so the note always
|
|
@@ -3388,6 +3499,7 @@ function render(ir, svg, m) {
|
|
|
3388
3499
|
markPlayhead(ir); // #284 item 2: same, for the step the run is sitting on
|
|
3389
3500
|
markOperatorCards(ir); // #234 free rider: same, for the operating loop's home
|
|
3390
3501
|
markDriftedCards(ir); // #404: same, for a bound card the plan would change — after the carve/operator stamps, so it can see and yield to them
|
|
3502
|
+
markPendingCards(ir); // #422: and the cards whose member has not answered yet
|
|
3391
3503
|
applyLayout(); // #228: last, so the hand-placed deltas ride on top of every other pass
|
|
3392
3504
|
renderDial();
|
|
3393
3505
|
}
|
|
@@ -3483,7 +3595,13 @@ function setupGraphViewBox(svg) {
|
|
|
3483
3595
|
const a = (svg.getAttribute("viewBox") || "").split(/\s+/).map(Number);
|
|
3484
3596
|
if (a.length === 4 && a.every((n) => !Number.isNaN(n))) {
|
|
3485
3597
|
vbInit = a.slice();
|
|
3486
|
-
|
|
3598
|
+
// #396 item 2: land on the fit that keeps the panel's footprint clear, not
|
|
3599
|
+
// on the raw box — a re-render (a zoom change, an env change, the SSE
|
|
3600
|
+
// re-pull) goes through here and never through fitGraph, so the first
|
|
3601
|
+
// column would slide back under the panel at every one of them.
|
|
3602
|
+
vb = fittedViewBox() || a.slice();
|
|
3603
|
+
vbAtFit = true;
|
|
3604
|
+
applyVB();
|
|
3487
3605
|
} else {
|
|
3488
3606
|
vbInit = vb = null;
|
|
3489
3607
|
}
|
|
@@ -3494,11 +3612,178 @@ function currentSvg() {
|
|
|
3494
3612
|
function applyVB() {
|
|
3495
3613
|
const s = currentSvg();
|
|
3496
3614
|
if (s && vb) s.setAttribute("viewBox", vb.join(" "));
|
|
3615
|
+
scaleBoxLabels(); // #396 item 7b: box titles/badges keep a legible size at every zoom
|
|
3616
|
+
}
|
|
3617
|
+
|
|
3618
|
+
// #396 item 2: the panel is `position: fixed` and floats OVER the graph pane
|
|
3619
|
+
// (index.html), so "fit the pane" put the leftmost column of cards underneath
|
|
3620
|
+
// it — a whole column of the terralith, half waterpark's `github` box — at
|
|
3621
|
+
// boot and after every re-render. The panel is furniture the reader cannot see
|
|
3622
|
+
// through, so the fit's target is the pane MINUS whatever the panel is
|
|
3623
|
+
// currently covering.
|
|
3624
|
+
//
|
|
3625
|
+
// Read at fit time, never remembered: the panel drags, docks to either side and
|
|
3626
|
+
// collapses to its tab bar, and a fit computed against where it used to be is
|
|
3627
|
+
// the same bug with a different offset. Collapsed or hidden → no footprint, and
|
|
3628
|
+
// the fit is the whole pane exactly as before.
|
|
3629
|
+
//
|
|
3630
|
+
// Horizontal only. The panel is a tall palette that docks left or right; a
|
|
3631
|
+
// top/bottom dock still leaves the full width, and insetting vertically for it
|
|
3632
|
+
// would shrink the graph for a strip the cards mostly clear anyway.
|
|
3633
|
+
const FIT_PANEL_MAX = 0.45; // the panel can never claim more than this of the pane
|
|
3634
|
+
|
|
3635
|
+
function panelInsets(pane) {
|
|
3636
|
+
const none = { left: 0, right: 0 };
|
|
3637
|
+
const p = document.getElementById("panel");
|
|
3638
|
+
if (!p || p.classList.contains("collapsed")) return none;
|
|
3639
|
+
const cs = getComputedStyle(p);
|
|
3640
|
+
if (cs.display === "none" || cs.visibility === "hidden" || Number(cs.opacity) === 0) return none;
|
|
3641
|
+
const r = p.getBoundingClientRect();
|
|
3642
|
+
if (!r.width || !r.height) return none;
|
|
3643
|
+
// Nothing to inset for a panel that has been dragged clear of the graph pane.
|
|
3644
|
+
if (r.bottom <= pane.top || r.top >= pane.bottom || r.right <= pane.left || r.left >= pane.right) return none;
|
|
3645
|
+
const cap = pane.width * FIT_PANEL_MAX;
|
|
3646
|
+
// Whichever side it is nearer to is the side it eats.
|
|
3647
|
+
return (r.left + r.right) / 2 < (pane.left + pane.right) / 2
|
|
3648
|
+
? { left: Math.min(cap, Math.max(0, r.right - pane.left)), right: 0 }
|
|
3649
|
+
: { left: 0, right: Math.min(cap, Math.max(0, pane.right - r.left)) };
|
|
3650
|
+
}
|
|
3651
|
+
|
|
3652
|
+
/**
|
|
3653
|
+
* The viewBox that fits the whole graph into the pane's UNCOVERED part.
|
|
3654
|
+
*
|
|
3655
|
+
* The returned box is given the pane's own aspect ratio, so `xMidYMid meet`
|
|
3656
|
+
* letterboxes nothing and the pane's pixels map to viewBox units at one known
|
|
3657
|
+
* scale: the graph is then placed by arithmetic — centred inside the band
|
|
3658
|
+
* between the insets, centred vertically — instead of wherever `meet` would
|
|
3659
|
+
* have dropped it. With no insets the result is pixel-identical to the old
|
|
3660
|
+
* `vb = vbInit`, which is why nothing without a panel over it moves.
|
|
3661
|
+
*/
|
|
3662
|
+
function fittedViewBox() {
|
|
3663
|
+
if (!vbInit) return null;
|
|
3664
|
+
const host = document.getElementById("graph");
|
|
3665
|
+
if (!host) return null;
|
|
3666
|
+
const pane = host.getBoundingClientRect();
|
|
3667
|
+
const [x0, y0, w0, h0] = vbInit;
|
|
3668
|
+
if (!(pane.width > 0 && pane.height > 0 && w0 > 0 && h0 > 0)) return null;
|
|
3669
|
+
const { left, right } = panelInsets(pane);
|
|
3670
|
+
const band = Math.max(1, pane.width - left - right);
|
|
3671
|
+
const s = Math.min(band / w0, pane.height / h0);
|
|
3672
|
+
const vw = pane.width / s;
|
|
3673
|
+
const vh = pane.height / s;
|
|
3674
|
+
return [x0 - left / s - (band / s - w0) / 2, y0 - (vh - h0) / 2, vw, vh];
|
|
3497
3675
|
}
|
|
3676
|
+
|
|
3498
3677
|
function fitGraph() {
|
|
3499
|
-
if (vbInit)
|
|
3500
|
-
|
|
3501
|
-
|
|
3678
|
+
if (!vbInit) return;
|
|
3679
|
+
vb = fittedViewBox() || vbInit.slice();
|
|
3680
|
+
vbAtFit = true;
|
|
3681
|
+
applyVB();
|
|
3682
|
+
}
|
|
3683
|
+
|
|
3684
|
+
// #396 item 2: the fit now depends on the pane — its size, and what the panel
|
|
3685
|
+
// covers of it — where `vbInit` alone depended on neither, so a graph sitting
|
|
3686
|
+
// at fit has to be re-fitted when the pane moves under it. It moves more often
|
|
3687
|
+
// than a window resize: the statusbar's note wraps onto a second line, the
|
|
3688
|
+
// inspect pane is dragged wider, a panel tab's content grows. Only while the
|
|
3689
|
+
// view IS the fit: a pan or a zoom takes ownership of the viewBox, and nothing
|
|
3690
|
+
// here may pull it back out from under the reader's hands.
|
|
3691
|
+
let vbAtFit = false;
|
|
3692
|
+
function watchPaneForFit(host) {
|
|
3693
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
3694
|
+
new ResizeObserver(() => {
|
|
3695
|
+
if (vbAtFit) fitGraph();
|
|
3696
|
+
}).observe(host);
|
|
3697
|
+
}
|
|
3698
|
+
|
|
3699
|
+
// --- #396 item 7b: box titles and badges that survive the fit ---------------
|
|
3700
|
+
//
|
|
3701
|
+
// pinhole draws a group box's title at a flat `font-size="12"` and its badge at
|
|
3702
|
+
// `11`, in viewBox UNITS. That reads as 12px on a graph whose fit is 1:1 and as
|
|
3703
|
+
// 7px on terralith-4, whose box is 8000 units wide — the one label that says
|
|
3704
|
+
// which member 301 cards belong to, and the one that counts them, are the two
|
|
3705
|
+
// things on the canvas a person cannot read at the zoom they arrive at.
|
|
3706
|
+
//
|
|
3707
|
+
// So the label is given a floor in SCREEN pixels: its size in units is raised
|
|
3708
|
+
// by the inverse of the current scale until it renders at its natural size, and
|
|
3709
|
+
// never below it, so anything already legible is untouched (zoomed IN, the
|
|
3710
|
+
// attribute is left at 12 and grows with the graph, exactly as before). The
|
|
3711
|
+
// anchor never moves — x, y and `text-anchor` are pinhole's — so a title stays
|
|
3712
|
+
// welded to its box's top-left corner and a badge to its top-right through
|
|
3713
|
+
// every pan and zoom; only the type gets bigger.
|
|
3714
|
+
//
|
|
3715
|
+
// The floor yields to the box: a label may not grow past a fraction of the
|
|
3716
|
+
// box's height, nor past its width. A sub-box too small to hold readable type
|
|
3717
|
+
// at this zoom says so by staying small, rather than by writing across its
|
|
3718
|
+
// neighbours.
|
|
3719
|
+
const BOX_LABEL_GUTTER = 34; // units below a box's top edge that the title row occupies
|
|
3720
|
+
const BOX_LABEL_MAX_H = 8; // a label may be at most box height / this
|
|
3721
|
+
const BOX_LABEL_EM = 0.62; // rough advance width per char, as a fraction of the size
|
|
3722
|
+
|
|
3723
|
+
let boxLabelCache = { svg: null, labels: [] };
|
|
3724
|
+
let boxLabelScale = null;
|
|
3725
|
+
|
|
3726
|
+
/** The `<text>` elements pinhole put in each group box's title gutter, with the
|
|
3727
|
+
* box they belong to and the size it drew them at. Matched by geometry rather
|
|
3728
|
+
* than by sibling order: a box's optional identity mark sits between the title
|
|
3729
|
+
* and the badge, and #228's layout pass lifts the rect and its title into a
|
|
3730
|
+
* wrapper `<g>` after the render, so neither order nor a common parent holds.
|
|
3731
|
+
* What geometry alone would let through — a card's own text, an edge chip's —
|
|
3732
|
+
* is excluded by the ancestor it sits under. */
|
|
3733
|
+
function boxLabelsOf(svg) {
|
|
3734
|
+
if (boxLabelCache.svg === svg) return boxLabelCache.labels;
|
|
3735
|
+
const labels = [];
|
|
3736
|
+
const boxes = [];
|
|
3737
|
+
for (const rect of svg.querySelectorAll("rect[data-group-id]")) {
|
|
3738
|
+
const b = {
|
|
3739
|
+
x: parseFloat(rect.getAttribute("x")),
|
|
3740
|
+
y: parseFloat(rect.getAttribute("y")),
|
|
3741
|
+
w: parseFloat(rect.getAttribute("width")),
|
|
3742
|
+
h: parseFloat(rect.getAttribute("height")),
|
|
3743
|
+
};
|
|
3744
|
+
if ([b.x, b.y, b.w, b.h].every((n) => Number.isFinite(n))) boxes.push(b);
|
|
3745
|
+
}
|
|
3746
|
+
if (boxes.length) {
|
|
3747
|
+
for (const t of svg.querySelectorAll("text")) {
|
|
3748
|
+
const x = parseFloat(t.getAttribute("x"));
|
|
3749
|
+
const y = parseFloat(t.getAttribute("y"));
|
|
3750
|
+
// pinhole's own size, stashed the first time so a re-index can never
|
|
3751
|
+
// read a size this pass already raised as if it were the natural one.
|
|
3752
|
+
const base = parseFloat(t.dataset.pinFont ?? t.getAttribute("font-size"));
|
|
3753
|
+
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(base)) continue;
|
|
3754
|
+
// Not a card's own text (inside its `g[data-node-id]`), not an edge
|
|
3755
|
+
// chip's (inside the edge's `g`), not a radial wedge label behold adds
|
|
3756
|
+
// itself. What is left at a box's title row IS the box's title row.
|
|
3757
|
+
if (t.closest("[data-node-id], [data-edge-from], [data-edge-to], #radial-labels")) continue;
|
|
3758
|
+
const box = boxes.find((b) => y > b.y && y <= b.y + BOX_LABEL_GUTTER && x >= b.x - 2 && x <= b.x + b.w + 2);
|
|
3759
|
+
if (!box) continue;
|
|
3760
|
+
t.dataset.pinFont = String(base);
|
|
3761
|
+
labels.push({ el: t, box, base });
|
|
3762
|
+
}
|
|
3763
|
+
}
|
|
3764
|
+
boxLabelCache = { svg, labels };
|
|
3765
|
+
boxLabelScale = null;
|
|
3766
|
+
return labels;
|
|
3767
|
+
}
|
|
3768
|
+
|
|
3769
|
+
/** Re-size every box label for the current zoom. Cheap and idempotent: the
|
|
3770
|
+
* scale is remembered, so the pan handler's stream of `applyVB` calls (which
|
|
3771
|
+
* move the viewBox without changing its width) touches no attribute at all. */
|
|
3772
|
+
function scaleBoxLabels() {
|
|
3773
|
+
const svg = currentSvg();
|
|
3774
|
+
if (!svg || !vb) return;
|
|
3775
|
+
const labels = boxLabelsOf(svg);
|
|
3776
|
+
if (!labels.length) return;
|
|
3777
|
+
const pane = svg.getBoundingClientRect();
|
|
3778
|
+
if (!(pane.width > 0 && pane.height > 0)) return;
|
|
3779
|
+
const scale = Math.min(pane.width / vb[2], pane.height / vb[3]);
|
|
3780
|
+
if (!(scale > 0) || scale === boxLabelScale) return;
|
|
3781
|
+
boxLabelScale = scale;
|
|
3782
|
+
for (const l of labels) {
|
|
3783
|
+
const chars = Math.max(4, (l.el.textContent || "").length);
|
|
3784
|
+
const cap = Math.max(l.base, Math.min(l.box.h / BOX_LABEL_MAX_H, l.box.w / (chars * BOX_LABEL_EM)));
|
|
3785
|
+
const size = Math.max(l.base, Math.min(l.base / scale, cap));
|
|
3786
|
+
l.el.setAttribute("font-size", String(Math.round(size * 10) / 10));
|
|
3502
3787
|
}
|
|
3503
3788
|
}
|
|
3504
3789
|
|
|
@@ -3537,6 +3822,7 @@ function revealNode(id) {
|
|
|
3537
3822
|
const w = Math.min(vbInit[2], Math.max(box.width * REVEAL_CARDS_ACROSS, vbInit[2] / 60));
|
|
3538
3823
|
const h = w * (vbInit[3] / vbInit[2]);
|
|
3539
3824
|
vb = [box.x + box.width / 2 - w / 2, box.y + box.height / 2 - h / 2, w, h];
|
|
3825
|
+
vbAtFit = false;
|
|
3540
3826
|
applyVB();
|
|
3541
3827
|
return true;
|
|
3542
3828
|
}
|
|
@@ -3555,6 +3841,7 @@ function ensureZoomControls(host) {
|
|
|
3555
3841
|
}
|
|
3556
3842
|
if (zoomWired) return;
|
|
3557
3843
|
zoomWired = true;
|
|
3844
|
+
watchPaneForFit(host);
|
|
3558
3845
|
host.addEventListener(
|
|
3559
3846
|
"wheel",
|
|
3560
3847
|
(e) => {
|
|
@@ -3576,6 +3863,7 @@ function ensureZoomControls(host) {
|
|
|
3576
3863
|
vb[1] = cy - ((cy - vb[1]) * nh) / vb[3];
|
|
3577
3864
|
vb[2] = nw;
|
|
3578
3865
|
vb[3] = nh;
|
|
3866
|
+
vbAtFit = false;
|
|
3579
3867
|
applyVB();
|
|
3580
3868
|
},
|
|
3581
3869
|
{ passive: false },
|
|
@@ -3608,6 +3896,7 @@ function ensureZoomControls(host) {
|
|
|
3608
3896
|
}
|
|
3609
3897
|
vb[0] -= (dx / r.width) * vb[2];
|
|
3610
3898
|
vb[1] -= (dy / r.height) * vb[3];
|
|
3899
|
+
vbAtFit = false;
|
|
3611
3900
|
px = e.clientX;
|
|
3612
3901
|
py = e.clientY;
|
|
3613
3902
|
applyVB();
|
|
@@ -4211,7 +4500,8 @@ function renderPreconditionError(body) {
|
|
|
4211
4500
|
}
|
|
4212
4501
|
|
|
4213
4502
|
// Fetch the current view (source graph, or the picked env's live overlay).
|
|
4214
|
-
|
|
4503
|
+
const load = createRefreshQueue(loadOnce);
|
|
4504
|
+
async function loadOnce(opts = {}, isCurrent = () => true) {
|
|
4215
4505
|
const meta = document.getElementById("meta");
|
|
4216
4506
|
// A background settle re-pull (post-apply) shouldn't flash the meta/overlay.
|
|
4217
4507
|
if (!opts.quiet) {
|
|
@@ -4230,6 +4520,14 @@ async function load(opts = {}) {
|
|
|
4230
4520
|
// cross-substrate entity overlay, which components never use.
|
|
4231
4521
|
q.set("components", "1");
|
|
4232
4522
|
if (view.env) q.set("env", view.env);
|
|
4523
|
+
} else if (view.stacks) {
|
|
4524
|
+
// The apply-order lens (#426): chant's cross-stack ordering, built fresh
|
|
4525
|
+
// for the same reason the ops lens is — it reads none of the entity
|
|
4526
|
+
// knobs. No env (it is computed at synthesis, with no substrate), no
|
|
4527
|
+
// detail, no radial. Tier rides along because a project's source can
|
|
4528
|
+
// branch on one, which can change which lexicons it partitions into.
|
|
4529
|
+
q = new URLSearchParams({ stacks: "1" });
|
|
4530
|
+
if (view.tier) q.set("tier", view.tier);
|
|
4233
4531
|
} else if (view.ops) {
|
|
4234
4532
|
// The ops lens (#284): the declared Ops, read from each Op's emitted
|
|
4235
4533
|
// op.json. Built fresh rather than layered onto the entity lenses,
|
|
@@ -4263,6 +4561,11 @@ async function load(opts = {}) {
|
|
|
4263
4561
|
} else if (view.env) {
|
|
4264
4562
|
endpoint = "/api/overlay";
|
|
4265
4563
|
q.set("env", view.env);
|
|
4564
|
+
// #422: draw the estate from source at once and recolour each member as
|
|
4565
|
+
// its own live read lands, instead of a blank canvas until the slowest
|
|
4566
|
+
// one finishes. The server only honours it for a composed estate, and
|
|
4567
|
+
// never in a static snapshot, where there is no stream to follow.
|
|
4568
|
+
if (!staticMode) q.set("progressive", "1");
|
|
4266
4569
|
// The runtime tier (#86) descends below the declaration boundary. It is
|
|
4267
4570
|
// live-only by nature — owner-referenced children exist in the cluster,
|
|
4268
4571
|
// never in your source — so it rides the overlay and means nothing
|
|
@@ -4294,6 +4597,7 @@ async function load(opts = {}) {
|
|
|
4294
4597
|
}
|
|
4295
4598
|
const res = await apiFetch(`${endpoint}?${q}`);
|
|
4296
4599
|
const body = await res.json();
|
|
4600
|
+
if (!isCurrent()) return; // a newer view/event is pending; do not paint the old result
|
|
4297
4601
|
if (!res.ok) {
|
|
4298
4602
|
// #72: a classified precondition failure (lint gate, not installed, a
|
|
4299
4603
|
// tier that needs credentials) gets the calmer entry/error card instead
|
|
@@ -4314,13 +4618,19 @@ async function load(opts = {}) {
|
|
|
4314
4618
|
autoZoomFallback = false;
|
|
4315
4619
|
applyZoom("resources");
|
|
4316
4620
|
renderStatusbar();
|
|
4317
|
-
return
|
|
4621
|
+
return loadOnce(opts, isCurrent);
|
|
4318
4622
|
}
|
|
4319
4623
|
autoZoomFallback = false;
|
|
4624
|
+
// #422: a progressive answer names the members it is still reading, and
|
|
4625
|
+
// the `member` stream above clears them one at a time. Any other answer
|
|
4626
|
+
// resets it, so a switch away from a progressive load never leaves a stale
|
|
4627
|
+
// "still reading" claim on screen.
|
|
4628
|
+
pendingMembers = body.meta && body.meta.mode === "progressive" ? [...(body.meta.pending || [])] : [];
|
|
4629
|
+
pendingTotal = pendingMembers.length;
|
|
4320
4630
|
render(body.ir, body.svg, body.meta);
|
|
4321
4631
|
} catch (err) {
|
|
4322
4632
|
// A background settle poll must not blow away a good graph on a transient error.
|
|
4323
|
-
if (!opts.quiet) {
|
|
4633
|
+
if (!opts.quiet && isCurrent()) {
|
|
4324
4634
|
// Text, never innerHTML — `err.message` embeds chant's own stderr, which
|
|
4325
4635
|
// is not ours to interpolate as markup (the sibling precondition card has
|
|
4326
4636
|
// said so since #72; this branch had been left behind).
|
|
@@ -4434,6 +4744,7 @@ async function initPickers() {
|
|
|
4434
4744
|
// honestly open. Both are read before the first load() below, so a non-chant
|
|
4435
4745
|
// estate never paints the components apology on its way to a picture.
|
|
4436
4746
|
runtimeCapable = !!info.runtimeCapable;
|
|
4747
|
+
stacksCapable = !!info.stacksCapable;
|
|
4437
4748
|
const boot = bootZoom(info.memberKinds);
|
|
4438
4749
|
if (boot !== "components") {
|
|
4439
4750
|
applyZoom(boot);
|
|
@@ -4460,20 +4771,39 @@ initPickers();
|
|
|
4460
4771
|
// No backend in a static export → no live event stream; a no-op keeps the
|
|
4461
4772
|
// `events.addEventListener(...)` wiring below harmless.
|
|
4462
4773
|
const events = staticMode ? { addEventListener() {} } : new EventSource("/api/events");
|
|
4463
|
-
//
|
|
4464
|
-
//
|
|
4465
|
-
//
|
|
4466
|
-
//
|
|
4467
|
-
|
|
4468
|
-
|
|
4469
|
-
|
|
4470
|
-
|
|
4471
|
-
|
|
4774
|
+
// One notification requests one refresh. Further notifications during a slow
|
|
4775
|
+
// read collapse to one follow-up; no wall-clock timers multiply estate scans.
|
|
4776
|
+
// #422: one frame per member as its live read settles. Payload-carrying, like
|
|
4777
|
+
// `apply` and `run` — the node set is already on screen, so this is id ->
|
|
4778
|
+
// status and the client repaints in place. Nothing is re-fetched: a refetch
|
|
4779
|
+
// here would re-run the whole estate read this stream exists to narrate.
|
|
4780
|
+
events.addEventListener("member", (e) => {
|
|
4781
|
+
let frame;
|
|
4782
|
+
try {
|
|
4783
|
+
frame = JSON.parse(e.data);
|
|
4784
|
+
} catch {
|
|
4785
|
+
return;
|
|
4786
|
+
}
|
|
4787
|
+
// The live pass itself failed. Say so on the now-line and stop claiming the
|
|
4788
|
+
// remaining members are still arriving, because nothing more is coming.
|
|
4789
|
+
if (frame.error) {
|
|
4790
|
+
pendingMembers = [];
|
|
4791
|
+
nowline(`the estate read failed — ${frame.error}`);
|
|
4792
|
+
renderStatusbar();
|
|
4793
|
+
return;
|
|
4794
|
+
}
|
|
4795
|
+
if (!lastGraphIr) return;
|
|
4796
|
+
const moved = applyMemberFrame(lastGraphIr, frame);
|
|
4797
|
+
pendingMembers = stillPending(pendingMembers, frame);
|
|
4798
|
+
if (moved.length) recolorNodesByCategory(lastGraphIr);
|
|
4799
|
+
markPendingCards(lastGraphIr);
|
|
4800
|
+
renderStatusbar();
|
|
4801
|
+
});
|
|
4802
|
+
|
|
4472
4803
|
events.addEventListener("changed", () => {
|
|
4473
4804
|
bulkDiffCache = null; // an op ran → per-node live state may have changed
|
|
4474
4805
|
load();
|
|
4475
4806
|
loadSubstrates(); // a bring-up (or any op) finished → re-detect readiness
|
|
4476
|
-
scheduleSettle();
|
|
4477
4807
|
});
|
|
4478
4808
|
|
|
4479
4809
|
// Substrate readiness (M5, #54): is each substrate the project needs actually
|
|
@@ -4610,11 +4940,19 @@ setInterval(() => {
|
|
|
4610
4940
|
// can't fire a second pull while one's in flight. Ref-counted — nested loads
|
|
4611
4941
|
// (graph + CI + resources) only lift the scrim when the last finishes.
|
|
4612
4942
|
let loadingDepth = 0;
|
|
4943
|
+
// #421: what the LAST read cost, shown while this one runs. The person watching
|
|
4944
|
+
// the scrim wants to know whether to wait or go away, and behold has never told
|
|
4945
|
+
// them. Fetched after a load settles rather than during one — asking the server
|
|
4946
|
+
// how slow it is while it is being slow helps nobody — so the line always
|
|
4947
|
+
// describes the previous read and never guesses at this one.
|
|
4948
|
+
let lastReadCost = null;
|
|
4613
4949
|
function showLoading(msg) {
|
|
4614
4950
|
loadingDepth++;
|
|
4615
4951
|
const o = document.getElementById("loading-overlay");
|
|
4616
4952
|
if (!o) return;
|
|
4617
4953
|
document.getElementById("loading-msg").textContent = msg || "loading…";
|
|
4954
|
+
const cost = document.getElementById("loading-cost");
|
|
4955
|
+
if (cost) cost.textContent = lastReadCost || "";
|
|
4618
4956
|
o.hidden = false;
|
|
4619
4957
|
}
|
|
4620
4958
|
function hideLoading() {
|
|
@@ -4622,6 +4960,27 @@ function hideLoading() {
|
|
|
4622
4960
|
if (loadingDepth > 0) return;
|
|
4623
4961
|
const o = document.getElementById("loading-overlay");
|
|
4624
4962
|
if (o) o.hidden = true;
|
|
4963
|
+
void refreshReadCost();
|
|
4964
|
+
}
|
|
4965
|
+
|
|
4966
|
+
// One cheap call on counters, never on the read path. `?reads=1` is what makes
|
|
4967
|
+
// that true: the bare route also runs the full doctor diagnosis, whose substrate
|
|
4968
|
+
// probes spawn docker/gh/helm/k3d with no timeout, and this runs after every
|
|
4969
|
+
// load settles. A cost line is never worth breaking a load over, so every
|
|
4970
|
+
// failure here leaves the previous line standing.
|
|
4971
|
+
//
|
|
4972
|
+
// A static export has no backend to ask and no reads to report, so it does not
|
|
4973
|
+
// ask. Without this guard the bundle logs a 404 on every page load and relies on
|
|
4974
|
+
// the fetch failing to stay silent, which is an outcome rather than a rule.
|
|
4975
|
+
async function refreshReadCost() {
|
|
4976
|
+
if (staticMode) return;
|
|
4977
|
+
try {
|
|
4978
|
+
const res = await fetch("/api/doctor?reads=1");
|
|
4979
|
+
if (!res.ok) return;
|
|
4980
|
+
lastReadCost = readCostLine((await res.json()).reads);
|
|
4981
|
+
} catch {
|
|
4982
|
+
/* no line rather than a wrong one */
|
|
4983
|
+
}
|
|
4625
4984
|
}
|
|
4626
4985
|
|
|
4627
4986
|
// #259: the op stream is chant's own stdout, and an Op that reports as JSON
|