@intentius/behold 0.11.0 → 0.12.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 +76 -1
- package/dist/cli.js +2452 -671
- package/example-argo-estate/app-a/package.json +2 -2
- package/example-argo-estate/app-b/package.json +2 -2
- package/example-argo-estate/control-plane/package.json +2 -2
- package/example-argo-estate/package-lock.json +100 -124
- package/example-carve/README.md +26 -5
- package/example-carve/app/package-lock.json +10 -10
- package/example-carve/app/package.json +2 -2
- package/example-flux-estate/app-a/package.json +2 -2
- package/example-flux-estate/app-b/package.json +2 -2
- package/example-flux-estate/control-plane/package.json +2 -2
- package/example-flux-estate/package-lock.json +100 -124
- package/example-k8s/package-lock.json +101 -125
- package/example-k8s/package.json +3 -3
- package/example-writes/package-lock.json +16 -16
- package/example-writes/package.json +3 -3
- package/package.json +3 -3
- package/web/app.js +614 -22
- package/web/carve-steps.js +79 -0
- package/web/carve-steps.test.js +67 -0
- package/web/index.html +61 -0
- package/web/operator.js +236 -0
- package/web/operator.test.js +158 -0
- package/web/run-playhead.js +194 -0
- package/web/run-playhead.test.js +134 -0
- package/web/theme.test.js +35 -0
package/dist/cli.js
CHANGED
|
@@ -716,11 +716,11 @@ async function createK8sClient(options = {}) {
|
|
|
716
716
|
}
|
|
717
717
|
return void 0;
|
|
718
718
|
}
|
|
719
|
-
async function
|
|
719
|
+
async function resolve9(selector, signal) {
|
|
720
720
|
return "apiVersion" in selector ? resolveByGvk(selector.apiVersion, selector.kind, signal) : resolveByResourceString(selector.resource, selector.group, signal);
|
|
721
721
|
}
|
|
722
722
|
async function resolveOrThrow(selector, signal) {
|
|
723
|
-
const info = await
|
|
723
|
+
const info = await resolve9(selector, signal);
|
|
724
724
|
if (!info) throw new UnknownResourceError(selectorText(selector));
|
|
725
725
|
return info;
|
|
726
726
|
}
|
|
@@ -732,7 +732,7 @@ async function createK8sClient(options = {}) {
|
|
|
732
732
|
return parts.join("/");
|
|
733
733
|
}
|
|
734
734
|
async function pathFor(ref, signal) {
|
|
735
|
-
const info = await
|
|
735
|
+
const info = await resolve9({ apiVersion: ref.apiVersion, kind: ref.kind }, signal);
|
|
736
736
|
if (!info) return void 0;
|
|
737
737
|
return objectPath(info, ref.name, ref.namespace);
|
|
738
738
|
}
|
|
@@ -847,7 +847,7 @@ async function createK8sClient(options = {}) {
|
|
|
847
847
|
return {
|
|
848
848
|
provenance,
|
|
849
849
|
defaultNamespace,
|
|
850
|
-
resolve:
|
|
850
|
+
resolve: resolve9,
|
|
851
851
|
read,
|
|
852
852
|
pathFor,
|
|
853
853
|
readIfPresent,
|
|
@@ -1038,8 +1038,8 @@ var init_src = __esm({
|
|
|
1038
1038
|
});
|
|
1039
1039
|
|
|
1040
1040
|
// src/cli.ts
|
|
1041
|
-
import { resolve as
|
|
1042
|
-
import { realpathSync, existsSync as existsSync14, readFileSync as
|
|
1041
|
+
import { resolve as resolve8, dirname as dirname7, join as join20 } from "node:path";
|
|
1042
|
+
import { realpathSync, existsSync as existsSync14, readFileSync as readFileSync16, rmSync as rmSync2 } from "node:fs";
|
|
1043
1043
|
import { fileURLToPath as fileURLToPath5 } from "node:url";
|
|
1044
1044
|
import { spawn as spawn6, spawnSync as spawnSync2 } from "node:child_process";
|
|
1045
1045
|
|
|
@@ -1120,8 +1120,8 @@ import { serve } from "@hono/node-server";
|
|
|
1120
1120
|
import { fileURLToPath as fileURLToPath3 } from "node:url";
|
|
1121
1121
|
import { execFile as execFile3 } from "node:child_process";
|
|
1122
1122
|
import { promisify as promisify3 } from "node:util";
|
|
1123
|
-
import { existsSync as existsSync13, readFileSync as
|
|
1124
|
-
import { basename, dirname as
|
|
1123
|
+
import { existsSync as existsSync13, readFileSync as readFileSync14 } from "node:fs";
|
|
1124
|
+
import { basename, dirname as dirname5, join as join18, relative as relative4, resolve as resolve6, sep as sep4 } from "node:path";
|
|
1125
1125
|
|
|
1126
1126
|
// src/recents.ts
|
|
1127
1127
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
@@ -1310,6 +1310,8 @@ function detectProjectShape(projectDir) {
|
|
|
1310
1310
|
}
|
|
1311
1311
|
|
|
1312
1312
|
// node_modules/@intentius/chant/src/yaml.ts
|
|
1313
|
+
var KEY_LINE = /^(\s*)([^\s:][^:]*?):(?=\s|$)\s*(.*)$/;
|
|
1314
|
+
var ITEM_KEY = /^([^\s:][^:]*?):(?=\s|$)\s*(.*)$/;
|
|
1313
1315
|
function parseYAML(content) {
|
|
1314
1316
|
try {
|
|
1315
1317
|
return JSON.parse(content);
|
|
@@ -1385,7 +1387,7 @@ function parseYAMLLines(lines, startIndex, baseIndent) {
|
|
|
1385
1387
|
const indent = line.search(/\S/);
|
|
1386
1388
|
if (indent < baseIndent) break;
|
|
1387
1389
|
if (indent > baseIndent && startIndex > 0) break;
|
|
1388
|
-
const keyMatch = line.match(
|
|
1390
|
+
const keyMatch = line.match(KEY_LINE);
|
|
1389
1391
|
if (keyMatch) {
|
|
1390
1392
|
const key = keyMatch[2].trim();
|
|
1391
1393
|
const inlineValue = keyMatch[3].trim();
|
|
@@ -1518,7 +1520,7 @@ function parseYAMLArray(lines, startIndex, baseIndent) {
|
|
|
1518
1520
|
if (itemMatch && indent === baseIndent) {
|
|
1519
1521
|
const itemValue = itemMatch[2].trim();
|
|
1520
1522
|
const isQuotedScalar = itemValue.startsWith('"') && itemValue.endsWith('"') || itemValue.startsWith("'") && itemValue.endsWith("'");
|
|
1521
|
-
const kvMatch = !isQuotedScalar && itemValue.match(
|
|
1523
|
+
const kvMatch = !isQuotedScalar && itemValue.match(ITEM_KEY);
|
|
1522
1524
|
if (kvMatch) {
|
|
1523
1525
|
const obj = {};
|
|
1524
1526
|
obj[kvMatch[1].trim()] = parseArrayItemValue(kvMatch[2].trim(), lines, i, indent + 2);
|
|
@@ -1534,7 +1536,7 @@ function parseYAMLArray(lines, startIndex, baseIndent) {
|
|
|
1534
1536
|
const ni = nextLine.search(/\S/);
|
|
1535
1537
|
if (ni < nextIndent) break;
|
|
1536
1538
|
if (ni > nextIndent) break;
|
|
1537
|
-
const nextKV = nextLine.match(
|
|
1539
|
+
const nextKV = nextLine.match(KEY_LINE);
|
|
1538
1540
|
if (nextKV) {
|
|
1539
1541
|
const nextVal = nextKV[3].trim();
|
|
1540
1542
|
obj[nextKV[2].trim()] = parseArrayItemValue(nextVal, lines, j, ni);
|
|
@@ -1572,13 +1574,68 @@ function parseScalar(value) {
|
|
|
1572
1574
|
if (value === "" || value === "~" || value === "null") return null;
|
|
1573
1575
|
if (value === "true" || value === "yes") return true;
|
|
1574
1576
|
if (value === "false" || value === "no") return false;
|
|
1575
|
-
if (value.
|
|
1576
|
-
|
|
1577
|
+
if (value.length >= 2) {
|
|
1578
|
+
if (value.startsWith('"') && value.endsWith('"')) {
|
|
1579
|
+
const unescaped = unescapeDoubleQuoted(value.slice(1, -1));
|
|
1580
|
+
return unescaped ?? value.slice(1, -1);
|
|
1581
|
+
}
|
|
1582
|
+
if (value.startsWith("'") && value.endsWith("'")) {
|
|
1583
|
+
return value.slice(1, -1).replace(/''/g, "'");
|
|
1584
|
+
}
|
|
1577
1585
|
}
|
|
1578
1586
|
const num = Number(value);
|
|
1579
1587
|
if (!isNaN(num) && value !== "") return num;
|
|
1580
1588
|
return value;
|
|
1581
1589
|
}
|
|
1590
|
+
var DQ_ESCAPES = {
|
|
1591
|
+
"0": "\0",
|
|
1592
|
+
a: "\x07",
|
|
1593
|
+
b: "\b",
|
|
1594
|
+
t: " ",
|
|
1595
|
+
" ": " ",
|
|
1596
|
+
n: "\n",
|
|
1597
|
+
v: "\v",
|
|
1598
|
+
f: "\f",
|
|
1599
|
+
r: "\r",
|
|
1600
|
+
e: "\x1B",
|
|
1601
|
+
" ": " ",
|
|
1602
|
+
'"': '"',
|
|
1603
|
+
"/": "/",
|
|
1604
|
+
"\\": "\\",
|
|
1605
|
+
N: "\x85",
|
|
1606
|
+
_: "\xA0",
|
|
1607
|
+
L: "\u2028",
|
|
1608
|
+
P: "\u2029"
|
|
1609
|
+
};
|
|
1610
|
+
function unescapeDoubleQuoted(body) {
|
|
1611
|
+
let out = "";
|
|
1612
|
+
for (let i = 0; i < body.length; i++) {
|
|
1613
|
+
const ch = body[i];
|
|
1614
|
+
if (ch === '"') return null;
|
|
1615
|
+
if (ch !== "\\") {
|
|
1616
|
+
out += ch;
|
|
1617
|
+
continue;
|
|
1618
|
+
}
|
|
1619
|
+
const esc = body[++i];
|
|
1620
|
+
if (esc === void 0) return null;
|
|
1621
|
+
const width = esc === "x" ? 2 : esc === "u" ? 4 : esc === "U" ? 8 : 0;
|
|
1622
|
+
if (width > 0) {
|
|
1623
|
+
const hex = body.slice(i + 1, i + 1 + width);
|
|
1624
|
+
if (hex.length < width || !/^[0-9a-fA-F]+$/.test(hex)) return null;
|
|
1625
|
+
out += String.fromCodePoint(parseInt(hex, 16));
|
|
1626
|
+
i += width;
|
|
1627
|
+
continue;
|
|
1628
|
+
}
|
|
1629
|
+
if (esc === "\n") {
|
|
1630
|
+
while (body[i + 1] === " " || body[i + 1] === " ") i++;
|
|
1631
|
+
continue;
|
|
1632
|
+
}
|
|
1633
|
+
const mapped = DQ_ESCAPES[esc];
|
|
1634
|
+
if (mapped === void 0) return null;
|
|
1635
|
+
out += mapped;
|
|
1636
|
+
}
|
|
1637
|
+
return out;
|
|
1638
|
+
}
|
|
1582
1639
|
|
|
1583
1640
|
// src/chant.ts
|
|
1584
1641
|
function envOverridesFor(opts) {
|
|
@@ -1836,6 +1893,25 @@ function lifecycleDiffArgs(env) {
|
|
|
1836
1893
|
function lifecycleDiffLive(projectDir, env, opts = {}) {
|
|
1837
1894
|
return runChantJson(lifecycleDiffArgs(env), projectDir, envOverridesFor(opts));
|
|
1838
1895
|
}
|
|
1896
|
+
function helmRendersArgs() {
|
|
1897
|
+
return ["helm", "renders", "--json"];
|
|
1898
|
+
}
|
|
1899
|
+
function helmRenders(projectDir, opts = {}) {
|
|
1900
|
+
return runChantJson(helmRendersArgs(), projectDir, envOverridesFor(opts));
|
|
1901
|
+
}
|
|
1902
|
+
function helmRenderDiffArgs(contentDigest, env) {
|
|
1903
|
+
return ["helm", "diff", contentDigest, env, "--live", "--json"];
|
|
1904
|
+
}
|
|
1905
|
+
async function helmRenderDiffLive(projectDir, contentDigest, env, opts = {}) {
|
|
1906
|
+
const args = helmRenderDiffArgs(contentDigest, env);
|
|
1907
|
+
const { code, stdout } = await runChantRaw(args, projectDir, envOverridesFor(opts));
|
|
1908
|
+
if (code !== 0) return void 0;
|
|
1909
|
+
try {
|
|
1910
|
+
return JSON.parse(stdout);
|
|
1911
|
+
} catch {
|
|
1912
|
+
return void 0;
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1839
1915
|
function applyArgs(target, env) {
|
|
1840
1916
|
return ["run", target, "--components", "--env", env, "--progress-json"];
|
|
1841
1917
|
}
|
|
@@ -2042,19 +2118,19 @@ function finishPipelineProgress(state, exitCode) {
|
|
|
2042
2118
|
}
|
|
2043
2119
|
|
|
2044
2120
|
// src/gh-run.ts
|
|
2045
|
-
var defaultGhExec = (args) => new Promise((
|
|
2121
|
+
var defaultGhExec = (args) => new Promise((resolve9) => {
|
|
2046
2122
|
let out = "";
|
|
2047
2123
|
let proc;
|
|
2048
2124
|
try {
|
|
2049
2125
|
proc = spawn2("gh", args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
2050
2126
|
} catch {
|
|
2051
|
-
|
|
2127
|
+
resolve9({ code: 127, out: "" });
|
|
2052
2128
|
return;
|
|
2053
2129
|
}
|
|
2054
2130
|
proc.stdout.on("data", (d) => out += d);
|
|
2055
2131
|
proc.stderr.on("data", (d) => out += d);
|
|
2056
|
-
proc.on("error", () =>
|
|
2057
|
-
proc.on("close", (code) =>
|
|
2132
|
+
proc.on("error", () => resolve9({ code: 127, out }));
|
|
2133
|
+
proc.on("close", (code) => resolve9({ code: code ?? 1, out }));
|
|
2058
2134
|
});
|
|
2059
2135
|
async function ghReady(exec = defaultGhExec) {
|
|
2060
2136
|
const { code, out } = await exec(["auth", "status"]);
|
|
@@ -2598,181 +2674,602 @@ function applyHelmArtifacts(ir, observed) {
|
|
|
2598
2674
|
return { charts, installed };
|
|
2599
2675
|
}
|
|
2600
2676
|
|
|
2601
|
-
// src/
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2677
|
+
// src/estate.ts
|
|
2678
|
+
import { statSync as statSync3 } from "node:fs";
|
|
2679
|
+
import { availableParallelism } from "node:os";
|
|
2680
|
+
import { join as join7, resolve as resolve3, sep } from "node:path";
|
|
2681
|
+
import { composeStacks, shortStackNames } from "@intentius/pinhole";
|
|
2682
|
+
|
|
2683
|
+
// src/member-ir.ts
|
|
2684
|
+
import { createHash } from "node:crypto";
|
|
2685
|
+
import { readdirSync as readdirSync3, statSync as statSync2 } from "node:fs";
|
|
2686
|
+
import { join as join6, relative, resolve as resolve2 } from "node:path";
|
|
2687
|
+
var SKIP = /* @__PURE__ */ new Set(["node_modules", "dist", ".git"]);
|
|
2688
|
+
function memberIrCacheSize(env = process.env) {
|
|
2689
|
+
const override = Number.parseInt(env.BEHOLD_ESTATE_IR_CACHE ?? "", 10);
|
|
2690
|
+
return Number.isInteger(override) && override >= 0 ? override : 64;
|
|
2691
|
+
}
|
|
2692
|
+
function memberSourceStamp(dir) {
|
|
2693
|
+
const root = resolve2(dir);
|
|
2694
|
+
const h = createHash("sha1");
|
|
2695
|
+
let any = false;
|
|
2696
|
+
const walk = (at) => {
|
|
2697
|
+
const entries = readdirSync3(at, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : 1);
|
|
2698
|
+
for (const e of entries) {
|
|
2699
|
+
if (SKIP.has(e.name)) continue;
|
|
2700
|
+
const path = join6(at, e.name);
|
|
2701
|
+
if (e.isDirectory()) {
|
|
2702
|
+
walk(path);
|
|
2703
|
+
continue;
|
|
2704
|
+
}
|
|
2705
|
+
if (!e.isFile()) continue;
|
|
2706
|
+
const st = statSync2(path);
|
|
2707
|
+
h.update(`${relative(root, path)}\0${st.mtimeMs}\0${st.size}
|
|
2708
|
+
`);
|
|
2709
|
+
any = true;
|
|
2710
|
+
}
|
|
2711
|
+
};
|
|
2712
|
+
try {
|
|
2713
|
+
walk(root);
|
|
2714
|
+
} catch {
|
|
2715
|
+
return void 0;
|
|
2629
2716
|
}
|
|
2630
|
-
return
|
|
2717
|
+
return any ? h.digest("hex") : void 0;
|
|
2631
2718
|
}
|
|
2632
|
-
function
|
|
2633
|
-
const
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
const bound = clusters.filter((n) => contextBindsCluster(boundContext, managedClusterName(n)));
|
|
2637
|
-
return bound.length === 1 ? bound[0] : void 0;
|
|
2719
|
+
function cacheKey(dir, opts) {
|
|
2720
|
+
const chant = resolveChant(dir);
|
|
2721
|
+
const canonical = JSON.stringify(opts, Object.keys(opts).sort());
|
|
2722
|
+
return `${resolve2(dir)}\0${chant.bin}\0${chant.version ?? ""}\0${canonical}`;
|
|
2638
2723
|
}
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
for (const n of k8sNodes) {
|
|
2647
|
-
if (!isNamespace(n)) continue;
|
|
2648
|
-
const name = metaString(n, "name");
|
|
2649
|
-
if (name && !namespaceByName.has(name)) namespaceByName.set(name, n.id);
|
|
2650
|
-
}
|
|
2651
|
-
const existing = new Set(ir.edges.map((e) => `${e.from}\0${e.to}`));
|
|
2652
|
-
const add = (from, to) => {
|
|
2653
|
-
if (from === to) return;
|
|
2654
|
-
const key = `${from}\0${to}`;
|
|
2655
|
-
if (existing.has(key)) return;
|
|
2656
|
-
existing.add(key);
|
|
2657
|
-
ir.edges.push({ from, to, kind: "ref", viaAttr: ANCHOR_VIA, inferred: true });
|
|
2658
|
-
};
|
|
2659
|
-
for (const n of k8sNodes) {
|
|
2660
|
-
if (isNamespace(n)) {
|
|
2661
|
-
add(cluster.id, n.id);
|
|
2662
|
-
continue;
|
|
2663
|
-
}
|
|
2664
|
-
const ns = metaString(n, "namespace");
|
|
2665
|
-
const nsNode = ns ? namespaceByName.get(ns) : void 0;
|
|
2666
|
-
add(nsNode ?? cluster.id, n.id);
|
|
2724
|
+
var cache = /* @__PURE__ */ new Map();
|
|
2725
|
+
var hits = 0;
|
|
2726
|
+
var misses = 0;
|
|
2727
|
+
function invalidateMember(dir) {
|
|
2728
|
+
if (dir === void 0) {
|
|
2729
|
+
cache.clear();
|
|
2730
|
+
return;
|
|
2667
2731
|
}
|
|
2668
|
-
|
|
2732
|
+
const root = resolve2(dir);
|
|
2733
|
+
for (const [key, entry] of cache) if (entry.dir === root) cache.delete(key);
|
|
2734
|
+
}
|
|
2735
|
+
async function memberIr(dir, opts = {}) {
|
|
2736
|
+
const size = memberIrCacheSize();
|
|
2737
|
+
if (opts.live || opts.overlay || size === 0) return graphIr(dir, opts);
|
|
2738
|
+
const stamp = memberSourceStamp(dir);
|
|
2739
|
+
const key = cacheKey(dir, opts);
|
|
2740
|
+
const hit = stamp !== void 0 ? cache.get(key) : void 0;
|
|
2741
|
+
if (hit && hit.stamp === stamp) {
|
|
2742
|
+
hits++;
|
|
2743
|
+
cache.delete(key);
|
|
2744
|
+
cache.set(key, hit);
|
|
2745
|
+
return structuredClone(hit.ir);
|
|
2746
|
+
}
|
|
2747
|
+
misses++;
|
|
2748
|
+
const ir = await graphIr(dir, opts);
|
|
2749
|
+
if (stamp === void 0 || memberSourceStamp(dir) !== stamp) return ir;
|
|
2750
|
+
cache.delete(key);
|
|
2751
|
+
cache.set(key, { dir: resolve2(dir), stamp, ir });
|
|
2752
|
+
for (const oldest of cache.keys()) {
|
|
2753
|
+
if (cache.size <= size) break;
|
|
2754
|
+
cache.delete(oldest);
|
|
2755
|
+
}
|
|
2756
|
+
return structuredClone(ir);
|
|
2669
2757
|
}
|
|
2670
2758
|
|
|
2671
|
-
// src/
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
"
|
|
2675
|
-
|
|
2676
|
-
"Microsoft.ContainerService/managedClusters",
|
|
2677
|
-
"Microsoft.ContainerInstance/containerGroups",
|
|
2678
|
-
"Microsoft.Web/sites",
|
|
2679
|
-
// network edge
|
|
2680
|
-
"Microsoft.Network/publicIPAddresses",
|
|
2681
|
-
"Microsoft.Network/loadBalancers",
|
|
2682
|
-
"Microsoft.Network/applicationGateways",
|
|
2683
|
-
"Microsoft.Network/natGateways",
|
|
2684
|
-
"Microsoft.Network/dnsZones",
|
|
2685
|
-
// data stores
|
|
2686
|
-
"Microsoft.Storage/storageAccounts",
|
|
2687
|
-
"Microsoft.Sql/servers",
|
|
2688
|
-
"Microsoft.DocumentDB/databaseAccounts",
|
|
2689
|
-
"Microsoft.Cache/redis",
|
|
2690
|
-
"Microsoft.DBforPostgreSQL/flexibleServers",
|
|
2691
|
-
// registry / identity edge
|
|
2692
|
-
"Microsoft.ContainerRegistry/registries"
|
|
2693
|
-
]);
|
|
2694
|
-
var AZURE_FABRIC_KINDS = /* @__PURE__ */ new Set([
|
|
2695
|
-
"Microsoft.Network/virtualNetworks",
|
|
2696
|
-
"Microsoft.Network/virtualNetworks_subnets",
|
|
2697
|
-
"Microsoft.Network/networkSecurityGroups",
|
|
2698
|
-
"Microsoft.Network/routeTables",
|
|
2699
|
-
"Microsoft.Network/networkInterfaces",
|
|
2700
|
-
// identity/authorization hubs — a role assignment or managed identity is
|
|
2701
|
-
// referenced by half the estate, so contracting through one invents edges
|
|
2702
|
-
// between unrelated resources.
|
|
2703
|
-
"Microsoft.Authorization/roleAssignments",
|
|
2704
|
-
"Microsoft.ManagedIdentity/userAssignedIdentities",
|
|
2705
|
-
"Microsoft.KeyVault/vaults"
|
|
2706
|
-
]);
|
|
2707
|
-
var VNET_KIND = "Microsoft.Network/virtualNetworks";
|
|
2708
|
-
var SUBNET_KIND = "Microsoft.Network/virtualNetworks_subnets";
|
|
2709
|
-
var NSG_KIND = "Microsoft.Network/networkSecurityGroups";
|
|
2710
|
-
var RESOURCE_ID_RE = /resourceId\(\s*([^)]*)\)/g;
|
|
2711
|
-
var QUOTED_RE = /'([^']*)'/g;
|
|
2712
|
-
function resourceIdNames(value) {
|
|
2713
|
-
if (typeof value !== "string" || !value.includes("resourceId(")) return [];
|
|
2714
|
-
const out = [];
|
|
2715
|
-
for (const call of value.matchAll(RESOURCE_ID_RE)) {
|
|
2716
|
-
const args = [...call[1].matchAll(QUOTED_RE)].map((m) => m[1]);
|
|
2717
|
-
if (args.length >= 2) out.push(args[args.length - 1]);
|
|
2759
|
+
// src/zoom-notes.ts
|
|
2760
|
+
function zoomNote(zoom, ir, compositeEdgesAttached) {
|
|
2761
|
+
const empty = ir.nodes.length === 0;
|
|
2762
|
+
if (zoom === "components") {
|
|
2763
|
+
return empty ? "no components discovered \u2014 nothing declares one in this project" : void 0;
|
|
2718
2764
|
}
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
function collectAzureRefs(value, out) {
|
|
2722
|
-
if (value === null || typeof value !== "object") {
|
|
2723
|
-
for (const name of resourceIdNames(value)) out.add(name);
|
|
2724
|
-
return;
|
|
2765
|
+
if (zoom === "logical") {
|
|
2766
|
+
return empty ? "logical is an AWS projection \u2014 no AWS resources in this estate" : void 0;
|
|
2725
2767
|
}
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2768
|
+
if (zoom === "composites") {
|
|
2769
|
+
return compositeEdgesAttached === 0 ? "no component ownership to join \u2014 showing the resource graph unchanged" : void 0;
|
|
2770
|
+
}
|
|
2771
|
+
if (zoom === "runtime") {
|
|
2772
|
+
const children = ir.nodes.filter((n) => n.runtimeOwner).length;
|
|
2773
|
+
return children === 0 ? "nothing below the declaration boundary \u2014 no owner-referenced children on this substrate" : void 0;
|
|
2774
|
+
}
|
|
2775
|
+
return void 0;
|
|
2729
2776
|
}
|
|
2730
|
-
function
|
|
2731
|
-
|
|
2732
|
-
return
|
|
2777
|
+
function logicalKept(before, after) {
|
|
2778
|
+
if (after > 0 && after * 3 >= before) return void 0;
|
|
2779
|
+
return after === 0 ? `logical projected nothing from ${before} resources \u2014 it is a cloud-topology lens, and this estate declares none of the kinds it nests (behold#74)` : `logical kept ${after} of ${before} resources \u2014 it is a cloud-topology lens, and the rest are kinds it does not nest (behold#74)`;
|
|
2733
2780
|
}
|
|
2734
|
-
function
|
|
2735
|
-
|
|
2781
|
+
function edgelessNote(zoom, ir, detail) {
|
|
2782
|
+
if (zoom === "components" || zoom === "logical") return void 0;
|
|
2783
|
+
if (ir.nodes.length === 0 || ir.edges.length > 0) return void 0;
|
|
2784
|
+
if (detail !== void 0 && detail < 3) {
|
|
2785
|
+
return "no edges at this detail \u2014 sourceRef/dependsOn and other attrs-derived references only appear at detail 3 (\u2318K \u2192 attributes, or add &detail=3)";
|
|
2786
|
+
}
|
|
2787
|
+
return "no edges \u2014 nothing in this estate references anything else";
|
|
2736
2788
|
}
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2789
|
+
var CLUSTER_SCOPED = /* @__PURE__ */ new Set([
|
|
2790
|
+
"K8s::Core::Namespace",
|
|
2791
|
+
"K8s::Rbac::ClusterRole",
|
|
2792
|
+
"K8s::Rbac::ClusterRoleBinding",
|
|
2793
|
+
"K8s::Storage::StorageClass",
|
|
2794
|
+
"K8s::Apiextensions::CustomResourceDefinition"
|
|
2795
|
+
]);
|
|
2796
|
+
function namespaceMismatchNote(nodes) {
|
|
2797
|
+
const k8s = nodes.filter((n) => n?.lexicon === "k8s");
|
|
2798
|
+
if (k8s.length === 0) return void 0;
|
|
2799
|
+
const pending = k8s.filter((n) => n.attrs?._status === "accent" && !CLUSTER_SCOPED.has(n.kind ?? ""));
|
|
2800
|
+
if (pending.length < 2) return void 0;
|
|
2801
|
+
if (pending.some((n) => typeof n.attrs?.metadata?.namespace === "string" && n.attrs.metadata.namespace)) return void 0;
|
|
2802
|
+
if (!k8s.some((n) => n.attrs?._status === "good")) return void 0;
|
|
2803
|
+
return `${pending.length} pending k8s objects declare no metadata.namespace \u2014 if a controller stamps it at apply time (e.g. Flux's targetNamespace), the live read looked in "default", not where they run`;
|
|
2741
2804
|
}
|
|
2742
|
-
function
|
|
2743
|
-
|
|
2744
|
-
|
|
2805
|
+
function namespaceJoinNote(joined) {
|
|
2806
|
+
if (joined.length === 0) return void 0;
|
|
2807
|
+
const each = joined.map((j) => `${j.name} in "${j.namespace}"`).join(", ");
|
|
2808
|
+
return `read ${each} \u2014 the namespace the estate's own Kustomization targetNamespace binds, not the app project's own declaration`;
|
|
2745
2809
|
}
|
|
2746
|
-
function
|
|
2747
|
-
const
|
|
2748
|
-
const
|
|
2749
|
-
return
|
|
2810
|
+
function notesFor(zoom, ir, compositeEdgesAttached, logicalBefore, detail) {
|
|
2811
|
+
const primary = zoom === "logical" && logicalBefore !== void 0 ? logicalKept(logicalBefore, ir.nodes.length) : zoomNote(zoom, ir, compositeEdgesAttached);
|
|
2812
|
+
const notes = [primary, edgelessNote(zoom, ir, detail)].filter((n) => n !== void 0);
|
|
2813
|
+
return notes.length ? notes.join(" \xB7 ") : void 0;
|
|
2750
2814
|
}
|
|
2751
|
-
function
|
|
2752
|
-
const
|
|
2753
|
-
if (
|
|
2754
|
-
const
|
|
2755
|
-
const
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
const
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
const subnets = azure.filter((n) => n.kind === SUBNET_KIND);
|
|
2763
|
-
const vnetByName = new Map(vnets.map((v) => [resourceName(v) ?? v.id, v]));
|
|
2764
|
-
const refOut = /* @__PURE__ */ new Map();
|
|
2765
|
-
for (const n of azure) {
|
|
2766
|
-
const names = /* @__PURE__ */ new Set();
|
|
2767
|
-
collectAzureRefs(n.attrs, names);
|
|
2768
|
-
const targets = /* @__PURE__ */ new Set();
|
|
2769
|
-
for (const name of names) {
|
|
2770
|
-
const target = byName.get(name) ?? byId.get(name);
|
|
2771
|
-
if (target && target.id !== n.id) targets.add(target.id);
|
|
2772
|
-
}
|
|
2773
|
-
if (targets.size) refOut.set(n.id, targets);
|
|
2815
|
+
function tierMismatchNote(ir, tiers, currentTier) {
|
|
2816
|
+
const values = tiers?.values ?? [];
|
|
2817
|
+
if (values.length < 2) return void 0;
|
|
2818
|
+
const byLexicon = /* @__PURE__ */ new Map();
|
|
2819
|
+
for (const n of ir.nodes) {
|
|
2820
|
+
const status = n.attrs?._status;
|
|
2821
|
+
if (status !== "good" && status !== "accent") continue;
|
|
2822
|
+
const lexicon = n.lexicon ?? "?";
|
|
2823
|
+
const c = byLexicon.get(lexicon) ?? { good: 0, accent: 0 };
|
|
2824
|
+
c[status === "good" ? "good" : "accent"]++;
|
|
2825
|
+
byLexicon.set(lexicon, c);
|
|
2774
2826
|
}
|
|
2775
|
-
for (const
|
|
2827
|
+
for (const [lexicon, { good, accent }] of [...byLexicon].sort()) {
|
|
2828
|
+
if (accent < 2 || accent * 2 < good + accent) continue;
|
|
2829
|
+
const here = currentTier ? `tier "${currentTier}"` : "the default tier";
|
|
2830
|
+
const others = values.filter((v) => v !== currentTier).join(", ");
|
|
2831
|
+
return `${accent} of ${good + accent} ${lexicon} resources read "declared, not deployed" at ${here} \u2014 if the estate runs another declared tier (${others}), pick it (\u2318K \u2192 tier)`;
|
|
2832
|
+
}
|
|
2833
|
+
return void 0;
|
|
2834
|
+
}
|
|
2835
|
+
|
|
2836
|
+
// src/estate.ts
|
|
2837
|
+
function estateReadPool(members, env = process.env) {
|
|
2838
|
+
const override = Number.parseInt(env.BEHOLD_ESTATE_CONCURRENCY ?? "", 10);
|
|
2839
|
+
const cap = Number.isInteger(override) && override >= 1 ? override : Math.min(4, availableParallelism());
|
|
2840
|
+
return Math.max(1, Math.min(cap, members));
|
|
2841
|
+
}
|
|
2842
|
+
async function mapPool(items, width, fn) {
|
|
2843
|
+
const results = new Array(items.length);
|
|
2844
|
+
let next = 0;
|
|
2845
|
+
const worker = async () => {
|
|
2846
|
+
for (let i = next++; i < items.length; i = next++) {
|
|
2847
|
+
results[i] = await fn(items[i], i);
|
|
2848
|
+
}
|
|
2849
|
+
};
|
|
2850
|
+
await Promise.all(Array.from({ length: Math.max(1, Math.min(width, items.length)) }, () => worker()));
|
|
2851
|
+
return results;
|
|
2852
|
+
}
|
|
2853
|
+
async function composeEstate(projectDirs, opts = {}) {
|
|
2854
|
+
const names = shortStackNames(projectDirs);
|
|
2855
|
+
const stacks = await mapPool(projectDirs, estateReadPool(projectDirs.length), async (dir, i) => ({
|
|
2856
|
+
name: names[i],
|
|
2857
|
+
ir: await memberIr(dir, opts)
|
|
2858
|
+
}));
|
|
2859
|
+
return composeStacks(stacks);
|
|
2860
|
+
}
|
|
2861
|
+
var firstLine2 = (e) => {
|
|
2862
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
2863
|
+
const m = msg.match(/exited \d+:\s*([\s\S]*)/);
|
|
2864
|
+
return (m ? m[1] : msg).split("\n")[0].trim().slice(0, 160);
|
|
2865
|
+
};
|
|
2866
|
+
function namespaceRuntimeOwners(name, ir) {
|
|
2867
|
+
const own = new Set(ir.nodes.map((n) => n.id));
|
|
2868
|
+
for (const n of ir.nodes) {
|
|
2869
|
+
if (n.runtimeOwner && own.has(n.runtimeOwner)) n.runtimeOwner = `${name}/${n.runtimeOwner}`;
|
|
2870
|
+
}
|
|
2871
|
+
return ir;
|
|
2872
|
+
}
|
|
2873
|
+
var NAMESPACE_JOIN_FLOOR = "0.44.5";
|
|
2874
|
+
var rec2 = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : void 0;
|
|
2875
|
+
var str2 = (v) => typeof v === "string" && v.length > 0 ? v : void 0;
|
|
2876
|
+
var realIsDir = (p) => {
|
|
2877
|
+
try {
|
|
2878
|
+
return statSync3(p).isDirectory();
|
|
2879
|
+
} catch {
|
|
2880
|
+
return false;
|
|
2881
|
+
}
|
|
2882
|
+
};
|
|
2883
|
+
function declaredNamespaceBindings(ir) {
|
|
2884
|
+
const out = [];
|
|
2885
|
+
for (const n of ir.nodes) {
|
|
2886
|
+
if (n.kind !== "K8s::Flux::Kustomization") continue;
|
|
2887
|
+
const spec = rec2(n.attrs?.spec);
|
|
2888
|
+
const namespace = str2(spec?.targetNamespace);
|
|
2889
|
+
const path = str2(spec?.path);
|
|
2890
|
+
if (namespace && path) out.push({ path, namespace });
|
|
2891
|
+
}
|
|
2892
|
+
return out;
|
|
2893
|
+
}
|
|
2894
|
+
function awaitsNamespaceBinding(ir) {
|
|
2895
|
+
const namespaced = ir.nodes.filter((n) => n.lexicon === "k8s" && !CLUSTER_SCOPED.has(n.kind ?? ""));
|
|
2896
|
+
if (namespaced.length === 0) return false;
|
|
2897
|
+
return namespaced.every((n) => !str2(rec2(n.attrs?.metadata)?.namespace));
|
|
2898
|
+
}
|
|
2899
|
+
function declaredSegments(path) {
|
|
2900
|
+
if (path.startsWith("/")) return void 0;
|
|
2901
|
+
const segs = path.split("/").filter((s) => s !== "" && s !== ".");
|
|
2902
|
+
if (segs.length === 0 || segs.includes("..")) return void 0;
|
|
2903
|
+
return segs;
|
|
2904
|
+
}
|
|
2905
|
+
function pathAlignment(declared, dir, isDir = realIsDir) {
|
|
2906
|
+
const want = declaredSegments(declared);
|
|
2907
|
+
if (!want) return 0;
|
|
2908
|
+
const root = resolve3(dir);
|
|
2909
|
+
const have = root.split(sep).filter(Boolean);
|
|
2910
|
+
for (let k = Math.min(want.length, have.length); k >= 1; k--) {
|
|
2911
|
+
if (!have.slice(-k).every((s, i) => s === want[i])) continue;
|
|
2912
|
+
if (!isDir(join7(root, ...want.slice(k)))) continue;
|
|
2913
|
+
return k;
|
|
2914
|
+
}
|
|
2915
|
+
return 0;
|
|
2916
|
+
}
|
|
2917
|
+
function joinNamespaceBindings(members, isDir = realIsDir) {
|
|
2918
|
+
const bindings = members.flatMap(
|
|
2919
|
+
(m) => m.ir ? declaredNamespaceBindings(m.ir).map((b) => ({ ...b, declaredBy: m.dir })) : []
|
|
2920
|
+
);
|
|
2921
|
+
if (bindings.length === 0) return [];
|
|
2922
|
+
const awaiting = members.filter((m) => m.ir && awaitsNamespaceBinding(m.ir));
|
|
2923
|
+
if (awaiting.length === 0) return [];
|
|
2924
|
+
const best = /* @__PURE__ */ new Map();
|
|
2925
|
+
for (const b of bindings) {
|
|
2926
|
+
let winner;
|
|
2927
|
+
let tied = false;
|
|
2928
|
+
for (const m of awaiting) {
|
|
2929
|
+
if (m.dir === b.declaredBy) continue;
|
|
2930
|
+
const score = pathAlignment(b.path, m.dir, isDir);
|
|
2931
|
+
if (score === 0) continue;
|
|
2932
|
+
if (!winner || score > winner.score) {
|
|
2933
|
+
winner = { dir: m.dir, score };
|
|
2934
|
+
tied = false;
|
|
2935
|
+
} else if (score === winner.score) {
|
|
2936
|
+
tied = true;
|
|
2937
|
+
}
|
|
2938
|
+
}
|
|
2939
|
+
if (!winner || tied) continue;
|
|
2940
|
+
const prev = best.get(winner.dir);
|
|
2941
|
+
if (prev === "ambiguous") continue;
|
|
2942
|
+
const join21 = { dir: winner.dir, namespace: b.namespace, path: b.path, declaredBy: b.declaredBy };
|
|
2943
|
+
if (!prev) best.set(winner.dir, { score: winner.score, join: join21 });
|
|
2944
|
+
else if (prev.join.namespace !== b.namespace) best.set(winner.dir, "ambiguous");
|
|
2945
|
+
else if (winner.score > prev.score) best.set(winner.dir, { score: winner.score, join: join21 });
|
|
2946
|
+
}
|
|
2947
|
+
return [...best.values()].filter((v) => v !== "ambiguous").map((v) => v.join);
|
|
2948
|
+
}
|
|
2949
|
+
async function estateNamespaceScopes(projectDirs, opts, isDir = realIsDir) {
|
|
2950
|
+
if (projectDirs.length < 2) return /* @__PURE__ */ new Map();
|
|
2951
|
+
const { live: _live, overlay: _overlay, namespace: _namespace, ...src } = opts;
|
|
2952
|
+
const irs = await mapPool(
|
|
2953
|
+
projectDirs,
|
|
2954
|
+
estateReadPool(projectDirs.length),
|
|
2955
|
+
(dir) => memberIr(dir, { ...src, detail: 3 }).catch(() => void 0)
|
|
2956
|
+
);
|
|
2957
|
+
const scopes = /* @__PURE__ */ new Map();
|
|
2958
|
+
for (const j of joinNamespaceBindings(projectDirs.map((dir, i) => ({ dir, ir: irs[i] })), isDir)) {
|
|
2959
|
+
if (!meetsFloor(resolveChant(j.dir).version, NAMESPACE_JOIN_FLOOR)) continue;
|
|
2960
|
+
scopes.set(j.dir, j.namespace);
|
|
2961
|
+
}
|
|
2962
|
+
return scopes;
|
|
2963
|
+
}
|
|
2964
|
+
function withoutJoinedMembers(nodes, joined) {
|
|
2965
|
+
if (joined.length === 0) return [...nodes];
|
|
2966
|
+
return nodes.filter((n) => !joined.some((j) => n.id.startsWith(`${j.name}/`)));
|
|
2967
|
+
}
|
|
2968
|
+
async function composeEstateOverlay(projectDirs, opts, classify) {
|
|
2969
|
+
const names = shortStackNames(projectDirs);
|
|
2970
|
+
const unobserved = [];
|
|
2971
|
+
const dropped = [];
|
|
2972
|
+
const joined = [];
|
|
2973
|
+
const stacks = new Array(projectDirs.length);
|
|
2974
|
+
const scopes = await estateNamespaceScopes(projectDirs, opts);
|
|
2975
|
+
await mapPool(projectDirs, estateReadPool(projectDirs.length), async (dir, i) => {
|
|
2976
|
+
const name = names[i];
|
|
2977
|
+
const namespace = scopes.get(dir);
|
|
2978
|
+
try {
|
|
2979
|
+
const live = { ...opts, live: true, overlay: true, ...namespace ? { namespace } : {} };
|
|
2980
|
+
stacks[i] = { name, ir: namespaceRuntimeOwners(name, classify(await graphIr(dir, live))) };
|
|
2981
|
+
if (namespace) joined.push({ name, namespace });
|
|
2982
|
+
} catch (err) {
|
|
2983
|
+
const reason = firstLine2(err);
|
|
2984
|
+
try {
|
|
2985
|
+
const { env: _env, live: _live, overlay: _overlay, ...srcOpts } = opts;
|
|
2986
|
+
const src = await memberIr(dir, srcOpts);
|
|
2987
|
+
for (const n of src.nodes) n.attrs = { ...n.attrs, _status: "neutral", _unobserved: reason };
|
|
2988
|
+
stacks[i] = { name, ir: src };
|
|
2989
|
+
unobserved.push({ name, reason });
|
|
2990
|
+
} catch (err2) {
|
|
2991
|
+
dropped.push({ name, reason: firstLine2(err2) });
|
|
2992
|
+
}
|
|
2993
|
+
}
|
|
2994
|
+
});
|
|
2995
|
+
const present = stacks.filter((s) => !!s);
|
|
2996
|
+
return {
|
|
2997
|
+
ir: composeStacks(present),
|
|
2998
|
+
observed: present.length - unobserved.length,
|
|
2999
|
+
total: projectDirs.length,
|
|
3000
|
+
unobserved,
|
|
3001
|
+
dropped,
|
|
3002
|
+
// Concurrent reads finish in whatever order the clusters answer; the note
|
|
3003
|
+
// this feeds should read the same twice.
|
|
3004
|
+
joined: joined.sort((a, b) => a.name.localeCompare(b.name))
|
|
3005
|
+
};
|
|
3006
|
+
}
|
|
3007
|
+
|
|
3008
|
+
// src/helm-drift.ts
|
|
3009
|
+
function countChanges(docs) {
|
|
3010
|
+
return docs.reduce((n, d) => n + d.changes.length, 0);
|
|
3011
|
+
}
|
|
3012
|
+
function provenanceOf(live) {
|
|
3013
|
+
const m = live.manifest;
|
|
3014
|
+
return {
|
|
3015
|
+
contentDigest: m.contentDigest,
|
|
3016
|
+
inputDigest: m.inputDigest,
|
|
3017
|
+
valuesDigest: m.valuesDigest,
|
|
3018
|
+
chart: m.chart,
|
|
3019
|
+
chartVersion: m.chartVersion,
|
|
3020
|
+
repo: m.repo,
|
|
3021
|
+
releaseName: m.releaseName,
|
|
3022
|
+
namespace: m.namespace,
|
|
3023
|
+
profile: m.capabilityProfile?.cluster ?? "",
|
|
3024
|
+
kubeVersion: m.capabilityProfile?.kubeVersion ?? "",
|
|
3025
|
+
renderedAt: m.renderedAt,
|
|
3026
|
+
helmVersion: m.helmVersion,
|
|
3027
|
+
chantVersion: m.chantVersion,
|
|
3028
|
+
sourceRef: m.sourceRef
|
|
3029
|
+
};
|
|
3030
|
+
}
|
|
3031
|
+
function helmRenderReport(record, live) {
|
|
3032
|
+
const base = { render: record.name, chart: record.chart };
|
|
3033
|
+
if (!live) {
|
|
3034
|
+
return { ...base, verdict: "unobserved", reason: record.contentDigest ? "no-stored-render" : "unpinned" };
|
|
3035
|
+
}
|
|
3036
|
+
const d = live.diff;
|
|
3037
|
+
const counts = {
|
|
3038
|
+
drifted: d.drifted.length,
|
|
3039
|
+
changes: countChanges(d.drifted),
|
|
3040
|
+
unchanged: d.unchanged.length,
|
|
3041
|
+
unobserved: d.unobserved.length,
|
|
3042
|
+
undeclared: d.undeclaredEntities.length
|
|
3043
|
+
};
|
|
3044
|
+
const provenance = provenanceOf(live);
|
|
3045
|
+
if (d.drifted.length > 0) {
|
|
3046
|
+
return { ...base, verdict: "drifted", counts, provenance, drifted: d.drifted };
|
|
3047
|
+
}
|
|
3048
|
+
if (d.drifted.length + d.accepted.length + d.unchanged.length === 0) {
|
|
3049
|
+
return { ...base, verdict: "unobserved", reason: "nothing-observed", counts, provenance };
|
|
3050
|
+
}
|
|
3051
|
+
if (d.unobserved.length > 0) {
|
|
3052
|
+
return { ...base, verdict: "unobserved", reason: "partly-observed", counts, provenance };
|
|
3053
|
+
}
|
|
3054
|
+
return { ...base, verdict: "in-sync", counts, provenance };
|
|
3055
|
+
}
|
|
3056
|
+
async function readHelmRenderDrift(projectDir, env, opts = {}) {
|
|
3057
|
+
const report = await helmRenders(projectDir, opts).catch(() => void 0);
|
|
3058
|
+
const records = report?.records ?? [];
|
|
3059
|
+
if (records.length === 0) return [];
|
|
3060
|
+
return mapPool(records, estateReadPool(records.length), async (record) => {
|
|
3061
|
+
if (!record.contentDigest) return helmRenderReport(record, void 0);
|
|
3062
|
+
const live = await helmRenderDiffLive(projectDir, record.contentDigest, env, opts).catch(() => void 0);
|
|
3063
|
+
return helmRenderReport(record, live);
|
|
3064
|
+
});
|
|
3065
|
+
}
|
|
3066
|
+
function chartNameOf2(node) {
|
|
3067
|
+
const name = node.attrs?.name;
|
|
3068
|
+
return typeof name === "string" && name.length > 0 ? name : void 0;
|
|
3069
|
+
}
|
|
3070
|
+
var SEVERITY = { drifted: 2, unobserved: 1, "in-sync": 0 };
|
|
3071
|
+
function applyHelmRenderDrift(ir, reports) {
|
|
3072
|
+
if (reports.length === 0) return { charts: 0, drifted: 0 };
|
|
3073
|
+
const byChart = /* @__PURE__ */ new Map();
|
|
3074
|
+
for (const r of reports) {
|
|
3075
|
+
const list3 = byChart.get(r.chart);
|
|
3076
|
+
if (list3) list3.push(r);
|
|
3077
|
+
else byChart.set(r.chart, [r]);
|
|
3078
|
+
}
|
|
3079
|
+
let charts = 0;
|
|
3080
|
+
let drifted = 0;
|
|
3081
|
+
for (const node of ir.nodes) {
|
|
3082
|
+
if (node.lexicon !== "helm" || node.kind !== "Helm::Chart") continue;
|
|
3083
|
+
const chartName = chartNameOf2(node);
|
|
3084
|
+
const matched = chartName ? byChart.get(chartName) : void 0;
|
|
3085
|
+
if (!matched || matched.length === 0) continue;
|
|
3086
|
+
const worst = matched.reduce((a, b) => SEVERITY[b.verdict] > SEVERITY[a.verdict] ? b : a);
|
|
3087
|
+
const attrs = node.attrs ??= {};
|
|
3088
|
+
attrs._renderDrift = matched.length === 1 ? worst : { ...worst, renders: matched };
|
|
3089
|
+
charts++;
|
|
3090
|
+
if (worst.verdict === "drifted") {
|
|
3091
|
+
attrs._status = "warn";
|
|
3092
|
+
drifted++;
|
|
3093
|
+
}
|
|
3094
|
+
}
|
|
3095
|
+
return { charts, drifted };
|
|
3096
|
+
}
|
|
3097
|
+
|
|
3098
|
+
// src/cluster-anchor.ts
|
|
3099
|
+
var MANAGED_CLUSTER_KINDS = /* @__PURE__ */ new Set([
|
|
3100
|
+
"AWS::EKS::Cluster",
|
|
3101
|
+
"GCP::Container::Cluster",
|
|
3102
|
+
"Microsoft.ContainerService/managedClusters",
|
|
3103
|
+
"K3d::Cluster"
|
|
3104
|
+
]);
|
|
3105
|
+
var ANCHOR_VIA = "runs-on";
|
|
3106
|
+
function metadata(node) {
|
|
3107
|
+
const m = node.attrs?.metadata;
|
|
3108
|
+
return m && typeof m === "object" && !Array.isArray(m) ? m : void 0;
|
|
3109
|
+
}
|
|
3110
|
+
function metaString(node, key) {
|
|
3111
|
+
const v = metadata(node)?.[key];
|
|
3112
|
+
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
3113
|
+
}
|
|
3114
|
+
function isK8s(node) {
|
|
3115
|
+
return node.lexicon === "k8s";
|
|
3116
|
+
}
|
|
3117
|
+
function isNamespace(node) {
|
|
3118
|
+
return node.kind === "K8s::Core::Namespace";
|
|
3119
|
+
}
|
|
3120
|
+
function managedClusterName(node) {
|
|
3121
|
+
const metaName = metaString(node, "name");
|
|
3122
|
+
if (metaName) return metaName;
|
|
3123
|
+
for (const key of ["name", "clusterName"]) {
|
|
3124
|
+
const v = node.attrs?.[key];
|
|
3125
|
+
if (typeof v === "string" && v.length > 0) return v;
|
|
3126
|
+
}
|
|
3127
|
+
return node.id;
|
|
3128
|
+
}
|
|
3129
|
+
function boundManagedCluster(nodes, boundContext) {
|
|
3130
|
+
const clusters = nodes.filter((n) => MANAGED_CLUSTER_KINDS.has(n.kind));
|
|
3131
|
+
if (clusters.length === 1) return clusters[0];
|
|
3132
|
+
if (clusters.length === 0 || !boundContext) return void 0;
|
|
3133
|
+
const bound = clusters.filter((n) => contextBindsCluster(boundContext, managedClusterName(n)));
|
|
3134
|
+
return bound.length === 1 ? bound[0] : void 0;
|
|
3135
|
+
}
|
|
3136
|
+
function addClusterAnchorEdges(ir, boundContext) {
|
|
3137
|
+
const nodes = ir.nodes;
|
|
3138
|
+
const cluster = boundManagedCluster(ir.nodes, boundContext);
|
|
3139
|
+
if (!cluster) return ir;
|
|
3140
|
+
const k8sNodes = nodes.filter(isK8s);
|
|
3141
|
+
if (k8sNodes.length === 0) return ir;
|
|
3142
|
+
const namespaceByName = /* @__PURE__ */ new Map();
|
|
3143
|
+
for (const n of k8sNodes) {
|
|
3144
|
+
if (!isNamespace(n)) continue;
|
|
3145
|
+
const name = metaString(n, "name");
|
|
3146
|
+
if (name && !namespaceByName.has(name)) namespaceByName.set(name, n.id);
|
|
3147
|
+
}
|
|
3148
|
+
const existing = new Set(ir.edges.map((e) => `${e.from}\0${e.to}`));
|
|
3149
|
+
const add = (from, to) => {
|
|
3150
|
+
if (from === to) return;
|
|
3151
|
+
const key = `${from}\0${to}`;
|
|
3152
|
+
if (existing.has(key)) return;
|
|
3153
|
+
existing.add(key);
|
|
3154
|
+
ir.edges.push({ from, to, kind: "ref", viaAttr: ANCHOR_VIA, inferred: true });
|
|
3155
|
+
};
|
|
3156
|
+
for (const n of k8sNodes) {
|
|
3157
|
+
if (isNamespace(n)) {
|
|
3158
|
+
add(cluster.id, n.id);
|
|
3159
|
+
continue;
|
|
3160
|
+
}
|
|
3161
|
+
const ns = metaString(n, "namespace");
|
|
3162
|
+
const nsNode = ns ? namespaceByName.get(ns) : void 0;
|
|
3163
|
+
add(nsNode ?? cluster.id, n.id);
|
|
3164
|
+
}
|
|
3165
|
+
return ir;
|
|
3166
|
+
}
|
|
3167
|
+
|
|
3168
|
+
// src/logical-azure.ts
|
|
3169
|
+
var AZURE_HEADLINE_KINDS = /* @__PURE__ */ new Set([
|
|
3170
|
+
// compute / workloads
|
|
3171
|
+
"Microsoft.Compute/virtualMachines",
|
|
3172
|
+
"Microsoft.Compute/virtualMachineScaleSets",
|
|
3173
|
+
"Microsoft.ContainerService/managedClusters",
|
|
3174
|
+
"Microsoft.ContainerInstance/containerGroups",
|
|
3175
|
+
"Microsoft.Web/sites",
|
|
3176
|
+
// network edge
|
|
3177
|
+
"Microsoft.Network/publicIPAddresses",
|
|
3178
|
+
"Microsoft.Network/loadBalancers",
|
|
3179
|
+
"Microsoft.Network/applicationGateways",
|
|
3180
|
+
"Microsoft.Network/natGateways",
|
|
3181
|
+
"Microsoft.Network/dnsZones",
|
|
3182
|
+
// data stores
|
|
3183
|
+
"Microsoft.Storage/storageAccounts",
|
|
3184
|
+
"Microsoft.Sql/servers",
|
|
3185
|
+
"Microsoft.DocumentDB/databaseAccounts",
|
|
3186
|
+
"Microsoft.Cache/redis",
|
|
3187
|
+
"Microsoft.DBforPostgreSQL/flexibleServers",
|
|
3188
|
+
// registry / identity edge
|
|
3189
|
+
"Microsoft.ContainerRegistry/registries"
|
|
3190
|
+
]);
|
|
3191
|
+
var AZURE_FABRIC_KINDS = /* @__PURE__ */ new Set([
|
|
3192
|
+
"Microsoft.Network/virtualNetworks",
|
|
3193
|
+
"Microsoft.Network/virtualNetworks_subnets",
|
|
3194
|
+
"Microsoft.Network/networkSecurityGroups",
|
|
3195
|
+
"Microsoft.Network/routeTables",
|
|
3196
|
+
"Microsoft.Network/networkInterfaces",
|
|
3197
|
+
// identity/authorization hubs — a role assignment or managed identity is
|
|
3198
|
+
// referenced by half the estate, so contracting through one invents edges
|
|
3199
|
+
// between unrelated resources.
|
|
3200
|
+
"Microsoft.Authorization/roleAssignments",
|
|
3201
|
+
"Microsoft.ManagedIdentity/userAssignedIdentities",
|
|
3202
|
+
"Microsoft.KeyVault/vaults"
|
|
3203
|
+
]);
|
|
3204
|
+
var VNET_KIND = "Microsoft.Network/virtualNetworks";
|
|
3205
|
+
var SUBNET_KIND = "Microsoft.Network/virtualNetworks_subnets";
|
|
3206
|
+
var NSG_KIND = "Microsoft.Network/networkSecurityGroups";
|
|
3207
|
+
var RESOURCE_ID_RE = /resourceId\(\s*([^)]*)\)/g;
|
|
3208
|
+
var QUOTED_RE = /'([^']*)'/g;
|
|
3209
|
+
function resourceIdNames(value) {
|
|
3210
|
+
if (typeof value !== "string" || !value.includes("resourceId(")) return [];
|
|
3211
|
+
const out = [];
|
|
3212
|
+
for (const call of value.matchAll(RESOURCE_ID_RE)) {
|
|
3213
|
+
const args = [...call[1].matchAll(QUOTED_RE)].map((m) => m[1]);
|
|
3214
|
+
if (args.length >= 2) out.push(args[args.length - 1]);
|
|
3215
|
+
}
|
|
3216
|
+
return out;
|
|
3217
|
+
}
|
|
3218
|
+
function collectAzureRefs(value, out) {
|
|
3219
|
+
if (value === null || typeof value !== "object") {
|
|
3220
|
+
for (const name of resourceIdNames(value)) out.add(name);
|
|
3221
|
+
return;
|
|
3222
|
+
}
|
|
3223
|
+
const ref = value.$ref;
|
|
3224
|
+
if (typeof ref === "string") out.add(ref.split(".")[0]);
|
|
3225
|
+
for (const v of Object.values(value)) collectAzureRefs(v, out);
|
|
3226
|
+
}
|
|
3227
|
+
function strAttr(node, key) {
|
|
3228
|
+
const v = node.attrs?.[key];
|
|
3229
|
+
return typeof v === "string" ? v : void 0;
|
|
3230
|
+
}
|
|
3231
|
+
function resourceName(node) {
|
|
3232
|
+
return strAttr(node, "name");
|
|
3233
|
+
}
|
|
3234
|
+
function subnetVnetName(node) {
|
|
3235
|
+
const name = resourceName(node);
|
|
3236
|
+
if (!name || !name.includes("/")) return void 0;
|
|
3237
|
+
return name.split("/")[0];
|
|
3238
|
+
}
|
|
3239
|
+
function subnetShortName(node) {
|
|
3240
|
+
const name = resourceName(node);
|
|
3241
|
+
return name?.includes("/") ? name.split("/").slice(1).join("/") : name;
|
|
3242
|
+
}
|
|
3243
|
+
function vnetCidr(node) {
|
|
3244
|
+
const space = node.attrs?.addressSpace;
|
|
3245
|
+
const prefixes = space?.addressPrefixes;
|
|
3246
|
+
return Array.isArray(prefixes) && typeof prefixes[0] === "string" ? prefixes[0] : void 0;
|
|
3247
|
+
}
|
|
3248
|
+
function projectAzureLogical(ir, env) {
|
|
3249
|
+
const azure = ir.nodes.filter((n) => n.lexicon === "azure");
|
|
3250
|
+
if (azure.length === 0) return { ir: { nodes: [], edges: [], groups: {} }, byContainer: {} };
|
|
3251
|
+
const componentOf = componentNamer(azure);
|
|
3252
|
+
const byId = new Map(azure.map((n) => [n.id, n]));
|
|
3253
|
+
const byName = /* @__PURE__ */ new Map();
|
|
3254
|
+
for (const n of azure) {
|
|
3255
|
+
const name = resourceName(n);
|
|
3256
|
+
if (name && !byName.has(name)) byName.set(name, n);
|
|
3257
|
+
}
|
|
3258
|
+
const vnets = azure.filter((n) => n.kind === VNET_KIND);
|
|
3259
|
+
const subnets = azure.filter((n) => n.kind === SUBNET_KIND);
|
|
3260
|
+
const vnetByName = new Map(vnets.map((v) => [resourceName(v) ?? v.id, v]));
|
|
3261
|
+
const refOut = /* @__PURE__ */ new Map();
|
|
3262
|
+
for (const n of azure) {
|
|
3263
|
+
const names = /* @__PURE__ */ new Set();
|
|
3264
|
+
collectAzureRefs(n.attrs, names);
|
|
3265
|
+
const targets = /* @__PURE__ */ new Set();
|
|
3266
|
+
for (const name of names) {
|
|
3267
|
+
const target = byName.get(name) ?? byId.get(name);
|
|
3268
|
+
if (target && target.id !== n.id) targets.add(target.id);
|
|
3269
|
+
}
|
|
3270
|
+
if (targets.size) refOut.set(n.id, targets);
|
|
3271
|
+
}
|
|
3272
|
+
for (const e of ir.edges) {
|
|
2776
3273
|
if (!byId.has(e.from) || !byId.has(e.to)) continue;
|
|
2777
3274
|
(refOut.get(e.from) ?? refOut.set(e.from, /* @__PURE__ */ new Set()).get(e.from)).add(e.to);
|
|
2778
3275
|
}
|
|
@@ -3088,7 +3585,7 @@ function projectK8sLogical(ir, env, boundContext) {
|
|
|
3088
3585
|
function releaseBoxTitle(chartName) {
|
|
3089
3586
|
return `release ${chartName}`;
|
|
3090
3587
|
}
|
|
3091
|
-
function
|
|
3588
|
+
function chartNameOf3(node) {
|
|
3092
3589
|
const name = node.attrs?.name;
|
|
3093
3590
|
return typeof name === "string" && name.length > 0 ? name : node.id;
|
|
3094
3591
|
}
|
|
@@ -3109,7 +3606,7 @@ function projectHelmLogical(ir) {
|
|
|
3109
3606
|
};
|
|
3110
3607
|
const k8s = ir.nodes.filter((n) => n.lexicon === "k8s");
|
|
3111
3608
|
for (const chart of charts) {
|
|
3112
|
-
const box = releaseBoxTitle(
|
|
3609
|
+
const box = releaseBoxTitle(chartNameOf3(chart));
|
|
3113
3610
|
child(box, chart.id);
|
|
3114
3611
|
const chartDir = dirOf(chart);
|
|
3115
3612
|
if (chartDir === "") continue;
|
|
@@ -3126,7 +3623,7 @@ function projectHelmLogical(ir) {
|
|
|
3126
3623
|
|
|
3127
3624
|
// src/logical-kustomize.ts
|
|
3128
3625
|
import { existsSync as existsSync5 } from "node:fs";
|
|
3129
|
-
import { join as
|
|
3626
|
+
import { join as join8 } from "node:path";
|
|
3130
3627
|
function overlayBoxTitle(name) {
|
|
3131
3628
|
return `overlay ${name}`;
|
|
3132
3629
|
}
|
|
@@ -3146,7 +3643,7 @@ function kustomizationRoot(bases, dir, exists) {
|
|
|
3146
3643
|
let current = dir;
|
|
3147
3644
|
for (let i = 0; i < 4 && current; i++) {
|
|
3148
3645
|
for (const base of bases) {
|
|
3149
|
-
if (exists(
|
|
3646
|
+
if (exists(join8(base, current, "kustomization.yaml")) || exists(join8(base, current, "kustomization.yml"))) {
|
|
3150
3647
|
return current;
|
|
3151
3648
|
}
|
|
3152
3649
|
}
|
|
@@ -3722,83 +4219,6 @@ function addCompositeDepsCounted(ir, dag) {
|
|
|
3722
4219
|
return { ir: out, attached: out.edges.length - before };
|
|
3723
4220
|
}
|
|
3724
4221
|
|
|
3725
|
-
// src/zoom-notes.ts
|
|
3726
|
-
function zoomNote(zoom, ir, compositeEdgesAttached) {
|
|
3727
|
-
const empty = ir.nodes.length === 0;
|
|
3728
|
-
if (zoom === "components") {
|
|
3729
|
-
return empty ? "no components discovered \u2014 nothing declares one in this project" : void 0;
|
|
3730
|
-
}
|
|
3731
|
-
if (zoom === "logical") {
|
|
3732
|
-
return empty ? "logical is an AWS projection \u2014 no AWS resources in this estate" : void 0;
|
|
3733
|
-
}
|
|
3734
|
-
if (zoom === "composites") {
|
|
3735
|
-
return compositeEdgesAttached === 0 ? "no component ownership to join \u2014 showing the resource graph unchanged" : void 0;
|
|
3736
|
-
}
|
|
3737
|
-
if (zoom === "runtime") {
|
|
3738
|
-
const children = ir.nodes.filter((n) => n.runtimeOwner).length;
|
|
3739
|
-
return children === 0 ? "nothing below the declaration boundary \u2014 no owner-referenced children on this substrate" : void 0;
|
|
3740
|
-
}
|
|
3741
|
-
return void 0;
|
|
3742
|
-
}
|
|
3743
|
-
function logicalKept(before, after) {
|
|
3744
|
-
if (after > 0 && after * 3 >= before) return void 0;
|
|
3745
|
-
return after === 0 ? `logical projected nothing from ${before} resources \u2014 it is a cloud-topology lens, and this estate declares none of the kinds it nests (behold#74)` : `logical kept ${after} of ${before} resources \u2014 it is a cloud-topology lens, and the rest are kinds it does not nest (behold#74)`;
|
|
3746
|
-
}
|
|
3747
|
-
function edgelessNote(zoom, ir, detail) {
|
|
3748
|
-
if (zoom === "components" || zoom === "logical") return void 0;
|
|
3749
|
-
if (ir.nodes.length === 0 || ir.edges.length > 0) return void 0;
|
|
3750
|
-
if (detail !== void 0 && detail < 3) {
|
|
3751
|
-
return "no edges at this detail \u2014 sourceRef/dependsOn and other attrs-derived references only appear at detail 3 (\u2318K \u2192 attributes, or add &detail=3)";
|
|
3752
|
-
}
|
|
3753
|
-
return "no edges \u2014 nothing in this estate references anything else";
|
|
3754
|
-
}
|
|
3755
|
-
var CLUSTER_SCOPED = /* @__PURE__ */ new Set([
|
|
3756
|
-
"K8s::Core::Namespace",
|
|
3757
|
-
"K8s::Rbac::ClusterRole",
|
|
3758
|
-
"K8s::Rbac::ClusterRoleBinding",
|
|
3759
|
-
"K8s::Storage::StorageClass",
|
|
3760
|
-
"K8s::Apiextensions::CustomResourceDefinition"
|
|
3761
|
-
]);
|
|
3762
|
-
function namespaceMismatchNote(nodes) {
|
|
3763
|
-
const k8s = nodes.filter((n) => n?.lexicon === "k8s");
|
|
3764
|
-
if (k8s.length === 0) return void 0;
|
|
3765
|
-
const pending = k8s.filter((n) => n.attrs?._status === "accent" && !CLUSTER_SCOPED.has(n.kind ?? ""));
|
|
3766
|
-
if (pending.length < 2) return void 0;
|
|
3767
|
-
if (pending.some((n) => typeof n.attrs?.metadata?.namespace === "string" && n.attrs.metadata.namespace)) return void 0;
|
|
3768
|
-
if (!k8s.some((n) => n.attrs?._status === "good")) return void 0;
|
|
3769
|
-
return `${pending.length} pending k8s objects declare no metadata.namespace \u2014 if a controller stamps it at apply time (e.g. Flux's targetNamespace), the live read looked in "default", not where they run`;
|
|
3770
|
-
}
|
|
3771
|
-
function namespaceJoinNote(joined) {
|
|
3772
|
-
if (joined.length === 0) return void 0;
|
|
3773
|
-
const each = joined.map((j) => `${j.name} in "${j.namespace}"`).join(", ");
|
|
3774
|
-
return `read ${each} \u2014 the namespace the estate's own Kustomization targetNamespace binds, not the app project's own declaration`;
|
|
3775
|
-
}
|
|
3776
|
-
function notesFor(zoom, ir, compositeEdgesAttached, logicalBefore, detail) {
|
|
3777
|
-
const primary = zoom === "logical" && logicalBefore !== void 0 ? logicalKept(logicalBefore, ir.nodes.length) : zoomNote(zoom, ir, compositeEdgesAttached);
|
|
3778
|
-
const notes = [primary, edgelessNote(zoom, ir, detail)].filter((n) => n !== void 0);
|
|
3779
|
-
return notes.length ? notes.join(" \xB7 ") : void 0;
|
|
3780
|
-
}
|
|
3781
|
-
function tierMismatchNote(ir, tiers, currentTier) {
|
|
3782
|
-
const values = tiers?.values ?? [];
|
|
3783
|
-
if (values.length < 2) return void 0;
|
|
3784
|
-
const byLexicon = /* @__PURE__ */ new Map();
|
|
3785
|
-
for (const n of ir.nodes) {
|
|
3786
|
-
const status = n.attrs?._status;
|
|
3787
|
-
if (status !== "good" && status !== "accent") continue;
|
|
3788
|
-
const lexicon = n.lexicon ?? "?";
|
|
3789
|
-
const c = byLexicon.get(lexicon) ?? { good: 0, accent: 0 };
|
|
3790
|
-
c[status === "good" ? "good" : "accent"]++;
|
|
3791
|
-
byLexicon.set(lexicon, c);
|
|
3792
|
-
}
|
|
3793
|
-
for (const [lexicon, { good, accent }] of [...byLexicon].sort()) {
|
|
3794
|
-
if (accent < 2 || accent * 2 < good + accent) continue;
|
|
3795
|
-
const here = currentTier ? `tier "${currentTier}"` : "the default tier";
|
|
3796
|
-
const others = values.filter((v) => v !== currentTier).join(", ");
|
|
3797
|
-
return `${accent} of ${good + accent} ${lexicon} resources read "declared, not deployed" at ${here} \u2014 if the estate runs another declared tier (${others}), pick it (\u2318K \u2192 tier)`;
|
|
3798
|
-
}
|
|
3799
|
-
return void 0;
|
|
3800
|
-
}
|
|
3801
|
-
|
|
3802
4222
|
// src/resources.ts
|
|
3803
4223
|
var NON_RESOURCE_KINDS = /* @__PURE__ */ new Set(["AWS::CloudFormation::Parameter", "chant:output"]);
|
|
3804
4224
|
function nonResourceEntities(ir) {
|
|
@@ -3830,6 +4250,19 @@ function resourcesByComponent(ir, known) {
|
|
|
3830
4250
|
}
|
|
3831
4251
|
|
|
3832
4252
|
// src/reconcile.ts
|
|
4253
|
+
var DISRUPTION_SEVERITY = {
|
|
4254
|
+
"in-place": 0,
|
|
4255
|
+
rolling: 1,
|
|
4256
|
+
unknown: 1,
|
|
4257
|
+
replace: 2,
|
|
4258
|
+
destroy: 3
|
|
4259
|
+
};
|
|
4260
|
+
var DISRUPTION_LEVELS = ["in-place", "rolling", "replace", "destroy", "unknown"];
|
|
4261
|
+
function worstDisruption(a, b) {
|
|
4262
|
+
if (!a) return b;
|
|
4263
|
+
if (!b) return a;
|
|
4264
|
+
return DISRUPTION_SEVERITY[b] > DISRUPTION_SEVERITY[a] ? b : a;
|
|
4265
|
+
}
|
|
3833
4266
|
function summarizePlan(plan, byComponent, nonResource) {
|
|
3834
4267
|
const componentByEntity = /* @__PURE__ */ new Map();
|
|
3835
4268
|
for (const [component, resources] of Object.entries(byComponent)) {
|
|
@@ -3838,6 +4271,9 @@ function summarizePlan(plan, byComponent, nonResource) {
|
|
|
3838
4271
|
const counts = {};
|
|
3839
4272
|
const unobservedCounts = {};
|
|
3840
4273
|
const runtimeCounts = {};
|
|
4274
|
+
const disruption = Object.fromEntries(DISRUPTION_LEVELS.map((l) => [l, 0]));
|
|
4275
|
+
const disruptionByComponent = {};
|
|
4276
|
+
let disruptionUncorrelated;
|
|
3841
4277
|
let uncorrelated = 0;
|
|
3842
4278
|
let unobservedUncorrelated = 0;
|
|
3843
4279
|
let runtimeUncorrelated = 0;
|
|
@@ -3863,6 +4299,12 @@ function summarizePlan(plan, byComponent, nonResource) {
|
|
|
3863
4299
|
total++;
|
|
3864
4300
|
if (component) counts[component] = (counts[component] ?? 0) + 1;
|
|
3865
4301
|
else uncorrelated++;
|
|
4302
|
+
const level = entry.disruption;
|
|
4303
|
+
if (!level) continue;
|
|
4304
|
+
if (!DISRUPTION_LEVELS.includes(level)) continue;
|
|
4305
|
+
disruption[level]++;
|
|
4306
|
+
if (component) disruptionByComponent[component] = worstDisruption(disruptionByComponent[component], level);
|
|
4307
|
+
else disruptionUncorrelated = worstDisruption(disruptionUncorrelated, level);
|
|
3866
4308
|
}
|
|
3867
4309
|
return {
|
|
3868
4310
|
env: plan.env,
|
|
@@ -3874,7 +4316,10 @@ function summarizePlan(plan, byComponent, nonResource) {
|
|
|
3874
4316
|
unobservedUncorrelated,
|
|
3875
4317
|
runtime,
|
|
3876
4318
|
runtimeByComponent: runtimeCounts,
|
|
3877
|
-
runtimeUncorrelated
|
|
4319
|
+
runtimeUncorrelated,
|
|
4320
|
+
disruption,
|
|
4321
|
+
disruptionByComponent,
|
|
4322
|
+
...disruptionUncorrelated ? { disruptionUncorrelated } : {}
|
|
3878
4323
|
};
|
|
3879
4324
|
}
|
|
3880
4325
|
|
|
@@ -3890,10 +4335,10 @@ import {
|
|
|
3890
4335
|
|
|
3891
4336
|
// src/icon-packs.ts
|
|
3892
4337
|
import { readFileSync as readFileSync6 } from "node:fs";
|
|
3893
|
-
import { dirname as dirname3, join as
|
|
4338
|
+
import { dirname as dirname3, join as join9 } from "node:path";
|
|
3894
4339
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
3895
|
-
var ICON_ROOT =
|
|
3896
|
-
var
|
|
4340
|
+
var ICON_ROOT = join9(dirname3(fileURLToPath2(import.meta.url)), "..", "web", "icons");
|
|
4341
|
+
var cache2 = /* @__PURE__ */ new Map();
|
|
3897
4342
|
function unprefix(body) {
|
|
3898
4343
|
return body.replace(/\s(?:inkscape|sodipodi):[\w-]+\s*=\s*(["'])[\s\S]*?\1/g, "").replace(/\sxlink:href\s*=/g, " href=");
|
|
3899
4344
|
}
|
|
@@ -3912,9 +4357,9 @@ function plate(glyph) {
|
|
|
3912
4357
|
return { ...glyph, body: `${ground}<g transform="${inset}">${glyph.body}</g>` };
|
|
3913
4358
|
}
|
|
3914
4359
|
function loadIcon(rel) {
|
|
3915
|
-
const hit =
|
|
4360
|
+
const hit = cache2.get(rel);
|
|
3916
4361
|
if (hit) return hit;
|
|
3917
|
-
const raw = readFileSync6(
|
|
4362
|
+
const raw = readFileSync6(join9(ICON_ROOT, `${rel}.svg`), "utf8");
|
|
3918
4363
|
const open = raw.indexOf("<svg");
|
|
3919
4364
|
const openEnd = raw.indexOf(">", open);
|
|
3920
4365
|
const close = raw.lastIndexOf("</svg");
|
|
@@ -3923,7 +4368,7 @@ function loadIcon(rel) {
|
|
|
3923
4368
|
if (!viewBox) throw new Error(`vendored icon has no viewBox: ${rel}.svg`);
|
|
3924
4369
|
const bare = { body: unprefix(raw.slice(openEnd + 1, close).trim()), colored: true, viewBox };
|
|
3925
4370
|
const glyph = PLATED.has(rel) ? plate(bare) : bare;
|
|
3926
|
-
|
|
4371
|
+
cache2.set(rel, glyph);
|
|
3927
4372
|
return glyph;
|
|
3928
4373
|
}
|
|
3929
4374
|
var K8S_ICON_BY_KIND = {
|
|
@@ -4133,45 +4578,681 @@ function carveReportToIr(report) {
|
|
|
4133
4578
|
...inputs.length ? { deferredInputs: inputs.join(", ") } : {}
|
|
4134
4579
|
}
|
|
4135
4580
|
};
|
|
4136
|
-
});
|
|
4137
|
-
const seen = /* @__PURE__ */ new Set();
|
|
4138
|
-
const edges = [];
|
|
4139
|
-
for (const r of resources) {
|
|
4140
|
-
for (const e of boundaryEdgesOf(r)) {
|
|
4141
|
-
const direction = directionOf(e);
|
|
4142
|
-
const from = direction === "inbound" ? e.survivor : e.carved;
|
|
4143
|
-
const to = direction === "inbound" ? e.carved : e.survivor;
|
|
4144
|
-
if (!known.has(from) || !known.has(to)) continue;
|
|
4145
|
-
const key = `${from}|${to}`;
|
|
4146
|
-
if (seen.has(key)) continue;
|
|
4147
|
-
seen.add(key);
|
|
4148
|
-
edges.push({
|
|
4149
|
-
from,
|
|
4150
|
-
to,
|
|
4151
|
-
kind: "ref",
|
|
4152
|
-
viaAttr: direction,
|
|
4153
|
-
...e.attrs?.length ? { toAttr: e.attrs.join(", ") } : {}
|
|
4154
|
-
});
|
|
4581
|
+
});
|
|
4582
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4583
|
+
const edges = [];
|
|
4584
|
+
for (const r of resources) {
|
|
4585
|
+
for (const e of boundaryEdgesOf(r)) {
|
|
4586
|
+
const direction = directionOf(e);
|
|
4587
|
+
const from = direction === "inbound" ? e.survivor : e.carved;
|
|
4588
|
+
const to = direction === "inbound" ? e.carved : e.survivor;
|
|
4589
|
+
if (!known.has(from) || !known.has(to)) continue;
|
|
4590
|
+
const key = `${from}|${to}`;
|
|
4591
|
+
if (seen.has(key)) continue;
|
|
4592
|
+
seen.add(key);
|
|
4593
|
+
edges.push({
|
|
4594
|
+
from,
|
|
4595
|
+
to,
|
|
4596
|
+
kind: "ref",
|
|
4597
|
+
viaAttr: direction,
|
|
4598
|
+
...e.attrs?.length ? { toAttr: e.attrs.join(", ") } : {}
|
|
4599
|
+
});
|
|
4600
|
+
}
|
|
4601
|
+
}
|
|
4602
|
+
const byStack = {};
|
|
4603
|
+
for (const order of ["good", "warn", "neutral"]) {
|
|
4604
|
+
const members = resources.filter((r) => statusForBand(r.band, r.score) === order).map((r) => r.address);
|
|
4605
|
+
if (members.length) byStack[verdictForStatus(order)] = members;
|
|
4606
|
+
}
|
|
4607
|
+
return { nodes, edges, groups: { byStack } };
|
|
4608
|
+
}
|
|
4609
|
+
function carveNote(report, ir) {
|
|
4610
|
+
const counts = Object.entries(report.bands ?? {}).filter(([, n]) => n > 0).map(([band, n]) => `${n} ${band}`).join(" \xB7 ");
|
|
4611
|
+
const head = `chant carve advisory${report.from ? ` for ${report.from}` : ""}${counts ? ` \u2014 ${counts}` : ""}`;
|
|
4612
|
+
if (ir.edges.length) return `${head}. Read-only: nothing is emitted, patched, or applied.`;
|
|
4613
|
+
return `${head}. This report carries per-resource boundary COUNTS but no edge lists, so no boundary edges are drawn (chant#1636). Read-only: nothing is emitted, patched, or applied.`;
|
|
4614
|
+
}
|
|
4615
|
+
|
|
4616
|
+
// src/carve-manifest.ts
|
|
4617
|
+
import { readdirSync as readdirSync4, readFileSync as readFileSync7, statSync as statSync4 } from "node:fs";
|
|
4618
|
+
import { dirname as dirname4, join as join10 } from "node:path";
|
|
4619
|
+
var CARVE_MANIFEST_SUFFIX = ".carve.json";
|
|
4620
|
+
var APPLY_IS_HUMAN = "`chant carve apply` graduates ownership \u2014 it resolves the marker that makes chant the owner of a live resource. behold shows what the manifest records and echoes the command; it has no apply endpoint and no apply button, by design. Read the graduation plan, then run it yourself.";
|
|
4621
|
+
var isRecord2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
4622
|
+
var strings = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
|
|
4623
|
+
function parseCarveManifest(value) {
|
|
4624
|
+
if (!isRecord2(value)) return null;
|
|
4625
|
+
if (value.version !== 1) return null;
|
|
4626
|
+
if (typeof value.target !== "string" || !value.target) return null;
|
|
4627
|
+
return value;
|
|
4628
|
+
}
|
|
4629
|
+
function readCarveManifest(path, readFile = (p) => readFileSync7(p, "utf8")) {
|
|
4630
|
+
let text;
|
|
4631
|
+
try {
|
|
4632
|
+
text = readFile(path);
|
|
4633
|
+
} catch {
|
|
4634
|
+
return null;
|
|
4635
|
+
}
|
|
4636
|
+
try {
|
|
4637
|
+
return parseCarveManifest(JSON.parse(text));
|
|
4638
|
+
} catch {
|
|
4639
|
+
return null;
|
|
4640
|
+
}
|
|
4641
|
+
}
|
|
4642
|
+
function carveStageOf(m) {
|
|
4643
|
+
if (m.apply) return "applied";
|
|
4644
|
+
if (m.bridge) return "bridged";
|
|
4645
|
+
return "emitted";
|
|
4646
|
+
}
|
|
4647
|
+
function carveWordFor(stage) {
|
|
4648
|
+
switch (stage) {
|
|
4649
|
+
case "applied":
|
|
4650
|
+
return "carved \u2192 chant";
|
|
4651
|
+
case "bridged":
|
|
4652
|
+
return "bridged \u2014 apply is yours";
|
|
4653
|
+
default:
|
|
4654
|
+
return "emitted \u2014 not bridged";
|
|
4655
|
+
}
|
|
4656
|
+
}
|
|
4657
|
+
function carveToneFor(stage) {
|
|
4658
|
+
return stage === "applied" ? "good" : "accent";
|
|
4659
|
+
}
|
|
4660
|
+
function carveStageNote(state) {
|
|
4661
|
+
switch (state.stage) {
|
|
4662
|
+
case "applied":
|
|
4663
|
+
return `graduated \u2014 \`carve apply\` recorded the ownership marker` + (state.ownership?.stack ? ` (stack ${state.ownership.stack}${state.ownership.env ? `, env ${state.ownership.env}` : ""})` : "") + ". chant owns it.";
|
|
4664
|
+
case "bridged":
|
|
4665
|
+
return "emitted and bridged \u2014 the survivor patch is proposed. Terraform still owns the resource until `carve apply`.";
|
|
4666
|
+
default:
|
|
4667
|
+
return "emitted \u2014 typed chant source exists, and the surviving Terraform still reads the resource directly. `carve bridge` is next.";
|
|
4668
|
+
}
|
|
4669
|
+
}
|
|
4670
|
+
function carveStateOf(m, path) {
|
|
4671
|
+
const stage = carveStageOf(m);
|
|
4672
|
+
const deferred = Object.entries(m.emit?.params ?? {}).map(([name, p]) => `${name} \u2190 ${p?.survivor ?? "a survivor"}`);
|
|
4673
|
+
return {
|
|
4674
|
+
target: m.target,
|
|
4675
|
+
...m.tfType ? { tfType: m.tfType } : {},
|
|
4676
|
+
stage,
|
|
4677
|
+
graduated: stage === "applied",
|
|
4678
|
+
// The last stamped step wins — that is the age of the state, not the age
|
|
4679
|
+
// of the carve.
|
|
4680
|
+
...m.apply?.at || m.bridge?.at || m.emit?.at ? { at: m.apply?.at ?? m.bridge?.at ?? m.emit?.at } : {},
|
|
4681
|
+
files: strings(m.emit?.files),
|
|
4682
|
+
excised: strings(m.bridge?.excised),
|
|
4683
|
+
deferredInputs: deferred,
|
|
4684
|
+
...m.apply ? {
|
|
4685
|
+
ownership: {
|
|
4686
|
+
...m.apply.marker?.stack ? { stack: m.apply.marker.stack } : {},
|
|
4687
|
+
...m.apply.marker?.env ? { env: m.apply.marker.env } : {},
|
|
4688
|
+
...m.apply.ownershipTags ? { tags: m.apply.ownershipTags } : {}
|
|
4689
|
+
}
|
|
4690
|
+
} : {},
|
|
4691
|
+
...m.from ? { from: m.from } : {},
|
|
4692
|
+
outDir: dirname4(path),
|
|
4693
|
+
path
|
|
4694
|
+
};
|
|
4695
|
+
}
|
|
4696
|
+
var nodeCarveIo = {
|
|
4697
|
+
readdir: (dir) => readdirSync4(dir),
|
|
4698
|
+
isDirectory: (p) => {
|
|
4699
|
+
try {
|
|
4700
|
+
return statSync4(p).isDirectory();
|
|
4701
|
+
} catch {
|
|
4702
|
+
return false;
|
|
4703
|
+
}
|
|
4704
|
+
},
|
|
4705
|
+
readFile: (p) => readFileSync7(p, "utf8")
|
|
4706
|
+
};
|
|
4707
|
+
var MANIFEST_SCAN_DEPTH = 2;
|
|
4708
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", "cdk.out", ".terraform", ".git"]);
|
|
4709
|
+
function listCarveManifests(dir, io = nodeCarveIo, depth = MANIFEST_SCAN_DEPTH) {
|
|
4710
|
+
const out = [];
|
|
4711
|
+
const walk = (d, level) => {
|
|
4712
|
+
let entries;
|
|
4713
|
+
try {
|
|
4714
|
+
entries = io.readdir(d).sort();
|
|
4715
|
+
} catch {
|
|
4716
|
+
return;
|
|
4717
|
+
}
|
|
4718
|
+
for (const name of entries) {
|
|
4719
|
+
const full = join10(d, name);
|
|
4720
|
+
if (name.endsWith(CARVE_MANIFEST_SUFFIX)) {
|
|
4721
|
+
out.push(full);
|
|
4722
|
+
continue;
|
|
4723
|
+
}
|
|
4724
|
+
if (level >= depth) continue;
|
|
4725
|
+
if (name.startsWith(".") || SKIP_DIRS.has(name)) continue;
|
|
4726
|
+
if (io.isDirectory(full)) walk(full, level + 1);
|
|
4727
|
+
}
|
|
4728
|
+
};
|
|
4729
|
+
walk(dir, 0);
|
|
4730
|
+
return out;
|
|
4731
|
+
}
|
|
4732
|
+
function readCarveStates(dirs, io = nodeCarveIo, depth = MANIFEST_SCAN_DEPTH) {
|
|
4733
|
+
const rank = { emitted: 0, bridged: 1, applied: 2 };
|
|
4734
|
+
const byTarget = /* @__PURE__ */ new Map();
|
|
4735
|
+
for (const dir of dirs) {
|
|
4736
|
+
for (const path of listCarveManifests(dir, io, depth)) {
|
|
4737
|
+
const manifest = readCarveManifest(path, io.readFile);
|
|
4738
|
+
if (!manifest) continue;
|
|
4739
|
+
const state = carveStateOf(manifest, path);
|
|
4740
|
+
const held = byTarget.get(state.target);
|
|
4741
|
+
if (!held || rank[state.stage] > rank[held.stage]) byTarget.set(state.target, state);
|
|
4742
|
+
}
|
|
4743
|
+
}
|
|
4744
|
+
return byTarget;
|
|
4745
|
+
}
|
|
4746
|
+
function carveProgress(states, total) {
|
|
4747
|
+
let applied = 0;
|
|
4748
|
+
let bridged = 0;
|
|
4749
|
+
let emitted = 0;
|
|
4750
|
+
for (const s of states) {
|
|
4751
|
+
if (s.stage === "applied") applied++;
|
|
4752
|
+
else if (s.stage === "bridged") bridged++;
|
|
4753
|
+
else emitted++;
|
|
4754
|
+
}
|
|
4755
|
+
const inFlight = bridged + emitted;
|
|
4756
|
+
const parts = [];
|
|
4757
|
+
if (emitted) parts.push(`${emitted} emitted`);
|
|
4758
|
+
if (bridged) parts.push(`${bridged} bridged`);
|
|
4759
|
+
return {
|
|
4760
|
+
applied,
|
|
4761
|
+
bridged,
|
|
4762
|
+
emitted,
|
|
4763
|
+
inFlight,
|
|
4764
|
+
total: typeof total === "number" ? total : null,
|
|
4765
|
+
label: typeof total === "number" ? `${applied} of ${total} carved` : `${applied} carved`,
|
|
4766
|
+
detail: parts.length ? `${parts.join(", ")} \u2014 apply is yours` : ""
|
|
4767
|
+
};
|
|
4768
|
+
}
|
|
4769
|
+
function applyCommandFor(state, labels = {}) {
|
|
4770
|
+
const env = state.ownership?.env ?? "<env>";
|
|
4771
|
+
const stack = state.ownership?.stack ?? "<stack>";
|
|
4772
|
+
const from = labels.from ?? state.from ?? "<terraform-dir>";
|
|
4773
|
+
const out = labels.out ?? state.outDir;
|
|
4774
|
+
return `chant carve apply --from ${from} --output ${out} --env ${env} --stack ${stack} --write-source`;
|
|
4775
|
+
}
|
|
4776
|
+
function carveStatePayload(states, total, labels = () => ({})) {
|
|
4777
|
+
const list3 = [...states.values()].sort((a, b) => a.target.localeCompare(b.target));
|
|
4778
|
+
return {
|
|
4779
|
+
manifests: list3.length,
|
|
4780
|
+
progress: carveProgress(list3, total),
|
|
4781
|
+
states: list3.map((s) => ({ ...s, note: carveStageNote(s), applyCommand: applyCommandFor(s, labels(s)) })),
|
|
4782
|
+
apply: { human: true, note: APPLY_IS_HUMAN }
|
|
4783
|
+
};
|
|
4784
|
+
}
|
|
4785
|
+
function splitCarveState(tfIr, states, opts = {}) {
|
|
4786
|
+
if (states.size === 0) return { tf: tfIr, graduated: [] };
|
|
4787
|
+
const shorten = opts.shorten ?? ((p) => p);
|
|
4788
|
+
const source = (files) => files.map(shorten).join(", ");
|
|
4789
|
+
const gone = /* @__PURE__ */ new Set();
|
|
4790
|
+
const graduated = [];
|
|
4791
|
+
const nodes = [];
|
|
4792
|
+
for (const node of tfIr.nodes) {
|
|
4793
|
+
const state = states.get(node.id);
|
|
4794
|
+
if (!state) {
|
|
4795
|
+
nodes.push(node);
|
|
4796
|
+
continue;
|
|
4797
|
+
}
|
|
4798
|
+
if (state.graduated) {
|
|
4799
|
+
gone.add(node.id);
|
|
4800
|
+
graduated.push({
|
|
4801
|
+
...node,
|
|
4802
|
+
attrs: {
|
|
4803
|
+
...node.attrs,
|
|
4804
|
+
_status: "good",
|
|
4805
|
+
carve: carveWordFor(state.stage),
|
|
4806
|
+
carveState: state.stage,
|
|
4807
|
+
...state.ownership?.stack ? { ownedStack: state.ownership.stack } : {},
|
|
4808
|
+
...state.ownership?.env ? { ownedEnv: state.ownership.env } : {},
|
|
4809
|
+
...state.at ? { carvedAt: state.at } : {},
|
|
4810
|
+
...state.files.length ? { chantSource: source(state.files) } : {}
|
|
4811
|
+
}
|
|
4812
|
+
});
|
|
4813
|
+
continue;
|
|
4814
|
+
}
|
|
4815
|
+
nodes.push({
|
|
4816
|
+
...node,
|
|
4817
|
+
attrs: {
|
|
4818
|
+
...node.attrs,
|
|
4819
|
+
_status: carveToneFor(state.stage),
|
|
4820
|
+
carve: carveWordFor(state.stage),
|
|
4821
|
+
carveState: state.stage,
|
|
4822
|
+
...state.at ? { carvedAt: state.at } : {},
|
|
4823
|
+
...state.files.length ? { chantSource: source(state.files) } : {},
|
|
4824
|
+
...state.excised.length ? { excisesOnApply: state.excised.join(", ") } : {},
|
|
4825
|
+
...state.deferredInputs.length ? { deferredInputs: state.deferredInputs.join(", ") } : {}
|
|
4826
|
+
}
|
|
4827
|
+
});
|
|
4828
|
+
}
|
|
4829
|
+
const byStack = {};
|
|
4830
|
+
for (const [band, members] of Object.entries(tfIr.groups?.byStack ?? {})) {
|
|
4831
|
+
const left = members.filter((id) => !gone.has(id));
|
|
4832
|
+
if (left.length) byStack[band] = left;
|
|
4833
|
+
}
|
|
4834
|
+
return {
|
|
4835
|
+
tf: {
|
|
4836
|
+
nodes,
|
|
4837
|
+
edges: tfIr.edges.filter((e) => !gone.has(e.from) && !gone.has(e.to)),
|
|
4838
|
+
groups: { ...tfIr.groups, byStack }
|
|
4839
|
+
},
|
|
4840
|
+
graduated
|
|
4841
|
+
};
|
|
4842
|
+
}
|
|
4843
|
+
var GRADUATED_BAND = "carved \u2192 chant";
|
|
4844
|
+
function bandGraduated(tf, graduated) {
|
|
4845
|
+
if (graduated.length === 0) return tf;
|
|
4846
|
+
return {
|
|
4847
|
+
nodes: [...graduated, ...tf.nodes],
|
|
4848
|
+
edges: tf.edges,
|
|
4849
|
+
groups: {
|
|
4850
|
+
...tf.groups,
|
|
4851
|
+
byStack: {
|
|
4852
|
+
[GRADUATED_BAND]: graduated.map((n) => n.id),
|
|
4853
|
+
...tf.groups?.byStack ?? {}
|
|
4854
|
+
}
|
|
4855
|
+
}
|
|
4856
|
+
};
|
|
4857
|
+
}
|
|
4858
|
+
function carveStateNote(progress, states) {
|
|
4859
|
+
if (states.size === 0) return "";
|
|
4860
|
+
const head = ` Carve state (from ${states.size} manifest${states.size === 1 ? "" : "s"}): ${progress.label}`;
|
|
4861
|
+
return progress.detail ? `${head}, ${progress.detail}.` : `${head}.`;
|
|
4862
|
+
}
|
|
4863
|
+
|
|
4864
|
+
// src/ops-lens.ts
|
|
4865
|
+
var SUPPORTED_MAJOR2 = 1;
|
|
4866
|
+
var refuse2 = (error, remedy) => ({ ok: false, refusal: { error, code: "op-ir", remedy } });
|
|
4867
|
+
var HOW_TO_EMIT = "Build the project with a chant >= 0.50 (`chant build --lexicon temporal`), which writes `dist/ops/<name>/op.json` beside each Op's generated workflow.";
|
|
4868
|
+
var isRecord3 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
4869
|
+
function parseOpIr(value) {
|
|
4870
|
+
if (!isRecord3(value)) {
|
|
4871
|
+
return refuse2("That file isn't an op.json \u2014 its top level isn't a JSON object.", HOW_TO_EMIT);
|
|
4872
|
+
}
|
|
4873
|
+
if (typeof value.name !== "string" || !Array.isArray(value.phases)) {
|
|
4874
|
+
return refuse2(
|
|
4875
|
+
"That JSON doesn't look like an op.json \u2014 it needs a string `name` and a `phases` array.",
|
|
4876
|
+
HOW_TO_EMIT
|
|
4877
|
+
);
|
|
4878
|
+
}
|
|
4879
|
+
const bad = value.phases.findIndex((p) => !isRecord3(p) || typeof p.name !== "string" || !Array.isArray(p.steps));
|
|
4880
|
+
if (bad >= 0) {
|
|
4881
|
+
return refuse2(
|
|
4882
|
+
`That JSON has a \`phases\` array, but entry ${bad} isn't a phase (needs a string \`name\` and a \`steps\` array).`,
|
|
4883
|
+
HOW_TO_EMIT
|
|
4884
|
+
);
|
|
4885
|
+
}
|
|
4886
|
+
const version = value.formatVersion;
|
|
4887
|
+
if (version !== void 0) {
|
|
4888
|
+
const major = Number(String(version).split(".")[0]);
|
|
4889
|
+
if (!Number.isFinite(major) || major !== SUPPORTED_MAJOR2) {
|
|
4890
|
+
return refuse2(
|
|
4891
|
+
`This op.json declares format version ${String(version)}; behold reads version ${SUPPORTED_MAJOR2}.`,
|
|
4892
|
+
"Upgrade behold, or render the Op with a behold that speaks its version."
|
|
4893
|
+
);
|
|
4894
|
+
}
|
|
4895
|
+
}
|
|
4896
|
+
return { ok: true, ir: value };
|
|
4897
|
+
}
|
|
4898
|
+
function readOpIr(path, readFile) {
|
|
4899
|
+
let text;
|
|
4900
|
+
try {
|
|
4901
|
+
text = readFile(path);
|
|
4902
|
+
} catch (err) {
|
|
4903
|
+
return refuse2(`Couldn't read ${path}: ${err instanceof Error ? err.message : String(err)}`, HOW_TO_EMIT);
|
|
4904
|
+
}
|
|
4905
|
+
let json;
|
|
4906
|
+
try {
|
|
4907
|
+
json = JSON.parse(text);
|
|
4908
|
+
} catch (err) {
|
|
4909
|
+
return refuse2(`${path} isn't valid JSON: ${err instanceof Error ? err.message : String(err)}`, HOW_TO_EMIT);
|
|
4910
|
+
}
|
|
4911
|
+
return parseOpIr(json);
|
|
4912
|
+
}
|
|
4913
|
+
function receiptName(step) {
|
|
4914
|
+
const r = step.receipt;
|
|
4915
|
+
if (typeof r === "string") return r;
|
|
4916
|
+
return r && typeof r.name === "string" && r.name || "effect";
|
|
4917
|
+
}
|
|
4918
|
+
function stepLabel(step) {
|
|
4919
|
+
if (step.kind === "gate") return `gate:${step.signalName}`;
|
|
4920
|
+
if (step.kind === "effect") return `effect:${receiptName(step)}`;
|
|
4921
|
+
return step.fn;
|
|
4922
|
+
}
|
|
4923
|
+
function stepKind(step) {
|
|
4924
|
+
if (step.kind === "gate") return "Gate";
|
|
4925
|
+
if (step.kind === "effect") return "Effect";
|
|
4926
|
+
return step.fn;
|
|
4927
|
+
}
|
|
4928
|
+
function stepStatus(step) {
|
|
4929
|
+
return step.kind === "gate" ? "warn" : step.kind === "effect" ? "accent" : "neutral";
|
|
4930
|
+
}
|
|
4931
|
+
function argsLine(args) {
|
|
4932
|
+
const entries = Object.entries(args ?? {});
|
|
4933
|
+
if (!entries.length) return void 0;
|
|
4934
|
+
return entries.map(([k, v]) => `${k}=${typeof v === "object" && v !== null ? JSON.stringify(v) : String(v)}`).join(", ");
|
|
4935
|
+
}
|
|
4936
|
+
function profileLine(p) {
|
|
4937
|
+
if (!p) return void 0;
|
|
4938
|
+
const parts = [];
|
|
4939
|
+
if (p.startToCloseTimeout) parts.push(`${p.startToCloseTimeout} start-to-close`);
|
|
4940
|
+
if (p.heartbeatTimeout) parts.push(`${p.heartbeatTimeout} heartbeat`);
|
|
4941
|
+
if (p.retry?.maximumAttempts) parts.push(`${p.retry.maximumAttempts} attempts`);
|
|
4942
|
+
if (p.retry?.initialInterval) parts.push(`from ${p.retry.initialInterval}`);
|
|
4943
|
+
return parts.length ? parts.join(", ") : void 0;
|
|
4944
|
+
}
|
|
4945
|
+
function placeSteps(op) {
|
|
4946
|
+
const out = [];
|
|
4947
|
+
const lists = [
|
|
4948
|
+
[op.phases ?? [], "phases"],
|
|
4949
|
+
[op.onFailure ?? [], "onFailure"]
|
|
4950
|
+
];
|
|
4951
|
+
const used = /* @__PURE__ */ new Set();
|
|
4952
|
+
for (const [phases, when] of lists) {
|
|
4953
|
+
for (const phase of phases) {
|
|
4954
|
+
const box = when === "onFailure" ? `${op.name} \xB7 onFailure: ${phase.name}` : `${op.name} \xB7 ${phase.name}`;
|
|
4955
|
+
for (const step of phase.steps ?? []) {
|
|
4956
|
+
let id = `${op.name}/${phase.name}/${stepLabel(step)}`;
|
|
4957
|
+
for (let n = 2; used.has(id); n += 1) id = `${op.name}/${phase.name}/${stepLabel(step)}#${n}`;
|
|
4958
|
+
used.add(id);
|
|
4959
|
+
out.push({ id, step, phase, when, box });
|
|
4960
|
+
}
|
|
4961
|
+
}
|
|
4962
|
+
}
|
|
4963
|
+
return out;
|
|
4964
|
+
}
|
|
4965
|
+
function opPlacedSteps(op) {
|
|
4966
|
+
return placeSteps(op).map((p) => ({
|
|
4967
|
+
id: p.id,
|
|
4968
|
+
step: p.step,
|
|
4969
|
+
phase: p.phase.name,
|
|
4970
|
+
parallel: Boolean(p.phase.parallel),
|
|
4971
|
+
when: p.when
|
|
4972
|
+
}));
|
|
4973
|
+
}
|
|
4974
|
+
var RULE_BOX = (opName) => `${opName} \xB7 rule table`;
|
|
4975
|
+
var ruleId = (opName, rule) => `${opName}/rules/${rule.id}`;
|
|
4976
|
+
var isPredicate = (v) => isRecord3(v) && typeof v.kind === "string";
|
|
4977
|
+
function isConvergeRule(v) {
|
|
4978
|
+
if (!isRecord3(v)) return false;
|
|
4979
|
+
if (typeof v.id !== "string" || !v.id) return false;
|
|
4980
|
+
if (typeof v.why !== "string" || !v.why) return false;
|
|
4981
|
+
if (!isPredicate(v.when)) return false;
|
|
4982
|
+
const then = v.then;
|
|
4983
|
+
if (!isRecord3(then)) return false;
|
|
4984
|
+
return then.kind === "run" && typeof then.op === "string" || then.kind === "report" && typeof then.reason === "string";
|
|
4985
|
+
}
|
|
4986
|
+
function convergeTable(op) {
|
|
4987
|
+
for (const placed of placeSteps(op)) {
|
|
4988
|
+
const { step } = placed;
|
|
4989
|
+
if (step.kind !== "activity" || step.fn !== "convergeTick") continue;
|
|
4990
|
+
const rules = step.args?.rules;
|
|
4991
|
+
if (!Array.isArray(rules)) return void 0;
|
|
4992
|
+
const kept = rules.filter(isConvergeRule);
|
|
4993
|
+
if (!kept.length) return void 0;
|
|
4994
|
+
return {
|
|
4995
|
+
tickStepId: placed.id,
|
|
4996
|
+
phase: placed.phase.name,
|
|
4997
|
+
rules: kept,
|
|
4998
|
+
...typeof step.args?.dial === "string" ? { dial: step.args.dial } : {},
|
|
4999
|
+
...typeof step.args?.budget === "number" ? { budget: step.args.budget } : {},
|
|
5000
|
+
...typeof step.args?.env === "string" ? { env: step.args.env } : {}
|
|
5001
|
+
};
|
|
5002
|
+
}
|
|
5003
|
+
return void 0;
|
|
5004
|
+
}
|
|
5005
|
+
var COMPARISON_TEXT = {
|
|
5006
|
+
eq: "=",
|
|
5007
|
+
neq: "!=",
|
|
5008
|
+
gt: ">",
|
|
5009
|
+
gte: ">=",
|
|
5010
|
+
lt: "<",
|
|
5011
|
+
lte: "<="
|
|
5012
|
+
};
|
|
5013
|
+
function predicateText(predicate) {
|
|
5014
|
+
switch (predicate.kind) {
|
|
5015
|
+
case "field-comparison": {
|
|
5016
|
+
const op = COMPARISON_TEXT[predicate.op];
|
|
5017
|
+
if (!op) return `(unreadable comparison on ${predicate.field})`;
|
|
5018
|
+
const value = typeof predicate.value === "string" ? JSON.stringify(predicate.value) : String(predicate.value);
|
|
5019
|
+
return `${predicate.field} ${op} ${value}`;
|
|
5020
|
+
}
|
|
5021
|
+
case "field-truthiness":
|
|
5022
|
+
return `${predicate.field} is ${predicate.op}`;
|
|
5023
|
+
case "all-of":
|
|
5024
|
+
case "any-of": {
|
|
5025
|
+
const joiner = predicate.kind === "all-of" ? " and " : " or ";
|
|
5026
|
+
const parts = (predicate.predicates ?? []).filter(isPredicate);
|
|
5027
|
+
if (!parts.length) return predicate.kind === "all-of" ? "(always)" : "(never)";
|
|
5028
|
+
return parts.map((p) => p.kind === "all-of" || p.kind === "any-of" ? `(${predicateText(p)})` : predicateText(p)).join(joiner);
|
|
5029
|
+
}
|
|
5030
|
+
default:
|
|
5031
|
+
return "(predicate outside the kinds behold reads)";
|
|
5032
|
+
}
|
|
5033
|
+
}
|
|
5034
|
+
function actionText(action) {
|
|
5035
|
+
return action.kind === "run" ? `run(${action.op})` : `report: ${action.reason}`;
|
|
5036
|
+
}
|
|
5037
|
+
function ruleStatus(rule, declared) {
|
|
5038
|
+
if (rule.then.kind !== "run") return "neutral";
|
|
5039
|
+
return declared.has(rule.then.op) ? "accent" : "warn";
|
|
5040
|
+
}
|
|
5041
|
+
function ruleNode(op, table, rule, declared) {
|
|
5042
|
+
const dispatches = rule.then.kind === "run" && declared.has(rule.then.op) ? rule.then.op : void 0;
|
|
5043
|
+
const dangling = rule.then.kind === "run" && !declared.has(rule.then.op) ? rule.then.op : void 0;
|
|
5044
|
+
return {
|
|
5045
|
+
id: ruleId(op.name, rule),
|
|
5046
|
+
// The card's kind, as an activity's is its `fn`: a converge rule is what
|
|
5047
|
+
// this is, and the whole table reads as one kind of thing in the track.
|
|
5048
|
+
kind: "ConvergeRule",
|
|
5049
|
+
lexicon: "op",
|
|
5050
|
+
attrs: {
|
|
5051
|
+
_status: ruleStatus(rule, declared),
|
|
5052
|
+
_step: "rule",
|
|
5053
|
+
rule: rule.id,
|
|
5054
|
+
op: op.name,
|
|
5055
|
+
phase: table.phase,
|
|
5056
|
+
// The condition, the action, and the rationale — the three columns a rule
|
|
5057
|
+
// table has. `why` is chant's required field, verbatim: the accessible-ops
|
|
5058
|
+
// promise is that the answer to "why did it do that" is already written
|
|
5059
|
+
// down, so behold's job is to show it, not to summarise it.
|
|
5060
|
+
when: predicateText(rule.when),
|
|
5061
|
+
then: actionText(rule.then),
|
|
5062
|
+
why: rule.why,
|
|
5063
|
+
...dispatches ? { dispatches } : {},
|
|
5064
|
+
// A `then` naming an Op that isn't in this set: stated, not guessed at.
|
|
5065
|
+
// chant's TMP014 refuses a run() naming an Op the PROJECT doesn't declare,
|
|
5066
|
+
// so this is behold's own composition boundary showing through, and saying
|
|
5067
|
+
// which one it is beats an edge into nowhere.
|
|
5068
|
+
...dangling ? { dangling: `${dangling} \u2014 not among the Ops rendered here, so no edge is drawn` } : {},
|
|
5069
|
+
// `flapThreshold` absent means chant's own default applies. Named the way
|
|
5070
|
+
// a gate's unset timeout is, rather than restating a number chant owns.
|
|
5071
|
+
flap: rule.flapThreshold !== void 0 ? `${rule.flapThreshold} consecutive ticks` : "(chant default)",
|
|
5072
|
+
...table.dial ? { dial: table.dial } : {}
|
|
5073
|
+
}
|
|
5074
|
+
};
|
|
5075
|
+
}
|
|
5076
|
+
function nodeFor(op, placed, guards) {
|
|
5077
|
+
const { step, phase, when } = placed;
|
|
5078
|
+
const common = {
|
|
5079
|
+
op: op.name,
|
|
5080
|
+
phase: phase.name,
|
|
5081
|
+
...phase.parallel ? { parallel: true } : {},
|
|
5082
|
+
...when === "onFailure" ? { when: "onFailure" } : {}
|
|
5083
|
+
};
|
|
5084
|
+
if (step.kind === "gate") {
|
|
5085
|
+
return {
|
|
5086
|
+
id: placed.id,
|
|
5087
|
+
kind: stepKind(step),
|
|
5088
|
+
lexicon: "op",
|
|
5089
|
+
attrs: {
|
|
5090
|
+
_status: stepStatus(step),
|
|
5091
|
+
_step: "gate",
|
|
5092
|
+
gate: step.signalName,
|
|
5093
|
+
timeout: step.timeout ?? "(chant default)",
|
|
5094
|
+
...common,
|
|
5095
|
+
...step.description ? { description: step.description } : {},
|
|
5096
|
+
// "gates drawn as their own marks on the step they guard" (#284): the
|
|
5097
|
+
// gate names what it holds back, so the pause reads from the gate card
|
|
5098
|
+
// without hunting for the next one in the track.
|
|
5099
|
+
...guards ? { guards } : {}
|
|
5100
|
+
}
|
|
5101
|
+
};
|
|
5102
|
+
}
|
|
5103
|
+
if (step.kind === "effect") {
|
|
5104
|
+
return {
|
|
5105
|
+
id: placed.id,
|
|
5106
|
+
kind: stepKind(step),
|
|
5107
|
+
lexicon: "op",
|
|
5108
|
+
attrs: {
|
|
5109
|
+
_status: stepStatus(step),
|
|
5110
|
+
_step: "effect",
|
|
5111
|
+
effect: receiptName(step),
|
|
5112
|
+
...common,
|
|
5113
|
+
...step.expectation !== void 0 ? { expectation: String(step.expectation) } : {},
|
|
5114
|
+
...step.description ? { description: step.description } : {},
|
|
5115
|
+
// The nested steps, named but not drawn — see OpIrEffectStep's doc.
|
|
5116
|
+
...step.steps?.length ? { runs: step.steps.map(stepLabel).join(" \u2192 ") } : {}
|
|
5117
|
+
}
|
|
5118
|
+
};
|
|
5119
|
+
}
|
|
5120
|
+
const profile = step.profile;
|
|
5121
|
+
const policy = profileLine(profile ? op.activityProfiles?.[profile] : void 0);
|
|
5122
|
+
const args = argsLine(step.args);
|
|
5123
|
+
return {
|
|
5124
|
+
id: placed.id,
|
|
5125
|
+
kind: stepKind(step),
|
|
5126
|
+
lexicon: "op",
|
|
5127
|
+
attrs: {
|
|
5128
|
+
_status: stepStatus(step),
|
|
5129
|
+
_step: "activity",
|
|
5130
|
+
fn: step.fn,
|
|
5131
|
+
...common,
|
|
5132
|
+
...profile ? { profile } : {},
|
|
5133
|
+
...policy ? { policy } : {},
|
|
5134
|
+
// The resolved args, verbatim from the IR. They carry the Op's SCOPE
|
|
5135
|
+
// (env, stack, template path) and never an estate entity id — see the
|
|
5136
|
+
// module doc on why no edge is drawn from them.
|
|
5137
|
+
...args ? { args } : {},
|
|
5138
|
+
...step.outcomeAttribute ? { outcome: step.outcomeAttribute.from ? `${step.outcomeAttribute.name} \u2190 ${step.outcomeAttribute.from}` : step.outcomeAttribute.name } : {},
|
|
5139
|
+
...op.activityContracts?.[step.fn] ? { contract: "declared (args/returns JSON Schema in op.json)" } : {}
|
|
4155
5140
|
}
|
|
5141
|
+
};
|
|
5142
|
+
}
|
|
5143
|
+
function chainPhases(placed, when) {
|
|
5144
|
+
const byPhase = [];
|
|
5145
|
+
for (const p of placed) {
|
|
5146
|
+
if (p.when !== when) continue;
|
|
5147
|
+
const last = byPhase[byPhase.length - 1];
|
|
5148
|
+
if (last && last.phase === p.phase) last.ids.push(p.id);
|
|
5149
|
+
else byPhase.push({ phase: p.phase, ids: [p.id] });
|
|
4156
5150
|
}
|
|
5151
|
+
return byPhase.map(
|
|
5152
|
+
({ phase, ids }) => phase.parallel ? { heads: ids, tails: ids } : { heads: ids.slice(0, 1), tails: ids.slice(-1) }
|
|
5153
|
+
);
|
|
5154
|
+
}
|
|
5155
|
+
function opsToIr(ops) {
|
|
5156
|
+
const nodes = [];
|
|
5157
|
+
const edges = [];
|
|
4157
5158
|
const byStack = {};
|
|
4158
|
-
|
|
4159
|
-
|
|
4160
|
-
|
|
5159
|
+
const endpoints = /* @__PURE__ */ new Map();
|
|
5160
|
+
const declared = new Set(ops.map((o) => o.name));
|
|
5161
|
+
const dispatches = [];
|
|
5162
|
+
for (const op of ops) {
|
|
5163
|
+
const placed = placeSteps(op);
|
|
5164
|
+
for (let i = 0; i < placed.length; i += 1) {
|
|
5165
|
+
const p = placed[i];
|
|
5166
|
+
const next = placed[i + 1];
|
|
5167
|
+
const guards = p.step.kind === "gate" && next && next.when === p.when ? stepLabel(next.step) : void 0;
|
|
5168
|
+
nodes.push(nodeFor(op, p, guards));
|
|
5169
|
+
(byStack[p.box] ??= []).push(p.id);
|
|
5170
|
+
}
|
|
5171
|
+
for (const when of ["phases", "onFailure"]) {
|
|
5172
|
+
const track = chainPhases(placed, when);
|
|
5173
|
+
for (let i = 0; i + 1 < track.length; i += 1) {
|
|
5174
|
+
for (const from of track[i].tails) {
|
|
5175
|
+
for (const to of track[i + 1].heads) edges.push({ from, to, kind: "ref", viaAttr: "then" });
|
|
5176
|
+
}
|
|
5177
|
+
}
|
|
5178
|
+
if (when === "phases" && track.length) {
|
|
5179
|
+
endpoints.set(op.name, { first: track[0].heads, last: track[track.length - 1].tails });
|
|
5180
|
+
}
|
|
5181
|
+
}
|
|
5182
|
+
const table = convergeTable(op);
|
|
5183
|
+
if (!table) continue;
|
|
5184
|
+
for (const rule of table.rules) {
|
|
5185
|
+
const node = ruleNode(op, table, rule, declared);
|
|
5186
|
+
nodes.push(node);
|
|
5187
|
+
(byStack[RULE_BOX(op.name)] ??= []).push(node.id);
|
|
5188
|
+
edges.push({ from: table.tickStepId, to: node.id, kind: "ref", viaAttr: "rule" });
|
|
5189
|
+
if (rule.then.kind === "run") dispatches.push({ from: node.id, op: rule.then.op });
|
|
5190
|
+
}
|
|
5191
|
+
}
|
|
5192
|
+
for (const op of ops) {
|
|
5193
|
+
const mine = endpoints.get(op.name);
|
|
5194
|
+
if (!mine) continue;
|
|
5195
|
+
for (const dep of op.depends ?? []) {
|
|
5196
|
+
const theirs = endpoints.get(dep);
|
|
5197
|
+
if (!theirs) continue;
|
|
5198
|
+
for (const from of theirs.last) {
|
|
5199
|
+
for (const to of mine.first) edges.push({ from, to, kind: "ref", viaAttr: "depends" });
|
|
5200
|
+
}
|
|
5201
|
+
}
|
|
5202
|
+
}
|
|
5203
|
+
for (const { from, op } of dispatches) {
|
|
5204
|
+
const target = endpoints.get(op);
|
|
5205
|
+
if (!target) continue;
|
|
5206
|
+
for (const to of target.first) edges.push({ from, to, kind: "ref", viaAttr: "run" });
|
|
4161
5207
|
}
|
|
4162
5208
|
return { nodes, edges, groups: { byStack } };
|
|
4163
5209
|
}
|
|
4164
|
-
function
|
|
4165
|
-
const
|
|
4166
|
-
const
|
|
4167
|
-
|
|
4168
|
-
|
|
5210
|
+
function opCardFields(node) {
|
|
5211
|
+
const step = node.attrs._step;
|
|
5212
|
+
const phase = typeof node.attrs.phase === "string" ? node.attrs.phase : void 0;
|
|
5213
|
+
const declared = step === "gate" ? [
|
|
5214
|
+
{ label: "gate", value: String(node.attrs.gate ?? "") },
|
|
5215
|
+
{ label: "timeout", value: String(node.attrs.timeout ?? "") }
|
|
5216
|
+
] : step === "effect" ? [
|
|
5217
|
+
{ label: "effect", value: String(node.attrs.effect ?? "") },
|
|
5218
|
+
...phase ? [{ label: "phase", value: phase }] : []
|
|
5219
|
+
] : step === "activity" ? [
|
|
5220
|
+
...phase ? [{ label: "phase", value: phase }] : [],
|
|
5221
|
+
...typeof node.attrs.profile === "string" ? [{ label: "profile", value: node.attrs.profile }] : []
|
|
5222
|
+
] : (
|
|
5223
|
+
// A converge rule card leads with the two columns that make it a
|
|
5224
|
+
// rule — the condition and what it does about it. `why` is the long
|
|
5225
|
+
// one and belongs in the inspect pane, where it reads in full
|
|
5226
|
+
// rather than being cut to fit a card.
|
|
5227
|
+
step === "rule" ? [
|
|
5228
|
+
{ label: "when", value: String(node.attrs.when ?? "") },
|
|
5229
|
+
{ label: "then", value: String(node.attrs.then ?? "") }
|
|
5230
|
+
] : void 0
|
|
5231
|
+
);
|
|
5232
|
+
if (!declared) return void 0;
|
|
5233
|
+
const run3 = typeof node.attrs.run === "string" ? node.attrs.run : void 0;
|
|
5234
|
+
return run3 ? [{ label: "run", value: run3 }, ...declared.slice(0, 1)] : declared;
|
|
5235
|
+
}
|
|
5236
|
+
function opsNote(ops, ir) {
|
|
5237
|
+
const gates = ir.nodes.filter((n) => n.attrs._step === "gate").length;
|
|
5238
|
+
const rules = ir.nodes.filter((n) => n.attrs._step === "rule");
|
|
5239
|
+
const steps = ir.nodes.length - rules.length;
|
|
5240
|
+
const dangling = rules.filter((n) => n.attrs.dangling !== void 0).length;
|
|
5241
|
+
const counts = [
|
|
5242
|
+
`${ops.length} declared Op${ops.length === 1 ? "" : "s"}`,
|
|
5243
|
+
`${steps} step${steps === 1 ? "" : "s"}`,
|
|
5244
|
+
...gates ? [`${gates} gate${gates === 1 ? "" : "s"}`] : [],
|
|
5245
|
+
...rules.length ? [`${rules.length} converge rule${rules.length === 1 ? "" : "s"}`] : []
|
|
5246
|
+
].join(" \xB7 ");
|
|
5247
|
+
const ruleNote = rules.length ? ` Each converge rule carries the why chant requires of it, verbatim, and a rule whose then is run(<op>) is drawn as an edge to that Op's first step.` + (dangling ? ` ${dangling} rule${dangling === 1 ? " names an Op" : "s name Ops"} not in this set \u2014 stated on the card, not drawn.` : "") : "";
|
|
5248
|
+
return `${counts}, from each Op's emitted dist/ops/<name>/op.json.${ruleNote} No estate cross-links: op.json names an Op's env and stack but never an estate entity id, so nothing here is joined to the graph's nodes (chant ask). Read-only: this renders the declared Op \u2014 nothing is started, signalled, or applied.`;
|
|
4169
5249
|
}
|
|
4170
5250
|
|
|
4171
5251
|
// src/render.ts
|
|
4172
5252
|
registerPack({ lexicon: "k8s", iconFor: k8sIconFor });
|
|
4173
5253
|
registerPack({ lexicon: "helm", iconFor: helmIconFor });
|
|
4174
5254
|
registerPack({ lexicon: "terraform", iconFor: () => void 0, fields: carveCardFields });
|
|
5255
|
+
registerPack({ lexicon: "op", iconFor: () => void 0, fields: opCardFields });
|
|
4175
5256
|
function renderArchitecture(ir, byContainer, opts = {}) {
|
|
4176
5257
|
const spread = Math.min(1.5, ir.edges.length / Math.max(ir.nodes.length, 1));
|
|
4177
5258
|
const layout = layoutArchitecture(ir, byContainer, {
|
|
@@ -4261,7 +5342,8 @@ function bandedPlan(ir, bands) {
|
|
|
4261
5342
|
return { placed, boxes, width: panelW, height: Math.max(1, top - BAND_GAP) };
|
|
4262
5343
|
}
|
|
4263
5344
|
function renderCarveEstate(tfIr, appIr, opts) {
|
|
4264
|
-
const
|
|
5345
|
+
const staged = withCarveState(tfIr, appIr, opts);
|
|
5346
|
+
const comp = composeCarveEstate(staged.tf, staged.appNs, { ...opts, carvedTitle: staged.carvedTitle });
|
|
4265
5347
|
const svg = renderSvg(comp.ir, comp.layout, {
|
|
4266
5348
|
fit: true,
|
|
4267
5349
|
hideTitle: true,
|
|
@@ -4270,6 +5352,17 @@ function renderCarveEstate(tfIr, appIr, opts) {
|
|
|
4270
5352
|
});
|
|
4271
5353
|
return { svg, ir: comp.ir };
|
|
4272
5354
|
}
|
|
5355
|
+
function withCarveState(tfIr, appIr, opts) {
|
|
5356
|
+
const states = opts.carved ?? /* @__PURE__ */ new Map();
|
|
5357
|
+
const { tf, graduated } = splitCarveState(tfIr, states, { ...opts.shorten ? { shorten: opts.shorten } : {} });
|
|
5358
|
+
const ns = namespaceAppIr(appIr, opts.appTitle);
|
|
5359
|
+
const progress = carveProgress(states.values(), tfIr.nodes.length);
|
|
5360
|
+
return {
|
|
5361
|
+
tf,
|
|
5362
|
+
appNs: { ...ns, nodes: [...ns.nodes, ...graduated] },
|
|
5363
|
+
carvedTitle: states.size ? `carved so far \u2014 ${progress.label}` : "carved so far"
|
|
5364
|
+
};
|
|
5365
|
+
}
|
|
4273
5366
|
function namespaceAppIr(appIr, appTitle) {
|
|
4274
5367
|
const prefix = `${appTitle.split(" ")[0]}/`;
|
|
4275
5368
|
const appId = (id) => `${prefix}${id}`;
|
|
@@ -4282,7 +5375,7 @@ function namespaceAppIr(appIr, appTitle) {
|
|
|
4282
5375
|
}
|
|
4283
5376
|
function composeCarveEstate(tfIr, appNs, opts) {
|
|
4284
5377
|
const tfPlan = bandedPlan(tfIr, tfIr.groups.byStack ?? {});
|
|
4285
|
-
const appPlan = bandedPlan(appNs, { "carved so far": appNs.nodes.map((n) => n.id) });
|
|
5378
|
+
const appPlan = bandedPlan(appNs, { [opts.carvedTitle ?? "carved so far"]: appNs.nodes.map((n) => n.id) });
|
|
4286
5379
|
const memberH = (plan) => TITLE + PAD + plan.height + PAD;
|
|
4287
5380
|
const memberW = (plan) => plan.width + PAD * 2;
|
|
4288
5381
|
const appX0 = memberW(tfPlan) + MEMBER_GAP;
|
|
@@ -4321,23 +5414,25 @@ function composeCarveEstate(tfIr, appNs, opts) {
|
|
|
4321
5414
|
};
|
|
4322
5415
|
}
|
|
4323
5416
|
function renderCarveMorph(tfIr, appIr, carved, opts) {
|
|
4324
|
-
const
|
|
4325
|
-
const
|
|
5417
|
+
const staged = withCarveState(tfIr, appIr, opts);
|
|
5418
|
+
const appNs = staged.appNs;
|
|
5419
|
+
const morphOpts = { ...opts, carvedTitle: "carved so far" };
|
|
5420
|
+
const before = composeCarveEstate(staged.tf, appNs, morphOpts);
|
|
4326
5421
|
const gone = new Set(carved);
|
|
4327
|
-
const bands =
|
|
5422
|
+
const bands = staged.tf.groups.byStack ?? {};
|
|
4328
5423
|
const bandsAfter = {};
|
|
4329
5424
|
for (const [band, members] of Object.entries(bands)) {
|
|
4330
5425
|
const left = members.filter((id) => !gone.has(id));
|
|
4331
5426
|
if (left.length) bandsAfter[band] = left;
|
|
4332
5427
|
}
|
|
4333
5428
|
const tfAfter = {
|
|
4334
|
-
nodes:
|
|
4335
|
-
edges:
|
|
5429
|
+
nodes: staged.tf.nodes.filter((n) => !gone.has(n.id)),
|
|
5430
|
+
edges: staged.tf.edges.filter((e) => !gone.has(e.from) && !gone.has(e.to)),
|
|
4336
5431
|
groups: { byStack: bandsAfter }
|
|
4337
5432
|
};
|
|
4338
|
-
const carvedNodes =
|
|
5433
|
+
const carvedNodes = staged.tf.nodes.filter((n) => gone.has(n.id)).map((n) => ({ ...n, attrs: { ...n.attrs, _status: "good", carve: "carved \u2192 chant" } }));
|
|
4339
5434
|
const appAfter = { ...appNs, nodes: [...appNs.nodes, ...carvedNodes] };
|
|
4340
|
-
const after = composeCarveEstate(tfAfter, appAfter,
|
|
5435
|
+
const after = composeCarveEstate(tfAfter, appAfter, morphOpts);
|
|
4341
5436
|
const view = (name, comp) => ({
|
|
4342
5437
|
name,
|
|
4343
5438
|
ir: comp.ir,
|
|
@@ -4599,12 +5694,12 @@ function radializeLayout(layout, groupOf, size = /* @__PURE__ */ new Map()) {
|
|
|
4599
5694
|
}
|
|
4600
5695
|
|
|
4601
5696
|
// src/carve-actions.ts
|
|
4602
|
-
import { existsSync as existsSync8, mkdirSync as mkdirSync3, readFileSync as
|
|
4603
|
-
import { isAbsolute, join as
|
|
5697
|
+
import { existsSync as existsSync8, mkdirSync as mkdirSync3, readFileSync as readFileSync10, readdirSync as readdirSync5, statSync as statSync5 } from "node:fs";
|
|
5698
|
+
import { isAbsolute, join as join13, relative as relative2, resolve as resolve4, sep as sep2 } from "node:path";
|
|
4604
5699
|
|
|
4605
5700
|
// src/layout.ts
|
|
4606
|
-
import { existsSync as existsSync6, mkdirSync as mkdirSync2, readFileSync as
|
|
4607
|
-
import { join as
|
|
5701
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync2, readFileSync as readFileSync8, renameSync, rmSync, writeFileSync as writeFileSync2, accessSync, constants } from "node:fs";
|
|
5702
|
+
import { join as join11 } from "node:path";
|
|
4608
5703
|
var LAYOUT_DIR = ".behold";
|
|
4609
5704
|
var LAYOUT_FILE = "layout.json";
|
|
4610
5705
|
var MAX_BODY_BYTES = 64 * 1024;
|
|
@@ -4645,7 +5740,7 @@ function lensFromQuery(params) {
|
|
|
4645
5740
|
return [slug(zoom), params.get("radial") === "1" ? "radial" : null, stack ? slug(`stack-${stack}`) : null].filter(Boolean).join("+");
|
|
4646
5741
|
}
|
|
4647
5742
|
function layoutPath(projectDir) {
|
|
4648
|
-
return
|
|
5743
|
+
return join11(projectDir, LAYOUT_DIR, LAYOUT_FILE);
|
|
4649
5744
|
}
|
|
4650
5745
|
function unwritableReason(projectDir) {
|
|
4651
5746
|
if (!projectDir || !existsSync6(projectDir)) return `no such project directory: ${projectDir || "(none)"}`;
|
|
@@ -4661,7 +5756,7 @@ function readLayoutFile(projectDir) {
|
|
|
4661
5756
|
const file = layoutPath(projectDir);
|
|
4662
5757
|
try {
|
|
4663
5758
|
if (!existsSync6(file)) return empty;
|
|
4664
|
-
const raw =
|
|
5759
|
+
const raw = readFileSync8(file, "utf8");
|
|
4665
5760
|
if (raw.length > MAX_FILE_BYTES) return empty;
|
|
4666
5761
|
const parsed = JSON.parse(raw);
|
|
4667
5762
|
if (!parsed || typeof parsed !== "object" || !parsed.lenses || typeof parsed.lenses !== "object") return empty;
|
|
@@ -4701,7 +5796,7 @@ function writeLens(projectDir, lens, deltas) {
|
|
|
4701
5796
|
}
|
|
4702
5797
|
const body = JSON.stringify({ version: 1, lenses }, null, 2) + "\n";
|
|
4703
5798
|
if (body.length > MAX_FILE_BYTES) throw new LayoutTooLarge(`a layout sidecar is capped at ${MAX_FILE_BYTES} bytes`);
|
|
4704
|
-
mkdirSync2(
|
|
5799
|
+
mkdirSync2(join11(projectDir, LAYOUT_DIR), { recursive: true });
|
|
4705
5800
|
const tmp = `${file}.tmp`;
|
|
4706
5801
|
writeFileSync2(tmp, body, "utf8");
|
|
4707
5802
|
renameSync(tmp, file);
|
|
@@ -4786,8 +5881,8 @@ function reanchorEdges(svg, moved) {
|
|
|
4786
5881
|
|
|
4787
5882
|
// src/carve-live.ts
|
|
4788
5883
|
import { spawn as spawn3 } from "node:child_process";
|
|
4789
|
-
import { readFileSync as
|
|
4790
|
-
import { join as
|
|
5884
|
+
import { readFileSync as readFileSync9, writeFileSync as writeFileSync3, existsSync as existsSync7 } from "node:fs";
|
|
5885
|
+
import { join as join12 } from "node:path";
|
|
4791
5886
|
var LIVE_CONTAINER = "behold-carve-floci";
|
|
4792
5887
|
var LIVE_PORT = 4602;
|
|
4793
5888
|
var LIVE_IMAGE = "floci/floci:1.5.34";
|
|
@@ -4846,9 +5941,9 @@ async function teardownScratchFloci() {
|
|
|
4846
5941
|
await capture("docker", ["rm", "-f", LIVE_CONTAINER], process.cwd()).catch(() => void 0);
|
|
4847
5942
|
}
|
|
4848
5943
|
async function applyIntoFloci(fromDir, step) {
|
|
4849
|
-
const template =
|
|
5944
|
+
const template = join12(fromDir, "floci-override.tf.disabled");
|
|
4850
5945
|
if (!existsSync7(template)) return `no floci-override.tf.disabled in ${fromDir} \u2014 this estate wasn't authored for the live tier`;
|
|
4851
|
-
writeFileSync3(
|
|
5946
|
+
writeFileSync3(join12(fromDir, "floci_override.tf"), armOverride(readFileSync9(template, "utf8"), LIVE_PORT));
|
|
4852
5947
|
const init = await step("terraform", ["init", "-input=false", "-no-color"], fromDir);
|
|
4853
5948
|
if (init !== 0) return `terraform init exited ${init} (provider downloads need network)`;
|
|
4854
5949
|
const apply = await step(
|
|
@@ -4872,7 +5967,7 @@ ${tail}`, exitCode: r.code };
|
|
|
4872
5967
|
// src/carve-actions.ts
|
|
4873
5968
|
async function runCarvePlan(demo) {
|
|
4874
5969
|
if (!demo?.live) {
|
|
4875
|
-
return
|
|
5970
|
+
return refuse3(
|
|
4876
5971
|
"carve-action",
|
|
4877
5972
|
"terraform plan is the live tier's beat, and this server isn't serving one",
|
|
4878
5973
|
"run `behold demo carve --live` (needs docker + terraform)"
|
|
@@ -4880,7 +5975,7 @@ async function runCarvePlan(demo) {
|
|
|
4880
5975
|
}
|
|
4881
5976
|
const r = await runLivePlan(demo.from);
|
|
4882
5977
|
if ("error" in r) {
|
|
4883
|
-
return
|
|
5978
|
+
return refuse3("carve-action", r.error, "Is the scratch Floci still up? `docker ps` should list " + demo.live.container + ".");
|
|
4884
5979
|
}
|
|
4885
5980
|
return {
|
|
4886
5981
|
ok: true,
|
|
@@ -4892,14 +5987,14 @@ async function runCarvePlan(demo) {
|
|
|
4892
5987
|
}
|
|
4893
5988
|
async function runCarveObserve(demo, select) {
|
|
4894
5989
|
if (!demo.live) {
|
|
4895
|
-
return
|
|
5990
|
+
return refuse3(
|
|
4896
5991
|
"carve-action",
|
|
4897
5992
|
"the observe beat reads the scratch Floci, and this server isn't serving one",
|
|
4898
5993
|
"run `behold demo carve --live` (needs docker + terraform)"
|
|
4899
5994
|
);
|
|
4900
5995
|
}
|
|
4901
|
-
if (!existsSync8(
|
|
4902
|
-
return
|
|
5996
|
+
if (!existsSync8(join13(demo.out, "src"))) {
|
|
5997
|
+
return refuse3("carve-action", "nothing has been emitted yet", "run Emit first \u2014 observe reads the carveout emit writes");
|
|
4903
5998
|
}
|
|
4904
5999
|
const args = ["lifecycle", "diff", "prod", "--live", "--json"];
|
|
4905
6000
|
const run3 = await runChantRaw(args, demo.out, { AWS_ENDPOINT_URL: demo.live.endpoint }).catch((err) => ({
|
|
@@ -4908,18 +6003,26 @@ async function runCarveObserve(demo, select) {
|
|
|
4908
6003
|
stderr: err instanceof Error ? err.message : String(err)
|
|
4909
6004
|
}));
|
|
4910
6005
|
if (run3.code !== 0) {
|
|
4911
|
-
return
|
|
6006
|
+
return refuse3(
|
|
4912
6007
|
"carve-action",
|
|
4913
6008
|
`chant lifecycle diff exited ${run3.code}: ${merge(run3, demo.root) || "(no output)"}`,
|
|
4914
6009
|
"Is the scratch Floci still up? `docker ps` should list " + demo.live.container + "."
|
|
4915
6010
|
);
|
|
4916
6011
|
}
|
|
4917
|
-
let
|
|
6012
|
+
let parsed;
|
|
4918
6013
|
try {
|
|
4919
|
-
|
|
6014
|
+
parsed = JSON.parse(run3.stdout);
|
|
4920
6015
|
} catch {
|
|
4921
|
-
return
|
|
6016
|
+
return refuse3("carve-action", "chant lifecycle diff answered something that isn't JSON", "re-run \u2014 a partial read is not a verdict");
|
|
6017
|
+
}
|
|
6018
|
+
if (parsed.stacks && !parsed.lexicons) {
|
|
6019
|
+
return refuse3(
|
|
6020
|
+
"carve-action",
|
|
6021
|
+
"chant lifecycle diff answered a stacked shape ({stacks}) \u2014 observe only reads the stack-less {lexicons} shape",
|
|
6022
|
+
"point the target at a stack-less carveout, or file a shape-read gap against observe for the stacked case"
|
|
6023
|
+
);
|
|
4922
6024
|
}
|
|
6025
|
+
const aws = parsed.lexicons?.aws ?? {};
|
|
4923
6026
|
const entity = select.split(".").pop() ?? select;
|
|
4924
6027
|
const command = `AWS_ENDPOINT_URL=${demo.live.endpoint} chant ${args.join(" ")} # in ${shortenIn(demo.out, demo.root)}`;
|
|
4925
6028
|
const queried = aws.resources?.queried?.[entity];
|
|
@@ -4954,13 +6057,13 @@ async function runCarveObserve(demo, select) {
|
|
|
4954
6057
|
var MAX_ARTIFACT_BYTES = 64 * 1024;
|
|
4955
6058
|
var MAX_ARTIFACTS = 24;
|
|
4956
6059
|
var MAX_OUTPUT_BYTES = 32 * 1024;
|
|
4957
|
-
var
|
|
6060
|
+
var refuse3 = (code, error, remedy) => ({
|
|
4958
6061
|
ok: false,
|
|
4959
6062
|
refusal: { error, code, remedy }
|
|
4960
6063
|
});
|
|
4961
6064
|
var BUILD_CAVEAT = "The gate shown is `chant lint`, not `chant build`. chant#1637's fold is applied now: the bucket's versioning and public-access-block sub-resources come out as native props, not just folded-in mentions. `chant build` still fails, but on one rule only \u2014 WAW042, a TLS-deny bucket policy the source Terraform never declared. That's real drift the estate inherited, not a gap in the carve. example-carve/README.md, \"Why build still fails\", has the full statement.";
|
|
4962
6065
|
function insideDemo(root, p) {
|
|
4963
|
-
const rel =
|
|
6066
|
+
const rel = relative2(resolve4(root), resolve4(p));
|
|
4964
6067
|
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
|
4965
6068
|
}
|
|
4966
6069
|
function selectFromReport(report, raw) {
|
|
@@ -4981,17 +6084,17 @@ function readArtifacts(demo, dir, filter) {
|
|
|
4981
6084
|
if (out.length >= MAX_ARTIFACTS || depth > 2) return;
|
|
4982
6085
|
let entries;
|
|
4983
6086
|
try {
|
|
4984
|
-
entries =
|
|
6087
|
+
entries = readdirSync5(d).sort();
|
|
4985
6088
|
} catch {
|
|
4986
6089
|
return;
|
|
4987
6090
|
}
|
|
4988
6091
|
for (const name of entries) {
|
|
4989
6092
|
if (out.length >= MAX_ARTIFACTS) return;
|
|
4990
6093
|
if (name === "node_modules" || name.startsWith(".")) continue;
|
|
4991
|
-
const full =
|
|
6094
|
+
const full = join13(d, name);
|
|
4992
6095
|
let st;
|
|
4993
6096
|
try {
|
|
4994
|
-
st =
|
|
6097
|
+
st = statSync5(full);
|
|
4995
6098
|
} catch {
|
|
4996
6099
|
continue;
|
|
4997
6100
|
}
|
|
@@ -4999,11 +6102,11 @@ function readArtifacts(demo, dir, filter) {
|
|
|
4999
6102
|
walk(full, depth + 1);
|
|
5000
6103
|
continue;
|
|
5001
6104
|
}
|
|
5002
|
-
const rel =
|
|
6105
|
+
const rel = relative2(demo.root, full).split(sep2).join("/");
|
|
5003
6106
|
if (!filter(rel)) continue;
|
|
5004
6107
|
let text = "";
|
|
5005
6108
|
try {
|
|
5006
|
-
text =
|
|
6109
|
+
text = readFileSync10(full, "utf8");
|
|
5007
6110
|
} catch {
|
|
5008
6111
|
continue;
|
|
5009
6112
|
}
|
|
@@ -5025,17 +6128,17 @@ function carveSlug(select) {
|
|
|
5025
6128
|
}
|
|
5026
6129
|
var clip = (s) => s.length > MAX_OUTPUT_BYTES ? s.slice(0, MAX_OUTPUT_BYTES) + "\n\u2026 (truncated)" : s;
|
|
5027
6130
|
function shortenIn(arg, root) {
|
|
5028
|
-
return arg.startsWith(root +
|
|
6131
|
+
return arg.startsWith(root + sep2) ? arg.slice(root.length + 1) : arg === root ? "." : arg;
|
|
5029
6132
|
}
|
|
5030
6133
|
function merge(r, root) {
|
|
5031
6134
|
const joined = stripAnsi([r.stdout, r.stderr].filter((s) => s.trim()).join("\n").trim());
|
|
5032
|
-
return clip(joined.split(root +
|
|
6135
|
+
return clip(joined.split(root + sep2).join("").split(root).join("."));
|
|
5033
6136
|
}
|
|
5034
6137
|
async function runCarveEmit(demo, select) {
|
|
5035
6138
|
const block = carveWriteBlock(demo);
|
|
5036
|
-
if (block) return
|
|
6139
|
+
if (block) return refuse3("read-only", block, "Start the walkthrough with `behold demo carve`.");
|
|
5037
6140
|
mkdirSync3(demo.out, { recursive: true });
|
|
5038
|
-
const reportFile =
|
|
6141
|
+
const reportFile = join13(demo.out, `${carveSlug(select)}-boundary.json`);
|
|
5039
6142
|
const args = [
|
|
5040
6143
|
"carve",
|
|
5041
6144
|
"emit",
|
|
@@ -5056,7 +6159,7 @@ async function runCarveEmit(demo, select) {
|
|
|
5056
6159
|
}));
|
|
5057
6160
|
const output = merge(run3, demo.root);
|
|
5058
6161
|
if (run3.code !== 0) {
|
|
5059
|
-
return
|
|
6162
|
+
return refuse3(
|
|
5060
6163
|
"carve-action",
|
|
5061
6164
|
`chant carve emit exited ${run3.code}: ${output || "(no output)"}`,
|
|
5062
6165
|
"The offline emit needs `@cdktf/hcl2json` in the demo copy and the demo's own chant install \u2014 `npm install` in the copy's `app/` and re-run `behold demo carve`."
|
|
@@ -5064,10 +6167,10 @@ async function runCarveEmit(demo, select) {
|
|
|
5064
6167
|
}
|
|
5065
6168
|
let boundary = null;
|
|
5066
6169
|
try {
|
|
5067
|
-
boundary = JSON.parse(
|
|
6170
|
+
boundary = JSON.parse(readFileSync10(reportFile, "utf8"));
|
|
5068
6171
|
} catch {
|
|
5069
6172
|
}
|
|
5070
|
-
const lintPath =
|
|
6173
|
+
const lintPath = relative2(demo.project, join13(demo.out, "src")).split(sep2).join("/");
|
|
5071
6174
|
const lintArgs = ["lint", lintPath];
|
|
5072
6175
|
const lint = await runChantRaw(lintArgs, demo.project).catch((err) => ({
|
|
5073
6176
|
code: 127,
|
|
@@ -5091,7 +6194,7 @@ async function runCarveEmit(demo, select) {
|
|
|
5091
6194
|
}
|
|
5092
6195
|
async function runCarveBridge(demo, select) {
|
|
5093
6196
|
const block = carveWriteBlock(demo);
|
|
5094
|
-
if (block) return
|
|
6197
|
+
if (block) return refuse3("read-only", block, "Start the walkthrough with `behold demo carve`.");
|
|
5095
6198
|
mkdirSync3(demo.out, { recursive: true });
|
|
5096
6199
|
const args = [
|
|
5097
6200
|
"carve",
|
|
@@ -5111,7 +6214,7 @@ async function runCarveBridge(demo, select) {
|
|
|
5111
6214
|
}));
|
|
5112
6215
|
const output = merge(run3, demo.root);
|
|
5113
6216
|
if (run3.code !== 0) {
|
|
5114
|
-
return
|
|
6217
|
+
return refuse3(
|
|
5115
6218
|
"carve-action",
|
|
5116
6219
|
`chant carve bridge exited ${run3.code}: ${output || "(no output)"}`,
|
|
5117
6220
|
"Run the Emit step first \u2014 bridge reads the carve manifest emit leaves in the output directory."
|
|
@@ -5130,8 +6233,8 @@ async function runCarveBridge(demo, select) {
|
|
|
5130
6233
|
}
|
|
5131
6234
|
|
|
5132
6235
|
// src/ops.ts
|
|
5133
|
-
import { readdirSync as
|
|
5134
|
-
import { join as
|
|
6236
|
+
import { readdirSync as readdirSync6, readFileSync as readFileSync11, existsSync as existsSync9 } from "node:fs";
|
|
6237
|
+
import { join as join14 } from "node:path";
|
|
5135
6238
|
var APPLY_TARGET_LEXICON = {
|
|
5136
6239
|
cloudformation: "aws",
|
|
5137
6240
|
kubectl: "k8s",
|
|
@@ -5149,45 +6252,623 @@ function kindOf(content) {
|
|
|
5149
6252
|
if (/AuditOp\b/.test(content)) return "audit";
|
|
5150
6253
|
return "op";
|
|
5151
6254
|
}
|
|
5152
|
-
function discoverOps(projectDir) {
|
|
5153
|
-
const seen = /* @__PURE__ */ new Set();
|
|
6255
|
+
function discoverOps(projectDir) {
|
|
6256
|
+
const seen = /* @__PURE__ */ new Set();
|
|
6257
|
+
const out = [];
|
|
6258
|
+
for (const sub of ["ops", "src", "."]) {
|
|
6259
|
+
const dir = join14(projectDir, sub);
|
|
6260
|
+
if (!existsSync9(dir)) continue;
|
|
6261
|
+
for (const f of readdirSync6(dir)) {
|
|
6262
|
+
if (!f.endsWith(".op.ts")) continue;
|
|
6263
|
+
const content = readFileSync11(join14(dir, f), "utf8");
|
|
6264
|
+
const name = content.match(/name:\s*["'`]([^"'`]+)["'`]/)?.[1];
|
|
6265
|
+
if (!name || seen.has(name)) continue;
|
|
6266
|
+
seen.add(name);
|
|
6267
|
+
const gate = content.match(/signalName:\s*["'`]([^"'`]+)["'`]/)?.[1];
|
|
6268
|
+
const env = content.match(/\benv:\s*["'`]([^"'`]+)["'`]/)?.[1];
|
|
6269
|
+
const target = content.match(/\btarget:\s*["'`]([^"'`]+)["'`]/)?.[1];
|
|
6270
|
+
const substrate = target ? APPLY_TARGET_LEXICON[target] : void 0;
|
|
6271
|
+
const irPath = opIrPath(projectDir, name);
|
|
6272
|
+
out.push({
|
|
6273
|
+
name,
|
|
6274
|
+
kind: kindOf(content),
|
|
6275
|
+
dir: projectDir,
|
|
6276
|
+
...gate ? { gate } : {},
|
|
6277
|
+
...env ? { env } : {},
|
|
6278
|
+
...substrate ? { substrate } : {},
|
|
6279
|
+
...existsSync9(irPath) ? { irPath } : {}
|
|
6280
|
+
});
|
|
6281
|
+
}
|
|
6282
|
+
}
|
|
6283
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
6284
|
+
}
|
|
6285
|
+
function opIrPath(projectDir, name) {
|
|
6286
|
+
return join14(projectDir, "dist", "ops", name, "op.json");
|
|
6287
|
+
}
|
|
6288
|
+
function discoverOpIrs(projectDirs) {
|
|
6289
|
+
const seen = /* @__PURE__ */ new Set();
|
|
6290
|
+
const out = [];
|
|
6291
|
+
for (const dir of projectDirs) {
|
|
6292
|
+
const root = join14(dir, "dist", "ops");
|
|
6293
|
+
if (!existsSync9(root)) continue;
|
|
6294
|
+
for (const name of readdirSync6(root)) {
|
|
6295
|
+
if (seen.has(name)) continue;
|
|
6296
|
+
const path = opIrPath(dir, name);
|
|
6297
|
+
if (!existsSync9(path)) continue;
|
|
6298
|
+
seen.add(name);
|
|
6299
|
+
out.push({ name, dir, path });
|
|
6300
|
+
}
|
|
6301
|
+
}
|
|
6302
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
6303
|
+
}
|
|
6304
|
+
function discoverEstateOps(projectDirs) {
|
|
6305
|
+
const seen = /* @__PURE__ */ new Set();
|
|
6306
|
+
const out = [];
|
|
6307
|
+
for (const dir of projectDirs) {
|
|
6308
|
+
for (const op of discoverOps(dir)) {
|
|
6309
|
+
if (seen.has(op.name)) continue;
|
|
6310
|
+
seen.add(op.name);
|
|
6311
|
+
out.push(op);
|
|
6312
|
+
}
|
|
6313
|
+
}
|
|
6314
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
6315
|
+
}
|
|
6316
|
+
|
|
6317
|
+
// src/run-playhead.ts
|
|
6318
|
+
var RECEIPT_READ_FN = "receiptRead";
|
|
6319
|
+
var RECEIPT_WRITE_FN = "receiptWrite";
|
|
6320
|
+
var STEP_STATUSES = /* @__PURE__ */ new Set(["ok", "fail", "skipped"]);
|
|
6321
|
+
var isRecord4 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
6322
|
+
function asStepRecord(value) {
|
|
6323
|
+
if (!isRecord4(value)) return null;
|
|
6324
|
+
if (typeof value.phase !== "string" || typeof value.fn !== "string") return null;
|
|
6325
|
+
if (typeof value.status !== "string" || !STEP_STATUSES.has(value.status)) return null;
|
|
6326
|
+
if (typeof value.durationMs !== "number") return null;
|
|
6327
|
+
return value;
|
|
6328
|
+
}
|
|
6329
|
+
function parseRunProgressLine(line) {
|
|
6330
|
+
let parsed;
|
|
6331
|
+
try {
|
|
6332
|
+
parsed = JSON.parse(line);
|
|
6333
|
+
} catch {
|
|
6334
|
+
return null;
|
|
6335
|
+
}
|
|
6336
|
+
const one = asStepRecord(parsed);
|
|
6337
|
+
if (one) return [one];
|
|
6338
|
+
if (isRecord4(parsed) && typeof parsed.op === "string" && Array.isArray(parsed.records)) {
|
|
6339
|
+
const records = parsed.records.map(asStepRecord).filter((r) => r !== null);
|
|
6340
|
+
return records.length ? records : null;
|
|
6341
|
+
}
|
|
6342
|
+
return null;
|
|
6343
|
+
}
|
|
6344
|
+
function recordLine(record) {
|
|
6345
|
+
const mark = record.status === "ok" ? "\u2713" : record.status === "fail" ? "\u2717" : "\u2022";
|
|
6346
|
+
const took = record.status === "skipped" ? "skipped" : record.durationMs < 1e3 ? `${record.durationMs}ms` : `${(record.durationMs / 1e3).toFixed(1)}s`;
|
|
6347
|
+
return `${mark} ${record.phase} \xB7 ${record.fn} \u2014 ${took}${record.error ? ` \u2014 ${record.error}` : ""}`;
|
|
6348
|
+
}
|
|
6349
|
+
var ANSI = /\x1b\[[0-9;]*m/g;
|
|
6350
|
+
var strip = (s) => s.replace(ANSI, "");
|
|
6351
|
+
function statusFields(stdout) {
|
|
6352
|
+
const out = /* @__PURE__ */ new Map();
|
|
6353
|
+
for (const raw of strip(stdout).split("\n")) {
|
|
6354
|
+
const m = /^\s{2,}([A-Za-z][A-Za-z ]*?)\s*:\s*(.*)$/.exec(raw);
|
|
6355
|
+
if (m) out.set(m[1].trim(), m[2].trim());
|
|
6356
|
+
}
|
|
6357
|
+
return out;
|
|
6358
|
+
}
|
|
6359
|
+
function parseGateLine(line) {
|
|
6360
|
+
const m = /^(.*?)(?: — (.*?))? \(waiting since (.+)\)$/.exec(line.trim());
|
|
6361
|
+
if (!m) return null;
|
|
6362
|
+
const signalName = m[1].trim();
|
|
6363
|
+
if (!signalName) return null;
|
|
6364
|
+
return { signalName, ...m[2] ? { description: m[2].trim() } : {}, since: m[3].trim() };
|
|
6365
|
+
}
|
|
6366
|
+
function readRunStatus(op, run3) {
|
|
6367
|
+
if (run3.code !== 0) {
|
|
6368
|
+
const message = strip(run3.stderr).trim() || strip(run3.stdout).trim() || `chant run status exited ${run3.code}`;
|
|
6369
|
+
if (/not available in local mode|@temporalio\/client is not installed/.test(message)) {
|
|
6370
|
+
return {
|
|
6371
|
+
ok: false,
|
|
6372
|
+
refusal: {
|
|
6373
|
+
error: `No Temporal run state for "${op}" \u2014 ${message.split("\n")[0].replace(/^error:\s*/, "")}`,
|
|
6374
|
+
code: "no-temporal",
|
|
6375
|
+
remedy: "Gate state and per-step run records come from a durable run. Configure a Temporal profile in chant.config.ts (and `npm install @temporalio/client`), then run the Op with --temporal."
|
|
6376
|
+
}
|
|
6377
|
+
};
|
|
6378
|
+
}
|
|
6379
|
+
if (/not found|NotFound/.test(message)) {
|
|
6380
|
+
return {
|
|
6381
|
+
ok: false,
|
|
6382
|
+
refusal: {
|
|
6383
|
+
error: `No run of "${op}" to report \u2014 ${message.split("\n")[0].replace(/^error:\s*/, "")}`,
|
|
6384
|
+
code: "no-run",
|
|
6385
|
+
remedy: "Start the Op (Run) and the playhead fills in as each step settles."
|
|
6386
|
+
}
|
|
6387
|
+
};
|
|
6388
|
+
}
|
|
6389
|
+
return {
|
|
6390
|
+
ok: false,
|
|
6391
|
+
refusal: {
|
|
6392
|
+
error: `chant run status ${op} failed: ${message.split("\n")[0].replace(/^error:\s*/, "")}`,
|
|
6393
|
+
code: "run-status",
|
|
6394
|
+
remedy: "Run `chant run status " + op + " --temporal` in the project to see the whole error."
|
|
6395
|
+
}
|
|
6396
|
+
};
|
|
6397
|
+
}
|
|
6398
|
+
const f = statusFields(run3.stdout);
|
|
6399
|
+
const activities = /^(\d+)\/(\d+)/.exec(f.get("Activities") ?? "");
|
|
6400
|
+
const gateLine = f.get("Gate");
|
|
6401
|
+
return {
|
|
6402
|
+
ok: true,
|
|
6403
|
+
read: {
|
|
6404
|
+
op,
|
|
6405
|
+
...f.get("Workflow ID") ? { workflowId: f.get("Workflow ID") } : {},
|
|
6406
|
+
...f.get("Run ID") ? { runId: f.get("Run ID") } : {},
|
|
6407
|
+
...f.get("Status") ? { status: f.get("Status") } : {},
|
|
6408
|
+
...f.get("Started") ? { startedAt: f.get("Started") } : {},
|
|
6409
|
+
...f.get("Closed") ? { closedAt: f.get("Closed") } : {},
|
|
6410
|
+
...activities ? { activities: { completed: Number(activities[1]), scheduled: Number(activities[2]) } } : {},
|
|
6411
|
+
// No Gate line means chant printed none, which it does both when the
|
|
6412
|
+
// query says "nothing pending" and when the Op registers no handler.
|
|
6413
|
+
// Either way: no gate is waiting on a human right now.
|
|
6414
|
+
gate: gateLine ? parseGateLine(gateLine) : null
|
|
6415
|
+
}
|
|
6416
|
+
};
|
|
6417
|
+
}
|
|
6418
|
+
var initialRunState = {
|
|
6419
|
+
op: null,
|
|
6420
|
+
mode: null,
|
|
6421
|
+
status: "idle",
|
|
6422
|
+
records: [],
|
|
6423
|
+
gate: null,
|
|
6424
|
+
gateNote: null,
|
|
6425
|
+
startedAt: null,
|
|
6426
|
+
endedAt: null,
|
|
6427
|
+
lost: null
|
|
6428
|
+
};
|
|
6429
|
+
function runStarted(op, mode, at) {
|
|
6430
|
+
return { ...initialRunState, op, mode, status: "running", startedAt: at };
|
|
6431
|
+
}
|
|
6432
|
+
function runRecord(state, record) {
|
|
6433
|
+
return { ...state, records: [...state.records, record] };
|
|
6434
|
+
}
|
|
6435
|
+
function runGate(state, result) {
|
|
6436
|
+
return result.ok ? { ...state, gate: result.read.gate, gateNote: null } : { ...state, gate: null, gateNote: result.refusal.error };
|
|
6437
|
+
}
|
|
6438
|
+
function runEnded(state, exitCode, at) {
|
|
6439
|
+
if (state.status !== "running") return state;
|
|
6440
|
+
if (exitCode === 0) return { ...state, status: "ok", endedAt: at };
|
|
6441
|
+
if (state.records.some((r) => r.status === "fail")) return { ...state, status: "failed", endedAt: at };
|
|
6442
|
+
return {
|
|
6443
|
+
...state,
|
|
6444
|
+
status: "lost",
|
|
6445
|
+
endedAt: at,
|
|
6446
|
+
lost: `the progress stream ended (exit ${exitCode}) after ${state.records.length} settled step${state.records.length === 1 ? "" : "s"} without a failed step \u2014 behold does not know whether the Op failed or the stream broke, so the playhead stays at the last settled step.`
|
|
6447
|
+
};
|
|
6448
|
+
}
|
|
6449
|
+
function slotsFor(placed) {
|
|
6450
|
+
const out = [];
|
|
6451
|
+
for (const p of placed) {
|
|
6452
|
+
if (p.step.kind === "activity") {
|
|
6453
|
+
out.push({ id: p.id, phase: p.phase, fn: p.step.fn });
|
|
6454
|
+
} else if (p.step.kind === "effect") {
|
|
6455
|
+
out.push({ id: p.id, phase: p.phase, fn: RECEIPT_READ_FN });
|
|
6456
|
+
for (const nested of p.step.steps ?? []) {
|
|
6457
|
+
if (nested.kind === "activity") out.push({ id: p.id, phase: p.phase, fn: nested.fn });
|
|
6458
|
+
}
|
|
6459
|
+
out.push({ id: p.id, phase: p.phase, fn: RECEIPT_WRITE_FN });
|
|
6460
|
+
}
|
|
6461
|
+
}
|
|
6462
|
+
return out;
|
|
6463
|
+
}
|
|
6464
|
+
function mergeRun(prev, record) {
|
|
6465
|
+
const rank = { fail: 3, ok: 2, skipped: 1 };
|
|
6466
|
+
const keepPrev = prev && rank[prev.status] >= rank[record.status];
|
|
6467
|
+
return {
|
|
6468
|
+
status: keepPrev ? prev.status : record.status,
|
|
6469
|
+
durationMs: (prev?.durationMs ?? 0) + record.durationMs,
|
|
6470
|
+
...keepPrev && prev.error ? { error: prev.error } : record.error ? { error: record.error } : {},
|
|
6471
|
+
...keepPrev && prev.outcome ? { outcome: prev.outcome } : record.outcome ? { outcome: `${record.outcome.name}=${String(record.outcome.value)}` } : {},
|
|
6472
|
+
records: (prev?.records ?? 0) + 1
|
|
6473
|
+
};
|
|
6474
|
+
}
|
|
6475
|
+
function joinRun(op, records, gate) {
|
|
6476
|
+
const placed = opPlacedSteps(op);
|
|
6477
|
+
const slots = slotsFor(placed);
|
|
6478
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
6479
|
+
for (const s of slots) {
|
|
6480
|
+
const key = `${s.phase}\0${s.fn}`;
|
|
6481
|
+
if (!byKey.has(key)) byKey.set(key, []);
|
|
6482
|
+
byKey.get(key).push({ id: s.id });
|
|
6483
|
+
}
|
|
6484
|
+
const cursor = /* @__PURE__ */ new Map();
|
|
6485
|
+
const byNode = /* @__PURE__ */ new Map();
|
|
6486
|
+
const unplaced = [];
|
|
6487
|
+
let lastSettledIndex = -1;
|
|
6488
|
+
const indexOf = new Map(placed.map((p, i) => [p.id, i]));
|
|
6489
|
+
for (const record of records) {
|
|
6490
|
+
const key = `${record.phase}\0${record.fn}`;
|
|
6491
|
+
const list3 = byKey.get(key);
|
|
6492
|
+
const i = cursor.get(key) ?? 0;
|
|
6493
|
+
const slot = list3?.[i];
|
|
6494
|
+
if (!slot) {
|
|
6495
|
+
unplaced.push(record);
|
|
6496
|
+
continue;
|
|
6497
|
+
}
|
|
6498
|
+
cursor.set(key, i + 1);
|
|
6499
|
+
byNode.set(slot.id, mergeRun(byNode.get(slot.id), record));
|
|
6500
|
+
if (record.status !== "skipped") {
|
|
6501
|
+
lastSettledIndex = Math.max(lastSettledIndex, indexOf.get(slot.id) ?? -1);
|
|
6502
|
+
}
|
|
6503
|
+
}
|
|
6504
|
+
if (gate) {
|
|
6505
|
+
const gateNode = placed.find((p) => p.step.kind === "gate" && p.step.signalName === gate.signalName);
|
|
6506
|
+
if (gateNode) {
|
|
6507
|
+
return {
|
|
6508
|
+
byNode,
|
|
6509
|
+
at: gateNode.id,
|
|
6510
|
+
atReason: `waiting for ${gate.signalName} since ${gate.since}`,
|
|
6511
|
+
unplaced
|
|
6512
|
+
};
|
|
6513
|
+
}
|
|
6514
|
+
}
|
|
6515
|
+
const next = placed.find((p, i) => i > lastSettledIndex && p.when === "phases");
|
|
6516
|
+
return {
|
|
6517
|
+
byNode,
|
|
6518
|
+
at: lastSettledIndex >= 0 && next ? next.id : null,
|
|
6519
|
+
atReason: lastSettledIndex >= 0 && next ? "reached \u2014 not settled yet" : null,
|
|
6520
|
+
unplaced
|
|
6521
|
+
};
|
|
6522
|
+
}
|
|
6523
|
+
var TONE = {
|
|
6524
|
+
ok: "good",
|
|
6525
|
+
fail: "warn",
|
|
6526
|
+
skipped: "neutral"
|
|
6527
|
+
};
|
|
6528
|
+
var RAN = { ok: "ok", fail: "failed", skipped: "skipped" };
|
|
6529
|
+
function tookLabel(ms) {
|
|
6530
|
+
return ms < 1e3 ? `${ms}ms` : `${(ms / 1e3).toFixed(1)}s`;
|
|
6531
|
+
}
|
|
6532
|
+
function paintPlayhead(ir, join21, gate) {
|
|
6533
|
+
if (!join21.byNode.size && !join21.at) return ir;
|
|
6534
|
+
const nodes = ir.nodes.map((node) => {
|
|
6535
|
+
const run3 = join21.byNode.get(node.id);
|
|
6536
|
+
const here = join21.at === node.id;
|
|
6537
|
+
if (!run3 && !here) return node;
|
|
6538
|
+
const isGate = node.attrs?._step === "gate";
|
|
6539
|
+
const attrs = { ...node.attrs };
|
|
6540
|
+
if (run3) {
|
|
6541
|
+
attrs._status = TONE[run3.status];
|
|
6542
|
+
attrs.run = run3.status === "skipped" ? "skipped" : `${RAN[run3.status]} \xB7 ${tookLabel(run3.durationMs)}`;
|
|
6543
|
+
attrs.ran = tookLabel(run3.durationMs);
|
|
6544
|
+
if (run3.error) attrs.error = run3.error;
|
|
6545
|
+
if (run3.outcome) attrs.outcome = run3.outcome;
|
|
6546
|
+
if (run3.records > 1) attrs.runRecords = run3.records;
|
|
6547
|
+
}
|
|
6548
|
+
if (here) {
|
|
6549
|
+
attrs._playhead = true;
|
|
6550
|
+
attrs._status = isGate && gate ? "warn" : "accent";
|
|
6551
|
+
attrs.run = isGate && gate ? `waiting for approval since ${gate.since}` : join21.atReason ?? "here";
|
|
6552
|
+
if (isGate && gate?.description) attrs.gateDescription = gate.description;
|
|
6553
|
+
}
|
|
6554
|
+
return { ...node, attrs };
|
|
6555
|
+
});
|
|
6556
|
+
return { ...ir, nodes };
|
|
6557
|
+
}
|
|
6558
|
+
function pendingGateCard(op, gate, ir) {
|
|
6559
|
+
const node = ir.nodes.find((n) => n.attrs?._step === "gate" && n.attrs?.gate === gate.signalName && n.attrs?.op === op);
|
|
6560
|
+
return {
|
|
6561
|
+
op,
|
|
6562
|
+
signalName: gate.signalName,
|
|
6563
|
+
...gate.description ? { description: gate.description } : {},
|
|
6564
|
+
since: gate.since,
|
|
6565
|
+
...node ? { node: node.id } : {},
|
|
6566
|
+
...node && typeof node.attrs?.guards === "string" ? { guards: node.attrs.guards } : {},
|
|
6567
|
+
approve: {
|
|
6568
|
+
method: "POST",
|
|
6569
|
+
path: `/api/ops/${encodeURIComponent(op)}/signal/${encodeURIComponent(gate.signalName)}`
|
|
6570
|
+
}
|
|
6571
|
+
};
|
|
6572
|
+
}
|
|
6573
|
+
function playheadNote(state, join21) {
|
|
6574
|
+
if (!state.op) {
|
|
6575
|
+
return "No run state: nothing has been run from this behold session, so every step paints as declared.";
|
|
6576
|
+
}
|
|
6577
|
+
const settled = join21 ? join21.byNode.size : state.records.length;
|
|
6578
|
+
const bits = [];
|
|
6579
|
+
const head = state.status === "idle" ? `Run state for ${state.op}: no per-step records \u2014 this behold session didn't start the run, so only the gate query answers` : `Run: ${state.op} (${state.mode === "temporal" ? "durable" : "local executor"}) \u2014 ${settled} step${settled === 1 ? "" : "s"} settled`;
|
|
6580
|
+
if (state.status === "running") {
|
|
6581
|
+
bits.push(
|
|
6582
|
+
state.mode === "local" ? "still running; the local executor reports every step at once when it ends, so the track fills in on completion" : "still running; only settled steps are painted"
|
|
6583
|
+
);
|
|
6584
|
+
}
|
|
6585
|
+
if (state.status === "ok") bits.push("the run exited cleanly");
|
|
6586
|
+
if (state.status === "failed") bits.push("a step failed");
|
|
6587
|
+
if (state.status === "lost") bits.push(`stream lost \u2014 ${state.lost ?? "the progress stream ended without a verdict"}`);
|
|
6588
|
+
if (state.gate) {
|
|
6589
|
+
bits.push(`waiting on gate ${state.gate.signalName} since ${state.gate.since} \u2014 approve is a delegated signal, behold never signals on its own`);
|
|
6590
|
+
} else if (state.gateNote) {
|
|
6591
|
+
bits.push(`gate state unavailable \u2014 ${state.gateNote}`);
|
|
6592
|
+
}
|
|
6593
|
+
if (join21?.unplaced.length) {
|
|
6594
|
+
bits.push(
|
|
6595
|
+
`${join21.unplaced.length} record${join21.unplaced.length === 1 ? "" : "s"} matched no declared step and ${join21.unplaced.length === 1 ? "is" : "are"} not painted`
|
|
6596
|
+
);
|
|
6597
|
+
}
|
|
6598
|
+
if (!bits.length) return `${head}.`;
|
|
6599
|
+
const rest = bits.join("; ");
|
|
6600
|
+
return `${head} \u2014 ${rest}.`;
|
|
6601
|
+
}
|
|
6602
|
+
|
|
6603
|
+
// src/operator.ts
|
|
6604
|
+
var ANSI2 = /\x1b\[[0-9;]*m/g;
|
|
6605
|
+
var strip2 = (s) => s.replace(ANSI2, "");
|
|
6606
|
+
var isRecord5 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
6607
|
+
var HOW_TO_DECLARE = "An operating loop is a ConvergeOp: declare one in a `*.op.ts` (`ConvergeOp({ name, env, dial, rules })` from @intentius/chant-lexicon-temporal), build the project, and tick it with `chant operator` (or a cron / CronJob invoking it).";
|
|
6608
|
+
function readRow(value) {
|
|
6609
|
+
if (!isRecord5(value)) return null;
|
|
6610
|
+
if (typeof value.op !== "string" || typeof value.env !== "string") return null;
|
|
6611
|
+
if (!Array.isArray(value.pendingGates)) return null;
|
|
6612
|
+
const gates = [];
|
|
6613
|
+
for (const g of value.pendingGates) {
|
|
6614
|
+
if (!isRecord5(g) || typeof g.rule !== "string" || typeof g.gate !== "string") return null;
|
|
6615
|
+
gates.push({ rule: g.rule, gate: g.gate, ...typeof g.op === "string" ? { op: g.op } : {} });
|
|
6616
|
+
}
|
|
6617
|
+
const tick = isRecord5(value.lastTick) ? value.lastTick : void 0;
|
|
6618
|
+
const usable = tick && typeof tick.timestamp === "string" && typeof tick.log === "string";
|
|
6619
|
+
const lease = isRecord5(value.lease) ? value.lease : void 0;
|
|
6620
|
+
return {
|
|
6621
|
+
op: value.op,
|
|
6622
|
+
env: value.env,
|
|
6623
|
+
...usable ? { lastTick: tick } : {},
|
|
6624
|
+
pendingGates: gates,
|
|
6625
|
+
...lease && typeof lease.holder === "string" && typeof lease.expiresAt === "string" ? { lease: { holder: lease.holder, expiresAt: lease.expiresAt } } : {}
|
|
6626
|
+
};
|
|
6627
|
+
}
|
|
6628
|
+
function readOperatorStatus(run3) {
|
|
6629
|
+
const err = strip2(run3.stderr);
|
|
6630
|
+
const out = strip2(run3.stdout).trim();
|
|
6631
|
+
if (run3.code !== 0) {
|
|
6632
|
+
const message = err.trim() || out || `chant operator status exited ${run3.code}`;
|
|
6633
|
+
const first = message.split("\n")[0].replace(/^error:\s*/i, "");
|
|
6634
|
+
if (/Unknown command|Unknown operator subcommand/i.test(message)) {
|
|
6635
|
+
return {
|
|
6636
|
+
ok: false,
|
|
6637
|
+
refusal: {
|
|
6638
|
+
error: `This chant has no \`operator status\` \u2014 ${first}`,
|
|
6639
|
+
code: "no-operator-cli",
|
|
6640
|
+
remedy: "The operating loop's status surface landed in chant 0.52 (chant#1485). Upgrade chant to read it."
|
|
6641
|
+
}
|
|
6642
|
+
};
|
|
6643
|
+
}
|
|
6644
|
+
return {
|
|
6645
|
+
ok: false,
|
|
6646
|
+
refusal: {
|
|
6647
|
+
error: `chant operator status failed: ${first}`,
|
|
6648
|
+
code: "operator-status",
|
|
6649
|
+
remedy: "Run `chant operator status --json` in the project to see the whole error."
|
|
6650
|
+
}
|
|
6651
|
+
};
|
|
6652
|
+
}
|
|
6653
|
+
if (!out) {
|
|
6654
|
+
return {
|
|
6655
|
+
ok: false,
|
|
6656
|
+
refusal: {
|
|
6657
|
+
error: "This project declares no operating loop \u2014 chant found no ConvergeOp.",
|
|
6658
|
+
code: "no-operator",
|
|
6659
|
+
remedy: HOW_TO_DECLARE
|
|
6660
|
+
}
|
|
6661
|
+
};
|
|
6662
|
+
}
|
|
6663
|
+
let parsed;
|
|
6664
|
+
try {
|
|
6665
|
+
parsed = JSON.parse(out);
|
|
6666
|
+
} catch (e) {
|
|
6667
|
+
return {
|
|
6668
|
+
ok: false,
|
|
6669
|
+
refusal: {
|
|
6670
|
+
error: `chant operator status --json didn't print JSON: ${e instanceof Error ? e.message : String(e)}`,
|
|
6671
|
+
code: "operator-status",
|
|
6672
|
+
remedy: "Run `chant operator status --json` in the project and check what it printed."
|
|
6673
|
+
}
|
|
6674
|
+
};
|
|
6675
|
+
}
|
|
6676
|
+
if (!Array.isArray(parsed)) {
|
|
6677
|
+
return {
|
|
6678
|
+
ok: false,
|
|
6679
|
+
refusal: {
|
|
6680
|
+
error: "chant operator status --json answered with something that isn't an array of ConvergeOp rows.",
|
|
6681
|
+
code: "operator-status",
|
|
6682
|
+
remedy: "behold reads one row per ConvergeOp: `{op, env, lastTick?, pendingGates[], lease?}`."
|
|
6683
|
+
}
|
|
6684
|
+
};
|
|
6685
|
+
}
|
|
6686
|
+
const rows = [];
|
|
6687
|
+
for (const value of parsed) {
|
|
6688
|
+
const row = readRow(value);
|
|
6689
|
+
if (!row) {
|
|
6690
|
+
return {
|
|
6691
|
+
ok: false,
|
|
6692
|
+
refusal: {
|
|
6693
|
+
error: "chant operator status --json answered with a row behold couldn't read (needs `op`, `env`, `pendingGates[]`).",
|
|
6694
|
+
code: "operator-status",
|
|
6695
|
+
remedy: "Check `chant operator status --json` in the project \u2014 and file the shape change if chant's is the newer one."
|
|
6696
|
+
}
|
|
6697
|
+
};
|
|
6698
|
+
}
|
|
6699
|
+
rows.push(row);
|
|
6700
|
+
}
|
|
6701
|
+
return { ok: true, rows };
|
|
6702
|
+
}
|
|
6703
|
+
function declaredConvergeOps(ops) {
|
|
6704
|
+
return ops.filter((o) => o.searchAttributes?.Converge === "true").map((o) => ({
|
|
6705
|
+
name: o.name,
|
|
6706
|
+
...o.searchAttributes?.Env ? { env: o.searchAttributes.Env } : {},
|
|
6707
|
+
...o.searchAttributes?.Dial ? { dial: o.searchAttributes.Dial } : {}
|
|
6708
|
+
}));
|
|
6709
|
+
}
|
|
6710
|
+
var MANAGED_BY = "app.kubernetes.io/managed-by";
|
|
6711
|
+
var COMPONENT = "app.kubernetes.io/component";
|
|
6712
|
+
var NAME_LABEL = "app.kubernetes.io/name";
|
|
6713
|
+
var CHANT = "chant";
|
|
6714
|
+
var CONVERGE_TICK = "converge-tick";
|
|
6715
|
+
var CRONJOB_KIND = "K8s::Batch::CronJob";
|
|
6716
|
+
var NAMESPACE_KIND = "K8s::Core::Namespace";
|
|
6717
|
+
var rec3 = (v) => isRecord5(v) ? v : void 0;
|
|
6718
|
+
var str3 = (v) => typeof v === "string" && v ? v : void 0;
|
|
6719
|
+
var labelsOf = (n) => rec3(rec3(n.attrs?.metadata)?.labels) ?? {};
|
|
6720
|
+
function operatorHomes(ir) {
|
|
6721
|
+
const byNamespace = /* @__PURE__ */ new Map();
|
|
6722
|
+
const conflicted = /* @__PURE__ */ new Set();
|
|
6723
|
+
for (const n of ir.nodes) {
|
|
6724
|
+
if (n.kind !== CRONJOB_KIND) continue;
|
|
6725
|
+
const labels = labelsOf(n);
|
|
6726
|
+
if (labels[MANAGED_BY] !== CHANT || labels[COMPONENT] !== CONVERGE_TICK) continue;
|
|
6727
|
+
const meta = rec3(n.attrs?.metadata);
|
|
6728
|
+
const ns = str3(meta?.namespace);
|
|
6729
|
+
const op = str3(meta?.name);
|
|
6730
|
+
if (!ns || !op) continue;
|
|
6731
|
+
const home = byNamespace.get(ns) ?? { namespace: ns, ticks: [] };
|
|
6732
|
+
const stack = str3(labels[NAME_LABEL]);
|
|
6733
|
+
if (stack && !conflicted.has(ns)) {
|
|
6734
|
+
if (home.stack === void 0) home.stack = stack;
|
|
6735
|
+
else if (home.stack !== stack) {
|
|
6736
|
+
conflicted.add(ns);
|
|
6737
|
+
delete home.stack;
|
|
6738
|
+
}
|
|
6739
|
+
}
|
|
6740
|
+
const schedule = str3(rec3(n.attrs?.spec)?.schedule);
|
|
6741
|
+
home.ticks.push({ node: n.id, op, ...schedule ? { schedule } : {} });
|
|
6742
|
+
byNamespace.set(ns, home);
|
|
6743
|
+
}
|
|
6744
|
+
if (!byNamespace.size) return [];
|
|
6745
|
+
for (const n of ir.nodes) {
|
|
6746
|
+
if (n.kind !== NAMESPACE_KIND) continue;
|
|
6747
|
+
const name = str3(rec3(n.attrs?.metadata)?.name);
|
|
6748
|
+
const home = name ? byNamespace.get(name) : void 0;
|
|
6749
|
+
if (home && !home.node) home.node = n.id;
|
|
6750
|
+
}
|
|
6751
|
+
for (const home of byNamespace.values()) home.ticks.sort((a, b) => a.op.localeCompare(b.op));
|
|
6752
|
+
return [...byNamespace.values()].sort((a, b) => a.namespace.localeCompare(b.namespace));
|
|
6753
|
+
}
|
|
6754
|
+
function markOperatorHome(ir) {
|
|
6755
|
+
const homes = operatorHomes(ir);
|
|
6756
|
+
if (!homes.length) return ir;
|
|
6757
|
+
const marks = /* @__PURE__ */ new Map();
|
|
6758
|
+
for (const home of homes) {
|
|
6759
|
+
if (home.node) {
|
|
6760
|
+
marks.set(home.node, {
|
|
6761
|
+
role: "home",
|
|
6762
|
+
namespace: home.namespace,
|
|
6763
|
+
...home.stack ? { stack: home.stack } : {},
|
|
6764
|
+
ticks: home.ticks.map((t) => t.op)
|
|
6765
|
+
});
|
|
6766
|
+
}
|
|
6767
|
+
for (const tick of home.ticks) {
|
|
6768
|
+
marks.set(tick.node, {
|
|
6769
|
+
role: "tick",
|
|
6770
|
+
namespace: home.namespace,
|
|
6771
|
+
...home.stack ? { stack: home.stack } : {},
|
|
6772
|
+
op: tick.op,
|
|
6773
|
+
...tick.schedule ? { schedule: tick.schedule } : {}
|
|
6774
|
+
});
|
|
6775
|
+
}
|
|
6776
|
+
}
|
|
6777
|
+
return {
|
|
6778
|
+
...ir,
|
|
6779
|
+
nodes: ir.nodes.map((n) => {
|
|
6780
|
+
const mark = marks.get(n.id);
|
|
6781
|
+
return mark ? { ...n, attrs: { ...n.attrs, _operator: mark } } : n;
|
|
6782
|
+
})
|
|
6783
|
+
};
|
|
6784
|
+
}
|
|
6785
|
+
function operatorStrip(rows, now = Date.now()) {
|
|
6786
|
+
const out = rows.map((r) => {
|
|
6787
|
+
const expiresAt = r.lease ? Date.parse(r.lease.expiresAt) : NaN;
|
|
6788
|
+
const lease = !r.lease ? "free" : Number.isFinite(expiresAt) && expiresAt <= now ? "expired" : "held";
|
|
6789
|
+
return {
|
|
6790
|
+
op: r.op,
|
|
6791
|
+
env: r.env,
|
|
6792
|
+
log: r.lastTick?.log ?? null,
|
|
6793
|
+
at: r.lastTick?.timestamp ?? null,
|
|
6794
|
+
lease,
|
|
6795
|
+
leaseHolder: r.lease?.holder ?? null,
|
|
6796
|
+
leaseExpiresAt: r.lease?.expiresAt ?? null,
|
|
6797
|
+
pendingGates: r.pendingGates.length
|
|
6798
|
+
};
|
|
6799
|
+
});
|
|
6800
|
+
return { rows: out, pendingGates: out.reduce((n, r) => n + r.pendingGates, 0) };
|
|
6801
|
+
}
|
|
6802
|
+
var CONVERGE_GATE_LOOP = "converge";
|
|
6803
|
+
var APPROVE_SEMANTICS = "Approving records a fact in the gate ledger; it does not unblock the dispatch. The next tick reads it.";
|
|
6804
|
+
function convergeGateCards(rows) {
|
|
5154
6805
|
const out = [];
|
|
5155
|
-
for (const
|
|
5156
|
-
const
|
|
5157
|
-
|
|
5158
|
-
for (const f of readdirSync4(dir)) {
|
|
5159
|
-
if (!f.endsWith(".op.ts")) continue;
|
|
5160
|
-
const content = readFileSync10(join11(dir, f), "utf8");
|
|
5161
|
-
const name = content.match(/name:\s*["'`]([^"'`]+)["'`]/)?.[1];
|
|
5162
|
-
if (!name || seen.has(name)) continue;
|
|
5163
|
-
seen.add(name);
|
|
5164
|
-
const gate = content.match(/signalName:\s*["'`]([^"'`]+)["'`]/)?.[1];
|
|
5165
|
-
const env = content.match(/\benv:\s*["'`]([^"'`]+)["'`]/)?.[1];
|
|
5166
|
-
const target = content.match(/\btarget:\s*["'`]([^"'`]+)["'`]/)?.[1];
|
|
5167
|
-
const substrate = target ? APPLY_TARGET_LEXICON[target] : void 0;
|
|
6806
|
+
for (const row of rows) {
|
|
6807
|
+
for (const g of row.pendingGates) {
|
|
6808
|
+
if (!g.op) continue;
|
|
5168
6809
|
out.push({
|
|
5169
|
-
|
|
5170
|
-
|
|
5171
|
-
|
|
5172
|
-
|
|
5173
|
-
|
|
5174
|
-
|
|
6810
|
+
loop: CONVERGE_GATE_LOOP,
|
|
6811
|
+
rule: g.rule,
|
|
6812
|
+
op: g.op,
|
|
6813
|
+
gate: g.gate,
|
|
6814
|
+
convergeOp: row.op,
|
|
6815
|
+
env: row.env,
|
|
6816
|
+
since: row.lastTick?.timestamp ?? null,
|
|
6817
|
+
semantics: APPROVE_SEMANTICS,
|
|
6818
|
+
approve: {
|
|
6819
|
+
method: "POST",
|
|
6820
|
+
path: `/api/operator/approve/${encodeURIComponent(g.op)}/${encodeURIComponent(g.gate)}`,
|
|
6821
|
+
command: `chant approve ${g.op} ${g.gate}`
|
|
6822
|
+
}
|
|
5175
6823
|
});
|
|
5176
6824
|
}
|
|
5177
6825
|
}
|
|
5178
|
-
return out
|
|
6826
|
+
return out;
|
|
5179
6827
|
}
|
|
5180
|
-
|
|
5181
|
-
|
|
5182
|
-
|
|
5183
|
-
|
|
5184
|
-
|
|
5185
|
-
|
|
5186
|
-
|
|
5187
|
-
|
|
5188
|
-
|
|
6828
|
+
var initialOperatorState = {
|
|
6829
|
+
declared: [],
|
|
6830
|
+
strip: null,
|
|
6831
|
+
gates: [],
|
|
6832
|
+
note: null,
|
|
6833
|
+
code: null,
|
|
6834
|
+
readAt: null
|
|
6835
|
+
};
|
|
6836
|
+
function operatorRead(state, result, at, now = Date.now()) {
|
|
6837
|
+
if (!result.ok) {
|
|
6838
|
+
return { ...state, strip: null, gates: [], note: result.refusal.error, code: result.refusal.code, readAt: at };
|
|
5189
6839
|
}
|
|
5190
|
-
return
|
|
6840
|
+
return {
|
|
6841
|
+
...state,
|
|
6842
|
+
strip: operatorStrip(result.rows, now),
|
|
6843
|
+
gates: convergeGateCards(result.rows),
|
|
6844
|
+
note: null,
|
|
6845
|
+
code: null,
|
|
6846
|
+
readAt: at
|
|
6847
|
+
};
|
|
6848
|
+
}
|
|
6849
|
+
function operatorNote(state) {
|
|
6850
|
+
if (!state.declared.length) {
|
|
6851
|
+
return "No operating loop: this project declares no ConvergeOp, so there is no operator strip.";
|
|
6852
|
+
}
|
|
6853
|
+
const loops = `${state.declared.length} ConvergeOp${state.declared.length === 1 ? "" : "s"}`;
|
|
6854
|
+
if (!state.strip) {
|
|
6855
|
+
return state.note ? `Operating loop: ${loops} declared \u2014 status unavailable: ${state.note}` : `Operating loop: ${loops} declared \u2014 not read yet.`;
|
|
6856
|
+
}
|
|
6857
|
+
const bits = [];
|
|
6858
|
+
const ticked = state.strip.rows.filter((r) => r.at).length;
|
|
6859
|
+
bits.push(
|
|
6860
|
+
ticked ? `last tick only \u2014 chant's status surface exposes one tick per loop, not the history (chant#2029), so this is a strip and not a timeline` : `no tick recorded yet on any loop`
|
|
6861
|
+
);
|
|
6862
|
+
const held = state.strip.rows.filter((r) => r.lease === "held").length;
|
|
6863
|
+
if (held) bits.push(`${held} lease${held === 1 ? "" : "s"} held`);
|
|
6864
|
+
const expired = state.strip.rows.filter((r) => r.lease === "expired").length;
|
|
6865
|
+
if (expired) bits.push(`${expired} lease${expired === 1 ? "" : "s"} expired \u2014 the next round reclaims it`);
|
|
6866
|
+
if (state.strip.pendingGates) {
|
|
6867
|
+
bits.push(
|
|
6868
|
+
`${state.strip.pendingGates} converge gate${state.strip.pendingGates === 1 ? "" : "s"} pending \u2014 approving one records a fact for the next tick, it does not release a workflow (that is the run gate, a different card), and a gate fact carries no URL yet (chant#2028)`
|
|
6869
|
+
);
|
|
6870
|
+
}
|
|
6871
|
+
return `Operating loop: ${loops} \u2014 ${bits.join("; ")}.`;
|
|
5191
6872
|
}
|
|
5192
6873
|
|
|
5193
6874
|
// src/adopt.ts
|
|
@@ -5270,21 +6951,21 @@ function classifyHealth(status) {
|
|
|
5270
6951
|
if (HEALTHY.test(text)) return "healthy";
|
|
5271
6952
|
return "unknown";
|
|
5272
6953
|
}
|
|
5273
|
-
function
|
|
6954
|
+
function rec4(v) {
|
|
5274
6955
|
return typeof v === "object" && v !== null && !Array.isArray(v) ? v : void 0;
|
|
5275
6956
|
}
|
|
5276
|
-
function
|
|
6957
|
+
function str4(v) {
|
|
5277
6958
|
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
5278
6959
|
}
|
|
5279
6960
|
function statusTree(o) {
|
|
5280
|
-
const attrs =
|
|
6961
|
+
const attrs = rec4(o.attributes);
|
|
5281
6962
|
if (!attrs) return void 0;
|
|
5282
|
-
return
|
|
6963
|
+
return rec4(attrs.status) ?? attrs;
|
|
5283
6964
|
}
|
|
5284
6965
|
function specTree(o) {
|
|
5285
|
-
const attrs =
|
|
6966
|
+
const attrs = rec4(o.attributes);
|
|
5286
6967
|
if (!attrs) return void 0;
|
|
5287
|
-
return
|
|
6968
|
+
return rec4(attrs.spec) ?? attrs;
|
|
5288
6969
|
}
|
|
5289
6970
|
var ARGO_HEALTH = {
|
|
5290
6971
|
Healthy: "healthy",
|
|
@@ -5310,9 +6991,9 @@ function argoErrorMessage(tree) {
|
|
|
5310
6991
|
if (m) return m[2];
|
|
5311
6992
|
continue;
|
|
5312
6993
|
}
|
|
5313
|
-
const type =
|
|
6994
|
+
const type = str4(rec4(c)?.type);
|
|
5314
6995
|
if (!type || !ARGO_ERROR_CONDITION.test(type)) continue;
|
|
5315
|
-
const message =
|
|
6996
|
+
const message = str4(rec4(c)?.message);
|
|
5316
6997
|
if (message) return message;
|
|
5317
6998
|
}
|
|
5318
6999
|
return void 0;
|
|
@@ -5320,10 +7001,10 @@ function argoErrorMessage(tree) {
|
|
|
5320
7001
|
function argoVerdict(o) {
|
|
5321
7002
|
if (o.type !== "K8s::Argo::Application") return void 0;
|
|
5322
7003
|
const tree = statusTree(o);
|
|
5323
|
-
let health =
|
|
5324
|
-
let sync =
|
|
7004
|
+
let health = str4(rec4(tree?.health)?.status);
|
|
7005
|
+
let sync = str4(rec4(tree?.sync)?.status);
|
|
5325
7006
|
if (!health && !sync) {
|
|
5326
|
-
const word =
|
|
7007
|
+
const word = str4(o.status);
|
|
5327
7008
|
if (word === "READY") {
|
|
5328
7009
|
health = "Healthy";
|
|
5329
7010
|
sync = "Synced";
|
|
@@ -5349,16 +7030,16 @@ function readyFromConditions(tree) {
|
|
|
5349
7030
|
const conditions = tree?.conditions;
|
|
5350
7031
|
if (!Array.isArray(conditions)) return void 0;
|
|
5351
7032
|
for (const c of conditions) {
|
|
5352
|
-
const cond =
|
|
7033
|
+
const cond = rec4(c);
|
|
5353
7034
|
if (!cond || cond.type !== "Ready") continue;
|
|
5354
|
-
const status =
|
|
7035
|
+
const status = str4(cond.status);
|
|
5355
7036
|
if (status !== "True" && status !== "False" && status !== "Unknown") continue;
|
|
5356
|
-
return { status, reason:
|
|
7037
|
+
return { status, reason: str4(cond.reason) };
|
|
5357
7038
|
}
|
|
5358
7039
|
return void 0;
|
|
5359
7040
|
}
|
|
5360
7041
|
function readyFromChantConditions(o) {
|
|
5361
|
-
const conditions =
|
|
7042
|
+
const conditions = rec4(o.attributes)?.conditions;
|
|
5362
7043
|
if (!Array.isArray(conditions)) return void 0;
|
|
5363
7044
|
for (const c of conditions) {
|
|
5364
7045
|
if (typeof c !== "string") continue;
|
|
@@ -5370,7 +7051,7 @@ function readyFromChantConditions(o) {
|
|
|
5370
7051
|
return void 0;
|
|
5371
7052
|
}
|
|
5372
7053
|
function readyFromStatusWord(o) {
|
|
5373
|
-
const word =
|
|
7054
|
+
const word = str4(o.status);
|
|
5374
7055
|
if (!word || word === "PRESENT") return void 0;
|
|
5375
7056
|
if (word === "READY") return { status: "True" };
|
|
5376
7057
|
if (word === "NOT-READY") return { status: "False" };
|
|
@@ -5380,8 +7061,8 @@ function describeReady(ready) {
|
|
|
5380
7061
|
return `Ready=${ready.status}${ready.reason ? ` (${ready.reason})` : ""}`;
|
|
5381
7062
|
}
|
|
5382
7063
|
function revisionVerdict(tree) {
|
|
5383
|
-
const applied =
|
|
5384
|
-
const attempted =
|
|
7064
|
+
const applied = str4(tree?.lastAppliedRevision);
|
|
7065
|
+
const attempted = str4(tree?.lastAttemptedRevision);
|
|
5385
7066
|
if (!applied || !attempted || applied === attempted) return void 0;
|
|
5386
7067
|
return { health: "progressing", detail: `applied ${applied}, attempted ${attempted}` };
|
|
5387
7068
|
}
|
|
@@ -5394,23 +7075,23 @@ function fluxVerdict(o) {
|
|
|
5394
7075
|
if (ready?.status === "False") return { health: "degraded", detail: describeReady(ready) };
|
|
5395
7076
|
if (ready?.status === "Unknown") return { health: "progressing", detail: describeReady(ready) };
|
|
5396
7077
|
if (ready?.status === "True") return revisionVerdict(tree) ?? { health: "healthy", detail: describeReady(ready) };
|
|
5397
|
-
if (o.type === FLUX_HELMREPOSITORY_TYPE &&
|
|
7078
|
+
if (o.type === FLUX_HELMREPOSITORY_TYPE && str4(specTree(o)?.type) === "oci") {
|
|
5398
7079
|
return { health: "healthy", detail: "spec.type=oci (nothing to reconcile)" };
|
|
5399
7080
|
}
|
|
5400
7081
|
const conditions = tree?.conditions;
|
|
5401
7082
|
const reconciling = Array.isArray(conditions) && conditions.some((c) => {
|
|
5402
|
-
const cond =
|
|
7083
|
+
const cond = rec4(c);
|
|
5403
7084
|
return cond?.type === "Reconciling" && cond.status === "True";
|
|
5404
7085
|
});
|
|
5405
7086
|
if (reconciling) return { health: "progressing", detail: "Reconciling=True" };
|
|
5406
|
-
if (!
|
|
7087
|
+
if (!str4(o.status) || o.status === "PRESENT") {
|
|
5407
7088
|
return { health: "progressing", detail: "no Ready condition yet" };
|
|
5408
7089
|
}
|
|
5409
7090
|
return void 0;
|
|
5410
7091
|
}
|
|
5411
7092
|
var DEPLOYMENT_TYPE = "K8s::Apps::Deployment";
|
|
5412
7093
|
function unhappyCondition(o, type) {
|
|
5413
|
-
const fromStrings =
|
|
7094
|
+
const fromStrings = rec4(o.attributes)?.conditions;
|
|
5414
7095
|
if (Array.isArray(fromStrings)) {
|
|
5415
7096
|
for (const c of fromStrings) {
|
|
5416
7097
|
if (typeof c === "string" && c.startsWith(type)) return c;
|
|
@@ -5420,13 +7101,13 @@ function unhappyCondition(o, type) {
|
|
|
5420
7101
|
const raw = tree?.conditions;
|
|
5421
7102
|
if (Array.isArray(raw)) {
|
|
5422
7103
|
for (const c of raw) {
|
|
5423
|
-
const cond =
|
|
7104
|
+
const cond = rec4(c);
|
|
5424
7105
|
if (!cond || cond.type !== type) continue;
|
|
5425
|
-
const status =
|
|
7106
|
+
const status = str4(cond.status);
|
|
5426
7107
|
const unhappy = type === "ReplicaFailure" ? status === "True" : status === "False";
|
|
5427
7108
|
if (!unhappy) continue;
|
|
5428
|
-
const reason =
|
|
5429
|
-
const message =
|
|
7109
|
+
const reason = str4(cond.reason);
|
|
7110
|
+
const message = str4(cond.message);
|
|
5430
7111
|
return `${type}${reason ? `=${reason}` : ""}${message ? `: ${message}` : ""}`;
|
|
5431
7112
|
}
|
|
5432
7113
|
}
|
|
@@ -5555,6 +7236,19 @@ var OpRunner = class {
|
|
|
5555
7236
|
* before the first event lands so a reload between trigger and first event
|
|
5556
7237
|
* doesn't show the PREVIOUS run's stale terminal state). */
|
|
5557
7238
|
lastApplyProgress = initialApplyProgress;
|
|
7239
|
+
/** The run playhead (#284 item 2): the settled StepRecords of the last Op
|
|
7240
|
+
* `trigger()` started, plus the last `gateState` answer. Kept after the run
|
|
7241
|
+
* ends and across renders for the same reason `lastApplyProgress` is — a
|
|
7242
|
+
* client opening the ops lens mid-run (or after a reload) hydrates the
|
|
7243
|
+
* playhead instead of starting blank. In-process only: a run started
|
|
7244
|
+
* elsewhere is invisible here, and `chant run status` (src/server.ts's
|
|
7245
|
+
* `/api/ops/:name/status`) is what answers for one. */
|
|
7246
|
+
lastRunState = initialRunState;
|
|
7247
|
+
/** The operator strip (#234 join 3): the last `chant operator status --json`
|
|
7248
|
+
* read, plus the ConvergeOps the built project declares. Held here for the
|
|
7249
|
+
* reason `lastRunState` is — it is a durable status read, not a run, and a
|
|
7250
|
+
* client opening the ops lens hydrates from it instead of starting blank. */
|
|
7251
|
+
lastOperatorState = initialOperatorState;
|
|
5558
7252
|
/** #195: re-point delegated writes at a switched project. The runner reads
|
|
5559
7253
|
* `deps.projectDir` at trigger time, so this is the whole retarget. */
|
|
5560
7254
|
retarget(projectDir) {
|
|
@@ -5569,6 +7263,32 @@ var OpRunner = class {
|
|
|
5569
7263
|
get applyProgress() {
|
|
5570
7264
|
return this.lastApplyProgress;
|
|
5571
7265
|
}
|
|
7266
|
+
/** The run playhead's model (#284 item 2) — `initialRunState` until an Op has
|
|
7267
|
+
* been triggered this session. */
|
|
7268
|
+
get runState() {
|
|
7269
|
+
return this.lastRunState;
|
|
7270
|
+
}
|
|
7271
|
+
/**
|
|
7272
|
+
* Fold a `chant run status <name>` read (src/run-playhead.ts's
|
|
7273
|
+
* `readRunStatus`) into the playhead, and broadcast it.
|
|
7274
|
+
*
|
|
7275
|
+
* The gate is a workflow QUERY, not part of the record stream, so it can only
|
|
7276
|
+
* arrive this way. A read for an Op other than the one being watched is
|
|
7277
|
+
* returned to the caller but not folded — one playhead, one Op, and silently
|
|
7278
|
+
* re-pointing it at whatever was last polled would make the picture lie about
|
|
7279
|
+
* which run it shows. A read arriving before anything has run this session
|
|
7280
|
+
* adopts the Op: a gate pending on a run behold didn't start is exactly the
|
|
7281
|
+
* case the pending card exists for.
|
|
7282
|
+
*/
|
|
7283
|
+
noteGate(op, result) {
|
|
7284
|
+
if (this.lastRunState.op !== null && this.lastRunState.op !== op) return this.lastRunState;
|
|
7285
|
+
const base = this.lastRunState.op === op ? this.lastRunState : { ...initialRunState, op, mode: "temporal" };
|
|
7286
|
+
const next = runGate(base, result);
|
|
7287
|
+
const changed = JSON.stringify(next) !== JSON.stringify(this.lastRunState);
|
|
7288
|
+
this.lastRunState = next;
|
|
7289
|
+
if (changed) this.emitRun();
|
|
7290
|
+
return this.lastRunState;
|
|
7291
|
+
}
|
|
5572
7292
|
/**
|
|
5573
7293
|
* Start `chant run <name>` unless one is already running (the Sync/Adopt/auto-
|
|
5574
7294
|
* sync path). `cwd` is the Op's own project dir (#31 multi-estate); defaults to
|
|
@@ -5578,9 +7298,78 @@ var OpRunner = class {
|
|
|
5578
7298
|
* set it from the Op's declared gate: chant refuses a gated Op outright in
|
|
5579
7299
|
* local mode ("gates and schedules need a durable runtime"), so without the
|
|
5580
7300
|
* flag a gated Op could never run from behold at all.
|
|
7301
|
+
*
|
|
7302
|
+
* Either way the run is asked for structured per-step records (#284 item 2,
|
|
7303
|
+
* chant#1676) — `--progress-json` streams one NDJSON `StepRecord` per settled
|
|
7304
|
+
* step on the durable path; `--json` prints the whole `OpRunResult` at the end
|
|
7305
|
+
* on the local one. Same record shape, which is what lets one playhead render
|
|
7306
|
+
* both. `--json` replaces chant's own human per-step summary on the local
|
|
7307
|
+
* path, so each parsed record is re-emitted to the `op` now-line
|
|
7308
|
+
* ({@link recordLine}) and the operator loses no narration.
|
|
5581
7309
|
*/
|
|
5582
7310
|
trigger(name, opEnv, cwd, temporal) {
|
|
5583
|
-
|
|
7311
|
+
if (this.current) return false;
|
|
7312
|
+
const mode = temporal ? "temporal" : "local";
|
|
7313
|
+
const args = temporal ? ["run", name, "--temporal", "--progress-json"] : ["run", name, "--json"];
|
|
7314
|
+
this.lastRunState = runStarted(name, mode, (/* @__PURE__ */ new Date()).toISOString());
|
|
7315
|
+
this.emitRun();
|
|
7316
|
+
return this.start(
|
|
7317
|
+
args,
|
|
7318
|
+
name,
|
|
7319
|
+
opEnv,
|
|
7320
|
+
cwd,
|
|
7321
|
+
(line) => {
|
|
7322
|
+
const records = parseRunProgressLine(line);
|
|
7323
|
+
if (!records) return false;
|
|
7324
|
+
for (const record of records) {
|
|
7325
|
+
this.lastRunState = runRecord(this.lastRunState, record);
|
|
7326
|
+
this.deps.broadcaster.emit("op", recordLine(record));
|
|
7327
|
+
}
|
|
7328
|
+
this.emitRun();
|
|
7329
|
+
return true;
|
|
7330
|
+
},
|
|
7331
|
+
(code) => {
|
|
7332
|
+
this.lastRunState = runEnded(this.lastRunState, code, (/* @__PURE__ */ new Date()).toISOString());
|
|
7333
|
+
this.emitRun();
|
|
7334
|
+
}
|
|
7335
|
+
);
|
|
7336
|
+
}
|
|
7337
|
+
emitRun() {
|
|
7338
|
+
this.deps.broadcaster.emit("run", JSON.stringify(this.lastRunState));
|
|
7339
|
+
}
|
|
7340
|
+
/** The operator strip's model (#234 join 3) — `initialOperatorState` until the
|
|
7341
|
+
* ops lens has been opened once. */
|
|
7342
|
+
get operatorState() {
|
|
7343
|
+
return this.lastOperatorState;
|
|
7344
|
+
}
|
|
7345
|
+
/**
|
|
7346
|
+
* Record which ConvergeOps the built project declares, read from the emitted
|
|
7347
|
+
* op.json (src/operator.ts's `declaredConvergeOps`) — no subprocess, and the
|
|
7348
|
+
* thing that gates the strip. Broadcast on a change so a project rebuilt (or
|
|
7349
|
+
* switched, #195) under an open tab grows or loses its strip without a reload.
|
|
7350
|
+
*/
|
|
7351
|
+
noteDeclaredConvergeOps(declared) {
|
|
7352
|
+
return this.foldOperator({ ...this.lastOperatorState, declared });
|
|
7353
|
+
}
|
|
7354
|
+
/**
|
|
7355
|
+
* Fold a `chant operator status --json` read (src/operator.ts's
|
|
7356
|
+
* `readOperatorStatus`) into the strip, and broadcast it.
|
|
7357
|
+
*
|
|
7358
|
+
* Broadcast only a CHANGED answer, for `noteGate`'s reason: a client asks when
|
|
7359
|
+
* it opens the ops lens and re-pulls the lens on the broadcast, so an
|
|
7360
|
+
* unchanged answer that still broadcast would have the two chasing each other.
|
|
7361
|
+
*/
|
|
7362
|
+
noteOperator(result, at = (/* @__PURE__ */ new Date()).toISOString()) {
|
|
7363
|
+
return this.foldOperator(operatorRead(this.lastOperatorState, result, at));
|
|
7364
|
+
}
|
|
7365
|
+
/** Compare-and-broadcast, ignoring `readAt` — the instant behold asked changes
|
|
7366
|
+
* on every poll and is not itself news; broadcasting on it would make the
|
|
7367
|
+
* interval a repaint loop. */
|
|
7368
|
+
foldOperator(next) {
|
|
7369
|
+
const same = JSON.stringify({ ...next, readAt: null }) === JSON.stringify({ ...this.lastOperatorState, readAt: null });
|
|
7370
|
+
this.lastOperatorState = next;
|
|
7371
|
+
if (!same) this.deps.broadcaster.emit("operator", JSON.stringify(next));
|
|
7372
|
+
return this.lastOperatorState;
|
|
5584
7373
|
}
|
|
5585
7374
|
/**
|
|
5586
7375
|
* Run an arbitrary `chant` invocation through the same guard/stream/PR/capture
|
|
@@ -5712,8 +7501,12 @@ var OpRunner = class {
|
|
|
5712
7501
|
* fully handled that line (apply()'s progress-JSON parsing) — `start()` then
|
|
5713
7502
|
* skips its own `op`/`pr` broadcast for that one line, leaving every other
|
|
5714
7503
|
* line's raw-log fallback untouched.
|
|
7504
|
+
*
|
|
7505
|
+
* `onExit` (the run playhead, #284 item 2) sees the exit code the moment the
|
|
7506
|
+
* process ends — the only signal that separates "the run finished" from "the
|
|
7507
|
+
* stream stopped", and the reason the playhead never has to infer completion.
|
|
5715
7508
|
*/
|
|
5716
|
-
start(args, label, opEnv, cwd, onLine) {
|
|
7509
|
+
start(args, label, opEnv, cwd, onLine, onExit) {
|
|
5717
7510
|
if (this.current) return false;
|
|
5718
7511
|
const { projectDir, broadcaster } = this.deps;
|
|
5719
7512
|
broadcaster.emit("op", `\u25B6 chant ${args.join(" ")}`);
|
|
@@ -5726,6 +7519,7 @@ var OpRunner = class {
|
|
|
5726
7519
|
this.current = label;
|
|
5727
7520
|
void op.done.then((code) => {
|
|
5728
7521
|
broadcaster.emit("op", `\u25A0 ${label} exited ${code}`);
|
|
7522
|
+
onExit?.(code);
|
|
5729
7523
|
this.current = null;
|
|
5730
7524
|
Promise.resolve(this.deps.onDone(opEnv)).then(() => broadcaster.emit("changed")).catch((err) => broadcaster.emit("op", `\u26A0 post-op capture: ${err instanceof Error ? err.message : String(err)}`));
|
|
5731
7525
|
});
|
|
@@ -5735,24 +7529,24 @@ var OpRunner = class {
|
|
|
5735
7529
|
|
|
5736
7530
|
// src/substrates.ts
|
|
5737
7531
|
import { spawn as spawn4 } from "node:child_process";
|
|
5738
|
-
import { existsSync as existsSync10, readFileSync as
|
|
5739
|
-
import { join as
|
|
7532
|
+
import { existsSync as existsSync10, readFileSync as readFileSync12 } from "node:fs";
|
|
7533
|
+
import { join as join15 } from "node:path";
|
|
5740
7534
|
import { platform } from "node:os";
|
|
5741
7535
|
var DOCKER_DOWN_DETAIL = "docker is down";
|
|
5742
7536
|
function probe(cmd, args) {
|
|
5743
|
-
return new Promise((
|
|
7537
|
+
return new Promise((resolve9) => {
|
|
5744
7538
|
let out = "";
|
|
5745
7539
|
let proc;
|
|
5746
7540
|
try {
|
|
5747
7541
|
proc = spawn4(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
5748
7542
|
} catch {
|
|
5749
|
-
|
|
7543
|
+
resolve9({ code: 127, out: "" });
|
|
5750
7544
|
return;
|
|
5751
7545
|
}
|
|
5752
7546
|
proc.stdout.on("data", (d) => out += d);
|
|
5753
7547
|
proc.stderr.on("data", (d) => out += d);
|
|
5754
|
-
proc.on("error", () =>
|
|
5755
|
-
proc.on("close", (code) =>
|
|
7548
|
+
proc.on("error", () => resolve9({ code: 127, out }));
|
|
7549
|
+
proc.on("close", (code) => resolve9({ code: code ?? 1, out }));
|
|
5756
7550
|
});
|
|
5757
7551
|
}
|
|
5758
7552
|
async function dockerAvailable(run3) {
|
|
@@ -5765,11 +7559,11 @@ async function dockerRunning(run3, nameFilter) {
|
|
|
5765
7559
|
return out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
|
|
5766
7560
|
}
|
|
5767
7561
|
function scriptBringUp(projectDir, relPath, label) {
|
|
5768
|
-
return existsSync10(
|
|
7562
|
+
return existsSync10(join15(projectDir, relPath)) ? { label, cmd: "bash", args: [relPath] } : void 0;
|
|
5769
7563
|
}
|
|
5770
7564
|
function projectLexicons(projectDir) {
|
|
5771
7565
|
try {
|
|
5772
|
-
const src =
|
|
7566
|
+
const src = readFileSync12(join15(projectDir, "chant.config.ts"), "utf-8");
|
|
5773
7567
|
const m = src.match(/lexicons\s*:\s*\[([^\]]*)\]/);
|
|
5774
7568
|
if (!m) return [];
|
|
5775
7569
|
return [...m[1].matchAll(/["']([^"']+)["']/g)].map((x) => x[1]);
|
|
@@ -5821,7 +7615,7 @@ async function detectSubstrates(projectDir, preview = false, boundContext, probe
|
|
|
5821
7615
|
["forgejo", "Forgejo", ".forgejo", "test/forgejo-runtime-e2e.sh"]
|
|
5822
7616
|
];
|
|
5823
7617
|
for (const [name, label, marker, script] of forges) {
|
|
5824
|
-
if (!existsSync10(
|
|
7618
|
+
if (!existsSync10(join15(projectDir, marker))) continue;
|
|
5825
7619
|
const c = docker ? await dockerRunning(run3, name) : [];
|
|
5826
7620
|
const d = dep(c.length > 0, "container up", "on-demand (pipeline run)");
|
|
5827
7621
|
subs.push({
|
|
@@ -5855,7 +7649,7 @@ async function detectSubstrates(projectDir, preview = false, boundContext, probe
|
|
|
5855
7649
|
detail: endpoint ? `targeting ${endpoint}` : "real Fly (FLY_FLAPS_BASE_URL unset)"
|
|
5856
7650
|
});
|
|
5857
7651
|
}
|
|
5858
|
-
if (existsSync10(
|
|
7652
|
+
if (existsSync10(join15(projectDir, ".github", "workflows"))) {
|
|
5859
7653
|
const gh = await run3("gh", ["auth", "status"]);
|
|
5860
7654
|
const ready = gh.code === 0;
|
|
5861
7655
|
subs.push({
|
|
@@ -5868,7 +7662,7 @@ async function detectSubstrates(projectDir, preview = false, boundContext, probe
|
|
|
5868
7662
|
if (lexicons.includes("temporal")) {
|
|
5869
7663
|
let hasProfiles = false;
|
|
5870
7664
|
try {
|
|
5871
|
-
hasProfiles = /temporal\s*:\s*\{[\s\S]{0,400}?profiles\s*:/.test(
|
|
7665
|
+
hasProfiles = /temporal\s*:\s*\{[\s\S]{0,400}?profiles\s*:/.test(readFileSync12(join15(projectDir, "chant.config.ts"), "utf-8"));
|
|
5872
7666
|
} catch {
|
|
5873
7667
|
}
|
|
5874
7668
|
subs.push({
|
|
@@ -5971,185 +7765,9 @@ async function openRollbackBranches(projectDir, env) {
|
|
|
5971
7765
|
}
|
|
5972
7766
|
}
|
|
5973
7767
|
|
|
5974
|
-
// src/estate.ts
|
|
5975
|
-
import { statSync as statSync3 } from "node:fs";
|
|
5976
|
-
import { availableParallelism } from "node:os";
|
|
5977
|
-
import { join as join13, resolve as resolve3, sep as sep2 } from "node:path";
|
|
5978
|
-
import { composeStacks, shortStackNames } from "@intentius/pinhole";
|
|
5979
|
-
function estateReadPool(members, env = process.env) {
|
|
5980
|
-
const override = Number.parseInt(env.BEHOLD_ESTATE_CONCURRENCY ?? "", 10);
|
|
5981
|
-
const cap = Number.isInteger(override) && override >= 1 ? override : Math.min(4, availableParallelism());
|
|
5982
|
-
return Math.max(1, Math.min(cap, members));
|
|
5983
|
-
}
|
|
5984
|
-
async function mapPool(items, width, fn) {
|
|
5985
|
-
const results = new Array(items.length);
|
|
5986
|
-
let next = 0;
|
|
5987
|
-
const worker = async () => {
|
|
5988
|
-
for (let i = next++; i < items.length; i = next++) {
|
|
5989
|
-
results[i] = await fn(items[i], i);
|
|
5990
|
-
}
|
|
5991
|
-
};
|
|
5992
|
-
await Promise.all(Array.from({ length: Math.max(1, Math.min(width, items.length)) }, () => worker()));
|
|
5993
|
-
return results;
|
|
5994
|
-
}
|
|
5995
|
-
async function composeEstate(projectDirs, opts = {}) {
|
|
5996
|
-
const names = shortStackNames(projectDirs);
|
|
5997
|
-
const stacks = await mapPool(projectDirs, estateReadPool(projectDirs.length), async (dir, i) => ({
|
|
5998
|
-
name: names[i],
|
|
5999
|
-
ir: await graphIr(dir, opts)
|
|
6000
|
-
}));
|
|
6001
|
-
return composeStacks(stacks);
|
|
6002
|
-
}
|
|
6003
|
-
var firstLine2 = (e) => {
|
|
6004
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
6005
|
-
const m = msg.match(/exited \d+:\s*([\s\S]*)/);
|
|
6006
|
-
return (m ? m[1] : msg).split("\n")[0].trim().slice(0, 160);
|
|
6007
|
-
};
|
|
6008
|
-
function namespaceRuntimeOwners(name, ir) {
|
|
6009
|
-
const own = new Set(ir.nodes.map((n) => n.id));
|
|
6010
|
-
for (const n of ir.nodes) {
|
|
6011
|
-
if (n.runtimeOwner && own.has(n.runtimeOwner)) n.runtimeOwner = `${name}/${n.runtimeOwner}`;
|
|
6012
|
-
}
|
|
6013
|
-
return ir;
|
|
6014
|
-
}
|
|
6015
|
-
var NAMESPACE_JOIN_FLOOR = "0.44.5";
|
|
6016
|
-
var rec3 = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : void 0;
|
|
6017
|
-
var str3 = (v) => typeof v === "string" && v.length > 0 ? v : void 0;
|
|
6018
|
-
var realIsDir = (p) => {
|
|
6019
|
-
try {
|
|
6020
|
-
return statSync3(p).isDirectory();
|
|
6021
|
-
} catch {
|
|
6022
|
-
return false;
|
|
6023
|
-
}
|
|
6024
|
-
};
|
|
6025
|
-
function declaredNamespaceBindings(ir) {
|
|
6026
|
-
const out = [];
|
|
6027
|
-
for (const n of ir.nodes) {
|
|
6028
|
-
if (n.kind !== "K8s::Flux::Kustomization") continue;
|
|
6029
|
-
const spec = rec3(n.attrs?.spec);
|
|
6030
|
-
const namespace = str3(spec?.targetNamespace);
|
|
6031
|
-
const path = str3(spec?.path);
|
|
6032
|
-
if (namespace && path) out.push({ path, namespace });
|
|
6033
|
-
}
|
|
6034
|
-
return out;
|
|
6035
|
-
}
|
|
6036
|
-
function awaitsNamespaceBinding(ir) {
|
|
6037
|
-
const namespaced = ir.nodes.filter((n) => n.lexicon === "k8s" && !CLUSTER_SCOPED.has(n.kind ?? ""));
|
|
6038
|
-
if (namespaced.length === 0) return false;
|
|
6039
|
-
return namespaced.every((n) => !str3(rec3(n.attrs?.metadata)?.namespace));
|
|
6040
|
-
}
|
|
6041
|
-
function declaredSegments(path) {
|
|
6042
|
-
if (path.startsWith("/")) return void 0;
|
|
6043
|
-
const segs = path.split("/").filter((s) => s !== "" && s !== ".");
|
|
6044
|
-
if (segs.length === 0 || segs.includes("..")) return void 0;
|
|
6045
|
-
return segs;
|
|
6046
|
-
}
|
|
6047
|
-
function pathAlignment(declared, dir, isDir = realIsDir) {
|
|
6048
|
-
const want = declaredSegments(declared);
|
|
6049
|
-
if (!want) return 0;
|
|
6050
|
-
const root = resolve3(dir);
|
|
6051
|
-
const have = root.split(sep2).filter(Boolean);
|
|
6052
|
-
for (let k = Math.min(want.length, have.length); k >= 1; k--) {
|
|
6053
|
-
if (!have.slice(-k).every((s, i) => s === want[i])) continue;
|
|
6054
|
-
if (!isDir(join13(root, ...want.slice(k)))) continue;
|
|
6055
|
-
return k;
|
|
6056
|
-
}
|
|
6057
|
-
return 0;
|
|
6058
|
-
}
|
|
6059
|
-
function joinNamespaceBindings(members, isDir = realIsDir) {
|
|
6060
|
-
const bindings = members.flatMap(
|
|
6061
|
-
(m) => m.ir ? declaredNamespaceBindings(m.ir).map((b) => ({ ...b, declaredBy: m.dir })) : []
|
|
6062
|
-
);
|
|
6063
|
-
if (bindings.length === 0) return [];
|
|
6064
|
-
const awaiting = members.filter((m) => m.ir && awaitsNamespaceBinding(m.ir));
|
|
6065
|
-
if (awaiting.length === 0) return [];
|
|
6066
|
-
const best = /* @__PURE__ */ new Map();
|
|
6067
|
-
for (const b of bindings) {
|
|
6068
|
-
let winner;
|
|
6069
|
-
let tied = false;
|
|
6070
|
-
for (const m of awaiting) {
|
|
6071
|
-
if (m.dir === b.declaredBy) continue;
|
|
6072
|
-
const score = pathAlignment(b.path, m.dir, isDir);
|
|
6073
|
-
if (score === 0) continue;
|
|
6074
|
-
if (!winner || score > winner.score) {
|
|
6075
|
-
winner = { dir: m.dir, score };
|
|
6076
|
-
tied = false;
|
|
6077
|
-
} else if (score === winner.score) {
|
|
6078
|
-
tied = true;
|
|
6079
|
-
}
|
|
6080
|
-
}
|
|
6081
|
-
if (!winner || tied) continue;
|
|
6082
|
-
const prev = best.get(winner.dir);
|
|
6083
|
-
if (prev === "ambiguous") continue;
|
|
6084
|
-
const join19 = { dir: winner.dir, namespace: b.namespace, path: b.path, declaredBy: b.declaredBy };
|
|
6085
|
-
if (!prev) best.set(winner.dir, { score: winner.score, join: join19 });
|
|
6086
|
-
else if (prev.join.namespace !== b.namespace) best.set(winner.dir, "ambiguous");
|
|
6087
|
-
else if (winner.score > prev.score) best.set(winner.dir, { score: winner.score, join: join19 });
|
|
6088
|
-
}
|
|
6089
|
-
return [...best.values()].filter((v) => v !== "ambiguous").map((v) => v.join);
|
|
6090
|
-
}
|
|
6091
|
-
async function estateNamespaceScopes(projectDirs, opts, isDir = realIsDir) {
|
|
6092
|
-
if (projectDirs.length < 2) return /* @__PURE__ */ new Map();
|
|
6093
|
-
const { live: _live, overlay: _overlay, namespace: _namespace, ...src } = opts;
|
|
6094
|
-
const irs = await mapPool(
|
|
6095
|
-
projectDirs,
|
|
6096
|
-
estateReadPool(projectDirs.length),
|
|
6097
|
-
(dir) => graphIr(dir, { ...src, detail: 3 }).catch(() => void 0)
|
|
6098
|
-
);
|
|
6099
|
-
const scopes = /* @__PURE__ */ new Map();
|
|
6100
|
-
for (const j of joinNamespaceBindings(projectDirs.map((dir, i) => ({ dir, ir: irs[i] })), isDir)) {
|
|
6101
|
-
if (!meetsFloor(resolveChant(j.dir).version, NAMESPACE_JOIN_FLOOR)) continue;
|
|
6102
|
-
scopes.set(j.dir, j.namespace);
|
|
6103
|
-
}
|
|
6104
|
-
return scopes;
|
|
6105
|
-
}
|
|
6106
|
-
function withoutJoinedMembers(nodes, joined) {
|
|
6107
|
-
if (joined.length === 0) return [...nodes];
|
|
6108
|
-
return nodes.filter((n) => !joined.some((j) => n.id.startsWith(`${j.name}/`)));
|
|
6109
|
-
}
|
|
6110
|
-
async function composeEstateOverlay(projectDirs, opts, classify) {
|
|
6111
|
-
const names = shortStackNames(projectDirs);
|
|
6112
|
-
const unobserved = [];
|
|
6113
|
-
const dropped = [];
|
|
6114
|
-
const joined = [];
|
|
6115
|
-
const stacks = new Array(projectDirs.length);
|
|
6116
|
-
const scopes = await estateNamespaceScopes(projectDirs, opts);
|
|
6117
|
-
await mapPool(projectDirs, estateReadPool(projectDirs.length), async (dir, i) => {
|
|
6118
|
-
const name = names[i];
|
|
6119
|
-
const namespace = scopes.get(dir);
|
|
6120
|
-
try {
|
|
6121
|
-
const live = { ...opts, live: true, overlay: true, ...namespace ? { namespace } : {} };
|
|
6122
|
-
stacks[i] = { name, ir: namespaceRuntimeOwners(name, classify(await graphIr(dir, live))) };
|
|
6123
|
-
if (namespace) joined.push({ name, namespace });
|
|
6124
|
-
} catch (err) {
|
|
6125
|
-
const reason = firstLine2(err);
|
|
6126
|
-
try {
|
|
6127
|
-
const { env: _env, live: _live, overlay: _overlay, ...srcOpts } = opts;
|
|
6128
|
-
const src = await graphIr(dir, srcOpts);
|
|
6129
|
-
for (const n of src.nodes) n.attrs = { ...n.attrs, _status: "neutral", _unobserved: reason };
|
|
6130
|
-
stacks[i] = { name, ir: src };
|
|
6131
|
-
unobserved.push({ name, reason });
|
|
6132
|
-
} catch (err2) {
|
|
6133
|
-
dropped.push({ name, reason: firstLine2(err2) });
|
|
6134
|
-
}
|
|
6135
|
-
}
|
|
6136
|
-
});
|
|
6137
|
-
const present = stacks.filter((s) => !!s);
|
|
6138
|
-
return {
|
|
6139
|
-
ir: composeStacks(present),
|
|
6140
|
-
observed: present.length - unobserved.length,
|
|
6141
|
-
total: projectDirs.length,
|
|
6142
|
-
unobserved,
|
|
6143
|
-
dropped,
|
|
6144
|
-
// Concurrent reads finish in whatever order the clusters answer; the note
|
|
6145
|
-
// this feeds should read the same twice.
|
|
6146
|
-
joined: joined.sort((a, b) => a.name.localeCompare(b.name))
|
|
6147
|
-
};
|
|
6148
|
-
}
|
|
6149
|
-
|
|
6150
7768
|
// src/events.ts
|
|
6151
7769
|
import { watch, existsSync as existsSync11 } from "node:fs";
|
|
6152
|
-
import { join as
|
|
7770
|
+
import { join as join16 } from "node:path";
|
|
6153
7771
|
var Broadcaster = class {
|
|
6154
7772
|
listeners = /* @__PURE__ */ new Set();
|
|
6155
7773
|
subscribe(fn) {
|
|
@@ -6167,7 +7785,7 @@ var Broadcaster = class {
|
|
|
6167
7785
|
};
|
|
6168
7786
|
var IGNORE = /(^|[\\/])(node_modules|dist|\.git)([\\/]|$)/;
|
|
6169
7787
|
function watchSource(projectDir, onChange, debounceMs = 200) {
|
|
6170
|
-
const dir = existsSync11(
|
|
7788
|
+
const dir = existsSync11(join16(projectDir, "src")) ? join16(projectDir, "src") : projectDir;
|
|
6171
7789
|
let timer;
|
|
6172
7790
|
const watcher = watch(dir, { recursive: true }, (_event, file) => {
|
|
6173
7791
|
const name = typeof file === "string" ? file : "";
|
|
@@ -6416,8 +8034,8 @@ function renderLanes(frames, summaries) {
|
|
|
6416
8034
|
status: Object.fromEntries(f.ir.nodes.map((n) => [n.id, n.attrs?._status ?? ""])),
|
|
6417
8035
|
lexicon: Object.fromEntries(f.ir.nodes.map((n) => [n.id, n.lexicon]))
|
|
6418
8036
|
}));
|
|
6419
|
-
const
|
|
6420
|
-
return doc.includes("</body>") ? doc.replace("</body>", `${
|
|
8037
|
+
const strip3 = `<style>${LANES_CSS}</style>` + THEME_BOOT + `<div id="behold-diff"></div><div id="behold-lanes"><div class="hd"><span>deployment lanes</span><span id="behold-lanes-meta"></span><span class="rt" id="behold-lanes-rt" style="display:none">offset \u2014 graph shows real time</span><a href="/">\u2190 graph</a><span id="behold-lanes-pickers"></span></div><canvas id="behold-lanes-canvas"></canvas></div>` + laneStripScript(laneFrames);
|
|
8038
|
+
return doc.includes("</body>") ? doc.replace("</body>", `${strip3}</body>`) : doc + strip3;
|
|
6421
8039
|
}
|
|
6422
8040
|
|
|
6423
8041
|
// src/emulator.ts
|
|
@@ -6455,13 +8073,13 @@ async function emulatorDown(projectDir) {
|
|
|
6455
8073
|
}
|
|
6456
8074
|
|
|
6457
8075
|
// src/demos.ts
|
|
6458
|
-
import { readFileSync as
|
|
6459
|
-
import { join as
|
|
8076
|
+
import { readFileSync as readFileSync13, existsSync as existsSync12, cpSync } from "node:fs";
|
|
8077
|
+
import { join as join17, relative as relative3, resolve as resolve5, sep as sep3 } from "node:path";
|
|
6460
8078
|
import { spawn as spawn5, spawnSync } from "node:child_process";
|
|
6461
8079
|
function loadDemoRegistry(pkgRoot2) {
|
|
6462
8080
|
let raw;
|
|
6463
8081
|
try {
|
|
6464
|
-
raw = JSON.parse(
|
|
8082
|
+
raw = JSON.parse(readFileSync13(join17(pkgRoot2, "demos.json"), "utf8"));
|
|
6465
8083
|
} catch {
|
|
6466
8084
|
return [];
|
|
6467
8085
|
}
|
|
@@ -6501,22 +8119,22 @@ function fetchesFromNetwork(entry) {
|
|
|
6501
8119
|
return entry.source === "git";
|
|
6502
8120
|
}
|
|
6503
8121
|
function demoTargetDir(entry, cwd = process.cwd()) {
|
|
6504
|
-
const legacy =
|
|
8122
|
+
const legacy = resolve5(cwd, "behold-demo");
|
|
6505
8123
|
if (entry.name === "writes" && existsSync12(legacy)) return legacy;
|
|
6506
|
-
return
|
|
8124
|
+
return resolve5(cwd, "behold-demos", entry.name);
|
|
6507
8125
|
}
|
|
6508
8126
|
async function loadDemo(entry, opts) {
|
|
6509
8127
|
const { pkgRoot: pkgRoot2, target } = opts;
|
|
6510
8128
|
const say = (line) => opts.log?.(`behold demo ${entry.name} \u2192 ${line}`);
|
|
6511
8129
|
if (!existsSync12(target)) {
|
|
6512
8130
|
if (entry.source === "bundled") {
|
|
6513
|
-
const bundled =
|
|
8131
|
+
const bundled = join17(pkgRoot2, entry.dir);
|
|
6514
8132
|
if (!existsSync12(bundled)) return { ok: false, error: `this install has no bundled ${entry.dir}` };
|
|
6515
8133
|
say(`copying to ${target} (it's yours \u2014 edit it)`);
|
|
6516
8134
|
try {
|
|
6517
8135
|
cpSync(bundled, target, {
|
|
6518
8136
|
recursive: true,
|
|
6519
|
-
filter: (src) => !
|
|
8137
|
+
filter: (src) => !relative3(bundled, src).split(sep3).includes("node_modules")
|
|
6520
8138
|
});
|
|
6521
8139
|
} catch (err) {
|
|
6522
8140
|
return { ok: false, error: `copy failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
@@ -6530,7 +8148,7 @@ async function loadDemo(entry, opts) {
|
|
|
6530
8148
|
} else {
|
|
6531
8149
|
say(`reusing ${target}`);
|
|
6532
8150
|
}
|
|
6533
|
-
if (existsSync12(
|
|
8151
|
+
if (existsSync12(join17(target, "package.json")) && !existsSync12(join17(target, "node_modules"))) {
|
|
6534
8152
|
say("npm install\u2026");
|
|
6535
8153
|
if (await runStep("npm", ["install"], { cwd: target, shell: process.platform === "win32" }) !== 0) {
|
|
6536
8154
|
return { ok: false, error: `npm install failed in ${target}` };
|
|
@@ -6542,7 +8160,7 @@ async function loadDemo(entry, opts) {
|
|
|
6542
8160
|
return { ok: false, error: `setup failed (${entry.setup})` };
|
|
6543
8161
|
}
|
|
6544
8162
|
}
|
|
6545
|
-
return { ok: true, serveDirs: entry.serve.dirs?.length ? entry.serve.dirs.map((d) =>
|
|
8163
|
+
return { ok: true, serveDirs: entry.serve.dirs?.length ? entry.serve.dirs.map((d) => join17(target, d)) : [target] };
|
|
6546
8164
|
}
|
|
6547
8165
|
function runStep(cmd, args, opts = {}) {
|
|
6548
8166
|
return new Promise((res) => {
|
|
@@ -6553,8 +8171,8 @@ function runStep(cmd, args, opts = {}) {
|
|
|
6553
8171
|
}
|
|
6554
8172
|
|
|
6555
8173
|
// src/server.ts
|
|
6556
|
-
var pkgRoot =
|
|
6557
|
-
var webRoot =
|
|
8174
|
+
var pkgRoot = join18(dirname5(fileURLToPath3(import.meta.url)), "..");
|
|
8175
|
+
var webRoot = join18(pkgRoot, "web");
|
|
6558
8176
|
var execFileP = async (cmd, args) => (await promisify3(execFile3)(cmd, args, { encoding: "utf8", timeout: 1e4 })).stdout;
|
|
6559
8177
|
function optsFromQuery(url, tierEnvVar, projectDir) {
|
|
6560
8178
|
const q = url.searchParams;
|
|
@@ -6603,8 +8221,8 @@ function tierFailure(tier, message) {
|
|
|
6603
8221
|
}
|
|
6604
8222
|
function beholdVersion() {
|
|
6605
8223
|
try {
|
|
6606
|
-
const here =
|
|
6607
|
-
return JSON.parse(
|
|
8224
|
+
const here = dirname5(fileURLToPath3(import.meta.url));
|
|
8225
|
+
return JSON.parse(readFileSync14(join18(here, "..", "package.json"), "utf8")).version ?? "unknown";
|
|
6608
8226
|
} catch {
|
|
6609
8227
|
return "unknown";
|
|
6610
8228
|
}
|
|
@@ -6649,7 +8267,9 @@ async function captureFrame(projectDir, env, frames, broadcaster) {
|
|
|
6649
8267
|
}
|
|
6650
8268
|
}
|
|
6651
8269
|
function carveRoutes(app, reportPath, demo) {
|
|
6652
|
-
const load = () => readCarveReport(reportPath, (p) =>
|
|
8270
|
+
const load = () => readCarveReport(reportPath, (p) => readFileSync14(p, "utf8"));
|
|
8271
|
+
const carveDirs = [dirname5(reportPath), ...demo ? [demo.out] : []];
|
|
8272
|
+
const carveStates = () => readCarveStates(carveDirs, nodeCarveIo, 0);
|
|
6653
8273
|
const demoBlock = () => carveWriteBlock(demo);
|
|
6654
8274
|
const demoInfo = () => {
|
|
6655
8275
|
if (!demo) return null;
|
|
@@ -6661,8 +8281,8 @@ function carveRoutes(app, reportPath, demo) {
|
|
|
6661
8281
|
project: demo.project,
|
|
6662
8282
|
out: demo.out,
|
|
6663
8283
|
// Paths the UI can print without leaking the operator's whole home dir.
|
|
6664
|
-
outLabel:
|
|
6665
|
-
fromLabel:
|
|
8284
|
+
outLabel: relative4(demo.root, demo.out).split(sep4).join("/"),
|
|
8285
|
+
fromLabel: relative4(demo.root, demo.from).split(sep4).join("/"),
|
|
6666
8286
|
runnable: !block,
|
|
6667
8287
|
...block ? { reason: block } : {},
|
|
6668
8288
|
// The live tier, when this boot armed it: the scratch endpoint the
|
|
@@ -6679,7 +8299,7 @@ function carveRoutes(app, reportPath, demo) {
|
|
|
6679
8299
|
let appGraph;
|
|
6680
8300
|
const appGraphOnce = () => {
|
|
6681
8301
|
if (!demo) return Promise.resolve(null);
|
|
6682
|
-
appGraph ??= graphIr(demo.project).then((ir) => ir.nodes.length ? { ir, label:
|
|
8302
|
+
appGraph ??= graphIr(demo.project).then((ir) => ir.nodes.length ? { ir, label: relative4(demo.root, demo.project).split(sep4).join("/") } : null).catch(() => null);
|
|
6683
8303
|
return appGraph;
|
|
6684
8304
|
};
|
|
6685
8305
|
app.get("/api/graph", async (c) => {
|
|
@@ -6687,10 +8307,21 @@ function carveRoutes(app, reportPath, demo) {
|
|
|
6687
8307
|
if (!parsed.ok) return c.json(parsed.refusal, 422);
|
|
6688
8308
|
const tfIr = carveReportToIr(parsed.report);
|
|
6689
8309
|
const appSide = await appGraphOnce();
|
|
8310
|
+
const states = carveStates();
|
|
8311
|
+
const shorten = demo ? (p) => shortenIn(p, demo.root) : (p) => p;
|
|
8312
|
+
const single = () => {
|
|
8313
|
+
const split = splitCarveState(tfIr, states, { shorten });
|
|
8314
|
+
return bandGraduated(split.tf, split.graduated);
|
|
8315
|
+
};
|
|
6690
8316
|
const { svg, ir } = appSide ? renderCarveEstate(tfIr, appSide.ir, {
|
|
6691
|
-
tfTitle: `${
|
|
6692
|
-
appTitle: `${appSide.label} \u2014 chant
|
|
6693
|
-
|
|
8317
|
+
tfTitle: `${relative4(demo.root, demo.from).split(sep4).join("/")} \u2014 terraform`,
|
|
8318
|
+
appTitle: `${appSide.label} \u2014 chant`,
|
|
8319
|
+
carved: states,
|
|
8320
|
+
shorten
|
|
8321
|
+
}) : (() => {
|
|
8322
|
+
const banded = single();
|
|
8323
|
+
return { ...renderBanded(banded), ir: banded };
|
|
8324
|
+
})();
|
|
6694
8325
|
return c.json({
|
|
6695
8326
|
ir,
|
|
6696
8327
|
svg,
|
|
@@ -6702,7 +8333,7 @@ function carveRoutes(app, reportPath, demo) {
|
|
|
6702
8333
|
carve: true,
|
|
6703
8334
|
// A demo whose own advisor run failed says so on the statusbar, not
|
|
6704
8335
|
// only in the terminal the viewer isn't looking at.
|
|
6705
|
-
note: carveNote(parsed.report, tfIr) + (demo?.degraded ? ` Degraded: ${demo.degraded}` : "")
|
|
8336
|
+
note: carveNote(parsed.report, tfIr) + carveStateNote(carveProgress(states.values(), tfIr.nodes.length), states) + (demo?.degraded ? ` Degraded: ${demo.degraded}` : "")
|
|
6706
8337
|
}
|
|
6707
8338
|
});
|
|
6708
8339
|
});
|
|
@@ -6734,14 +8365,22 @@ function carveRoutes(app, reportPath, demo) {
|
|
|
6734
8365
|
}
|
|
6735
8366
|
const tfIr = carveReportToIr(parsed.report);
|
|
6736
8367
|
const html = renderCarveMorph(tfIr, appSide.ir, [select], {
|
|
6737
|
-
tfTitle: `${
|
|
8368
|
+
tfTitle: `${relative4(demo.root, demo.from).split(sep4).join("/")} \u2014 terraform`,
|
|
6738
8369
|
appTitle: `${appSide.label} \u2014 chant`,
|
|
6739
|
-
title: `carve \u2014 ${select}
|
|
8370
|
+
title: `carve \u2014 ${select}`,
|
|
8371
|
+
carved: carveStates(),
|
|
8372
|
+
shorten: (p) => shortenIn(p, demo.root)
|
|
6740
8373
|
});
|
|
6741
8374
|
return c.html(html);
|
|
6742
8375
|
});
|
|
6743
8376
|
app.get("/api/project", (c) => {
|
|
6744
8377
|
const parsed = load();
|
|
8378
|
+
const total = parsed.ok ? parsed.report.count ?? parsed.report.resources.length : null;
|
|
8379
|
+
const state = carveStatePayload(
|
|
8380
|
+
carveStates(),
|
|
8381
|
+
total,
|
|
8382
|
+
() => demo ? { from: shortenIn(demo.from, demo.root), out: shortenIn(demo.out, demo.root) } : {}
|
|
8383
|
+
);
|
|
6745
8384
|
return c.json({
|
|
6746
8385
|
projectDir: reportPath,
|
|
6747
8386
|
recents: [],
|
|
@@ -6755,11 +8394,12 @@ function carveRoutes(app, reportPath, demo) {
|
|
|
6755
8394
|
carve: parsed.ok ? {
|
|
6756
8395
|
report: reportPath,
|
|
6757
8396
|
from: parsed.report.from ?? null,
|
|
6758
|
-
count:
|
|
8397
|
+
count: total,
|
|
6759
8398
|
bands: parsed.report.bands ?? {},
|
|
6760
8399
|
advisory: parsed.report.advisory ?? null,
|
|
6761
|
-
demo: demoInfo()
|
|
6762
|
-
|
|
8400
|
+
demo: demoInfo(),
|
|
8401
|
+
state
|
|
8402
|
+
} : { report: reportPath, demo: demoInfo(), state }
|
|
6763
8403
|
});
|
|
6764
8404
|
});
|
|
6765
8405
|
const runStep2 = async (c, run3) => {
|
|
@@ -6869,7 +8509,17 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
6869
8509
|
// of starting blank — the `apply` SSE event (below) carries every update
|
|
6870
8510
|
// after that. `status: "idle"` (initialApplyProgress) when nothing has
|
|
6871
8511
|
// applied yet this session.
|
|
6872
|
-
applyProgress: runner.applyProgress
|
|
8512
|
+
applyProgress: runner.applyProgress,
|
|
8513
|
+
// The run playhead (#284 item 2): the settled StepRecords of the Op this
|
|
8514
|
+
// session last triggered, so a client opening (or reloading) mid-run
|
|
8515
|
+
// hydrates the playhead instead of starting blank — the `run` SSE event
|
|
8516
|
+
// carries every update after that. `status: "idle"` when nothing has run.
|
|
8517
|
+
runState: runner.runState,
|
|
8518
|
+
// #234 join 3: the operator strip's last known model, so a client that
|
|
8519
|
+
// opens (or reloads) hydrates the strip instead of starting blank — same
|
|
8520
|
+
// reason `runState` is here. `declared: []` until the ops lens has been
|
|
8521
|
+
// opened once, since that is where the op.json read happens.
|
|
8522
|
+
operatorState: runner.operatorState
|
|
6873
8523
|
})
|
|
6874
8524
|
);
|
|
6875
8525
|
app.post("/api/ops/:name/run", (c) => {
|
|
@@ -6910,8 +8560,8 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
6910
8560
|
return c.json({ started: true, name, ran: label });
|
|
6911
8561
|
});
|
|
6912
8562
|
app.post("/api/local/reset", (c) => {
|
|
6913
|
-
const down =
|
|
6914
|
-
const up =
|
|
8563
|
+
const down = join18(cfg.projectDir, "scripts/local/local-down.sh");
|
|
8564
|
+
const up = join18(cfg.projectDir, "scripts/local/local-up.sh");
|
|
6915
8565
|
if (!existsSync13(down) || !existsSync13(up)) {
|
|
6916
8566
|
return c.json({ error: "no local-down.sh / local-up.sh in scripts/local \u2014 reset is only for local emulator projects" }, 400);
|
|
6917
8567
|
}
|
|
@@ -6920,6 +8570,62 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
6920
8570
|
}
|
|
6921
8571
|
return c.json({ started: true, ran: "local-down + local-up" });
|
|
6922
8572
|
});
|
|
8573
|
+
app.get("/api/ops/:name/status", async (c) => {
|
|
8574
|
+
const name = c.req.param("name");
|
|
8575
|
+
const info = estateOps().find((o) => o.name === name);
|
|
8576
|
+
const run3 = await runChantRaw(["run", "status", name, "--temporal"], info?.dir ?? cfg.projectDir);
|
|
8577
|
+
const result = readRunStatus(name, run3);
|
|
8578
|
+
const runState = runner.noteGate(name, result);
|
|
8579
|
+
if (!result.ok) return c.json({ ...result.refusal, runState }, 422);
|
|
8580
|
+
return c.json({ ...result.read, runState });
|
|
8581
|
+
});
|
|
8582
|
+
const convergeDirs = () => {
|
|
8583
|
+
const dirs = [];
|
|
8584
|
+
const declared = [];
|
|
8585
|
+
for (const f of discoverOpIrs(estateDirs)) {
|
|
8586
|
+
const parsed = readOpIr(f.path, (p) => readFileSync14(p, "utf8"));
|
|
8587
|
+
if (!parsed.ok) continue;
|
|
8588
|
+
const mine = declaredConvergeOps([parsed.ir]);
|
|
8589
|
+
if (!mine.length) continue;
|
|
8590
|
+
declared.push(...mine);
|
|
8591
|
+
if (!dirs.includes(f.dir)) dirs.push(f.dir);
|
|
8592
|
+
}
|
|
8593
|
+
return { dirs, declared };
|
|
8594
|
+
};
|
|
8595
|
+
app.get("/api/operator/status", async (c) => {
|
|
8596
|
+
const { dirs, declared } = convergeDirs();
|
|
8597
|
+
runner.noteDeclaredConvergeOps(declared);
|
|
8598
|
+
if (!dirs.length) {
|
|
8599
|
+
const state2 = runner.noteOperator({
|
|
8600
|
+
ok: false,
|
|
8601
|
+
refusal: {
|
|
8602
|
+
error: "This project declares no operating loop \u2014 no emitted Op carries chant's `Converge` search attribute.",
|
|
8603
|
+
code: "no-operator",
|
|
8604
|
+
remedy: HOW_TO_DECLARE
|
|
8605
|
+
}
|
|
8606
|
+
});
|
|
8607
|
+
return c.json({ ...state2, operator: state2 }, 200);
|
|
8608
|
+
}
|
|
8609
|
+
const rows = [];
|
|
8610
|
+
for (const dir of dirs) {
|
|
8611
|
+
const run3 = await runChantRaw(["operator", "status", "--json"], dir);
|
|
8612
|
+
const result = readOperatorStatus(run3);
|
|
8613
|
+
if (!result.ok) return c.json({ ...result.refusal, operator: runner.noteOperator(result) }, 422);
|
|
8614
|
+
rows.push(...result.rows);
|
|
8615
|
+
}
|
|
8616
|
+
const state = runner.noteOperator({ ok: true, rows });
|
|
8617
|
+
return c.json({ ...state, operator: state });
|
|
8618
|
+
});
|
|
8619
|
+
app.post("/api/operator/approve/:op/:gate", async (c) => {
|
|
8620
|
+
const { op, gate } = c.req.param();
|
|
8621
|
+
const info = estateOps().find((o) => o.name === op);
|
|
8622
|
+
broadcaster.emit("op", `\u270E approve ${op} ${gate} \u2014 records a fact; the next tick acts on it`);
|
|
8623
|
+
const { code, stderr, stdout } = await runChantRaw(["approve", op, gate], info?.dir ?? cfg.projectDir);
|
|
8624
|
+
if (code !== 0) {
|
|
8625
|
+
return c.json({ error: stderr.trim() || stdout.trim() || `approve exited ${code}`, code: "approve" }, 500);
|
|
8626
|
+
}
|
|
8627
|
+
return c.json({ recorded: true, op, gate, semantics: APPROVE_SEMANTICS });
|
|
8628
|
+
});
|
|
6923
8629
|
app.post("/api/ops/:name/signal/:gate", async (c) => {
|
|
6924
8630
|
const { name, gate } = c.req.param();
|
|
6925
8631
|
const info = estateOps().find((o) => o.name === name);
|
|
@@ -6946,8 +8652,11 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
6946
8652
|
const { environments, lexicons, stacks, k8sProfiles } = await detectProject(cfg.projectDir);
|
|
6947
8653
|
const k8sTarget = lexicons.includes("k8s") ? resolveK8sTarget(k8sProfiles, cfg.env, await loadKubeconfig()) : void 0;
|
|
6948
8654
|
const axes = deployAxes(tierEnvVar, lexicons, k8sTarget);
|
|
8655
|
+
const emittedOps = discoverOpIrs(estateDirs).length;
|
|
8656
|
+
const carveState = carveStatePayload(readCarveStates(estateDirs));
|
|
6949
8657
|
return c.json({
|
|
6950
8658
|
projectDir: cfg.projectDir,
|
|
8659
|
+
...carveState.manifests ? { carve: { state: carveState } } : {},
|
|
6951
8660
|
// #195: the full estate composition (multi-project serves) and the
|
|
6952
8661
|
// switcher's recents, so the SPA's project section can show what's
|
|
6953
8662
|
// loaded and offer where to go. Recents exclude nothing here — the SPA
|
|
@@ -6978,6 +8687,12 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
6978
8687
|
// the names — `graphPath` (chant.ts) resolves a picked name to its `src`
|
|
6979
8688
|
// server-side; the SPA never needs the path.
|
|
6980
8689
|
...stacks?.length ? { stacks: stacks.map((s) => s.name) } : {},
|
|
8690
|
+
// The ops lens stop (#284), gated exactly like `tiers`/`stacks` above:
|
|
8691
|
+
// present only when the estate has actually emitted Ops, so a project on
|
|
8692
|
+
// a chant < 0.50, or one that has never been built, grows no dead zoom
|
|
8693
|
+
// stop and no dead ⌘K entry. The count, not a bare flag — the SPA shows
|
|
8694
|
+
// it, and "ops: 0" is not a thing this can ever say.
|
|
8695
|
+
...emittedOps ? { ops: emittedOps } : {},
|
|
6981
8696
|
targets: deployTargets(lexicons, k8sTarget),
|
|
6982
8697
|
// Where the k8s binding came from, so the SPA never implies behold chose
|
|
6983
8698
|
// it (#106). Absent for a project with no k8s lexicon or no resolvable
|
|
@@ -6989,9 +8704,9 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
6989
8704
|
app.post("/api/project/open", async (c) => {
|
|
6990
8705
|
if (cfg.previewMode) return c.json({ error: "switching projects is locked in preview mode" }, 403);
|
|
6991
8706
|
const body = await c.req.json().catch(() => ({}));
|
|
6992
|
-
const dir = typeof body.dir === "string" && body.dir.trim() ?
|
|
8707
|
+
const dir = typeof body.dir === "string" && body.dir.trim() ? resolve6(body.dir.trim()) : "";
|
|
6993
8708
|
if (!dir || !existsSync13(dir)) return c.json({ error: `no such directory: ${dir || "(no dir given)"}` }, 400);
|
|
6994
|
-
if (!existsSync13(
|
|
8709
|
+
if (!existsSync13(join18(dir, "chant.config.ts"))) {
|
|
6995
8710
|
return c.json({ error: `${dir} doesn't look like a chant project \u2014 no chant.config.ts` }, 400);
|
|
6996
8711
|
}
|
|
6997
8712
|
switchServedProject([dir]);
|
|
@@ -7011,7 +8726,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
7011
8726
|
};
|
|
7012
8727
|
app.post("/api/project/reveal", async (c) => {
|
|
7013
8728
|
const body = await c.req.json().catch(() => ({}));
|
|
7014
|
-
const dir = typeof body.dir === "string" && body.dir.trim() ?
|
|
8729
|
+
const dir = typeof body.dir === "string" && body.dir.trim() ? resolve6(body.dir.trim()) : cfg.projectDir;
|
|
7015
8730
|
const known = /* @__PURE__ */ new Set([cfg.projectDir, ...cfg.projectDirs ?? [], ...listRecents().map((r) => r.dir)]);
|
|
7016
8731
|
if (!known.has(dir)) return c.json({ error: "not a served or recent project directory" }, 400);
|
|
7017
8732
|
const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
|
|
@@ -7079,7 +8794,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
7079
8794
|
const loaded = await loadDemo(entry, { pkgRoot, target, log: (line) => process.stdout.write(line + "\n") });
|
|
7080
8795
|
if (!loaded.ok) return c.json({ error: loaded.error }, 500);
|
|
7081
8796
|
const dirs = loaded.serveDirs;
|
|
7082
|
-
if (!existsSync13(
|
|
8797
|
+
if (!existsSync13(join18(dirs[0], "chant.config.ts"))) {
|
|
7083
8798
|
return c.json({ error: `${entry.name} loaded to ${target} but ${dirs[0]} has no chant.config.ts` }, 500);
|
|
7084
8799
|
}
|
|
7085
8800
|
if (entry.serve.local) {
|
|
@@ -7177,7 +8892,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
7177
8892
|
path: "/api/demos/open",
|
|
7178
8893
|
desc: "load a demo and serve it: JSON body {name} (a catalog name, never a path) \u2014 copies/clones, installs, runs its setup, then switches (preview-locked)"
|
|
7179
8894
|
},
|
|
7180
|
-
{ method: "GET", path: "/api/graph", desc: "the graph {ir, svg, meta} \u2014 params: detail=0..3, components=1, logical=1, env, stack, tier, target, lens, up=1, down=1, radial=1, layout=1" },
|
|
8895
|
+
{ method: "GET", path: "/api/graph", desc: "the graph {ir, svg, meta} \u2014 params: detail=0..3, components=1, logical=1, ops=1, env, stack, tier, target, lens, up=1, down=1, radial=1, layout=1" },
|
|
7181
8896
|
...cfg.carveReport ? [
|
|
7182
8897
|
{ method: "GET", path: "/api/carve", desc: "carve mode: the raw `chant carve advise --json` peelability report this server is rendering" },
|
|
7183
8898
|
{
|
|
@@ -7199,14 +8914,17 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
7199
8914
|
{ method: "GET", path: "/api/resources", desc: "component \u2192 declared resources" },
|
|
7200
8915
|
{ method: "GET", path: "/api/ci", desc: "generated CI pipeline projection {stages, jobs, forge}" },
|
|
7201
8916
|
{ method: "GET", path: "/api/substrates", desc: "substrate readiness {substrates: [{name, label, status, detail, bringUp?}]}" },
|
|
7202
|
-
{ method: "GET", path: "/api/ops", desc: "committed Ops + adopt lexicons + apply progress" },
|
|
8917
|
+
{ method: "GET", path: "/api/ops", desc: "committed Ops + adopt lexicons + apply progress + run playhead" },
|
|
8918
|
+
{ method: "GET", path: "/api/ops/:name/status", desc: "an Op's durable run status + pending gate (chant run status)" },
|
|
8919
|
+
{ method: "GET", path: "/api/operator/status", desc: "the operating loop's strip: last tick per ConvergeOp + lease + pending converge gates" },
|
|
7203
8920
|
{ method: "GET", path: "/api/history", desc: "recent source commits (rollback targets)" },
|
|
7204
8921
|
{ method: "GET", path: "/api/frames", desc: "captured lanes frames" },
|
|
7205
|
-
{ method: "GET", path: "/api/events", desc: "SSE: changed / op / apply / pr" },
|
|
8922
|
+
{ method: "GET", path: "/api/events", desc: "SSE: changed / op / apply / run / pr" },
|
|
7206
8923
|
{ method: "POST", path: "/api/refresh", desc: "re-observe live now (?env=) \u2014 returns the fresh graph" },
|
|
7207
8924
|
{ method: "POST", path: "/api/apply", desc: "delegated apply: ?env=&component=<name|all> (guarded, preview-locked)" },
|
|
7208
8925
|
{ method: "POST", path: "/api/ops/:name/run", desc: "run a committed Op (delegated write)" },
|
|
7209
|
-
{ method: "POST", path: "/api/ops/:name/signal/:gate", desc: "approve an Op's gate" },
|
|
8926
|
+
{ method: "POST", path: "/api/ops/:name/signal/:gate", desc: "approve an Op's gate (releases the waiting run)" },
|
|
8927
|
+
{ method: "POST", path: "/api/operator/approve/:op/:gate", desc: "record a converge gate's resolution (chant approve \u2014 a fact for the next tick, not an unblock)" },
|
|
7210
8928
|
{ method: "POST", path: "/api/rollback", desc: "open a rollback PR: ?to=<sha>" },
|
|
7211
8929
|
{ method: "POST", path: "/api/substrates/:name/up", desc: "bring a substrate up" },
|
|
7212
8930
|
{ method: "POST", path: "/api/local/reset", desc: "reset the local emulator" },
|
|
@@ -7218,6 +8936,58 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
7218
8936
|
const url = new URL(c.req.url);
|
|
7219
8937
|
const opts = optsFromQuery(url, tierEnvVar, cfg.projectDir);
|
|
7220
8938
|
try {
|
|
8939
|
+
if (url.searchParams.get("ops") === "1") {
|
|
8940
|
+
const files = discoverOpIrs(estateDirs);
|
|
8941
|
+
if (!files.length) {
|
|
8942
|
+
return c.json(
|
|
8943
|
+
{
|
|
8944
|
+
error: "this project has no emitted Ops \u2014 nothing under dist/ops/<name>/op.json",
|
|
8945
|
+
code: "op-ir",
|
|
8946
|
+
remedy: HOW_TO_EMIT
|
|
8947
|
+
},
|
|
8948
|
+
422
|
|
8949
|
+
);
|
|
8950
|
+
}
|
|
8951
|
+
const parsedOps = [];
|
|
8952
|
+
for (const f of files) {
|
|
8953
|
+
const parsed = readOpIr(f.path, (p) => readFileSync14(p, "utf8"));
|
|
8954
|
+
if (!parsed.ok) return c.json(parsed.refusal, 422);
|
|
8955
|
+
parsedOps.push(parsed.ir);
|
|
8956
|
+
}
|
|
8957
|
+
const declaredIr = opsToIr(parsedOps);
|
|
8958
|
+
const runState = runner.runState;
|
|
8959
|
+
const ranOp = runState.op ? parsedOps.find((o) => o.name === runState.op) : void 0;
|
|
8960
|
+
const join21 = ranOp ? joinRun(ranOp, runState.records, runState.gate) : null;
|
|
8961
|
+
const opsIr = join21 ? paintPlayhead(declaredIr, join21, runState.gate) : declaredIr;
|
|
8962
|
+
const { svg: svg2 } = renderGraph(opsIr, { boxes: "byStack" });
|
|
8963
|
+
const operator = runner.noteDeclaredConvergeOps(declaredConvergeOps(parsedOps));
|
|
8964
|
+
return c.json({
|
|
8965
|
+
ir: opsIr,
|
|
8966
|
+
svg: svg2,
|
|
8967
|
+
meta: {
|
|
8968
|
+
projectDir: cfg.projectDir,
|
|
8969
|
+
env: cfg.env ?? null,
|
|
8970
|
+
tier: null,
|
|
8971
|
+
target: null,
|
|
8972
|
+
mode: "ops",
|
|
8973
|
+
ops: parsedOps.length,
|
|
8974
|
+
note: `${opsNote(parsedOps, opsIr)} ${playheadNote(runState, join21)} ${operatorNote(operator)}`,
|
|
8975
|
+
// The operator strip's own model: the ConvergeOps this project
|
|
8976
|
+
// declares, plus whatever the last status read said. `declared`
|
|
8977
|
+
// without a `strip` is the honest starting state — a loop exists and
|
|
8978
|
+
// behold hasn't asked about it yet.
|
|
8979
|
+
operator,
|
|
8980
|
+
// The playhead's own model, so the SPA renders the pending card and
|
|
8981
|
+
// the run legend from the same answer that painted the graph.
|
|
8982
|
+
run: runState,
|
|
8983
|
+
// #234's pending-approval card, whole: the gate, when it started
|
|
8984
|
+
// waiting, the step it holds back, and the delegated write its
|
|
8985
|
+
// approve button performs. Present only when `gateState` NAMED a
|
|
8986
|
+
// pending gate — never inferred from a stalled track.
|
|
8987
|
+
gate: runState.gate && runState.op ? pendingGateCard(runState.op, runState.gate, opsIr) : null
|
|
8988
|
+
}
|
|
8989
|
+
});
|
|
8990
|
+
}
|
|
7221
8991
|
const components = url.searchParams.get("components") === "1";
|
|
7222
8992
|
const logical = url.searchParams.get("logical") === "1";
|
|
7223
8993
|
const multi = cfg.projectDirs && cfg.projectDirs.length > 1;
|
|
@@ -7228,6 +8998,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
7228
8998
|
ir = await composeEstate(cfg.projectDirs, logical ? { ...opts, detail: 3 } : opts);
|
|
7229
8999
|
ir = addValueMatchEdges(ir);
|
|
7230
9000
|
ir = addK8sDeclaredEdges(ir);
|
|
9001
|
+
ir = markOperatorHome(ir);
|
|
7231
9002
|
const estateContext = await boundK8sContext(metaEnv ?? void 0);
|
|
7232
9003
|
ir = addClusterAnchorEdges(ir, estateContext);
|
|
7233
9004
|
if (logical) {
|
|
@@ -7272,6 +9043,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
7272
9043
|
if ((opts.detail ?? 2) < 3) ir = pruneImports(ir);
|
|
7273
9044
|
ir = addValueMatchEdges(ir);
|
|
7274
9045
|
ir = addK8sDeclaredEdges(ir);
|
|
9046
|
+
ir = markOperatorHome(ir);
|
|
7275
9047
|
ir = addClusterAnchorEdges(ir, await boundK8sContext(metaEnv ?? void 0));
|
|
7276
9048
|
}
|
|
7277
9049
|
let srcCompositeEdgesAttached;
|
|
@@ -7285,7 +9057,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
7285
9057
|
srcCompositeEdgesAttached = 0;
|
|
7286
9058
|
}
|
|
7287
9059
|
}
|
|
7288
|
-
if (!multi && !components && !logical && !opts.lens && ir.nodes.length === 0 && !existsSync13(
|
|
9060
|
+
if (!multi && !components && !logical && !opts.lens && ir.nodes.length === 0 && !existsSync13(join18(cfg.projectDir, "chant.config.ts"))) {
|
|
7289
9061
|
return c.json(noProjectError(cfg.projectDir), 404);
|
|
7290
9062
|
}
|
|
7291
9063
|
const radial = new URL(c.req.url).searchParams.get("radial") === "1";
|
|
@@ -7403,6 +9175,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
7403
9175
|
if ((detail ?? 2) < 3) ir2 = pruneImports(ir2);
|
|
7404
9176
|
ir2 = addValueMatchEdges(ir2);
|
|
7405
9177
|
ir2 = addK8sDeclaredEdges(ir2);
|
|
9178
|
+
ir2 = markOperatorHome(ir2);
|
|
7406
9179
|
const boundContext2 = await boundK8sContext(env);
|
|
7407
9180
|
ir2 = addClusterAnchorEdges(ir2, boundContext2);
|
|
7408
9181
|
const coverNote = est.unobserved.length || est.dropped.length ? `live observe covered ${est.observed} of ${est.total} projects \u2014 ` + [
|
|
@@ -7448,6 +9221,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
7448
9221
|
const observed = await lifecycleDiffLive(cfg.projectDir, env, query).then((d) => d.lexicons?.helm?.observedArtifacts).catch(() => void 0);
|
|
7449
9222
|
applyHelmArtifacts(ir, observed);
|
|
7450
9223
|
synthesizeHelmReleases(ir, observed, discoverReleaseUnits(cfg.projectDir));
|
|
9224
|
+
applyHelmRenderDrift(ir, await readHelmRenderDrift(cfg.projectDir, env, query));
|
|
7451
9225
|
}
|
|
7452
9226
|
ir = runtime ? attachRuntimeContainment(ir) : pruneRuntimeChildren(ir);
|
|
7453
9227
|
if (logical) {
|
|
@@ -7461,6 +9235,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
7461
9235
|
if ((query.detail ?? 2) < 3) ir = pruneImports(ir);
|
|
7462
9236
|
ir = addValueMatchEdges(ir);
|
|
7463
9237
|
ir = addK8sDeclaredEdges(ir);
|
|
9238
|
+
ir = markOperatorHome(ir);
|
|
7464
9239
|
ir = addClusterAnchorEdges(ir, boundContext);
|
|
7465
9240
|
let compositeEdgesAttached;
|
|
7466
9241
|
if (query.detail === 1) {
|
|
@@ -7496,6 +9271,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
7496
9271
|
const observed = await lifecycleDiffLive(cfg.projectDir, env).then((d) => d.lexicons?.helm?.observedArtifacts).catch(() => void 0);
|
|
7497
9272
|
applyHelmArtifacts(result.ir, observed);
|
|
7498
9273
|
synthesizeHelmReleases(result.ir, observed, discoverReleaseUnits(cfg.projectDir));
|
|
9274
|
+
applyHelmRenderDrift(result.ir, await readHelmRenderDrift(cfg.projectDir, env));
|
|
7499
9275
|
}
|
|
7500
9276
|
}
|
|
7501
9277
|
if (env) {
|
|
@@ -7623,9 +9399,9 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
7623
9399
|
}
|
|
7624
9400
|
return c.json({ started: true, component, env });
|
|
7625
9401
|
});
|
|
7626
|
-
const rel =
|
|
9402
|
+
const rel = relative4(process.cwd(), webRoot) || ".";
|
|
7627
9403
|
app.use("/*", serveStatic({ root: rel }));
|
|
7628
|
-
app.get("/", serveStatic({ path:
|
|
9404
|
+
app.get("/", serveStatic({ path: join18(rel, "index.html") }));
|
|
7629
9405
|
return app;
|
|
7630
9406
|
}
|
|
7631
9407
|
async function bootLocalEmulators(dir, who) {
|
|
@@ -7654,7 +9430,7 @@ async function bootLocalEmulators(dir, who) {
|
|
|
7654
9430
|
async function startServer(cfg) {
|
|
7655
9431
|
if (cfg.local) cfg.emulators = await bootLocalEmulators(cfg.projectDir, "behold serve --local");
|
|
7656
9432
|
for (const dir of cfg.projectDirs ?? [cfg.projectDir]) {
|
|
7657
|
-
if (existsSync13(
|
|
9433
|
+
if (existsSync13(join18(dir, "chant.config.ts"))) addRecent(dir);
|
|
7658
9434
|
}
|
|
7659
9435
|
const broadcaster = new Broadcaster();
|
|
7660
9436
|
const frames = new FrameBuffer();
|
|
@@ -7670,6 +9446,10 @@ async function startServer(cfg) {
|
|
|
7670
9446
|
broadcaster.emit("changed", memberDir ?? "");
|
|
7671
9447
|
void capture2();
|
|
7672
9448
|
};
|
|
9449
|
+
const onMemberSourceChange = (memberDir) => {
|
|
9450
|
+
invalidateMember(memberDir);
|
|
9451
|
+
onEstateChange(memberDir);
|
|
9452
|
+
};
|
|
7673
9453
|
const memberTag = (dir) => (cfg.projectDirs?.length ?? 0) > 1 ? ` [${basename(dir)}]` : "";
|
|
7674
9454
|
const onPollDrift = (dir, movedLexicons) => {
|
|
7675
9455
|
onEstateChange(dir);
|
|
@@ -7699,7 +9479,7 @@ async function startServer(cfg) {
|
|
|
7699
9479
|
};
|
|
7700
9480
|
const carve = !!cfg.carveReport;
|
|
7701
9481
|
let stopWatch = carve ? () => {
|
|
7702
|
-
} : watchSources(cfg.projectDirs ?? [cfg.projectDir],
|
|
9482
|
+
} : watchSources(cfg.projectDirs ?? [cfg.projectDir], onMemberSourceChange);
|
|
7703
9483
|
let stopPoll = !carve && cfg.env && cfg.pollSecs ? startDriftPoll({
|
|
7704
9484
|
intervalMs: cfg.pollSecs * 1e3,
|
|
7705
9485
|
// Per-member queries, swept sequentially inside the poll (#297): a
|
|
@@ -7716,7 +9496,7 @@ async function startServer(cfg) {
|
|
|
7716
9496
|
};
|
|
7717
9497
|
cfg.onProjectSwitch = (dir) => {
|
|
7718
9498
|
stopWatch();
|
|
7719
|
-
stopWatch = watchSources(cfg.projectDirs ?? [dir],
|
|
9499
|
+
stopWatch = watchSources(cfg.projectDirs ?? [dir], onMemberSourceChange);
|
|
7720
9500
|
stopPoll();
|
|
7721
9501
|
stopPoll = () => {
|
|
7722
9502
|
};
|
|
@@ -7789,12 +9569,12 @@ async function startServer(cfg) {
|
|
|
7789
9569
|
}
|
|
7790
9570
|
|
|
7791
9571
|
// src/export.ts
|
|
7792
|
-
import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync4, copyFileSync, readFileSync as
|
|
7793
|
-
import { join as
|
|
9572
|
+
import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync4, copyFileSync, readFileSync as readFileSync15, readdirSync as readdirSync7 } from "node:fs";
|
|
9573
|
+
import { join as join19, dirname as dirname6, basename as basename2 } from "node:path";
|
|
7794
9574
|
import { fileURLToPath as fileURLToPath4 } from "node:url";
|
|
7795
|
-
var LENS_PARAMS = ["components", "detail", "env", "logical", "radial", "tier"];
|
|
9575
|
+
var LENS_PARAMS = ["components", "detail", "env", "logical", "ops", "radial", "tier"];
|
|
7796
9576
|
function canonicalKey(path, params) {
|
|
7797
|
-
const flat = params.get("components") === "1" || params.get("logical") === "1";
|
|
9577
|
+
const flat = params.get("components") === "1" || params.get("logical") === "1" || params.get("ops") === "1";
|
|
7798
9578
|
const q = LENS_PARAMS.filter((k) => params.has(k) && !(flat && (k === "detail" || k === "radial"))).map((k) => `${k}=${params.get(k)}`).join("&");
|
|
7799
9579
|
return q ? `${path}?${q}` : path;
|
|
7800
9580
|
}
|
|
@@ -7808,6 +9588,7 @@ function captureKeys(axes) {
|
|
|
7808
9588
|
add("/api/project", {});
|
|
7809
9589
|
add("/api/substrates", {});
|
|
7810
9590
|
add("/api/ops", {});
|
|
9591
|
+
if (axes.ops) add("/api/graph", { ops: "1" });
|
|
7811
9592
|
const tiers = axes.tiers && axes.tiers.length ? axes.tiers : [""];
|
|
7812
9593
|
const envs = ["", ...axes.environments];
|
|
7813
9594
|
for (const env of envs) {
|
|
@@ -7838,7 +9619,7 @@ function captureKeys(axes) {
|
|
|
7838
9619
|
return [...keys];
|
|
7839
9620
|
}
|
|
7840
9621
|
function webDir() {
|
|
7841
|
-
return
|
|
9622
|
+
return join19(dirname6(fileURLToPath4(import.meta.url)), "..", "web");
|
|
7842
9623
|
}
|
|
7843
9624
|
function workerName(project, override) {
|
|
7844
9625
|
const raw = override ?? `behold-${basename2(project)}`;
|
|
@@ -7848,8 +9629,8 @@ function workerName(project, override) {
|
|
|
7848
9629
|
async function runExport(cfg, outDir, opts = {}) {
|
|
7849
9630
|
const app = createApp({ ...cfg, layoutWrites: false });
|
|
7850
9631
|
const proj = await (await app.request("/api/project")).json();
|
|
7851
|
-
const axes = { environments: proj.environments ?? [], tiers: proj.tiers ?? [] };
|
|
7852
|
-
const snapDir =
|
|
9632
|
+
const axes = { environments: proj.environments ?? [], tiers: proj.tiers ?? [], ...proj.ops ? { ops: proj.ops } : {} };
|
|
9633
|
+
const snapDir = join19(outDir, "snapshots");
|
|
7853
9634
|
mkdirSync4(snapDir, { recursive: true });
|
|
7854
9635
|
const keyToFile = {};
|
|
7855
9636
|
let ok = 0;
|
|
@@ -7858,7 +9639,7 @@ async function runExport(cfg, outDir, opts = {}) {
|
|
|
7858
9639
|
const res = await app.request(`${key}${key.includes("?") ? "&" : "?"}layout=1`);
|
|
7859
9640
|
const body = await res.text();
|
|
7860
9641
|
const file = slug2(key);
|
|
7861
|
-
writeFileSync4(
|
|
9642
|
+
writeFileSync4(join19(snapDir, file), body);
|
|
7862
9643
|
keyToFile[key] = `snapshots/${file}`;
|
|
7863
9644
|
if (res.ok) ok++;
|
|
7864
9645
|
else failed++;
|
|
@@ -7870,21 +9651,21 @@ async function runExport(cfg, outDir, opts = {}) {
|
|
|
7870
9651
|
axes,
|
|
7871
9652
|
keyToFile
|
|
7872
9653
|
};
|
|
7873
|
-
writeFileSync4(
|
|
7874
|
-
const html =
|
|
9654
|
+
writeFileSync4(join19(outDir, "manifest.json"), JSON.stringify(manifest, null, 2));
|
|
9655
|
+
const html = readFileSync15(join19(webDir(), "index.html"), "utf8").replace(
|
|
7875
9656
|
/<\/head>/i,
|
|
7876
9657
|
` <script>window.__BEHOLD_STATIC__ = true;</script>
|
|
7877
9658
|
</head>`
|
|
7878
9659
|
);
|
|
7879
|
-
writeFileSync4(
|
|
7880
|
-
for (const f of
|
|
9660
|
+
writeFileSync4(join19(outDir, "index.html"), html);
|
|
9661
|
+
for (const f of readdirSync7(webDir())) {
|
|
7881
9662
|
if (f === "index.html") continue;
|
|
7882
|
-
copyFileSync(
|
|
9663
|
+
copyFileSync(join19(webDir(), f), join19(outDir, f));
|
|
7883
9664
|
}
|
|
7884
|
-
writeFileSync4(
|
|
9665
|
+
writeFileSync4(join19(outDir, "README.md"), BUNDLE_README);
|
|
7885
9666
|
const name = workerName(cfg.projectDir, opts.name);
|
|
7886
9667
|
writeFileSync4(
|
|
7887
|
-
|
|
9668
|
+
join19(outDir, "wrangler.jsonc"),
|
|
7888
9669
|
JSON.stringify(
|
|
7889
9670
|
{ $schema: "node_modules/wrangler/config-schema.json", name, compatibility_date: "2025-06-01", assets: { directory: "." } },
|
|
7890
9671
|
null,
|
|
@@ -7932,7 +9713,7 @@ It's just files \u2014 GitHub Pages, S3, nginx, or Cloudflare Pages
|
|
|
7932
9713
|
`;
|
|
7933
9714
|
|
|
7934
9715
|
// src/doctor.ts
|
|
7935
|
-
import { relative as
|
|
9716
|
+
import { relative as relative5, resolve as resolve7 } from "node:path";
|
|
7936
9717
|
var list2 = (xs) => xs.join(", ");
|
|
7937
9718
|
var EMPTY_KUBECONFIG = { contexts: /* @__PURE__ */ new Map(), servers: /* @__PURE__ */ new Map() };
|
|
7938
9719
|
function summarize(xs, max = 4) {
|
|
@@ -7940,7 +9721,7 @@ function summarize(xs, max = 4) {
|
|
|
7940
9721
|
}
|
|
7941
9722
|
function labelFor(root, target, estate) {
|
|
7942
9723
|
if (!estate) return "";
|
|
7943
|
-
const rel =
|
|
9724
|
+
const rel = relative5(root, target);
|
|
7944
9725
|
return `${rel || "."}: `;
|
|
7945
9726
|
}
|
|
7946
9727
|
function chantCheck(root, targets, estate) {
|
|
@@ -7953,7 +9734,7 @@ function chantCheck(root, targets, estate) {
|
|
|
7953
9734
|
name: "chant",
|
|
7954
9735
|
status: "fail",
|
|
7955
9736
|
detail: `${list2(resolutions.map(describe))} \u2014 behold shells the project's own chant`,
|
|
7956
|
-
fix: `Run \`npm install\` in ${list2(missing.map(({ target }) =>
|
|
9737
|
+
fix: `Run \`npm install\` in ${list2(missing.map(({ target }) => relative5(root, target) || target))}`
|
|
7957
9738
|
};
|
|
7958
9739
|
}
|
|
7959
9740
|
const stale = resolutions.filter(({ res }) => !meetsFloor(res.version, floor));
|
|
@@ -8081,7 +9862,7 @@ function opsCheck(root, ops, chantSource, estate) {
|
|
|
8081
9862
|
return { name: "ops", status: "pass", detail: `${ops.length} committed: ${list2(names)}; ${mcp}` };
|
|
8082
9863
|
}
|
|
8083
9864
|
async function diagnose(dir, probes = {}) {
|
|
8084
|
-
const root =
|
|
9865
|
+
const root = resolve7(dir);
|
|
8085
9866
|
const shape = detectProjectShape(root);
|
|
8086
9867
|
const behold = beholdVersion();
|
|
8087
9868
|
if (shape.kind === "none") {
|
|
@@ -8101,16 +9882,16 @@ async function diagnose(dir, probes = {}) {
|
|
|
8101
9882
|
};
|
|
8102
9883
|
}
|
|
8103
9884
|
const estate = shape.kind === "estate";
|
|
8104
|
-
const members = (shape.members ?? []).map((m) =>
|
|
9885
|
+
const members = (shape.members ?? []).map((m) => resolve7(root, m));
|
|
8105
9886
|
const targets = estate ? members : [root];
|
|
8106
9887
|
const primary = targets[0];
|
|
8107
9888
|
const projectCheck = estate ? {
|
|
8108
9889
|
name: "project",
|
|
8109
9890
|
status: "pass",
|
|
8110
9891
|
detail: `estate of ${members.length} projects (${shape.membersFrom === "behold-config" ? ".behold.json members" : "npm workspaces"}): ${list2(
|
|
8111
|
-
members.map((m) =>
|
|
9892
|
+
members.map((m) => relative5(root, m))
|
|
8112
9893
|
)}`
|
|
8113
|
-
} : { name: "project", status: "pass", detail: `chant project (${
|
|
9894
|
+
} : { name: "project", status: "pass", detail: `chant project (${relative5(root, shape.configFile)})` };
|
|
8114
9895
|
const infos = await Promise.all(targets.map(async (t) => ({ target: t, info: await detectProject(t) })));
|
|
8115
9896
|
const declared = new Map(infos.map(({ target, info }) => [target, info.lexicons]));
|
|
8116
9897
|
const lexicons = [...new Set(infos.flatMap(({ info }) => info.lexicons))];
|
|
@@ -8335,7 +10116,7 @@ ${USAGE}`);
|
|
|
8335
10116
|
process.stderr.write("behold serve: --auto-sync needs --env and --poll (it acts on polled drift)\n");
|
|
8336
10117
|
process.exit(2);
|
|
8337
10118
|
}
|
|
8338
|
-
const dirs = projectDirs.map((d) =>
|
|
10119
|
+
const dirs = projectDirs.map((d) => resolve8(d));
|
|
8339
10120
|
for (const d of dirs) warnIfNotChantProject(d);
|
|
8340
10121
|
await startServer({
|
|
8341
10122
|
projectDir: dirs[0],
|
|
@@ -8354,7 +10135,7 @@ function warnIfNotChantProject(dir) {
|
|
|
8354
10135
|
if (shape.kind === "estate") {
|
|
8355
10136
|
process.stderr.write(
|
|
8356
10137
|
`behold: warning \u2014 ${dir} is an estate root, not a chant project itself.
|
|
8357
|
-
Serve its members composed: behold serve ${shape.members.map((m) =>
|
|
10138
|
+
Serve its members composed: behold serve ${shape.members.map((m) => join20(dir, m)).join(" ")}
|
|
8358
10139
|
`
|
|
8359
10140
|
);
|
|
8360
10141
|
return;
|
|
@@ -8389,8 +10170,8 @@ async function runCarve(rest) {
|
|
|
8389
10170
|
);
|
|
8390
10171
|
process.exit(2);
|
|
8391
10172
|
}
|
|
8392
|
-
const reportPath =
|
|
8393
|
-
const parsed = readCarveReport(reportPath, (p) =>
|
|
10173
|
+
const reportPath = resolve8(fileArg);
|
|
10174
|
+
const parsed = readCarveReport(reportPath, (p) => readFileSync16(p, "utf8"));
|
|
8394
10175
|
if (!parsed.ok) {
|
|
8395
10176
|
process.stderr.write(`behold carve: ${parsed.refusal.error}
|
|
8396
10177
|
${parsed.refusal.remedy}
|
|
@@ -8401,7 +10182,7 @@ async function runCarve(rest) {
|
|
|
8401
10182
|
`behold carve \u2014 ${parsed.report.resources.length} resource(s)/module(s) ranked${parsed.report.from ? ` from ${parsed.report.from}` : ""}
|
|
8402
10183
|
`
|
|
8403
10184
|
);
|
|
8404
|
-
await startServer({ projectDir:
|
|
10185
|
+
await startServer({ projectDir: dirname7(reportPath), carveReport: reportPath, port });
|
|
8405
10186
|
}
|
|
8406
10187
|
async function runDoctor(rest) {
|
|
8407
10188
|
let json = false;
|
|
@@ -8417,8 +10198,8 @@ async function runDoctor(rest) {
|
|
|
8417
10198
|
}
|
|
8418
10199
|
}
|
|
8419
10200
|
const dir = dirArg ?? ".";
|
|
8420
|
-
if (!existsSync14(
|
|
8421
|
-
process.stderr.write(`behold doctor: no such directory: ${
|
|
10201
|
+
if (!existsSync14(resolve8(dir))) {
|
|
10202
|
+
process.stderr.write(`behold doctor: no such directory: ${resolve8(dir)}
|
|
8422
10203
|
`);
|
|
8423
10204
|
process.exit(2);
|
|
8424
10205
|
}
|
|
@@ -8427,7 +10208,7 @@ async function runDoctor(rest) {
|
|
|
8427
10208
|
if (!report.ok) process.exitCode = 1;
|
|
8428
10209
|
}
|
|
8429
10210
|
async function runDemo(rest) {
|
|
8430
|
-
const pkgRoot2 =
|
|
10211
|
+
const pkgRoot2 = join20(dirname7(fileURLToPath5(import.meta.url)), "..");
|
|
8431
10212
|
const registry = loadDemoRegistry(pkgRoot2);
|
|
8432
10213
|
let port = 4600;
|
|
8433
10214
|
let live = false;
|
|
@@ -8481,7 +10262,7 @@ async function runDemo(rest) {
|
|
|
8481
10262
|
`);
|
|
8482
10263
|
process.exit(2);
|
|
8483
10264
|
}
|
|
8484
|
-
const target = dirArg ?
|
|
10265
|
+
const target = dirArg ? resolve8(dirArg) : demoTargetDir(entry);
|
|
8485
10266
|
const loaded = await loadDemo(entry, { pkgRoot: pkgRoot2, target, log: (line) => process.stdout.write(line + "\n") });
|
|
8486
10267
|
if (!loaded.ok) {
|
|
8487
10268
|
process.stderr.write(`behold demo ${entry.name}: ${loaded.error}
|
|
@@ -8512,7 +10293,7 @@ function spawnStep(cmd, args, cwd) {
|
|
|
8512
10293
|
});
|
|
8513
10294
|
}
|
|
8514
10295
|
async function serveCarveDemo(target, carve, port, live = false) {
|
|
8515
|
-
const at = (rel) =>
|
|
10296
|
+
const at = (rel) => resolve8(target, rel);
|
|
8516
10297
|
const project = at(carve.project);
|
|
8517
10298
|
const from = at(carve.from);
|
|
8518
10299
|
const state = carve.state ? at(carve.state) : void 0;
|
|
@@ -8552,7 +10333,7 @@ async function serveCarveDemo(target, carve, port, live = false) {
|
|
|
8552
10333
|
};
|
|
8553
10334
|
process.stdout.write("behold demo carve --live \u2192 the tfstate is real now; the advisor reads what terraform wrote.\n");
|
|
8554
10335
|
}
|
|
8555
|
-
if (existsSync14(
|
|
10336
|
+
if (existsSync14(join20(project, "package.json")) && !existsSync14(join20(project, "node_modules"))) {
|
|
8556
10337
|
process.stdout.write(`behold demo carve \u2192 npm install in ${carve.project}/ (the chant this walkthrough shells)\u2026
|
|
8557
10338
|
`);
|
|
8558
10339
|
const code = await spawnStep("npm", ["install"], project);
|
|
@@ -8563,7 +10344,7 @@ async function serveCarveDemo(target, carve, port, live = false) {
|
|
|
8563
10344
|
}
|
|
8564
10345
|
}
|
|
8565
10346
|
let degraded;
|
|
8566
|
-
if (!existsSync14(
|
|
10347
|
+
if (!existsSync14(join20(target, "node_modules", "@cdktf", "hcl2json"))) {
|
|
8567
10348
|
process.stdout.write("behold demo carve \u2192 npm install @cdktf/hcl2json (chant's HCL parser, ~2MB, once)\u2026\n");
|
|
8568
10349
|
const code = await spawnStep("npm", ["install", "--no-save", "--no-package-lock", "@cdktf/hcl2json"], target);
|
|
8569
10350
|
if (code !== 0) degraded = "couldn't install @cdktf/hcl2json (chant's HCL parser) \u2014 no network?";
|
|
@@ -8632,7 +10413,7 @@ async function runPreview(rest) {
|
|
|
8632
10413
|
process.stderr.write("behold preview: --port must be a number\n");
|
|
8633
10414
|
process.exit(2);
|
|
8634
10415
|
}
|
|
8635
|
-
const projectDir =
|
|
10416
|
+
const projectDir = resolve8(dirArg ?? process.cwd());
|
|
8636
10417
|
if (!existsSync14(projectDir)) {
|
|
8637
10418
|
process.stderr.write(`behold preview: project not found at ${projectDir}
|
|
8638
10419
|
`);
|
|
@@ -8653,21 +10434,21 @@ async function runPreview(rest) {
|
|
|
8653
10434
|
await startServer({ projectDir, port, env: "local", previewMode: true });
|
|
8654
10435
|
}
|
|
8655
10436
|
async function runExportCmd(rest) {
|
|
8656
|
-
let outDir =
|
|
10437
|
+
let outDir = resolve8("behold-export");
|
|
8657
10438
|
let env;
|
|
8658
10439
|
let name;
|
|
8659
10440
|
let dirArg;
|
|
8660
10441
|
let emulator = false;
|
|
8661
10442
|
for (let i = 0; i < rest.length; i++) {
|
|
8662
10443
|
const a = rest[i];
|
|
8663
|
-
if (a === "--out") outDir =
|
|
10444
|
+
if (a === "--out") outDir = resolve8(rest[++i]);
|
|
8664
10445
|
else if (a === "--env") env = rest[++i];
|
|
8665
10446
|
else if (a === "--name") name = rest[++i];
|
|
8666
10447
|
else if (a === "--emulator") emulator = true;
|
|
8667
10448
|
else if (a === "-h" || a === "--help") return void process.stdout.write(USAGE);
|
|
8668
10449
|
else if (!a.startsWith("-")) dirArg = a;
|
|
8669
10450
|
}
|
|
8670
|
-
const projectDir =
|
|
10451
|
+
const projectDir = resolve8(dirArg ?? process.cwd());
|
|
8671
10452
|
if (emulator) {
|
|
8672
10453
|
injectEmulatorEnv(env);
|
|
8673
10454
|
env ??= "local";
|