@intentius/behold 0.29.13 → 0.29.15

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/dist/cli.js CHANGED
@@ -2465,6 +2465,9 @@ function discoverTerragruntUnits(repo) {
2465
2465
  };
2466
2466
  }).sort((a, b) => a.dir < b.dir ? -1 : 1);
2467
2467
  }
2468
+ function hasTerragruntUnits(dir) {
2469
+ return discoverTerragruntUnits(dir).length > 0;
2470
+ }
2468
2471
  function terragruntPicture(repo, units, decl) {
2469
2472
  const onDisk = new Set(units.map((u) => u.dir));
2470
2473
  const declaredOnly = [];
@@ -11199,7 +11202,7 @@ var init_declaration = __esm({
11199
11202
 
11200
11203
  // src/cli.ts
11201
11204
  import { resolve as resolve35, dirname as dirname21, join as join53 } from "node:path";
11202
- import { realpathSync as realpathSync3, existsSync as existsSync32, readFileSync as readFileSync40, rmSync as rmSync4 } from "node:fs";
11205
+ import { realpathSync as realpathSync3, existsSync as existsSync32, readFileSync as readFileSync41, rmSync as rmSync4 } from "node:fs";
11203
11206
  import { fileURLToPath as fileURLToPath12 } from "node:url";
11204
11207
  import { spawn as spawn9, spawnSync as spawnSync4 } from "node:child_process";
11205
11208
 
@@ -11257,7 +11260,7 @@ import { serve } from "@hono/node-server";
11257
11260
  import { fileURLToPath as fileURLToPath10 } from "node:url";
11258
11261
  import { execFile as execFile5 } from "node:child_process";
11259
11262
  import { promisify as promisify3 } from "node:util";
11260
- import { existsSync as existsSync29, readFileSync as readFileSync36 } from "node:fs";
11263
+ import { existsSync as existsSync29, readFileSync as readFileSync37 } from "node:fs";
11261
11264
  import { basename as basename5, dirname as dirname18, join as join49, relative as relative11, resolve as resolve32, sep as sep10 } from "node:path";
11262
11265
 
11263
11266
  // src/recents.ts
@@ -15148,6 +15151,7 @@ function resourcesByRoot(ir, rootOf2) {
15148
15151
 
15149
15152
  // src/server.ts
15150
15153
  init_terragucci_components();
15154
+ init_terragrunt_units();
15151
15155
 
15152
15156
  // src/reconcile.ts
15153
15157
  var DISRUPTION_SEVERITY = {
@@ -24944,7 +24948,7 @@ function terragucciRoutes(app, opts, dirs) {
24944
24948
 
24945
24949
  // src/view-repos.ts
24946
24950
  import { AsyncLocalStorage as AsyncLocalStorage2 } from "node:async_hooks";
24947
- import { existsSync as existsSync23, readdirSync as readdirSync13 } from "node:fs";
24951
+ import { existsSync as existsSync23, readdirSync as readdirSync13, readFileSync as readFileSync30 } from "node:fs";
24948
24952
  import { join as join42, resolve as resolve26 } from "node:path";
24949
24953
  var NAME2 = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
24950
24954
  function repoName(value) {
@@ -24997,10 +25001,37 @@ function viewReposMiddleware(app, repos) {
24997
25001
  return scope.run(target, () => next());
24998
25002
  });
24999
25003
  }
25004
+ var SHA = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
25005
+ function checkoutCommit(dir) {
25006
+ const read = (f) => {
25007
+ try {
25008
+ return readFileSync30(join42(dir, ".git", f), "utf8");
25009
+ } catch {
25010
+ return void 0;
25011
+ }
25012
+ };
25013
+ const head = read("HEAD")?.trim();
25014
+ if (!head) return void 0;
25015
+ if (SHA.test(head)) return head;
25016
+ const ref2 = /^ref: (refs\/[^\s]+)$/.exec(head)?.[1];
25017
+ if (!ref2 || ref2.split("/").includes("..")) return void 0;
25018
+ const loose = read(ref2)?.trim();
25019
+ if (loose && SHA.test(loose)) return loose;
25020
+ for (const line of (read("packed-refs") ?? "").split("\n")) {
25021
+ const [sha, name] = line.trim().split(" ");
25022
+ if (name === ref2 && sha && SHA.test(sha)) return sha;
25023
+ }
25024
+ return void 0;
25025
+ }
25000
25026
  function viewRepoFacts(repos, servedDir) {
25001
25027
  if (!repos) return {};
25002
25028
  const here = [...repos.repos].find(([, t2]) => t2.projectDir === servedDir)?.[0];
25003
- return { ...here ? { viewRepo: here } : {}, viewRepos: [...repos.repos.keys()] };
25029
+ const commits = {};
25030
+ for (const [name, t2] of repos.repos) {
25031
+ const sha = checkoutCommit(t2.projectDir);
25032
+ if (sha) commits[name] = sha;
25033
+ }
25034
+ return { ...here ? { viewRepo: here } : {}, viewRepos: [...repos.repos.keys()], ...Object.keys(commits).length ? { viewCommits: commits } : {} };
25004
25035
  }
25005
25036
 
25006
25037
  // node_modules/@intentius/chant/src/lifecycle/gate-audit.ts
@@ -25455,7 +25486,7 @@ function lifecycleHistoryRoute(app, base, opts = {}) {
25455
25486
  }
25456
25487
 
25457
25488
  // src/lifecycle-releases.ts
25458
- import { existsSync as existsSync24, readFileSync as readFileSync30 } from "node:fs";
25489
+ import { existsSync as existsSync24, readFileSync as readFileSync31 } from "node:fs";
25459
25490
  import { join as join43 } from "node:path";
25460
25491
 
25461
25492
  // node_modules/@intentius/chant/src/lifecycle/legacy-digest.ts
@@ -25572,7 +25603,7 @@ function waveOrder(checkout) {
25572
25603
  const p = join43(checkout, WAVES_SPEC_FILE);
25573
25604
  if (!existsSync24(p)) return void 0;
25574
25605
  try {
25575
- const doc = JSON.parse(readFileSync30(p, "utf8"));
25606
+ const doc = JSON.parse(readFileSync31(p, "utf8"));
25576
25607
  const names = (doc.waves ?? []).map((w) => w?.name).filter((n) => typeof n === "string" && n.length > 0);
25577
25608
  return names.length ? [...new Set(names)] : void 0;
25578
25609
  } catch {
@@ -25725,7 +25756,7 @@ function releasesRoutes(app, base, opts = {}) {
25725
25756
  }
25726
25757
 
25727
25758
  // src/sql-yodeler-lane.ts
25728
- import { existsSync as existsSync25, readdirSync as readdirSync14, readFileSync as readFileSync31 } from "node:fs";
25759
+ import { existsSync as existsSync25, readdirSync as readdirSync14, readFileSync as readFileSync32 } from "node:fs";
25729
25760
  import { join as join44 } from "node:path";
25730
25761
  var YODEL_APPLY_OP = "yodel-apply";
25731
25762
  var YODEL_HEAD_PLANS_DIR = headPlansPath(YODEL_APPLY_OP, "").dir;
@@ -25761,7 +25792,7 @@ function readYodelWaves(checkout) {
25761
25792
  const p = join44(checkout, WAVES_SPEC_FILE);
25762
25793
  if (!existsSync25(p)) return void 0;
25763
25794
  try {
25764
- return parseYodelWaves(readFileSync31(p, "utf8"));
25795
+ return parseYodelWaves(readFileSync32(p, "utf8"));
25765
25796
  } catch (e) {
25766
25797
  return { path: WAVES_SPEC_FILE, waves: [], error: e instanceof Error ? e.message : String(e) };
25767
25798
  }
@@ -25890,7 +25921,7 @@ function sqlYodelerLaneRoute(app, checkout, opts = {}) {
25890
25921
 
25891
25922
  // src/terragucci-estate.ts
25892
25923
  init_yaml();
25893
- import { existsSync as existsSync26, readFileSync as readFileSync32, statSync as statSync14 } from "node:fs";
25924
+ import { existsSync as existsSync26, readFileSync as readFileSync33, statSync as statSync14 } from "node:fs";
25894
25925
  import { createRequire as createRequire7 } from "node:module";
25895
25926
  import { dirname as dirname14, extname, join as join45, resolve as resolve28 } from "node:path";
25896
25927
  import { fileURLToPath as fileURLToPath8 } from "node:url";
@@ -25899,7 +25930,7 @@ var RUN_SCHEMA2 = "terragucci.run/v1";
25899
25930
  function configProjects(file) {
25900
25931
  let text2;
25901
25932
  try {
25902
- text2 = readFileSync32(file, "utf8");
25933
+ text2 = readFileSync33(file, "utf8");
25903
25934
  } catch {
25904
25935
  return void 0;
25905
25936
  }
@@ -25968,8 +25999,8 @@ function runViewValidator(dirs = []) {
25968
25999
  if (hit) return hit;
25969
26000
  let v;
25970
26001
  try {
25971
- const version = JSON.parse(readFileSync32(join45(dist, "..", "package.json"), "utf8")).version ?? "";
25972
- const validate = new Ajv20204({ strict: false, allErrors: false }).compile(JSON.parse(readFileSync32(schema, "utf8")));
26002
+ const version = JSON.parse(readFileSync33(join45(dist, "..", "package.json"), "utf8")).version ?? "";
26003
+ const validate = new Ajv20204({ strict: false, allErrors: false }).compile(JSON.parse(readFileSync33(schema, "utf8")));
25973
26004
  v = {
25974
26005
  validation: { by: "schema", terragucci: version },
25975
26006
  check(doc) {
@@ -26232,7 +26263,7 @@ function controlRepoInfo(dirs) {
26232
26263
  }
26233
26264
 
26234
26265
  // src/yodel-report.ts
26235
- import { readdirSync as readdirSync15, readFileSync as readFileSync33, statSync as statSync15 } from "node:fs";
26266
+ import { readdirSync as readdirSync15, readFileSync as readFileSync34, statSync as statSync15 } from "node:fs";
26236
26267
  import { createRequire as createRequire8 } from "node:module";
26237
26268
  import { basename as basename4, dirname as dirname15, join as join46, resolve as resolve29 } from "node:path";
26238
26269
  import { fileURLToPath as fileURLToPath9 } from "node:url";
@@ -26669,7 +26700,7 @@ function packageRoot(from) {
26669
26700
  }
26670
26701
  for (let d = dirname15(entry); d !== dirname15(d); d = dirname15(d)) {
26671
26702
  try {
26672
- const pkg = JSON.parse(readFileSync33(join46(d, "package.json"), "utf8"));
26703
+ const pkg = JSON.parse(readFileSync34(join46(d, "package.json"), "utf8"));
26673
26704
  if (pkg.name === "@intentius/sql-yodeler") return d;
26674
26705
  } catch {
26675
26706
  }
@@ -26681,11 +26712,11 @@ function yodelValidator(dirs = []) {
26681
26712
  const root = packageRoot(from);
26682
26713
  if (!root) continue;
26683
26714
  try {
26684
- const version = JSON.parse(readFileSync33(join46(root, "package.json"), "utf8")).version ?? "";
26715
+ const version = JSON.parse(readFileSync34(join46(root, "package.json"), "utf8")).version ?? "";
26685
26716
  const key = `${root}\0${version}`;
26686
26717
  let v = installed.get(key);
26687
26718
  if (!v) {
26688
- const load2 = (n) => JSON.parse(readFileSync33(join46(root, "schemas", `${n}.schema.json`), "utf8"));
26719
+ const load2 = (n) => JSON.parse(readFileSync34(join46(root, "schemas", `${n}.schema.json`), "utf8"));
26689
26720
  v = compile({ common: load2("common"), report: load2("report"), drift: load2("drift") }, { by: "schema", from: "package", sqlYodeler: version });
26690
26721
  installed.set(key, v);
26691
26722
  }
@@ -26719,7 +26750,7 @@ function readYodelReport(source2, validator2) {
26719
26750
  for (const file of files) {
26720
26751
  let doc;
26721
26752
  try {
26722
- doc = JSON.parse(readFileSync33(file, "utf8"));
26753
+ doc = JSON.parse(readFileSync34(file, "utf8"));
26723
26754
  } catch (e) {
26724
26755
  if (file === named) refuse14(`${basename4(file)} is not JSON: ${e instanceof Error ? e.message : String(e)}`, remedy);
26725
26756
  ignored.push(basename4(file));
@@ -26806,7 +26837,7 @@ function yodelReportRoute(app, source2, dirs) {
26806
26837
 
26807
26838
  // src/demo-pins.ts
26808
26839
  init_terraform_member();
26809
- import { existsSync as existsSync27, lstatSync as lstatSync2, mkdirSync as mkdirSync7, readFileSync as readFileSync34, readlinkSync as readlinkSync2, symlinkSync as symlinkSync2, writeFileSync as writeFileSync6 } from "node:fs";
26840
+ import { existsSync as existsSync27, lstatSync as lstatSync2, mkdirSync as mkdirSync7, readFileSync as readFileSync35, readlinkSync as readlinkSync2, symlinkSync as symlinkSync2, writeFileSync as writeFileSync6 } from "node:fs";
26810
26841
  import { dirname as dirname16, join as join47, resolve as resolve30 } from "node:path";
26811
26842
  var isRecord21 = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
26812
26843
  function linkDemoPins(work, probes = {}) {
@@ -26814,7 +26845,7 @@ function linkDemoPins(work, probes = {}) {
26814
26845
  if (!existsSync27(file)) return { ok: true, linked: [] };
26815
26846
  let decl;
26816
26847
  try {
26817
- decl = JSON.parse(readFileSync34(file, "utf8"));
26848
+ decl = JSON.parse(readFileSync35(file, "utf8"));
26818
26849
  } catch {
26819
26850
  return { ok: true, linked: [] };
26820
26851
  }
@@ -26872,13 +26903,13 @@ function linksTo(link, target) {
26872
26903
  }
26873
26904
 
26874
26905
  // src/demos.ts
26875
- import { readFileSync as readFileSync35, existsSync as existsSync28, cpSync, mkdirSync as mkdirSync8 } from "node:fs";
26906
+ import { readFileSync as readFileSync36, existsSync as existsSync28, cpSync, mkdirSync as mkdirSync8 } from "node:fs";
26876
26907
  import { dirname as dirname17, isAbsolute as isAbsolute5, join as join48, relative as relative10, resolve as resolve31, sep as sep9 } from "node:path";
26877
26908
  import { spawn as spawn8, spawnSync as spawnSync3 } from "node:child_process";
26878
26909
  function readCatalog(file, catalog) {
26879
26910
  let raw;
26880
26911
  try {
26881
- raw = JSON.parse(readFileSync35(file, "utf8"));
26912
+ raw = JSON.parse(readFileSync36(file, "utf8"));
26882
26913
  } catch {
26883
26914
  return [];
26884
26915
  }
@@ -27080,7 +27111,7 @@ function tierFailure(tier, message2) {
27080
27111
  function beholdVersion() {
27081
27112
  try {
27082
27113
  const here = dirname18(fileURLToPath10(import.meta.url));
27083
- return JSON.parse(readFileSync36(join49(here, "..", "package.json"), "utf8")).version ?? "unknown";
27114
+ return JSON.parse(readFileSync37(join49(here, "..", "package.json"), "utf8")).version ?? "unknown";
27084
27115
  } catch {
27085
27116
  return "unknown";
27086
27117
  }
@@ -27127,7 +27158,7 @@ async function captureFrame(projectDir, env, frames, broadcaster) {
27127
27158
  }
27128
27159
  }
27129
27160
  function carveRoutes(app, reportPath, demo) {
27130
- const load2 = () => readCarveReport(reportPath, (p) => readFileSync36(p, "utf8"));
27161
+ const load2 = () => readCarveReport(reportPath, (p) => readFileSync37(p, "utf8"));
27131
27162
  const carveDirs = [dirname18(reportPath), ...demo ? [demo.out] : []];
27132
27163
  const carveStates = () => readCarveStates(carveDirs, nodeCarveIo, 0);
27133
27164
  const emittedNodes = async (states) => {
@@ -27559,7 +27590,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
27559
27590
  const dirs = [];
27560
27591
  const declared = [];
27561
27592
  for (const f of discoverOpIrs(estateDirs)) {
27562
- const parsed = readOpIr(f.path, (p) => readFileSync36(p, "utf8"));
27593
+ const parsed = readOpIr(f.path, (p) => readFileSync37(p, "utf8"));
27563
27594
  if (!parsed.ok) continue;
27564
27595
  const mine = declaredConvergeOps([parsed.ir]);
27565
27596
  if (!mine.length) continue;
@@ -28135,7 +28166,8 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
28135
28166
  const titles = waveTitles(ir, decl);
28136
28167
  const { svg } = renderGraph(ir, { waveTitles: titles });
28137
28168
  const waves = Object.keys(ir.groups.byWave ?? {}).length;
28138
- const note = `${ir.nodes.length} root${ir.nodes.length === 1 ? "" : "s"} in ${waves} wave${waves === 1 ? "" : "s"}, from ${TERRAGUCCI_COMPONENTS_FILE}`;
28169
+ const noun = hasTerragruntUnits(cfg.projectDir) ? "unit" : "root";
28170
+ const note = `${ir.nodes.length} ${noun}${ir.nodes.length === 1 ? "" : "s"} in ${waves} wave${waves === 1 ? "" : "s"}, from ${TERRAGUCCI_COMPONENTS_FILE}`;
28139
28171
  return {
28140
28172
  ir,
28141
28173
  svg,
@@ -28213,7 +28245,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
28213
28245
  }
28214
28246
  const parsedOps = [];
28215
28247
  for (const f of files) {
28216
- const parsed = readOpIr(f.path, (p) => readFileSync36(p, "utf8"));
28248
+ const parsed = readOpIr(f.path, (p) => readFileSync37(p, "utf8"));
28217
28249
  if (!parsed.ok) return c.json(parsed.refusal, 422);
28218
28250
  parsedOps.push(parsed.ir);
28219
28251
  }
@@ -29197,7 +29229,7 @@ init_chant();
29197
29229
 
29198
29230
  // src/workspace-convert.ts
29199
29231
  init_chant();
29200
- import { existsSync as existsSync30, mkdirSync as mkdirSync9, readFileSync as readFileSync37, renameSync as renameSync2, writeFileSync as writeFileSync7 } from "node:fs";
29232
+ import { existsSync as existsSync30, mkdirSync as mkdirSync9, readFileSync as readFileSync38, renameSync as renameSync2, writeFileSync as writeFileSync7 } from "node:fs";
29201
29233
  import { basename as basename6, dirname as dirname19, join as join50, relative as relative12, resolve as resolve33, sep as sep11 } from "node:path";
29202
29234
  import { shortStackNames as shortStackNames2 } from "@intentius/pinhole";
29203
29235
  init_project();
@@ -29219,7 +29251,7 @@ function installedVersion(pkg, dir) {
29219
29251
  const manifest = join50(at2, "node_modules", ...pkg.split("/"), "package.json");
29220
29252
  if (existsSync30(manifest)) {
29221
29253
  try {
29222
- const v = JSON.parse(readFileSync37(manifest, "utf8")).version;
29254
+ const v = JSON.parse(readFileSync38(manifest, "utf8")).version;
29223
29255
  return typeof v === "string" ? v : void 0;
29224
29256
  } catch {
29225
29257
  return void 0;
@@ -29319,7 +29351,7 @@ function applyConversion(plan) {
29319
29351
  }
29320
29352
 
29321
29353
  // src/export.ts
29322
- import { mkdirSync as mkdirSync10, writeFileSync as writeFileSync8, copyFileSync, cpSync as cpSync2, readFileSync as readFileSync38, readdirSync as readdirSync16, existsSync as existsSync31, realpathSync as realpathSync2 } from "node:fs";
29354
+ import { mkdirSync as mkdirSync10, writeFileSync as writeFileSync8, copyFileSync, cpSync as cpSync2, readFileSync as readFileSync39, readdirSync as readdirSync16, existsSync as existsSync31, realpathSync as realpathSync2 } from "node:fs";
29323
29355
  import { homedir as homedir3 } from "node:os";
29324
29356
  import { join as join51, dirname as dirname20, basename as basename7, relative as relative13, resolve as resolve34, sep as sep12 } from "node:path";
29325
29357
  import { createHash as createHash11 } from "node:crypto";
@@ -29452,7 +29484,7 @@ async function runExport(cfg, outDir, opts = {}) {
29452
29484
  keyToFile
29453
29485
  };
29454
29486
  writeFileSync8(join51(outDir, "manifest.json"), JSON.stringify(manifest, null, 2));
29455
- const html = readFileSync38(join51(webDir(), "index.html"), "utf8").replace(
29487
+ const html = readFileSync39(join51(webDir(), "index.html"), "utf8").replace(
29456
29488
  /<\/head>/i,
29457
29489
  ` <script>window.__BEHOLD_STATIC__ = true;</script>
29458
29490
  </head>`
@@ -29586,7 +29618,7 @@ async function publishBundle(outDir, target, client) {
29586
29618
  }
29587
29619
  const last = ["manifest.json", "index.html"];
29588
29620
  const order = [...files.filter((f) => !last.includes(f)), ...last.filter((f) => files.includes(f))];
29589
- for (const f of order) await client.put(at2(f), readFileSync38(join51(outDir, f)), contentType(f), CONTENT_ADDRESSED.test(f) ? IMMUTABLE : NO_CACHE);
29621
+ for (const f of order) await client.put(at2(f), readFileSync39(join51(outDir, f)), contentType(f), CONTENT_ADDRESSED.test(f) ? IMMUTABLE : NO_CACHE);
29590
29622
  const wrote = new Set(files);
29591
29623
  const removed = [];
29592
29624
  for (const f of previous) {
@@ -29638,7 +29670,7 @@ It's just files \u2014 GitHub Pages, S3, nginx, or Cloudflare Pages
29638
29670
 
29639
29671
  // src/publish-snapshots.ts
29640
29672
  import { execFileSync as execFileSync4 } from "node:child_process";
29641
- import { readFileSync as readFileSync39 } from "node:fs";
29673
+ import { readFileSync as readFileSync40 } from "node:fs";
29642
29674
  import { join as join52 } from "node:path";
29643
29675
  var DEFAULT_KEEP = 30;
29644
29676
  var SNAPSHOT_REPORTS_BASE = "../../../";
@@ -29727,7 +29759,7 @@ async function publishSnapshot(outDir, target, commit, client, opts = {}) {
29727
29759
  const keep = opts.keep ?? DEFAULT_KEEP;
29728
29760
  if (!Number.isInteger(keep) || keep < 1) throw new Error(`--keep takes a whole number of at least 1, not ${keep}`);
29729
29761
  const at2 = (f) => `${target.prefix}/${f}`;
29730
- const local = JSON.parse(readFileSync39(join52(outDir, "manifest.json"), "utf8"));
29762
+ const local = JSON.parse(readFileSync40(join52(outDir, "manifest.json"), "utf8"));
29731
29763
  const generated = local.capturedAt ?? (/* @__PURE__ */ new Date()).toISOString();
29732
29764
  const entry = { commit: sha, at: commit.at ?? generated, project: opts.project ?? local.projectDir ?? "", generated };
29733
29765
  const bundle = await publishBundle(outDir, { bucket: target.bucket, prefix: at2(sha) }, client);
@@ -30264,7 +30296,7 @@ async function runCarve(rest) {
30264
30296
  process.exit(2);
30265
30297
  }
30266
30298
  const reportPath = resolve35(fileArg);
30267
- const parsed = readCarveReport(reportPath, (p) => readFileSync40(p, "utf8"));
30299
+ const parsed = readCarveReport(reportPath, (p) => readFileSync41(p, "utf8"));
30268
30300
  if (!parsed.ok) {
30269
30301
  process.stderr.write(`behold carve: ${parsed.refusal.error}
30270
30302
  ${parsed.refusal.remedy}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/behold",
3
- "version": "0.29.13",
3
+ "version": "0.29.15",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "description": "behold — a live control plane on chant. See your whole estate (every substrate in one graph), coloured by drift; act through delegated, gated Ops.",
package/web/app.js CHANGED
@@ -4173,7 +4173,7 @@ function kioskGlow() {
4173
4173
  };
4174
4174
  style.textContent = hoverCss(links, { root: boxes, wave: (w) => `#graph rect[data-group-id="wave-${w}"]` }, "filter: drop-shadow(0 0 2px var(--focus)) drop-shadow(0 0 7px var(--focus));");
4175
4175
  }
4176
- /** The dots by each root's name: on its box's title (Resources) or its card (Roots by wave). */
4176
+ /** The dots by each root's name: on its box's title (Resources) or its card (Components). */
4177
4177
  function kioskDots(svg) {
4178
4178
  for (const d of svg.querySelectorAll("[data-k-dot]")) d.remove();
4179
4179
  const roots = kioskRootList();
@@ -1,7 +1,7 @@
1
1
  // behold#571: the kiosk frame's popover. In the kiosk the graph is framed
2
2
  // bare (`?embed=1&bare=1`), with no Inspect pane, so a visitor pointing at a
3
3
  // card sees what the plan does to it here instead: in the Resources view, the
4
- // resource's planned change one attribute a line; in Roots by wave, the
4
+ // resource's planned change one attribute a line; in Components, the
5
5
  // root's resources and which of them change. Pure logic: what the popover
6
6
  // says, where it goes, and when it stays open. app.js draws it.
7
7
  //
@@ -148,7 +148,7 @@ export function resourceCardId(answer, root, resourceId) {
148
148
  }
149
149
 
150
150
  /**
151
- * What the popover says about one root's card (Roots by wave): its
151
+ * What the popover says about one root's card (Components): its
152
152
  * resources, each "changes", "no change" or "known after apply", and how
153
153
  * many change. `resources` is the components zoom's resources facet for the
154
154
  * card (`[{ kind, id }]`).
@@ -95,7 +95,7 @@ describe("what the popover says about a resource (behold#571)", () => {
95
95
  });
96
96
  });
97
97
 
98
- describe("what the popover says about a root, in Roots by wave (behold#571)", () => {
98
+ describe("what the popover says about a root, in Components (behold#571)", () => {
99
99
  it("lists its resources, each changes, no change or known after apply, and counts the changes", () => {
100
100
  const resources = [
101
101
  { kind: "terraform_remote_state", id: "app/data.terraform_remote_state.network" },
package/web/kiosk-repo.js CHANGED
@@ -23,3 +23,21 @@ export function repoUrl(url, repo) {
23
23
  q.set("repo", repo);
24
24
  return `${path}?${q.toString()}`;
25
25
  }
26
+
27
+ const sameCommit = (a, b) => typeof a === "string" && typeof b === "string" && !!a && !!b && (a.startsWith(b) || b.startsWith(a));
28
+
29
+ /**
30
+ * The checkout that holds the step's commit (behold#571). A step names the
31
+ * commit it is about (demo.json's `commit`: a pull request's head, then its
32
+ * merge); the repo's own checkout may sit on another one. Of `name` and the
33
+ * served checkouts named `<name>.<anything>`, the one whose commit
34
+ * (`/api/project`'s viewCommits) is the step's; `name` when none is, at the
35
+ * reset and on the base, which draw the base.
36
+ */
37
+ export function stepCheckout(name, demo, served, commits) {
38
+ if (!name || !demo || !demo.commit || !commits || !Array.isArray(served)) return name;
39
+ if (demo.scenario === "base" || demo.step === "reset") return name;
40
+ const at = (n) => sameCommit(commits[n], demo.commit);
41
+ if (at(name)) return name;
42
+ return served.find((n) => n.startsWith(`${name}.`) && at(n)) || name;
43
+ }
@@ -1,6 +1,6 @@
1
1
  // behold#571: the kiosk asks for its repo's checkout by name, on a behold that serves several.
2
2
  import { describe, expect, it } from "vitest";
3
- import { repoParam, repoUrl } from "./kiosk-repo.js";
3
+ import { repoParam, repoUrl, stepCheckout } from "./kiosk-repo.js";
4
4
  import { frameSrc } from "./kiosk.js";
5
5
  import { pageRepo, withRepo } from "./view-repo.js";
6
6
 
@@ -26,6 +26,33 @@ describe("repoUrl", () => {
26
26
  });
27
27
  });
28
28
 
29
+ describe("stepCheckout (behold#571)", () => {
30
+ const served = ["demo", "demo.change", "demo-terragrunt", "demo-terragrunt.change"];
31
+ const HEAD = "9eec25f7aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
32
+ const MERGE = "12f512fcbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
33
+ const BASE = "0d14834dcccccccccccccccccccccccccccccccc";
34
+ const commits = { demo: BASE, "demo.change": HEAD, "demo-terragrunt": BASE };
35
+ const step = (step, commit) => ({ scenario: "b-waves", step, commit });
36
+ it("draws the checkout at the pull request's head while the step is about it", () => {
37
+ expect(stepCheckout("demo", step("pull-request", HEAD), served, commits)).toBe("demo.change");
38
+ expect(stepCheckout("demo", step("planned", HEAD.slice(0, 8)), served, commits)).toBe("demo.change");
39
+ });
40
+ it("follows the merge once the driver checked it out, and draws the repo's checkout until then", () => {
41
+ expect(stepCheckout("demo", step("merged", MERGE), served, commits)).toBe("demo");
42
+ expect(stepCheckout("demo", step("merged", MERGE), served, { ...commits, "demo.change": MERGE })).toBe("demo.change");
43
+ });
44
+ it("draws the repo's own checkout at the reset, on the base, and with no commit or commits", () => {
45
+ expect(stepCheckout("demo", step("reset", HEAD), served, commits)).toBe("demo");
46
+ expect(stepCheckout("demo", { scenario: "base", step: "applied", commit: HEAD }, served, commits)).toBe("demo");
47
+ expect(stepCheckout("demo", step("pull-request", null), served, commits)).toBe("demo");
48
+ expect(stepCheckout("demo", step("pull-request", HEAD), served, null)).toBe("demo");
49
+ expect(stepCheckout(null, step("pull-request", HEAD), served, commits)).toBe(null);
50
+ });
51
+ it("picks only the repo's own checkouts", () => {
52
+ expect(stepCheckout("demo-terragrunt", step("pull-request", HEAD), served, commits)).toBe("demo-terragrunt");
53
+ });
54
+ });
55
+
29
56
  describe("the frame", () => {
30
57
  it("names the repo it draws, and nothing without one", () => {
31
58
  expect(frameSrc("http://h", "resources")).toBe("/?embed=1&bare=1&zoom=resources&host=http%3A%2F%2Fh");
@@ -361,8 +361,14 @@ export function notRunRepo(tool, served) {
361
361
  return name && Array.isArray(served) && served.includes(name) ? name : null;
362
362
  }
363
363
 
364
+ /** What a tool calls the thing with its own state: a Terragrunt unit, a root otherwise (behold#571). */
365
+ export function rootNoun(tool) {
366
+ return tool === "terragrunt" ? { one: "unit", many: "units" } : { one: "root", many: "roots" };
367
+ }
368
+
364
369
  /** The legend's words, without naming a competing engine on an engine's page. */
365
370
  export function rootWord(tool) {
371
+ if (tool === "terragrunt") return "a Terragrunt unit";
366
372
  return namesOther("Terraform", tool) ? "a root" : "a Terraform root";
367
373
  }
368
374
 
@@ -183,7 +183,8 @@ describe("the branding table (behold#571)", () => {
183
183
  expect(namesOther("runs on choudoufu", "terraform")).toBe(true);
184
184
  expect(namesOther("works with OpenTofu", "terraform")).toBe(false);
185
185
  expect(rootWord("choudoufu")).toBe("a root");
186
- expect(rootWord("terragrunt")).toBe("a Terraform root");
186
+ expect(rootWord("terragrunt")).toBe("a Terragrunt unit");
187
+ expect(rootWord("terraform")).toBe("a Terraform root");
187
188
  });
188
189
  it("never counts a resource address, a bucket or a path as a brand", () => {
189
190
  for (const t of ["terraform_data.app", "data.terraform_remote_state.network", "state in demo-terraform-state", "s3://demo-terraform-state/edge.tfstate", "developer.hashicorp.com/terraform", ".terraform.lock.hcl", "terragrunt-cache"]) {
package/web/kiosk.js CHANGED
@@ -17,8 +17,8 @@ import { PICK_HOLD_MS, annMark, mergeRun, startRuns } from "./kiosk-runs.js";
17
17
  import { PANEL_STEPS, PANEL_TITLE, approvalModel, cycleRuns, rowFacts, shortDigest } from "./kiosk-approval.js";
18
18
  import { getThemeName, mountThemePicker, onThemeChange } from "./theme.js";
19
19
  import { cssString, hoverCss, hoverFromMessage, hoverLinks, hoverMessage, isLightBg, rootSegments, rootText } from "./kiosk-roots.js";
20
- import { creditLines, filterIndex, keepRun, liveEstimate, liveFor, namesOther, notRunRepo, notRunWords, replayAt, replayBanner, replayIndex, rootWord, runAsOf, scrubDemo, servesRepo, vendorOf } from "./kiosk-vendor.js";
21
- import { repoParam, repoUrl } from "./kiosk-repo.js";
20
+ import { creditLines, filterIndex, keepRun, liveEstimate, liveFor, namesOther, notRunRepo, notRunWords, replayAt, replayBanner, replayIndex, rootNoun, rootWord, runAsOf, scrubDemo, servesRepo, vendorOf } from "./kiosk-vendor.js";
21
+ import { repoParam, repoUrl, stepCheckout } from "./kiosk-repo.js";
22
22
  import { STATE_WORDS, changingRoots, costLine, pinsCommit, planFitsPin, pullPin, readEdges, rootsCount, totalsLine, waveRows, blastRows } from "./terragucci-change.js";
23
23
 
24
24
  /** The scenarios terragucci#1014 numbers. An id not here is shown by the title demo.json carries. */
@@ -353,30 +353,29 @@ export function frameSrc(origin, zoom = "resources", repo = null, theme = null)
353
353
  }
354
354
 
355
355
  // ---------------------------------------------------------------------------
356
- // #571: which of behold's two views the frame shows. Before the merge, the
357
- // resources zoom: what the pull request changes and the reads it plans
358
- // before their values are known. From the merge on, the components zoom of a
359
- // terragucci repo (`terragucci.components.json`): one card per root in a box
360
- // per wave, titled with the job that applies it, in step with the jobs pane.
361
- // A viewer's click on the toggle holds a view for PICK_HOLD_MS after their
362
- // last click, as a run picked in the sidebar does; then the step leads again.
363
-
364
- /** The two views the toggle offers, with behold's zoom for each. */
356
+ // #571: which of behold's two views the frame shows. On a terragucci repo
357
+ // (`terragucci.components.json`) every step leads to the components zoom:
358
+ // one card per root in a box per wave, titled with the job that applies it,
359
+ // in step with the jobs pane. Before the merge it draws the pull request's
360
+ // planned waves, and from the merge on the run's. Resources, what the change
361
+ // touches resource by resource, is one click away. A viewer's click on the
362
+ // toggle holds a view for PICK_HOLD_MS after their last click, as a run
363
+ // picked in the sidebar does; then the step leads again.
364
+
365
+ /** The two views the toggle offers, with behold's zoom for each, and what each shows. */
365
366
  export const VIEWS = [
366
- { id: "resources", words: "Resources", zoom: "resources" },
367
- { id: "components", words: "Roots by wave", zoom: "components" },
367
+ { id: "resources", words: "Resources", zoom: "resources", shows: "each resource the change touches" },
368
+ { id: "components", words: "Components", zoom: "components", shows: "the pipeline's jobs and waves" },
368
369
  ];
369
370
 
370
371
  /**
371
- * The view a step shows: the pull request's steps the resources (what
372
- * changes, the unknown reads), every step from the merge on the components
373
- * (the jobs running), and the reset the components at rest. A step behold
374
- * doesn't know, and `stalled`, keep the components: the jobs pane says where
375
- * the pipeline stopped.
372
+ * The view a step shows: the components at every step (behold#571), the
373
+ * pull request's planned waves before the merge and the run's from it on.
374
+ * A step behold doesn't know keeps the components too.
376
375
  */
377
376
  export const STEP_VIEW = {
378
- "pull-request": "resources",
379
- planned: "resources",
377
+ "pull-request": "components",
378
+ planned: "components",
380
379
  merged: "components",
381
380
  applying: "components",
382
381
  waiting: "components",
@@ -406,16 +405,26 @@ export function kioskView(demo, { components = false, pick = null, now = Date.no
406
405
  const left = pick.at + PICK_HOLD_MS - now;
407
406
  if (left > 0) return { view: pick.view, pinned: true, secs: Math.ceil(left / 1000) };
408
407
  }
409
- return { view: demo ? stepView(demo.step) : "resources", pinned: false, secs: null };
408
+ return { view: demo ? stepView(demo.step) : "components", pinned: false, secs: null };
410
409
  }
411
410
 
412
411
  /** The line under the view saying how to read it, for the view shown. */
413
412
  export function legendWords(view, tool = null) {
414
413
  const root = rootWord(tool);
414
+ const n = rootNoun(tool);
415
+ const rest = `an arrow is one ${n.one} reading another's outputs, dashed while it reads planned values. A wave applies its ${n.many} together, after the waves it reads.`;
415
416
  if (view === "components") {
416
- return `Each card is ${root} with its own state, in a box per wave titled with the pipeline job that applies it; an arrow is one root reading another's outputs, dashed while it reads planned values. A wave applies its roots together, after the waves it reads.`;
417
+ return `Components shows the pipeline's jobs and waves. Each card is ${root} with its own state, in a box per wave titled with the job that applies it; ${rest}`;
417
418
  }
418
- return `Each box is ${root} with its own state; an arrow is one root reading another's outputs, dashed while it reads planned values. A wave applies its roots together, after the waves it reads.`;
419
+ return `Each box is ${root} with its own state; ${rest}`;
420
+ }
421
+
422
+
423
+ /** The tool the page shows: its own on a tool's page, else the running scenario's (demo.json `tool`). */
424
+ export function nounTool(s) {
425
+ if (s.vendor) return s.vendor;
426
+ const d = s.demo && s.demo.demo;
427
+ return d && typeof d.tool === "string" ? d.tool : null;
419
428
  }
420
429
 
421
430
  /** The behold zoom for a view. */
@@ -425,7 +434,7 @@ export function viewZoom(view) {
425
434
  }
426
435
 
427
436
  /**
428
- * The toggle, "Resources | Roots by wave", over the frame: the view shown
437
+ * The toggle, "Resources | Components", over the frame: the view shown
429
438
  * pressed, and a line saying whether the step leads or a click holds it.
430
439
  * Not drawn at all when the repo offers no components zoom.
431
440
  */
@@ -440,21 +449,24 @@ export function renderViewToggle(host, shown, { components = false, onPick = ()
440
449
  for (const v of VIEWS) {
441
450
  const b = el("button", `k-view${v.id === shown.view ? " k-view-on" : ""}`, v.words);
442
451
  b.type = "button";
452
+ b.setAttribute("title", v.shows);
443
453
  b.setAttribute("aria-pressed", String(v.id === shown.view));
444
454
  b.addEventListener("click", () => onPick(v.id));
445
455
  group.appendChild(b);
446
456
  }
447
457
  host.appendChild(group);
448
- host.appendChild(el("span", "k-view-note", shown.pinned ? `held by a click; the step leads again in ${shown.secs} s` : "follows the step"));
458
+ const on = VIEWS.find((v) => v.id === shown.view);
459
+ const lead = shown.pinned ? `held by a click; the step leads again in ${shown.secs} s` : "follows the step";
460
+ host.appendChild(el("span", "k-view-note", on ? `${on.shows} · ${lead}` : lead));
449
461
  }
450
462
 
451
463
  /** The one-line facts under the view: the pull request's totals, cost and blast radius, as #568 has them. */
452
- export function factLines(demo, answer) {
464
+ export function factLines(demo, answer, tool = null) {
453
465
  const pr = demo ? matchingPull(answer, demo) : null;
454
466
  if (!pr) return [];
455
467
  const out = [];
456
468
  const totals = totalsLine(pr.totals);
457
- if (totals) out.push(`pull request #${pr.run.pull_request} changes ${rootsCount(pr.totals_roots) || "across roots"}: ${totals}`);
469
+ if (totals) out.push(`pull request #${pr.run.pull_request} changes ${rootsCount(pr.totals_roots, rootNoun(tool)) || `across ${rootNoun(tool).many}`}: ${totals}`);
458
470
  const b = blastRows(answer);
459
471
  if (b && b.changed.length) out.push(`blast radius: ${b.changed.join(", ")}${b.downstream.length ? `; downstream, nearest first: ${b.downstream.join(", ")}` : ""}`);
460
472
  if (b && b.unplanned.length) out.push(`unchanged: ${b.unplanned.join(", ")}`);
@@ -526,7 +538,9 @@ function drawBar(bar, s, now, foot) {
526
538
  const left = el("div", "k-left");
527
539
  const titleBox = box("title", "k-box-title");
528
540
  const title = el("h1", "k-title");
529
- if (vendor) title.appendChild(brandEl(vendor));
541
+ // behold#571: /kiosk names the tool each scenario runs on, as its own page does.
542
+ const shownTool = vendor || (demo.scenario !== "base" ? vendorOf(demo.tool) : null);
543
+ if (shownTool) title.appendChild(brandEl(shownTool));
530
544
  title.append(el("span", "k-kicker", head.kicker), el("span", "k-title-text", head.title));
531
545
  if (title.setAttribute) title.setAttribute("title", `${head.kicker} · ${head.title || ""}`);
532
546
  titleBox.appendChild(title);
@@ -591,9 +605,9 @@ function drawBar(bar, s, now, foot) {
591
605
 
592
606
  if (foot) {
593
607
  // The legend reads the graph; with no graph shown there is nothing for it to explain.
594
- if (!s.noReports) foot.appendChild(el("span", "k-legend", legendWords(s.view || "resources", s.vendor || null)));
608
+ if (!s.noReports) foot.appendChild(el("span", "k-legend", legendWords(s.view || "resources", nounTool(s))));
595
609
  // behold#571: no line on where the narration was read from: a viewer new to the demo reads the facts.
596
- if (!s.noReports) for (const f of factLines(demo, s.answer)) foot.appendChild(elRoots("span", "k-fact", f, s));
610
+ if (!s.noReports) for (const f of factLines(demo, s.answer, nounTool(s))) foot.appendChild(elRoots("span", "k-fact", f, s));
597
611
  }
598
612
  }
599
613
 
@@ -737,7 +751,10 @@ export function startKiosk({ bar, frame, foot, side, main, views, tools = null }
737
751
  // name. `viewRepos` is what it serves, `aimed` the repo the reads are for.
738
752
  s.viewRepos = null;
739
753
  s.aimed = null;
740
- const wantRepo = () => repoParam(s.repo, s.viewRepos) || (vendor && s.mode === "none" ? notRunRepo(tool, s.viewRepos) : null);
754
+ s.viewCommits = null;
755
+ const repoNamed = () => repoParam(s.repo, s.viewRepos) || (vendor && s.mode === "none" ? notRunRepo(tool, s.viewRepos) : null);
756
+ // behold#571: the checkout at the step's commit, so a pull request's new roots and resources are drawn.
757
+ const wantRepo = () => stepCheckout(repoNamed(), s.demo && s.demo.demo, s.viewRepos, s.viewCommits);
741
758
  const fetchServed = fetchJson;
742
759
  fetchJson = (url) => fetchServed(repoUrl(url, wantRepo()));
743
760
  /** When the page moves to another served repo, read its project, reports and run view again, and point the frame at it. */
@@ -837,6 +854,8 @@ export function startKiosk({ bar, frame, foot, side, main, views, tools = null }
837
854
  const stage = d ? demoStage(d) : "none";
838
855
  if (stage === "pull-request" || stage === "either") void reports(true);
839
856
  if (stage === "apply" || stage === "either") void progress(true);
857
+ // behold#571: a commit no served checkout was on when last asked; the driver may have checked it out since.
858
+ if (d && d.commit && s.viewRepos && !Object.values(s.viewCommits || {}).some((c) => c.startsWith(d.commit) || d.commit.startsWith(c))) void readCommits();
840
859
  }
841
860
  };
842
861
  // behold#571: the replay's clock, read on every draw.
@@ -1032,10 +1051,19 @@ export function startKiosk({ bar, frame, foot, side, main, views, tools = null }
1032
1051
  frameSrcNow = src;
1033
1052
  frame.src = src;
1034
1053
  };
1054
+ /** behold#571: the commit each served checkout holds, read again when a step names a new one. */
1055
+ const readCommits = () => fetchJson("/api/project").then((p) => {
1056
+ if (p && !p.error && Array.isArray(p.viewRepos)) {
1057
+ s.viewRepos = p.viewRepos;
1058
+ s.viewCommits = p.viewCommits || null;
1059
+ draw();
1060
+ }
1061
+ });
1035
1062
  const readProject = () => fetchJson("/api/project").then((p) => {
1036
1063
  // behold#571: the repos this behold serves, and which one this answer is.
1037
1064
  if (p && !p.error && Array.isArray(p.viewRepos)) {
1038
1065
  s.viewRepos = p.viewRepos;
1066
+ s.viewCommits = p.viewCommits || null;
1039
1067
  if (s.aimed === null && p.viewRepo) s.aimed = p.viewRepo;
1040
1068
  }
1041
1069
  s.components = !!(p && !p.error && p.terragucciComponents);
package/web/kiosk.test.js CHANGED
@@ -50,6 +50,7 @@ describe("frameSrc (#569)", () => {
50
50
  describe("factLines (#569)", () => {
51
51
  it("says the pull request's totals and blast radius", () => {
52
52
  expect(factLines(demo("planned"), answer)).toEqual(["pull request #7 changes across 3 roots: 3 update", "blast radius: app, edge, network", "unchanged: other"]);
53
+ expect(factLines(demo("planned"), answer, "terragrunt")[0]).toBe("pull request #7 changes across 3 units: 3 update");
53
54
  });
54
55
  });
55
56
 
@@ -207,18 +208,19 @@ describe("the approval panel's drawing (behold#571)", () => {
207
208
  });
208
209
 
209
210
  describe("which view the frame shows (#571)", () => {
210
- it("shows the resources for the pull request's steps and the roots by wave from the merge on", () => {
211
- for (const step of ["pull-request", "planned"]) expect(stepView(step)).toBe("resources");
212
- for (const step of ["merged", "applying", "waiting", "approved", "failed", "resumed", "applied", "reset"]) expect(stepView(step)).toBe("components");
211
+ it("shows the components at every step, the pull request's planned waves included", () => {
212
+ for (const step of ["pull-request", "planned", "merged", "applying", "waiting", "approved", "failed", "resumed", "applied", "reset"]) expect(stepView(step)).toBe("components");
213
213
  expect(Object.keys(STEP_VIEW).sort()).toEqual(Object.keys(STEP_WORDS).sort());
214
214
  });
215
215
 
216
216
  it("follows the step, and only the resources when the repo offers no components zoom", () => {
217
- expect(kioskView(demo("planned"), { components: true })).toEqual({ view: "resources", pinned: false, secs: null });
217
+ expect(kioskView(demo("planned"), { components: true })).toEqual({ view: "components", pinned: false, secs: null });
218
+ expect(kioskView(demo("planned"), { components: true, pick: { view: "resources", at: 0 }, now: 1000 }).view).toBe("resources");
218
219
  expect(kioskView(demo("waiting"), { components: true })).toEqual({ view: "components", pinned: false, secs: null });
219
220
  expect(kioskView(demo("waiting"), { components: false })).toEqual({ view: "resources", pinned: false, secs: null });
220
221
  expect(kioskView(demo("waiting"), { components: false, pick: { view: "components", at: 0 }, now: 1000 }).view).toBe("resources");
221
- expect(kioskView(null, { components: true }).view).toBe("resources");
222
+ expect(kioskView(null, { components: true }).view).toBe("components");
223
+ expect(kioskView(null, { components: false }).view).toBe("resources");
222
224
  });
223
225
 
224
226
  it("holds a clicked view for 90 s after the last click, then follows the step again", () => {
@@ -242,8 +244,15 @@ describe("which view the frame shows (#571)", () => {
242
244
  });
243
245
 
244
246
  it("says how to read the view shown", () => {
245
- expect(legendWords("components")).toMatch(/^Each card is a Terraform root .* titled with the pipeline job that applies it/);
247
+ expect(legendWords("components")).toMatch(/^Components shows the pipeline's jobs and waves\. Each card is a Terraform root .* titled with the job that applies it/);
246
248
  expect(legendWords("resources")).toMatch(/^Each box is a Terraform root/);
249
+ // behold#571: a Terragrunt scenario names its units.
250
+ for (const v of ["components", "resources"]) {
251
+ const words = legendWords(v, "terragrunt");
252
+ expect(words).toMatch(/is a Terragrunt unit with its own state/);
253
+ expect(words).toMatch(/one unit reading another's outputs.*applies its units together/);
254
+ expect(words).not.toMatch(/\broots?\b/);
255
+ }
247
256
  });
248
257
 
249
258
  // A DOM for the toggle: elements with attributes and click listeners.
@@ -267,13 +276,14 @@ describe("which view the frame shows (#571)", () => {
267
276
  renderViewToggle(host, { view: "components", pinned: false, secs: null }, { components: true, onPick: (v) => picked.push(v) });
268
277
  expect(host.hidden).toBe(false);
269
278
  const buttons = host.children[0].children;
270
- expect(buttons.map((b) => [b.textContent, b.attrs["aria-pressed"]])).toEqual([["Resources", "false"], ["Roots by wave", "true"]]);
271
- expect(host.children[1].textContent).toBe("follows the step");
279
+ expect(buttons.map((b) => [b.textContent, b.attrs["aria-pressed"]])).toEqual([["Resources", "false"], ["Components", "true"]]);
280
+ expect(host.children[1].textContent).toBe("the pipeline's jobs and waves · follows the step");
281
+ expect(buttons[1].attrs.title).toBe("the pipeline's jobs and waves");
272
282
  buttons[0].listeners.click();
273
283
  expect(picked).toEqual(["resources"]);
274
284
 
275
285
  renderViewToggle(host, { view: "resources", pinned: true, secs: 42 }, { components: true });
276
- expect(host.children[1].textContent).toBe("held by a click; the step leads again in 42 s");
286
+ expect(host.children[1].textContent).toBe("each resource the change touches · held by a click; the step leads again in 42 s");
277
287
 
278
288
  renderViewToggle(host, { view: "resources", pinned: false, secs: null }, { components: false });
279
289
  expect(host.hidden).toBe(true);
@@ -129,9 +129,9 @@ export function totalsLine(totals) {
129
129
  }
130
130
 
131
131
  /** #568 step 2: "across 3 roots", from `totals_roots` (report minor 35); null on an older report, which has no count. */
132
- export function rootsCount(n) {
132
+ export function rootsCount(n, noun = { one: "root", many: "roots" }) {
133
133
  if (n === undefined || n === null) return null;
134
- return `across ${n} ${n === 1 ? "root" : "roots"}`;
134
+ return `across ${n} ${n === 1 ? noun.one : noun.many}`;
135
135
  }
136
136
 
137
137
  const money = (n) => (n === null || n === undefined ? "no figure" : n.toFixed(2));
@@ -62,16 +62,36 @@ export function runsCommit(pin, progress) {
62
62
  export function jobStatus(index, commit, jobName) {
63
63
  const runs = index && index.doc && Array.isArray(index.doc.runs) ? index.doc.runs : [];
64
64
  let skipped = null;
65
+ // behold#571: a newer run on the commit that skipped this job but ran
66
+ // another (a /terragucci apply re-run applies every wave in its
67
+ // apply-comment job) is the latest run for the wave.
68
+ let later = null;
65
69
  for (const run of runs) {
66
70
  if (!sameSha(run.commit, commit)) continue;
67
71
  const job = (run.jobs || []).find((j) => j.name === jobName);
68
72
  if (!job) continue;
69
- if (job.status !== "skipped") return { status: job.status, words: JOB_WORDS[job.status] || job.status, tone: JOB_TONE[job.status] || "idle", run: run.id };
73
+ if (job.status !== "skipped") {
74
+ return { status: job.status, words: JOB_WORDS[job.status] || job.status, tone: JOB_TONE[job.status] || "idle", run: run.id, ...(later ? { superseded: later } : {}) };
75
+ }
76
+ if (!later && (run.jobs || []).some((j) => j.status !== "skipped")) later = run.id;
70
77
  skipped = skipped || { status: "skipped", words: JOB_WORDS.skipped, tone: "idle", run: run.id };
71
78
  }
72
79
  return skipped;
73
80
  }
74
81
 
82
+ /** A wave's state from the run view, as a box's title says it after a re-run. */
83
+ export const WAVE_WORDS = { waiting: "held at the gate", applying: "running", applied: "applied", failed: "failed", refused: "refused" };
84
+
85
+ /**
86
+ * The status a wave's box shows: its job's, unless a later run on the commit
87
+ * took the wave over (`superseded`); then the run view's state for the wave,
88
+ * which is the latest run's.
89
+ */
90
+ export function waveStatus(status, state) {
91
+ if (!status || !status.superseded || !state || !WAVE_WORDS[state]) return status;
92
+ return { status: state, words: WAVE_WORDS[state], tone: toneOf(state), run: status.superseded };
93
+ }
94
+
75
95
  /** "apply-wave-2 · held at the gate", or the job alone without a status. */
76
96
  export function waveTitle(jobName, status) {
77
97
  return status ? `${jobName} · ${status.words}` : jobName;
@@ -122,9 +142,9 @@ export function componentMarks(answer, progress, facets, { runs = null, pin = nu
122
142
  const waveNumbers = [...new Set(jobs.map((j) => j.wave).filter((n) => typeof n === "number"))].sort((a, b) => a - b);
123
143
  for (const n of waveNumbers) {
124
144
  const jobName = jobs.find((j) => j.wave === n).jobName;
125
- const status = commit ? jobStatus(runs, commit, jobName) : null;
126
145
  const members = jobs.filter((j) => j.wave === n).map((j) => cards[j.component]).filter(Boolean);
127
146
  const w = waveState(answer, progress, stage, n);
147
+ const status = commit ? waveStatus(jobStatus(runs, commit, jobName), w) : null;
128
148
  const facts = [];
129
149
  for (const m of members) for (const f of m.facts) if (!facts.includes(f)) facts.push(f);
130
150
  // The plan a waiting wave is approved for first, as it's what the approval
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { JOB_WORDS, componentMarks, jobStatus, runsCommit, waveOfKey, waveTitle } from "./terragucci-components-live.js";
2
+ import { JOB_WORDS, componentMarks, jobStatus, runsCommit, waveOfKey, waveStatus, waveTitle } from "./terragucci-components-live.js";
3
3
 
4
4
  // #571: the live marks on a terragucci repo's components zoom. The facets are
5
5
  // the components graph's (src/__fixtures__/terragucci-components: network and
@@ -53,8 +53,8 @@ describe("a wave job's status from the pipeline's runs (#571)", () => {
53
53
  run(10, "0000000", [job("apply-wave-2", "failure")]),
54
54
  );
55
55
  expect(jobStatus(index, MERGE, "apply-wave-2")).toEqual({ status: "running", words: "running", tone: "busy", run: 12 });
56
- // The resume skipped wave 1: the merge's run says how it went.
57
- expect(jobStatus(index, MERGE, "apply-wave-1")).toEqual({ status: "success", words: "succeeded", tone: "ok", run: 11 });
56
+ // The resume skipped wave 1: the merge's run says how it went, and that a later run went on from it.
57
+ expect(jobStatus(index, MERGE, "apply-wave-1")).toEqual({ status: "success", words: "succeeded", tone: "ok", run: 11, superseded: 12 });
58
58
  // A short sha names the same commit.
59
59
  expect(jobStatus(index, MERGE.slice(0, 8), "apply-wave-2").run).toBe(12);
60
60
  });
@@ -116,6 +116,32 @@ describe("the component marks (#571)", () => {
116
116
  expect(m.waves[3].title).toBe("apply-wave-3 · skipped");
117
117
  });
118
118
 
119
+ it("titles a wave a re-run applied with the latest run's state, not the merge's failure (behold#571)", () => {
120
+ // As the demo's failure scenario has them: the merge's run fails wave 2; each
121
+ // /terragucci apply re-run applies the waves in its apply-comment job and skips the wave jobs.
122
+ const comment = (id, status) => run(id, MERGE, [job("apply-comment", status), job("apply-wave-1", "skipped"), job("apply-wave-2", "skipped"), job("apply-wave-3", "skipped")]);
123
+ const merge = run(11, MERGE, [job("apply-comment", "skipped"), job("apply-wave-1", "success"), job("apply-wave-2", "failure"), job("apply-wave-3", "queued")]);
124
+ const applied = { ...progress("applied"), states: progress("applied").states.map((w) => ({ ...w, state: "applied" })) };
125
+ const m = componentMarks(answer, applied, facets, { runs: runs(comment(13, "success"), comment(12, "waiting"), merge), pin: MERGE });
126
+ expect(m.cards.app).toMatchObject({ state: "applied" });
127
+ expect(m.waves[2]).toMatchObject({ state: "applied", tone: "ok", title: "apply-wave-2 · applied" });
128
+ expect(m.waves[3].title).toBe("apply-wave-3 · applied");
129
+ // While the re-run waits at the gate, the box says so.
130
+ const held = componentMarks(answer, progress("waiting"), facets, { runs: runs(comment(12, "waiting"), merge), pin: MERGE });
131
+ expect(held.waves[2].title).toBe("apply-wave-2 · held at the gate");
132
+ // Before any re-run, the merge's own failure.
133
+ const failed = componentMarks(answer, progress("failed"), facets, { runs: runs(merge), pin: MERGE });
134
+ expect(failed.waves[2].title).toBe("apply-wave-2 · failed");
135
+ });
136
+
137
+ it("keeps the job's status when no later run took the wave over, or the run view has no state for it", () => {
138
+ const st = { status: "failure", words: "failed", tone: "bad", run: 11 };
139
+ expect(waveStatus(st, "applied")).toBe(st);
140
+ expect(waveStatus({ ...st, superseded: 12 }, null)).toMatchObject({ status: "failure" });
141
+ expect(waveStatus({ ...st, superseded: 12 }, "applied")).toEqual({ status: "applied", words: "applied", tone: "ok", run: 12 });
142
+ expect(waveStatus(null, "applied")).toBe(null);
143
+ });
144
+
119
145
  it("draws the pull request's plan before the merge, with no job status", () => {
120
146
  const m = componentMarks(answer, null, facets, { runs: runs(run(11, MERGE, [job("apply-wave-2", "waiting")])), pin: `pull-41-a1b2c3d4` });
121
147
  expect(m.stage).toBe("pull-request");