@intentius/behold 0.11.0 → 0.13.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 +2708 -720
- 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"]);
|
|
@@ -2329,14 +2405,60 @@ function isOwnNameAttr(kind, key) {
|
|
|
2329
2405
|
return typeSegment(kind).toLowerCase().includes(prefix);
|
|
2330
2406
|
}
|
|
2331
2407
|
var MIN_NAME_LEN = 6;
|
|
2408
|
+
var MAX_NAME_LEN = 200;
|
|
2409
|
+
var MAX_DEPTH = 12;
|
|
2410
|
+
var SKIP_KEYS = /* @__PURE__ */ new Set([
|
|
2411
|
+
"status",
|
|
2412
|
+
"annotations",
|
|
2413
|
+
"labels",
|
|
2414
|
+
"matchLabels",
|
|
2415
|
+
"managedFields",
|
|
2416
|
+
"ownerReferences",
|
|
2417
|
+
"creationTimestamp",
|
|
2418
|
+
"resourceVersion",
|
|
2419
|
+
"uid",
|
|
2420
|
+
"selfLink",
|
|
2421
|
+
"generation",
|
|
2422
|
+
"finalizers"
|
|
2423
|
+
]);
|
|
2424
|
+
function joinable(v) {
|
|
2425
|
+
return v.length >= MIN_NAME_LEN && v.length <= MAX_NAME_LEN && !/\s/.test(v);
|
|
2426
|
+
}
|
|
2427
|
+
function stringLeaves(attrs) {
|
|
2428
|
+
const out = [];
|
|
2429
|
+
const onPath = /* @__PURE__ */ new Set();
|
|
2430
|
+
const step = (v, key, path, depth) => {
|
|
2431
|
+
if (typeof v === "string") {
|
|
2432
|
+
out.push({ path, key, value: v });
|
|
2433
|
+
return;
|
|
2434
|
+
}
|
|
2435
|
+
if (depth >= MAX_DEPTH || !v || typeof v !== "object") return;
|
|
2436
|
+
if (onPath.has(v)) return;
|
|
2437
|
+
onPath.add(v);
|
|
2438
|
+
if (Array.isArray(v)) {
|
|
2439
|
+
for (const item of v) step(item, key, path, depth + 1);
|
|
2440
|
+
} else {
|
|
2441
|
+
for (const [k, val] of Object.entries(v)) {
|
|
2442
|
+
if (k.startsWith("_") || SKIP_KEYS.has(k)) continue;
|
|
2443
|
+
step(val, k, path ? `${path}.${k}` : k, depth + 1);
|
|
2444
|
+
}
|
|
2445
|
+
}
|
|
2446
|
+
onPath.delete(v);
|
|
2447
|
+
};
|
|
2448
|
+
for (const [k, v] of Object.entries(attrs ?? {})) {
|
|
2449
|
+
if (k.startsWith("_") || SKIP_KEYS.has(k)) continue;
|
|
2450
|
+
step(v, k, k, 0);
|
|
2451
|
+
}
|
|
2452
|
+
return out;
|
|
2453
|
+
}
|
|
2332
2454
|
function addValueMatchEdges(ir) {
|
|
2333
2455
|
const nodes = ir.nodes;
|
|
2334
2456
|
const owner = /* @__PURE__ */ new Map();
|
|
2335
2457
|
for (const n of nodes) {
|
|
2336
|
-
for (const
|
|
2337
|
-
if (
|
|
2338
|
-
if (!isOwnNameAttr(n.kind,
|
|
2339
|
-
owner.set(
|
|
2458
|
+
for (const { path, key, value } of stringLeaves(n.attrs)) {
|
|
2459
|
+
if (path !== key && path !== `metadata.${key}`) continue;
|
|
2460
|
+
if (!joinable(value) || !isOwnNameAttr(n.kind, key)) continue;
|
|
2461
|
+
owner.set(value, owner.has(value) ? null : n.id);
|
|
2340
2462
|
}
|
|
2341
2463
|
}
|
|
2342
2464
|
if (owner.size === 0) return ir;
|
|
@@ -2344,14 +2466,14 @@ function addValueMatchEdges(ir) {
|
|
|
2344
2466
|
for (const e of ir.edges) declared.add(`${e.from}\0${e.to}`);
|
|
2345
2467
|
const added = /* @__PURE__ */ new Set();
|
|
2346
2468
|
for (const a of nodes) {
|
|
2347
|
-
for (const
|
|
2348
|
-
if (
|
|
2349
|
-
const b = owner.get(
|
|
2469
|
+
for (const { path, value } of stringLeaves(a.attrs)) {
|
|
2470
|
+
if (!joinable(value)) continue;
|
|
2471
|
+
const b = owner.get(value);
|
|
2350
2472
|
if (!b || b === a.id) continue;
|
|
2351
2473
|
const key = `${a.id}\0${b}`;
|
|
2352
2474
|
if (declared.has(key) || added.has(key)) continue;
|
|
2353
2475
|
added.add(key);
|
|
2354
|
-
ir.edges.push({ from: a.id, to: b, kind: "ref", viaAttr:
|
|
2476
|
+
ir.edges.push({ from: a.id, to: b, kind: "ref", viaAttr: path, inferred: true });
|
|
2355
2477
|
}
|
|
2356
2478
|
}
|
|
2357
2479
|
return ir;
|
|
@@ -2364,6 +2486,11 @@ var WORKLOAD_KINDS = /* @__PURE__ */ new Set([
|
|
|
2364
2486
|
"K8s::Apps::DaemonSet",
|
|
2365
2487
|
"K8s::Apps::ReplicaSet"
|
|
2366
2488
|
]);
|
|
2489
|
+
var SELECTOR_CARRIERS = [
|
|
2490
|
+
{ kind: "K8s::Core::Service", read: (spec) => spec?.selector, viaAttr: "selector" },
|
|
2491
|
+
{ kind: "K8s::Policy::PodDisruptionBudget", read: (spec) => labelSelector(spec?.selector), viaAttr: "selector" },
|
|
2492
|
+
{ kind: "K8s::Networking::NetworkPolicy", read: (spec) => labelSelector(spec?.podSelector), viaAttr: "podSelector" }
|
|
2493
|
+
];
|
|
2367
2494
|
function rec(v) {
|
|
2368
2495
|
return v && typeof v === "object" && !Array.isArray(v) ? v : void 0;
|
|
2369
2496
|
}
|
|
@@ -2386,6 +2513,13 @@ function labelRecord(v) {
|
|
|
2386
2513
|
}
|
|
2387
2514
|
return Object.keys(out).length > 0 ? out : void 0;
|
|
2388
2515
|
}
|
|
2516
|
+
function labelSelector(v) {
|
|
2517
|
+
const sel = rec(v);
|
|
2518
|
+
if (!sel) return void 0;
|
|
2519
|
+
const expressions = sel.matchExpressions;
|
|
2520
|
+
if (Array.isArray(expressions) && expressions.length > 0) return void 0;
|
|
2521
|
+
return sel.matchLabels;
|
|
2522
|
+
}
|
|
2389
2523
|
function selects(selector, labels) {
|
|
2390
2524
|
return Object.entries(selector).every(([k, v]) => labels[k] === v);
|
|
2391
2525
|
}
|
|
@@ -2480,15 +2614,19 @@ function deriveK8sEdges(nodes) {
|
|
|
2480
2614
|
const target = candidates.length === 1 ? candidates[0] : candidates.find((p) => namespaceOf(p) === namespaceOf(n));
|
|
2481
2615
|
if (target) add(n.id, target.id, viaAttr);
|
|
2482
2616
|
};
|
|
2617
|
+
const addSelector = (n, raw, viaAttr) => {
|
|
2618
|
+
const selector = labelRecord(raw);
|
|
2619
|
+
if (!selector) return;
|
|
2620
|
+
for (const w of workloads) {
|
|
2621
|
+
if (namespaceOf(w) !== namespaceOf(n)) continue;
|
|
2622
|
+
const labels = templateLabels(w);
|
|
2623
|
+
if (labels && selects(selector, labels)) add(n.id, w.id, viaAttr);
|
|
2624
|
+
}
|
|
2625
|
+
};
|
|
2483
2626
|
for (const n of k8s) {
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
for (const w of workloads) {
|
|
2488
|
-
if (namespaceOf(w) !== namespaceOf(n)) continue;
|
|
2489
|
-
const labels = templateLabels(w);
|
|
2490
|
-
if (labels && selects(selector, labels)) add(n.id, w.id, "selector");
|
|
2491
|
-
}
|
|
2627
|
+
const carrier = SELECTOR_CARRIERS.find((c) => c.kind === n.kind);
|
|
2628
|
+
if (carrier) {
|
|
2629
|
+
addSelector(n, carrier.read(rec(n.attrs?.spec)), carrier.viaAttr);
|
|
2492
2630
|
continue;
|
|
2493
2631
|
}
|
|
2494
2632
|
if (n.kind === "K8s::Networking::Ingress") {
|
|
@@ -2598,172 +2736,597 @@ function applyHelmArtifacts(ir, observed) {
|
|
|
2598
2736
|
return { charts, installed };
|
|
2599
2737
|
}
|
|
2600
2738
|
|
|
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
|
-
|
|
2739
|
+
// src/estate.ts
|
|
2740
|
+
import { statSync as statSync3 } from "node:fs";
|
|
2741
|
+
import { availableParallelism } from "node:os";
|
|
2742
|
+
import { join as join7, resolve as resolve3, sep } from "node:path";
|
|
2743
|
+
import { composeStacks, shortStackNames } from "@intentius/pinhole";
|
|
2744
|
+
|
|
2745
|
+
// src/member-ir.ts
|
|
2746
|
+
import { createHash } from "node:crypto";
|
|
2747
|
+
import { readdirSync as readdirSync3, statSync as statSync2 } from "node:fs";
|
|
2748
|
+
import { join as join6, relative, resolve as resolve2 } from "node:path";
|
|
2749
|
+
var SKIP = /* @__PURE__ */ new Set(["node_modules", "dist", ".git"]);
|
|
2750
|
+
function memberIrCacheSize(env = process.env) {
|
|
2751
|
+
const override = Number.parseInt(env.BEHOLD_ESTATE_IR_CACHE ?? "", 10);
|
|
2752
|
+
return Number.isInteger(override) && override >= 0 ? override : 64;
|
|
2753
|
+
}
|
|
2754
|
+
function memberSourceStamp(dir) {
|
|
2755
|
+
const root = resolve2(dir);
|
|
2756
|
+
const h = createHash("sha1");
|
|
2757
|
+
let any = false;
|
|
2758
|
+
const walk = (at) => {
|
|
2759
|
+
const entries = readdirSync3(at, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : 1);
|
|
2760
|
+
for (const e of entries) {
|
|
2761
|
+
if (SKIP.has(e.name)) continue;
|
|
2762
|
+
const path = join6(at, e.name);
|
|
2763
|
+
if (e.isDirectory()) {
|
|
2764
|
+
walk(path);
|
|
2765
|
+
continue;
|
|
2766
|
+
}
|
|
2767
|
+
if (!e.isFile()) continue;
|
|
2768
|
+
const st = statSync2(path);
|
|
2769
|
+
h.update(`${relative(root, path)}\0${st.mtimeMs}\0${st.size}
|
|
2770
|
+
`);
|
|
2771
|
+
any = true;
|
|
2772
|
+
}
|
|
2773
|
+
};
|
|
2774
|
+
try {
|
|
2775
|
+
walk(root);
|
|
2776
|
+
} catch {
|
|
2777
|
+
return void 0;
|
|
2629
2778
|
}
|
|
2630
|
-
return
|
|
2779
|
+
return any ? h.digest("hex") : void 0;
|
|
2631
2780
|
}
|
|
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;
|
|
2781
|
+
function cacheKey(dir, opts) {
|
|
2782
|
+
const chant = resolveChant(dir);
|
|
2783
|
+
const canonical = JSON.stringify(opts, Object.keys(opts).sort());
|
|
2784
|
+
return `${resolve2(dir)}\0${chant.bin}\0${chant.version ?? ""}\0${canonical}`;
|
|
2638
2785
|
}
|
|
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);
|
|
2786
|
+
var cache = /* @__PURE__ */ new Map();
|
|
2787
|
+
var hits = 0;
|
|
2788
|
+
var misses = 0;
|
|
2789
|
+
function invalidateMember(dir) {
|
|
2790
|
+
if (dir === void 0) {
|
|
2791
|
+
cache.clear();
|
|
2792
|
+
return;
|
|
2667
2793
|
}
|
|
2668
|
-
|
|
2794
|
+
const root = resolve2(dir);
|
|
2795
|
+
for (const [key, entry] of cache) if (entry.dir === root) cache.delete(key);
|
|
2796
|
+
}
|
|
2797
|
+
async function memberIr(dir, opts = {}) {
|
|
2798
|
+
const size = memberIrCacheSize();
|
|
2799
|
+
if (opts.live || opts.overlay || size === 0) return graphIr(dir, opts);
|
|
2800
|
+
const stamp = memberSourceStamp(dir);
|
|
2801
|
+
const key = cacheKey(dir, opts);
|
|
2802
|
+
const hit = stamp !== void 0 ? cache.get(key) : void 0;
|
|
2803
|
+
if (hit && hit.stamp === stamp) {
|
|
2804
|
+
hits++;
|
|
2805
|
+
cache.delete(key);
|
|
2806
|
+
cache.set(key, hit);
|
|
2807
|
+
return structuredClone(hit.ir);
|
|
2808
|
+
}
|
|
2809
|
+
misses++;
|
|
2810
|
+
const ir = await graphIr(dir, opts);
|
|
2811
|
+
if (stamp === void 0 || memberSourceStamp(dir) !== stamp) return ir;
|
|
2812
|
+
cache.delete(key);
|
|
2813
|
+
cache.set(key, { dir: resolve2(dir), stamp, ir });
|
|
2814
|
+
for (const oldest of cache.keys()) {
|
|
2815
|
+
if (cache.size <= size) break;
|
|
2816
|
+
cache.delete(oldest);
|
|
2817
|
+
}
|
|
2818
|
+
return structuredClone(ir);
|
|
2669
2819
|
}
|
|
2670
2820
|
|
|
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]);
|
|
2821
|
+
// src/zoom-notes.ts
|
|
2822
|
+
function zoomNote(zoom, ir, compositeEdgesAttached) {
|
|
2823
|
+
const empty = ir.nodes.length === 0;
|
|
2824
|
+
if (zoom === "components") {
|
|
2825
|
+
return empty ? "no components discovered \u2014 nothing declares one in this project" : void 0;
|
|
2718
2826
|
}
|
|
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;
|
|
2827
|
+
if (zoom === "logical") {
|
|
2828
|
+
return empty ? "logical is an AWS projection \u2014 no AWS resources in this estate" : void 0;
|
|
2725
2829
|
}
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2830
|
+
if (zoom === "composites") {
|
|
2831
|
+
return compositeEdgesAttached === 0 ? "no component ownership to join \u2014 showing the resource graph unchanged" : void 0;
|
|
2832
|
+
}
|
|
2833
|
+
if (zoom === "runtime") {
|
|
2834
|
+
const children = ir.nodes.filter((n) => n.runtimeOwner).length;
|
|
2835
|
+
return children === 0 ? "nothing below the declaration boundary \u2014 no owner-referenced children on this substrate" : void 0;
|
|
2836
|
+
}
|
|
2837
|
+
return void 0;
|
|
2729
2838
|
}
|
|
2730
|
-
function
|
|
2731
|
-
|
|
2732
|
-
return
|
|
2839
|
+
function logicalKept(before, after) {
|
|
2840
|
+
if (after > 0 && after * 3 >= before) return void 0;
|
|
2841
|
+
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
2842
|
}
|
|
2734
|
-
function
|
|
2735
|
-
|
|
2843
|
+
function edgelessNote(zoom, ir, detail) {
|
|
2844
|
+
if (zoom === "components" || zoom === "logical") return void 0;
|
|
2845
|
+
if (ir.nodes.length === 0 || ir.edges.length > 0) return void 0;
|
|
2846
|
+
if (detail !== void 0 && detail < 3) {
|
|
2847
|
+
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)";
|
|
2848
|
+
}
|
|
2849
|
+
return "no edges \u2014 nothing in this estate references anything else";
|
|
2736
2850
|
}
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2851
|
+
var CLUSTER_SCOPED = /* @__PURE__ */ new Set([
|
|
2852
|
+
"K8s::Core::Namespace",
|
|
2853
|
+
"K8s::Rbac::ClusterRole",
|
|
2854
|
+
"K8s::Rbac::ClusterRoleBinding",
|
|
2855
|
+
"K8s::Storage::StorageClass",
|
|
2856
|
+
"K8s::Apiextensions::CustomResourceDefinition"
|
|
2857
|
+
]);
|
|
2858
|
+
function namespaceMismatchNote(nodes) {
|
|
2859
|
+
const k8s = nodes.filter((n) => n?.lexicon === "k8s");
|
|
2860
|
+
if (k8s.length === 0) return void 0;
|
|
2861
|
+
const pending = k8s.filter((n) => n.attrs?._status === "accent" && !CLUSTER_SCOPED.has(n.kind ?? ""));
|
|
2862
|
+
if (pending.length < 2) return void 0;
|
|
2863
|
+
if (pending.some((n) => typeof n.attrs?.metadata?.namespace === "string" && n.attrs.metadata.namespace)) return void 0;
|
|
2864
|
+
if (!k8s.some((n) => n.attrs?._status === "good")) return void 0;
|
|
2865
|
+
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
2866
|
}
|
|
2742
|
-
function
|
|
2743
|
-
|
|
2744
|
-
|
|
2867
|
+
function namespaceJoinNote(joined) {
|
|
2868
|
+
if (joined.length === 0) return void 0;
|
|
2869
|
+
const each = joined.map((j) => `${j.name} in "${j.namespace}"`).join(", ");
|
|
2870
|
+
return `read ${each} \u2014 the namespace the estate's own Kustomization targetNamespace binds, not the app project's own declaration`;
|
|
2745
2871
|
}
|
|
2746
|
-
function
|
|
2747
|
-
const
|
|
2748
|
-
const
|
|
2749
|
-
return
|
|
2872
|
+
function notesFor(zoom, ir, compositeEdgesAttached, logicalBefore, detail) {
|
|
2873
|
+
const primary = zoom === "logical" && logicalBefore !== void 0 ? logicalKept(logicalBefore, ir.nodes.length) : zoomNote(zoom, ir, compositeEdgesAttached);
|
|
2874
|
+
const notes = [primary, edgelessNote(zoom, ir, detail)].filter((n) => n !== void 0);
|
|
2875
|
+
return notes.length ? notes.join(" \xB7 ") : void 0;
|
|
2750
2876
|
}
|
|
2751
|
-
function
|
|
2752
|
-
const
|
|
2753
|
-
if (
|
|
2754
|
-
const
|
|
2755
|
-
const
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
const
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
const
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
const
|
|
2877
|
+
function tierMismatchNote(ir, tiers, currentTier) {
|
|
2878
|
+
const values = tiers?.values ?? [];
|
|
2879
|
+
if (values.length < 2) return void 0;
|
|
2880
|
+
const byLexicon = /* @__PURE__ */ new Map();
|
|
2881
|
+
for (const n of ir.nodes) {
|
|
2882
|
+
const status = n.attrs?._status;
|
|
2883
|
+
if (status !== "good" && status !== "accent") continue;
|
|
2884
|
+
const lexicon = n.lexicon ?? "?";
|
|
2885
|
+
const c = byLexicon.get(lexicon) ?? { good: 0, accent: 0 };
|
|
2886
|
+
c[status === "good" ? "good" : "accent"]++;
|
|
2887
|
+
byLexicon.set(lexicon, c);
|
|
2888
|
+
}
|
|
2889
|
+
for (const [lexicon, { good, accent }] of [...byLexicon].sort()) {
|
|
2890
|
+
if (accent < 2 || accent * 2 < good + accent) continue;
|
|
2891
|
+
const here = currentTier ? `tier "${currentTier}"` : "the default tier";
|
|
2892
|
+
const others = values.filter((v) => v !== currentTier).join(", ");
|
|
2893
|
+
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)`;
|
|
2894
|
+
}
|
|
2895
|
+
return void 0;
|
|
2896
|
+
}
|
|
2897
|
+
|
|
2898
|
+
// src/estate.ts
|
|
2899
|
+
function estateReadPool(members, env = process.env) {
|
|
2900
|
+
const override = Number.parseInt(env.BEHOLD_ESTATE_CONCURRENCY ?? "", 10);
|
|
2901
|
+
const cap = Number.isInteger(override) && override >= 1 ? override : Math.min(4, availableParallelism());
|
|
2902
|
+
return Math.max(1, Math.min(cap, members));
|
|
2903
|
+
}
|
|
2904
|
+
async function mapPool(items, width, fn) {
|
|
2905
|
+
const results = new Array(items.length);
|
|
2906
|
+
let next = 0;
|
|
2907
|
+
const worker = async () => {
|
|
2908
|
+
for (let i = next++; i < items.length; i = next++) {
|
|
2909
|
+
results[i] = await fn(items[i], i);
|
|
2910
|
+
}
|
|
2911
|
+
};
|
|
2912
|
+
await Promise.all(Array.from({ length: Math.max(1, Math.min(width, items.length)) }, () => worker()));
|
|
2913
|
+
return results;
|
|
2914
|
+
}
|
|
2915
|
+
function estateMembers(projectDirs) {
|
|
2916
|
+
const names = shortStackNames([...projectDirs]);
|
|
2917
|
+
return projectDirs.map((dir, i) => ({ name: names[i], dir }));
|
|
2918
|
+
}
|
|
2919
|
+
async function composeEstate(projectDirs, opts = {}) {
|
|
2920
|
+
const names = shortStackNames(projectDirs);
|
|
2921
|
+
const stacks = await mapPool(projectDirs, estateReadPool(projectDirs.length), async (dir, i) => ({
|
|
2922
|
+
name: names[i],
|
|
2923
|
+
ir: await memberIr(dir, opts)
|
|
2924
|
+
}));
|
|
2925
|
+
return composeStacks(stacks);
|
|
2926
|
+
}
|
|
2927
|
+
var firstLine2 = (e) => {
|
|
2928
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
2929
|
+
const m = msg.match(/exited \d+:\s*([\s\S]*)/);
|
|
2930
|
+
return (m ? m[1] : msg).split("\n")[0].trim().slice(0, 160);
|
|
2931
|
+
};
|
|
2932
|
+
function namespaceRuntimeOwners(name, ir) {
|
|
2933
|
+
const own = new Set(ir.nodes.map((n) => n.id));
|
|
2934
|
+
for (const n of ir.nodes) {
|
|
2935
|
+
if (n.runtimeOwner && own.has(n.runtimeOwner)) n.runtimeOwner = `${name}/${n.runtimeOwner}`;
|
|
2936
|
+
}
|
|
2937
|
+
return ir;
|
|
2938
|
+
}
|
|
2939
|
+
var NAMESPACE_JOIN_FLOOR = "0.44.5";
|
|
2940
|
+
var rec2 = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : void 0;
|
|
2941
|
+
var str2 = (v) => typeof v === "string" && v.length > 0 ? v : void 0;
|
|
2942
|
+
var realIsDir = (p) => {
|
|
2943
|
+
try {
|
|
2944
|
+
return statSync3(p).isDirectory();
|
|
2945
|
+
} catch {
|
|
2946
|
+
return false;
|
|
2947
|
+
}
|
|
2948
|
+
};
|
|
2949
|
+
function declaredNamespaceBindings(ir) {
|
|
2950
|
+
const out = [];
|
|
2951
|
+
for (const n of ir.nodes) {
|
|
2952
|
+
if (n.kind !== "K8s::Flux::Kustomization") continue;
|
|
2953
|
+
const spec = rec2(n.attrs?.spec);
|
|
2954
|
+
const namespace = str2(spec?.targetNamespace);
|
|
2955
|
+
const path = str2(spec?.path);
|
|
2956
|
+
if (namespace && path) out.push({ path, namespace });
|
|
2957
|
+
}
|
|
2958
|
+
return out;
|
|
2959
|
+
}
|
|
2960
|
+
function awaitsNamespaceBinding(ir) {
|
|
2961
|
+
const namespaced = ir.nodes.filter((n) => n.lexicon === "k8s" && !CLUSTER_SCOPED.has(n.kind ?? ""));
|
|
2962
|
+
if (namespaced.length === 0) return false;
|
|
2963
|
+
return namespaced.every((n) => !str2(rec2(n.attrs?.metadata)?.namespace));
|
|
2964
|
+
}
|
|
2965
|
+
function declaredSegments(path) {
|
|
2966
|
+
if (path.startsWith("/")) return void 0;
|
|
2967
|
+
const segs = path.split("/").filter((s) => s !== "" && s !== ".");
|
|
2968
|
+
if (segs.length === 0 || segs.includes("..")) return void 0;
|
|
2969
|
+
return segs;
|
|
2970
|
+
}
|
|
2971
|
+
function pathAlignment(declared, dir, isDir = realIsDir) {
|
|
2972
|
+
const want = declaredSegments(declared);
|
|
2973
|
+
if (!want) return 0;
|
|
2974
|
+
const root = resolve3(dir);
|
|
2975
|
+
const have = root.split(sep).filter(Boolean);
|
|
2976
|
+
for (let k = Math.min(want.length, have.length); k >= 1; k--) {
|
|
2977
|
+
if (!have.slice(-k).every((s, i) => s === want[i])) continue;
|
|
2978
|
+
if (!isDir(join7(root, ...want.slice(k)))) continue;
|
|
2979
|
+
return k;
|
|
2980
|
+
}
|
|
2981
|
+
return 0;
|
|
2982
|
+
}
|
|
2983
|
+
function joinNamespaceBindings(members, isDir = realIsDir) {
|
|
2984
|
+
const bindings = members.flatMap(
|
|
2985
|
+
(m) => m.ir ? declaredNamespaceBindings(m.ir).map((b) => ({ ...b, declaredBy: m.dir })) : []
|
|
2986
|
+
);
|
|
2987
|
+
if (bindings.length === 0) return [];
|
|
2988
|
+
const awaiting = members.filter((m) => m.ir && awaitsNamespaceBinding(m.ir));
|
|
2989
|
+
if (awaiting.length === 0) return [];
|
|
2990
|
+
const best = /* @__PURE__ */ new Map();
|
|
2991
|
+
for (const b of bindings) {
|
|
2992
|
+
let winner;
|
|
2993
|
+
let tied = false;
|
|
2994
|
+
for (const m of awaiting) {
|
|
2995
|
+
if (m.dir === b.declaredBy) continue;
|
|
2996
|
+
const score = pathAlignment(b.path, m.dir, isDir);
|
|
2997
|
+
if (score === 0) continue;
|
|
2998
|
+
if (!winner || score > winner.score) {
|
|
2999
|
+
winner = { dir: m.dir, score };
|
|
3000
|
+
tied = false;
|
|
3001
|
+
} else if (score === winner.score) {
|
|
3002
|
+
tied = true;
|
|
3003
|
+
}
|
|
3004
|
+
}
|
|
3005
|
+
if (!winner || tied) continue;
|
|
3006
|
+
const prev = best.get(winner.dir);
|
|
3007
|
+
if (prev === "ambiguous") continue;
|
|
3008
|
+
const join21 = { dir: winner.dir, namespace: b.namespace, path: b.path, declaredBy: b.declaredBy };
|
|
3009
|
+
if (!prev) best.set(winner.dir, { score: winner.score, join: join21 });
|
|
3010
|
+
else if (prev.join.namespace !== b.namespace) best.set(winner.dir, "ambiguous");
|
|
3011
|
+
else if (winner.score > prev.score) best.set(winner.dir, { score: winner.score, join: join21 });
|
|
3012
|
+
}
|
|
3013
|
+
return [...best.values()].filter((v) => v !== "ambiguous").map((v) => v.join);
|
|
3014
|
+
}
|
|
3015
|
+
async function estateNamespaceScopes(projectDirs, opts, isDir = realIsDir) {
|
|
3016
|
+
if (projectDirs.length < 2) return /* @__PURE__ */ new Map();
|
|
3017
|
+
const { live: _live, overlay: _overlay, namespace: _namespace, ...src } = opts;
|
|
3018
|
+
const irs = await mapPool(
|
|
3019
|
+
projectDirs,
|
|
3020
|
+
estateReadPool(projectDirs.length),
|
|
3021
|
+
(dir) => memberIr(dir, { ...src, detail: 3 }).catch(() => void 0)
|
|
3022
|
+
);
|
|
3023
|
+
const scopes = /* @__PURE__ */ new Map();
|
|
3024
|
+
for (const j of joinNamespaceBindings(projectDirs.map((dir, i) => ({ dir, ir: irs[i] })), isDir)) {
|
|
3025
|
+
if (!meetsFloor(resolveChant(j.dir).version, NAMESPACE_JOIN_FLOOR)) continue;
|
|
3026
|
+
scopes.set(j.dir, j.namespace);
|
|
3027
|
+
}
|
|
3028
|
+
return scopes;
|
|
3029
|
+
}
|
|
3030
|
+
function withoutJoinedMembers(nodes, joined) {
|
|
3031
|
+
if (joined.length === 0) return [...nodes];
|
|
3032
|
+
return nodes.filter((n) => !joined.some((j) => n.id.startsWith(`${j.name}/`)));
|
|
3033
|
+
}
|
|
3034
|
+
async function composeEstateOverlay(projectDirs, opts, classify) {
|
|
3035
|
+
const names = shortStackNames(projectDirs);
|
|
3036
|
+
const unobserved = [];
|
|
3037
|
+
const dropped = [];
|
|
3038
|
+
const joined = [];
|
|
3039
|
+
const stacks = new Array(projectDirs.length);
|
|
3040
|
+
const scopes = await estateNamespaceScopes(projectDirs, opts);
|
|
3041
|
+
await mapPool(projectDirs, estateReadPool(projectDirs.length), async (dir, i) => {
|
|
3042
|
+
const name = names[i];
|
|
3043
|
+
const namespace = scopes.get(dir);
|
|
3044
|
+
try {
|
|
3045
|
+
const live = { ...opts, live: true, overlay: true, ...namespace ? { namespace } : {} };
|
|
3046
|
+
stacks[i] = { name, ir: namespaceRuntimeOwners(name, classify(await graphIr(dir, live))) };
|
|
3047
|
+
if (namespace) joined.push({ name, namespace });
|
|
3048
|
+
} catch (err) {
|
|
3049
|
+
const reason = firstLine2(err);
|
|
3050
|
+
try {
|
|
3051
|
+
const { env: _env, live: _live, overlay: _overlay, ...srcOpts } = opts;
|
|
3052
|
+
const src = await memberIr(dir, srcOpts);
|
|
3053
|
+
for (const n of src.nodes) n.attrs = { ...n.attrs, _status: "neutral", _unobserved: reason };
|
|
3054
|
+
stacks[i] = { name, ir: src };
|
|
3055
|
+
unobserved.push({ name, reason });
|
|
3056
|
+
} catch (err2) {
|
|
3057
|
+
dropped.push({ name, reason: firstLine2(err2) });
|
|
3058
|
+
}
|
|
3059
|
+
}
|
|
3060
|
+
});
|
|
3061
|
+
const present = stacks.filter((s) => !!s);
|
|
3062
|
+
return {
|
|
3063
|
+
ir: composeStacks(present),
|
|
3064
|
+
observed: present.length - unobserved.length,
|
|
3065
|
+
total: projectDirs.length,
|
|
3066
|
+
unobserved,
|
|
3067
|
+
dropped,
|
|
3068
|
+
// Concurrent reads finish in whatever order the clusters answer; the note
|
|
3069
|
+
// this feeds should read the same twice.
|
|
3070
|
+
joined: joined.sort((a, b) => a.name.localeCompare(b.name))
|
|
3071
|
+
};
|
|
3072
|
+
}
|
|
3073
|
+
|
|
3074
|
+
// src/helm-drift.ts
|
|
3075
|
+
function countChanges(docs) {
|
|
3076
|
+
return docs.reduce((n, d) => n + d.changes.length, 0);
|
|
3077
|
+
}
|
|
3078
|
+
function provenanceOf(live) {
|
|
3079
|
+
const m = live.manifest;
|
|
3080
|
+
return {
|
|
3081
|
+
contentDigest: m.contentDigest,
|
|
3082
|
+
inputDigest: m.inputDigest,
|
|
3083
|
+
valuesDigest: m.valuesDigest,
|
|
3084
|
+
chart: m.chart,
|
|
3085
|
+
chartVersion: m.chartVersion,
|
|
3086
|
+
repo: m.repo,
|
|
3087
|
+
releaseName: m.releaseName,
|
|
3088
|
+
namespace: m.namespace,
|
|
3089
|
+
profile: m.capabilityProfile?.cluster ?? "",
|
|
3090
|
+
kubeVersion: m.capabilityProfile?.kubeVersion ?? "",
|
|
3091
|
+
renderedAt: m.renderedAt,
|
|
3092
|
+
helmVersion: m.helmVersion,
|
|
3093
|
+
chantVersion: m.chantVersion,
|
|
3094
|
+
sourceRef: m.sourceRef
|
|
3095
|
+
};
|
|
3096
|
+
}
|
|
3097
|
+
function helmRenderReport(record, live) {
|
|
3098
|
+
const base = { render: record.name, chart: record.chart };
|
|
3099
|
+
if (!live) {
|
|
3100
|
+
return { ...base, verdict: "unobserved", reason: record.contentDigest ? "no-stored-render" : "unpinned" };
|
|
3101
|
+
}
|
|
3102
|
+
const d = live.diff;
|
|
3103
|
+
const counts = {
|
|
3104
|
+
drifted: d.drifted.length,
|
|
3105
|
+
changes: countChanges(d.drifted),
|
|
3106
|
+
unchanged: d.unchanged.length,
|
|
3107
|
+
unobserved: d.unobserved.length,
|
|
3108
|
+
undeclared: d.undeclaredEntities.length
|
|
3109
|
+
};
|
|
3110
|
+
const provenance = provenanceOf(live);
|
|
3111
|
+
if (d.drifted.length > 0) {
|
|
3112
|
+
return { ...base, verdict: "drifted", counts, provenance, drifted: d.drifted };
|
|
3113
|
+
}
|
|
3114
|
+
if (d.drifted.length + d.accepted.length + d.unchanged.length === 0) {
|
|
3115
|
+
return { ...base, verdict: "unobserved", reason: "nothing-observed", counts, provenance };
|
|
3116
|
+
}
|
|
3117
|
+
if (d.unobserved.length > 0) {
|
|
3118
|
+
return { ...base, verdict: "unobserved", reason: "partly-observed", counts, provenance };
|
|
3119
|
+
}
|
|
3120
|
+
return { ...base, verdict: "in-sync", counts, provenance };
|
|
3121
|
+
}
|
|
3122
|
+
async function readHelmRenderDrift(projectDir, env, opts = {}) {
|
|
3123
|
+
const report = await helmRenders(projectDir, opts).catch(() => void 0);
|
|
3124
|
+
const records = report?.records ?? [];
|
|
3125
|
+
if (records.length === 0) return [];
|
|
3126
|
+
return mapPool(records, estateReadPool(records.length), async (record) => {
|
|
3127
|
+
if (!record.contentDigest) return helmRenderReport(record, void 0);
|
|
3128
|
+
const live = await helmRenderDiffLive(projectDir, record.contentDigest, env, opts).catch(() => void 0);
|
|
3129
|
+
return helmRenderReport(record, live);
|
|
3130
|
+
});
|
|
3131
|
+
}
|
|
3132
|
+
function chartNameOf2(node) {
|
|
3133
|
+
const name = node.attrs?.name;
|
|
3134
|
+
return typeof name === "string" && name.length > 0 ? name : void 0;
|
|
3135
|
+
}
|
|
3136
|
+
var SEVERITY = { drifted: 2, unobserved: 1, "in-sync": 0 };
|
|
3137
|
+
function applyHelmRenderDrift(ir, reports) {
|
|
3138
|
+
if (reports.length === 0) return { charts: 0, drifted: 0 };
|
|
3139
|
+
const byChart = /* @__PURE__ */ new Map();
|
|
3140
|
+
for (const r of reports) {
|
|
3141
|
+
const list3 = byChart.get(r.chart);
|
|
3142
|
+
if (list3) list3.push(r);
|
|
3143
|
+
else byChart.set(r.chart, [r]);
|
|
3144
|
+
}
|
|
3145
|
+
let charts = 0;
|
|
3146
|
+
let drifted = 0;
|
|
3147
|
+
for (const node of ir.nodes) {
|
|
3148
|
+
if (node.lexicon !== "helm" || node.kind !== "Helm::Chart") continue;
|
|
3149
|
+
const chartName = chartNameOf2(node);
|
|
3150
|
+
const matched = chartName ? byChart.get(chartName) : void 0;
|
|
3151
|
+
if (!matched || matched.length === 0) continue;
|
|
3152
|
+
const worst = matched.reduce((a, b) => SEVERITY[b.verdict] > SEVERITY[a.verdict] ? b : a);
|
|
3153
|
+
const attrs = node.attrs ??= {};
|
|
3154
|
+
attrs._renderDrift = matched.length === 1 ? worst : { ...worst, renders: matched };
|
|
3155
|
+
charts++;
|
|
3156
|
+
if (worst.verdict === "drifted") {
|
|
3157
|
+
attrs._status = "warn";
|
|
3158
|
+
drifted++;
|
|
3159
|
+
}
|
|
3160
|
+
}
|
|
3161
|
+
return { charts, drifted };
|
|
3162
|
+
}
|
|
3163
|
+
|
|
3164
|
+
// src/cluster-anchor.ts
|
|
3165
|
+
var MANAGED_CLUSTER_KINDS = /* @__PURE__ */ new Set([
|
|
3166
|
+
"AWS::EKS::Cluster",
|
|
3167
|
+
"GCP::Container::Cluster",
|
|
3168
|
+
"Microsoft.ContainerService/managedClusters",
|
|
3169
|
+
"K3d::Cluster"
|
|
3170
|
+
]);
|
|
3171
|
+
var ANCHOR_VIA = "runs-on";
|
|
3172
|
+
function metadata(node) {
|
|
3173
|
+
const m = node.attrs?.metadata;
|
|
3174
|
+
return m && typeof m === "object" && !Array.isArray(m) ? m : void 0;
|
|
3175
|
+
}
|
|
3176
|
+
function metaString(node, key) {
|
|
3177
|
+
const v = metadata(node)?.[key];
|
|
3178
|
+
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
3179
|
+
}
|
|
3180
|
+
function isK8s(node) {
|
|
3181
|
+
return node.lexicon === "k8s";
|
|
3182
|
+
}
|
|
3183
|
+
function isNamespace(node) {
|
|
3184
|
+
return node.kind === "K8s::Core::Namespace";
|
|
3185
|
+
}
|
|
3186
|
+
function managedClusterName(node) {
|
|
3187
|
+
const metaName = metaString(node, "name");
|
|
3188
|
+
if (metaName) return metaName;
|
|
3189
|
+
for (const key of ["name", "clusterName"]) {
|
|
3190
|
+
const v = node.attrs?.[key];
|
|
3191
|
+
if (typeof v === "string" && v.length > 0) return v;
|
|
3192
|
+
}
|
|
3193
|
+
return node.id;
|
|
3194
|
+
}
|
|
3195
|
+
function boundManagedCluster(nodes, boundContext) {
|
|
3196
|
+
const clusters = nodes.filter((n) => MANAGED_CLUSTER_KINDS.has(n.kind));
|
|
3197
|
+
if (clusters.length === 1) return clusters[0];
|
|
3198
|
+
if (clusters.length === 0 || !boundContext) return void 0;
|
|
3199
|
+
const bound = clusters.filter((n) => contextBindsCluster(boundContext, managedClusterName(n)));
|
|
3200
|
+
return bound.length === 1 ? bound[0] : void 0;
|
|
3201
|
+
}
|
|
3202
|
+
function addClusterAnchorEdges(ir, boundContext) {
|
|
3203
|
+
const nodes = ir.nodes;
|
|
3204
|
+
const cluster = boundManagedCluster(ir.nodes, boundContext);
|
|
3205
|
+
if (!cluster) return ir;
|
|
3206
|
+
const k8sNodes = nodes.filter(isK8s);
|
|
3207
|
+
if (k8sNodes.length === 0) return ir;
|
|
3208
|
+
const namespaceByName = /* @__PURE__ */ new Map();
|
|
3209
|
+
for (const n of k8sNodes) {
|
|
3210
|
+
if (!isNamespace(n)) continue;
|
|
3211
|
+
const name = metaString(n, "name");
|
|
3212
|
+
if (name && !namespaceByName.has(name)) namespaceByName.set(name, n.id);
|
|
3213
|
+
}
|
|
3214
|
+
const existing = new Set(ir.edges.map((e) => `${e.from}\0${e.to}`));
|
|
3215
|
+
const add = (from, to) => {
|
|
3216
|
+
if (from === to) return;
|
|
3217
|
+
const key = `${from}\0${to}`;
|
|
3218
|
+
if (existing.has(key)) return;
|
|
3219
|
+
existing.add(key);
|
|
3220
|
+
ir.edges.push({ from, to, kind: "ref", viaAttr: ANCHOR_VIA, inferred: true });
|
|
3221
|
+
};
|
|
3222
|
+
for (const n of k8sNodes) {
|
|
3223
|
+
if (isNamespace(n)) {
|
|
3224
|
+
add(cluster.id, n.id);
|
|
3225
|
+
continue;
|
|
3226
|
+
}
|
|
3227
|
+
const ns = metaString(n, "namespace");
|
|
3228
|
+
const nsNode = ns ? namespaceByName.get(ns) : void 0;
|
|
3229
|
+
add(nsNode ?? cluster.id, n.id);
|
|
3230
|
+
}
|
|
3231
|
+
return ir;
|
|
3232
|
+
}
|
|
3233
|
+
|
|
3234
|
+
// src/logical-azure.ts
|
|
3235
|
+
var AZURE_HEADLINE_KINDS = /* @__PURE__ */ new Set([
|
|
3236
|
+
// compute / workloads
|
|
3237
|
+
"Microsoft.Compute/virtualMachines",
|
|
3238
|
+
"Microsoft.Compute/virtualMachineScaleSets",
|
|
3239
|
+
"Microsoft.ContainerService/managedClusters",
|
|
3240
|
+
"Microsoft.ContainerInstance/containerGroups",
|
|
3241
|
+
"Microsoft.Web/sites",
|
|
3242
|
+
// network edge
|
|
3243
|
+
"Microsoft.Network/publicIPAddresses",
|
|
3244
|
+
"Microsoft.Network/loadBalancers",
|
|
3245
|
+
"Microsoft.Network/applicationGateways",
|
|
3246
|
+
"Microsoft.Network/natGateways",
|
|
3247
|
+
"Microsoft.Network/dnsZones",
|
|
3248
|
+
// data stores
|
|
3249
|
+
"Microsoft.Storage/storageAccounts",
|
|
3250
|
+
"Microsoft.Sql/servers",
|
|
3251
|
+
"Microsoft.DocumentDB/databaseAccounts",
|
|
3252
|
+
"Microsoft.Cache/redis",
|
|
3253
|
+
"Microsoft.DBforPostgreSQL/flexibleServers",
|
|
3254
|
+
// registry / identity edge
|
|
3255
|
+
"Microsoft.ContainerRegistry/registries"
|
|
3256
|
+
]);
|
|
3257
|
+
var AZURE_FABRIC_KINDS = /* @__PURE__ */ new Set([
|
|
3258
|
+
"Microsoft.Network/virtualNetworks",
|
|
3259
|
+
"Microsoft.Network/virtualNetworks_subnets",
|
|
3260
|
+
"Microsoft.Network/networkSecurityGroups",
|
|
3261
|
+
"Microsoft.Network/routeTables",
|
|
3262
|
+
"Microsoft.Network/networkInterfaces",
|
|
3263
|
+
// identity/authorization hubs — a role assignment or managed identity is
|
|
3264
|
+
// referenced by half the estate, so contracting through one invents edges
|
|
3265
|
+
// between unrelated resources.
|
|
3266
|
+
"Microsoft.Authorization/roleAssignments",
|
|
3267
|
+
"Microsoft.ManagedIdentity/userAssignedIdentities",
|
|
3268
|
+
"Microsoft.KeyVault/vaults"
|
|
3269
|
+
]);
|
|
3270
|
+
var VNET_KIND = "Microsoft.Network/virtualNetworks";
|
|
3271
|
+
var SUBNET_KIND = "Microsoft.Network/virtualNetworks_subnets";
|
|
3272
|
+
var NSG_KIND = "Microsoft.Network/networkSecurityGroups";
|
|
3273
|
+
var RESOURCE_ID_RE = /resourceId\(\s*([^)]*)\)/g;
|
|
3274
|
+
var QUOTED_RE = /'([^']*)'/g;
|
|
3275
|
+
function resourceIdNames(value) {
|
|
3276
|
+
if (typeof value !== "string" || !value.includes("resourceId(")) return [];
|
|
3277
|
+
const out = [];
|
|
3278
|
+
for (const call of value.matchAll(RESOURCE_ID_RE)) {
|
|
3279
|
+
const args = [...call[1].matchAll(QUOTED_RE)].map((m) => m[1]);
|
|
3280
|
+
if (args.length >= 2) out.push(args[args.length - 1]);
|
|
3281
|
+
}
|
|
3282
|
+
return out;
|
|
3283
|
+
}
|
|
3284
|
+
function collectAzureRefs(value, out) {
|
|
3285
|
+
if (value === null || typeof value !== "object") {
|
|
3286
|
+
for (const name of resourceIdNames(value)) out.add(name);
|
|
3287
|
+
return;
|
|
3288
|
+
}
|
|
3289
|
+
const ref = value.$ref;
|
|
3290
|
+
if (typeof ref === "string") out.add(ref.split(".")[0]);
|
|
3291
|
+
for (const v of Object.values(value)) collectAzureRefs(v, out);
|
|
3292
|
+
}
|
|
3293
|
+
function strAttr(node, key) {
|
|
3294
|
+
const v = node.attrs?.[key];
|
|
3295
|
+
return typeof v === "string" ? v : void 0;
|
|
3296
|
+
}
|
|
3297
|
+
function resourceName(node) {
|
|
3298
|
+
return strAttr(node, "name");
|
|
3299
|
+
}
|
|
3300
|
+
function subnetVnetName(node) {
|
|
3301
|
+
const name = resourceName(node);
|
|
3302
|
+
if (!name || !name.includes("/")) return void 0;
|
|
3303
|
+
return name.split("/")[0];
|
|
3304
|
+
}
|
|
3305
|
+
function subnetShortName(node) {
|
|
3306
|
+
const name = resourceName(node);
|
|
3307
|
+
return name?.includes("/") ? name.split("/").slice(1).join("/") : name;
|
|
3308
|
+
}
|
|
3309
|
+
function vnetCidr(node) {
|
|
3310
|
+
const space = node.attrs?.addressSpace;
|
|
3311
|
+
const prefixes = space?.addressPrefixes;
|
|
3312
|
+
return Array.isArray(prefixes) && typeof prefixes[0] === "string" ? prefixes[0] : void 0;
|
|
3313
|
+
}
|
|
3314
|
+
function projectAzureLogical(ir, env) {
|
|
3315
|
+
const azure = ir.nodes.filter((n) => n.lexicon === "azure");
|
|
3316
|
+
if (azure.length === 0) return { ir: { nodes: [], edges: [], groups: {} }, byContainer: {} };
|
|
3317
|
+
const componentOf = componentNamer(azure);
|
|
3318
|
+
const byId = new Map(azure.map((n) => [n.id, n]));
|
|
3319
|
+
const byName = /* @__PURE__ */ new Map();
|
|
3320
|
+
for (const n of azure) {
|
|
3321
|
+
const name = resourceName(n);
|
|
3322
|
+
if (name && !byName.has(name)) byName.set(name, n);
|
|
3323
|
+
}
|
|
3324
|
+
const vnets = azure.filter((n) => n.kind === VNET_KIND);
|
|
3325
|
+
const subnets = azure.filter((n) => n.kind === SUBNET_KIND);
|
|
3326
|
+
const vnetByName = new Map(vnets.map((v) => [resourceName(v) ?? v.id, v]));
|
|
3327
|
+
const refOut = /* @__PURE__ */ new Map();
|
|
3328
|
+
for (const n of azure) {
|
|
3329
|
+
const names = /* @__PURE__ */ new Set();
|
|
2767
3330
|
collectAzureRefs(n.attrs, names);
|
|
2768
3331
|
const targets = /* @__PURE__ */ new Set();
|
|
2769
3332
|
for (const name of names) {
|
|
@@ -3088,7 +3651,7 @@ function projectK8sLogical(ir, env, boundContext) {
|
|
|
3088
3651
|
function releaseBoxTitle(chartName) {
|
|
3089
3652
|
return `release ${chartName}`;
|
|
3090
3653
|
}
|
|
3091
|
-
function
|
|
3654
|
+
function chartNameOf3(node) {
|
|
3092
3655
|
const name = node.attrs?.name;
|
|
3093
3656
|
return typeof name === "string" && name.length > 0 ? name : node.id;
|
|
3094
3657
|
}
|
|
@@ -3109,7 +3672,7 @@ function projectHelmLogical(ir) {
|
|
|
3109
3672
|
};
|
|
3110
3673
|
const k8s = ir.nodes.filter((n) => n.lexicon === "k8s");
|
|
3111
3674
|
for (const chart of charts) {
|
|
3112
|
-
const box = releaseBoxTitle(
|
|
3675
|
+
const box = releaseBoxTitle(chartNameOf3(chart));
|
|
3113
3676
|
child(box, chart.id);
|
|
3114
3677
|
const chartDir = dirOf(chart);
|
|
3115
3678
|
if (chartDir === "") continue;
|
|
@@ -3126,7 +3689,7 @@ function projectHelmLogical(ir) {
|
|
|
3126
3689
|
|
|
3127
3690
|
// src/logical-kustomize.ts
|
|
3128
3691
|
import { existsSync as existsSync5 } from "node:fs";
|
|
3129
|
-
import { join as
|
|
3692
|
+
import { join as join8 } from "node:path";
|
|
3130
3693
|
function overlayBoxTitle(name) {
|
|
3131
3694
|
return `overlay ${name}`;
|
|
3132
3695
|
}
|
|
@@ -3146,7 +3709,7 @@ function kustomizationRoot(bases, dir, exists) {
|
|
|
3146
3709
|
let current = dir;
|
|
3147
3710
|
for (let i = 0; i < 4 && current; i++) {
|
|
3148
3711
|
for (const base of bases) {
|
|
3149
|
-
if (exists(
|
|
3712
|
+
if (exists(join8(base, current, "kustomization.yaml")) || exists(join8(base, current, "kustomization.yml"))) {
|
|
3150
3713
|
return current;
|
|
3151
3714
|
}
|
|
3152
3715
|
}
|
|
@@ -3445,8 +4008,42 @@ function projectTopology(ir, env, boundContext, sourceRoots) {
|
|
|
3445
4008
|
}
|
|
3446
4009
|
placeHelmReleases(nodes, byContainer, ir, env, boundContext);
|
|
3447
4010
|
nestReleaseBoxes(byContainer);
|
|
4011
|
+
retainCrossLensEdges(ir, projections, nodes, edges, byContainer);
|
|
3448
4012
|
return { ir: { nodes, edges, groups: {} }, byContainer };
|
|
3449
4013
|
}
|
|
4014
|
+
function retainCrossLensEdges(ir, projections, nodes, edges, byContainer) {
|
|
4015
|
+
const cards = new Set(nodes.map((n) => n.id));
|
|
4016
|
+
const perLens = projections.map((p) => new Set(p.ir.nodes.map((n) => n.id)));
|
|
4017
|
+
const pairKey = (a, b) => a < b ? `${a}|${b}` : `${b}|${a}`;
|
|
4018
|
+
const drawn = new Set(edges.map((e) => pairKey(e.from, e.to)));
|
|
4019
|
+
const parentsOf = /* @__PURE__ */ new Map();
|
|
4020
|
+
for (const [parent, children] of Object.entries(byContainer))
|
|
4021
|
+
for (const c of children) (parentsOf.get(c) ?? parentsOf.set(c, []).get(c)).push(parent);
|
|
4022
|
+
const nests = (outer, inner) => {
|
|
4023
|
+
const seen = /* @__PURE__ */ new Set([inner]);
|
|
4024
|
+
let frontier = [...parentsOf.get(inner) ?? []];
|
|
4025
|
+
while (frontier.length) {
|
|
4026
|
+
const next = [];
|
|
4027
|
+
for (const p of frontier) {
|
|
4028
|
+
if (seen.has(p)) continue;
|
|
4029
|
+
seen.add(p);
|
|
4030
|
+
if (p === outer) return true;
|
|
4031
|
+
for (const up of parentsOf.get(p) ?? []) if (!seen.has(up)) next.push(up);
|
|
4032
|
+
}
|
|
4033
|
+
frontier = next;
|
|
4034
|
+
}
|
|
4035
|
+
return false;
|
|
4036
|
+
};
|
|
4037
|
+
for (const e of ir.edges) {
|
|
4038
|
+
if (e.from === e.to || !cards.has(e.from) || !cards.has(e.to)) continue;
|
|
4039
|
+
if (perLens.some((ids) => ids.has(e.from) && ids.has(e.to))) continue;
|
|
4040
|
+
const key = pairKey(e.from, e.to);
|
|
4041
|
+
if (drawn.has(key)) continue;
|
|
4042
|
+
if (nests(e.from, e.to) || nests(e.to, e.from)) continue;
|
|
4043
|
+
drawn.add(key);
|
|
4044
|
+
edges.push(e);
|
|
4045
|
+
}
|
|
4046
|
+
}
|
|
3450
4047
|
function placeHelmReleases(nodes, byContainer, ir, env, boundContext) {
|
|
3451
4048
|
const releases = nodes.filter((n) => n.kind === "Helm::Release");
|
|
3452
4049
|
if (releases.length === 0) return;
|
|
@@ -3677,126 +4274,49 @@ function componentRepresentatives(ir, dag) {
|
|
|
3677
4274
|
if (typeof n !== "string" || !nodeById.has(n)) return false;
|
|
3678
4275
|
const src = sourceComponent(n);
|
|
3679
4276
|
return fallbackShaped ? src === comp.id : src === void 0 || src === comp.id;
|
|
3680
|
-
});
|
|
3681
|
-
if (owned.length === 0) continue;
|
|
3682
|
-
compToNode.set(comp.id, owned.find((n) => !n.startsWith("byo")) ?? owned[0]);
|
|
3683
|
-
}
|
|
3684
|
-
for (const n of ir.nodes) {
|
|
3685
|
-
const component = n.attrs?.component;
|
|
3686
|
-
if (typeof component !== "string" || !knownComponents2.has(component) || compToNode.has(component)) continue;
|
|
3687
|
-
compToNode.set(component, n.id);
|
|
3688
|
-
}
|
|
3689
|
-
const unmapped = /* @__PURE__ */ new Set();
|
|
3690
|
-
for (const e of dag.edges) {
|
|
3691
|
-
if (!compToNode.has(e.from)) unmapped.add(e.from);
|
|
3692
|
-
if (!compToNode.has(e.to)) unmapped.add(e.to);
|
|
3693
|
-
}
|
|
3694
|
-
if (unmapped.size > 0) {
|
|
3695
|
-
for (const n of ir.nodes) {
|
|
3696
|
-
if (n.kind.includes("::")) continue;
|
|
3697
|
-
const comp = kebabKind(n.kind);
|
|
3698
|
-
if (!unmapped.has(comp)) continue;
|
|
3699
|
-
const current = compToNode.get(comp);
|
|
3700
|
-
if (!current || current.startsWith("byo") && !n.id.startsWith("byo")) compToNode.set(comp, n.id);
|
|
3701
|
-
}
|
|
3702
|
-
}
|
|
3703
|
-
return compToNode;
|
|
3704
|
-
}
|
|
3705
|
-
function addCompositeDeps(ir, dag) {
|
|
3706
|
-
const compToNode = componentRepresentatives(ir, dag);
|
|
3707
|
-
const declared = new Set(ir.edges.map((e) => `${e.from}\0${e.to}`));
|
|
3708
|
-
for (const e of dag.edges) {
|
|
3709
|
-
const from = compToNode.get(e.from);
|
|
3710
|
-
const to = compToNode.get(e.to);
|
|
3711
|
-
if (!from || !to || from === to) continue;
|
|
3712
|
-
const key = `${from}\0${to}`;
|
|
3713
|
-
if (declared.has(key)) continue;
|
|
3714
|
-
declared.add(key);
|
|
3715
|
-
ir.edges.push({ from, to, kind: "ref", viaAttr: "dependsOn", inferred: true });
|
|
3716
|
-
}
|
|
3717
|
-
return ir;
|
|
3718
|
-
}
|
|
3719
|
-
function addCompositeDepsCounted(ir, dag) {
|
|
3720
|
-
const before = ir.edges.length;
|
|
3721
|
-
const out = addCompositeDeps(ir, dag);
|
|
3722
|
-
return { ir: out, attached: out.edges.length - before };
|
|
3723
|
-
}
|
|
3724
|
-
|
|
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();
|
|
4277
|
+
});
|
|
4278
|
+
if (owned.length === 0) continue;
|
|
4279
|
+
compToNode.set(comp.id, owned.find((n) => !n.startsWith("byo")) ?? owned[0]);
|
|
4280
|
+
}
|
|
3785
4281
|
for (const n of ir.nodes) {
|
|
3786
|
-
const
|
|
3787
|
-
if (
|
|
3788
|
-
|
|
3789
|
-
const c = byLexicon.get(lexicon) ?? { good: 0, accent: 0 };
|
|
3790
|
-
c[status === "good" ? "good" : "accent"]++;
|
|
3791
|
-
byLexicon.set(lexicon, c);
|
|
4282
|
+
const component = n.attrs?.component;
|
|
4283
|
+
if (typeof component !== "string" || !knownComponents2.has(component) || compToNode.has(component)) continue;
|
|
4284
|
+
compToNode.set(component, n.id);
|
|
3792
4285
|
}
|
|
3793
|
-
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
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)`;
|
|
4286
|
+
const unmapped = /* @__PURE__ */ new Set();
|
|
4287
|
+
for (const e of dag.edges) {
|
|
4288
|
+
if (!compToNode.has(e.from)) unmapped.add(e.from);
|
|
4289
|
+
if (!compToNode.has(e.to)) unmapped.add(e.to);
|
|
3798
4290
|
}
|
|
3799
|
-
|
|
4291
|
+
if (unmapped.size > 0) {
|
|
4292
|
+
for (const n of ir.nodes) {
|
|
4293
|
+
if (n.kind.includes("::")) continue;
|
|
4294
|
+
const comp = kebabKind(n.kind);
|
|
4295
|
+
if (!unmapped.has(comp)) continue;
|
|
4296
|
+
const current = compToNode.get(comp);
|
|
4297
|
+
if (!current || current.startsWith("byo") && !n.id.startsWith("byo")) compToNode.set(comp, n.id);
|
|
4298
|
+
}
|
|
4299
|
+
}
|
|
4300
|
+
return compToNode;
|
|
4301
|
+
}
|
|
4302
|
+
function addCompositeDeps(ir, dag) {
|
|
4303
|
+
const compToNode = componentRepresentatives(ir, dag);
|
|
4304
|
+
const declared = new Set(ir.edges.map((e) => `${e.from}\0${e.to}`));
|
|
4305
|
+
for (const e of dag.edges) {
|
|
4306
|
+
const from = compToNode.get(e.from);
|
|
4307
|
+
const to = compToNode.get(e.to);
|
|
4308
|
+
if (!from || !to || from === to) continue;
|
|
4309
|
+
const key = `${from}\0${to}`;
|
|
4310
|
+
if (declared.has(key)) continue;
|
|
4311
|
+
declared.add(key);
|
|
4312
|
+
ir.edges.push({ from, to, kind: "ref", viaAttr: "dependsOn", inferred: true });
|
|
4313
|
+
}
|
|
4314
|
+
return ir;
|
|
4315
|
+
}
|
|
4316
|
+
function addCompositeDepsCounted(ir, dag) {
|
|
4317
|
+
const before = ir.edges.length;
|
|
4318
|
+
const out = addCompositeDeps(ir, dag);
|
|
4319
|
+
return { ir: out, attached: out.edges.length - before };
|
|
3800
4320
|
}
|
|
3801
4321
|
|
|
3802
4322
|
// src/resources.ts
|
|
@@ -3830,6 +4350,19 @@ function resourcesByComponent(ir, known) {
|
|
|
3830
4350
|
}
|
|
3831
4351
|
|
|
3832
4352
|
// src/reconcile.ts
|
|
4353
|
+
var DISRUPTION_SEVERITY = {
|
|
4354
|
+
"in-place": 0,
|
|
4355
|
+
rolling: 1,
|
|
4356
|
+
unknown: 1,
|
|
4357
|
+
replace: 2,
|
|
4358
|
+
destroy: 3
|
|
4359
|
+
};
|
|
4360
|
+
var DISRUPTION_LEVELS = ["in-place", "rolling", "replace", "destroy", "unknown"];
|
|
4361
|
+
function worstDisruption(a, b) {
|
|
4362
|
+
if (!a) return b;
|
|
4363
|
+
if (!b) return a;
|
|
4364
|
+
return DISRUPTION_SEVERITY[b] > DISRUPTION_SEVERITY[a] ? b : a;
|
|
4365
|
+
}
|
|
3833
4366
|
function summarizePlan(plan, byComponent, nonResource) {
|
|
3834
4367
|
const componentByEntity = /* @__PURE__ */ new Map();
|
|
3835
4368
|
for (const [component, resources] of Object.entries(byComponent)) {
|
|
@@ -3838,6 +4371,9 @@ function summarizePlan(plan, byComponent, nonResource) {
|
|
|
3838
4371
|
const counts = {};
|
|
3839
4372
|
const unobservedCounts = {};
|
|
3840
4373
|
const runtimeCounts = {};
|
|
4374
|
+
const disruption = Object.fromEntries(DISRUPTION_LEVELS.map((l) => [l, 0]));
|
|
4375
|
+
const disruptionByComponent = {};
|
|
4376
|
+
let disruptionUncorrelated;
|
|
3841
4377
|
let uncorrelated = 0;
|
|
3842
4378
|
let unobservedUncorrelated = 0;
|
|
3843
4379
|
let runtimeUncorrelated = 0;
|
|
@@ -3863,6 +4399,12 @@ function summarizePlan(plan, byComponent, nonResource) {
|
|
|
3863
4399
|
total++;
|
|
3864
4400
|
if (component) counts[component] = (counts[component] ?? 0) + 1;
|
|
3865
4401
|
else uncorrelated++;
|
|
4402
|
+
const level = entry.disruption;
|
|
4403
|
+
if (!level) continue;
|
|
4404
|
+
if (!DISRUPTION_LEVELS.includes(level)) continue;
|
|
4405
|
+
disruption[level]++;
|
|
4406
|
+
if (component) disruptionByComponent[component] = worstDisruption(disruptionByComponent[component], level);
|
|
4407
|
+
else disruptionUncorrelated = worstDisruption(disruptionUncorrelated, level);
|
|
3866
4408
|
}
|
|
3867
4409
|
return {
|
|
3868
4410
|
env: plan.env,
|
|
@@ -3874,7 +4416,10 @@ function summarizePlan(plan, byComponent, nonResource) {
|
|
|
3874
4416
|
unobservedUncorrelated,
|
|
3875
4417
|
runtime,
|
|
3876
4418
|
runtimeByComponent: runtimeCounts,
|
|
3877
|
-
runtimeUncorrelated
|
|
4419
|
+
runtimeUncorrelated,
|
|
4420
|
+
disruption,
|
|
4421
|
+
disruptionByComponent,
|
|
4422
|
+
...disruptionUncorrelated ? { disruptionUncorrelated } : {}
|
|
3878
4423
|
};
|
|
3879
4424
|
}
|
|
3880
4425
|
|
|
@@ -3890,10 +4435,10 @@ import {
|
|
|
3890
4435
|
|
|
3891
4436
|
// src/icon-packs.ts
|
|
3892
4437
|
import { readFileSync as readFileSync6 } from "node:fs";
|
|
3893
|
-
import { dirname as dirname3, join as
|
|
4438
|
+
import { dirname as dirname3, join as join9 } from "node:path";
|
|
3894
4439
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
3895
|
-
var ICON_ROOT =
|
|
3896
|
-
var
|
|
4440
|
+
var ICON_ROOT = join9(dirname3(fileURLToPath2(import.meta.url)), "..", "web", "icons");
|
|
4441
|
+
var cache2 = /* @__PURE__ */ new Map();
|
|
3897
4442
|
function unprefix(body) {
|
|
3898
4443
|
return body.replace(/\s(?:inkscape|sodipodi):[\w-]+\s*=\s*(["'])[\s\S]*?\1/g, "").replace(/\sxlink:href\s*=/g, " href=");
|
|
3899
4444
|
}
|
|
@@ -3912,9 +4457,9 @@ function plate(glyph) {
|
|
|
3912
4457
|
return { ...glyph, body: `${ground}<g transform="${inset}">${glyph.body}</g>` };
|
|
3913
4458
|
}
|
|
3914
4459
|
function loadIcon(rel) {
|
|
3915
|
-
const hit =
|
|
4460
|
+
const hit = cache2.get(rel);
|
|
3916
4461
|
if (hit) return hit;
|
|
3917
|
-
const raw = readFileSync6(
|
|
4462
|
+
const raw = readFileSync6(join9(ICON_ROOT, `${rel}.svg`), "utf8");
|
|
3918
4463
|
const open = raw.indexOf("<svg");
|
|
3919
4464
|
const openEnd = raw.indexOf(">", open);
|
|
3920
4465
|
const close = raw.lastIndexOf("</svg");
|
|
@@ -3923,7 +4468,7 @@ function loadIcon(rel) {
|
|
|
3923
4468
|
if (!viewBox) throw new Error(`vendored icon has no viewBox: ${rel}.svg`);
|
|
3924
4469
|
const bare = { body: unprefix(raw.slice(openEnd + 1, close).trim()), colored: true, viewBox };
|
|
3925
4470
|
const glyph = PLATED.has(rel) ? plate(bare) : bare;
|
|
3926
|
-
|
|
4471
|
+
cache2.set(rel, glyph);
|
|
3927
4472
|
return glyph;
|
|
3928
4473
|
}
|
|
3929
4474
|
var K8S_ICON_BY_KIND = {
|
|
@@ -4110,68 +4655,704 @@ function carveReportToIr(report) {
|
|
|
4110
4655
|
kind: tfTypeOf(r.address, r.kind),
|
|
4111
4656
|
lexicon: "terraform",
|
|
4112
4657
|
attrs: {
|
|
4113
|
-
// The drift palette's channel — `_`-prefixed, so it paints the card and
|
|
4114
|
-
// stays out of both the card's fields and the inspect pane's list.
|
|
4115
|
-
_status: status,
|
|
4116
|
-
score: r.score,
|
|
4117
|
-
carve: verdictForStatus(status),
|
|
4118
|
-
band: r.band,
|
|
4119
|
-
arithmetic: scoreArithmetic(r),
|
|
4120
|
-
boundaryWork: boundaryWorkOf(r),
|
|
4121
|
-
...r.mapsTo ? { mapsTo: r.mapsTo } : {},
|
|
4122
|
-
tier: b.tier === null || b.tier === void 0 ? "none" : b.tier,
|
|
4123
|
-
inbound: b.inbound ?? 0,
|
|
4124
|
-
outbound: b.outbound ?? 0,
|
|
4125
|
-
outputs: b.outputs ?? 0,
|
|
4126
|
-
instances: b.instances ?? 1,
|
|
4127
|
-
dynamic: b.hasDynamic ?? false,
|
|
4128
|
-
// The predicted diff, when the report carries the edge lists: who needs
|
|
4129
|
-
// a `data` source the moment this is carved, and what becomes a
|
|
4130
|
-
// deploy-time input. Absent (not empty) when the report has no lists —
|
|
4131
|
-
// "none" and "not reported" are different claims.
|
|
4132
|
-
...patches.length ? { patchOnCarve: patches.join(", ") } : {},
|
|
4133
|
-
...inputs.length ? { deferredInputs: inputs.join(", ") } : {}
|
|
4658
|
+
// The drift palette's channel — `_`-prefixed, so it paints the card and
|
|
4659
|
+
// stays out of both the card's fields and the inspect pane's list.
|
|
4660
|
+
_status: status,
|
|
4661
|
+
score: r.score,
|
|
4662
|
+
carve: verdictForStatus(status),
|
|
4663
|
+
band: r.band,
|
|
4664
|
+
arithmetic: scoreArithmetic(r),
|
|
4665
|
+
boundaryWork: boundaryWorkOf(r),
|
|
4666
|
+
...r.mapsTo ? { mapsTo: r.mapsTo } : {},
|
|
4667
|
+
tier: b.tier === null || b.tier === void 0 ? "none" : b.tier,
|
|
4668
|
+
inbound: b.inbound ?? 0,
|
|
4669
|
+
outbound: b.outbound ?? 0,
|
|
4670
|
+
outputs: b.outputs ?? 0,
|
|
4671
|
+
instances: b.instances ?? 1,
|
|
4672
|
+
dynamic: b.hasDynamic ?? false,
|
|
4673
|
+
// The predicted diff, when the report carries the edge lists: who needs
|
|
4674
|
+
// a `data` source the moment this is carved, and what becomes a
|
|
4675
|
+
// deploy-time input. Absent (not empty) when the report has no lists —
|
|
4676
|
+
// "none" and "not reported" are different claims.
|
|
4677
|
+
...patches.length ? { patchOnCarve: patches.join(", ") } : {},
|
|
4678
|
+
...inputs.length ? { deferredInputs: inputs.join(", ") } : {}
|
|
4679
|
+
}
|
|
4680
|
+
};
|
|
4681
|
+
});
|
|
4682
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4683
|
+
const edges = [];
|
|
4684
|
+
for (const r of resources) {
|
|
4685
|
+
for (const e of boundaryEdgesOf(r)) {
|
|
4686
|
+
const direction = directionOf(e);
|
|
4687
|
+
const from = direction === "inbound" ? e.survivor : e.carved;
|
|
4688
|
+
const to = direction === "inbound" ? e.carved : e.survivor;
|
|
4689
|
+
if (!known.has(from) || !known.has(to)) continue;
|
|
4690
|
+
const key = `${from}|${to}`;
|
|
4691
|
+
if (seen.has(key)) continue;
|
|
4692
|
+
seen.add(key);
|
|
4693
|
+
edges.push({
|
|
4694
|
+
from,
|
|
4695
|
+
to,
|
|
4696
|
+
kind: "ref",
|
|
4697
|
+
viaAttr: direction,
|
|
4698
|
+
...e.attrs?.length ? { toAttr: e.attrs.join(", ") } : {}
|
|
4699
|
+
});
|
|
4700
|
+
}
|
|
4701
|
+
}
|
|
4702
|
+
const byStack = {};
|
|
4703
|
+
for (const order of ["good", "warn", "neutral"]) {
|
|
4704
|
+
const members = resources.filter((r) => statusForBand(r.band, r.score) === order).map((r) => r.address);
|
|
4705
|
+
if (members.length) byStack[verdictForStatus(order)] = members;
|
|
4706
|
+
}
|
|
4707
|
+
return { nodes, edges, groups: { byStack } };
|
|
4708
|
+
}
|
|
4709
|
+
function carveNote(report, ir) {
|
|
4710
|
+
const counts = Object.entries(report.bands ?? {}).filter(([, n]) => n > 0).map(([band, n]) => `${n} ${band}`).join(" \xB7 ");
|
|
4711
|
+
const head = `chant carve advisory${report.from ? ` for ${report.from}` : ""}${counts ? ` \u2014 ${counts}` : ""}`;
|
|
4712
|
+
if (ir.edges.length) return `${head}. Read-only: nothing is emitted, patched, or applied.`;
|
|
4713
|
+
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.`;
|
|
4714
|
+
}
|
|
4715
|
+
|
|
4716
|
+
// src/carve-manifest.ts
|
|
4717
|
+
import { readdirSync as readdirSync4, readFileSync as readFileSync7, statSync as statSync4 } from "node:fs";
|
|
4718
|
+
import { dirname as dirname4, join as join10 } from "node:path";
|
|
4719
|
+
var CARVE_MANIFEST_SUFFIX = ".carve.json";
|
|
4720
|
+
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.";
|
|
4721
|
+
var isRecord2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
4722
|
+
var strings = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
|
|
4723
|
+
function parseCarveManifest(value) {
|
|
4724
|
+
if (!isRecord2(value)) return null;
|
|
4725
|
+
if (value.version !== 1) return null;
|
|
4726
|
+
if (typeof value.target !== "string" || !value.target) return null;
|
|
4727
|
+
return value;
|
|
4728
|
+
}
|
|
4729
|
+
function readCarveManifest(path, readFile = (p) => readFileSync7(p, "utf8")) {
|
|
4730
|
+
let text;
|
|
4731
|
+
try {
|
|
4732
|
+
text = readFile(path);
|
|
4733
|
+
} catch {
|
|
4734
|
+
return null;
|
|
4735
|
+
}
|
|
4736
|
+
try {
|
|
4737
|
+
return parseCarveManifest(JSON.parse(text));
|
|
4738
|
+
} catch {
|
|
4739
|
+
return null;
|
|
4740
|
+
}
|
|
4741
|
+
}
|
|
4742
|
+
function carveStageOf(m) {
|
|
4743
|
+
if (m.apply) return "applied";
|
|
4744
|
+
if (m.bridge) return "bridged";
|
|
4745
|
+
return "emitted";
|
|
4746
|
+
}
|
|
4747
|
+
function carveWordFor(stage) {
|
|
4748
|
+
switch (stage) {
|
|
4749
|
+
case "applied":
|
|
4750
|
+
return "carved \u2192 chant";
|
|
4751
|
+
case "bridged":
|
|
4752
|
+
return "bridged \u2014 apply is yours";
|
|
4753
|
+
default:
|
|
4754
|
+
return "emitted \u2014 not bridged";
|
|
4755
|
+
}
|
|
4756
|
+
}
|
|
4757
|
+
function carveToneFor(stage) {
|
|
4758
|
+
return stage === "applied" ? "good" : "accent";
|
|
4759
|
+
}
|
|
4760
|
+
function carveStageNote(state) {
|
|
4761
|
+
switch (state.stage) {
|
|
4762
|
+
case "applied":
|
|
4763
|
+
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.";
|
|
4764
|
+
case "bridged":
|
|
4765
|
+
return "emitted and bridged \u2014 the survivor patch is proposed. Terraform still owns the resource until `carve apply`.";
|
|
4766
|
+
default:
|
|
4767
|
+
return "emitted \u2014 typed chant source exists, and the surviving Terraform still reads the resource directly. `carve bridge` is next.";
|
|
4768
|
+
}
|
|
4769
|
+
}
|
|
4770
|
+
function carveStateOf(m, path) {
|
|
4771
|
+
const stage = carveStageOf(m);
|
|
4772
|
+
const deferred = Object.entries(m.emit?.params ?? {}).map(([name, p]) => `${name} \u2190 ${p?.survivor ?? "a survivor"}`);
|
|
4773
|
+
return {
|
|
4774
|
+
target: m.target,
|
|
4775
|
+
...m.tfType ? { tfType: m.tfType } : {},
|
|
4776
|
+
stage,
|
|
4777
|
+
graduated: stage === "applied",
|
|
4778
|
+
// The last stamped step wins — that is the age of the state, not the age
|
|
4779
|
+
// of the carve.
|
|
4780
|
+
...m.apply?.at || m.bridge?.at || m.emit?.at ? { at: m.apply?.at ?? m.bridge?.at ?? m.emit?.at } : {},
|
|
4781
|
+
files: strings(m.emit?.files),
|
|
4782
|
+
excised: strings(m.bridge?.excised),
|
|
4783
|
+
deferredInputs: deferred,
|
|
4784
|
+
...m.apply ? {
|
|
4785
|
+
ownership: {
|
|
4786
|
+
...m.apply.marker?.stack ? { stack: m.apply.marker.stack } : {},
|
|
4787
|
+
...m.apply.marker?.env ? { env: m.apply.marker.env } : {},
|
|
4788
|
+
...m.apply.ownershipTags ? { tags: m.apply.ownershipTags } : {}
|
|
4789
|
+
}
|
|
4790
|
+
} : {},
|
|
4791
|
+
...m.from ? { from: m.from } : {},
|
|
4792
|
+
outDir: dirname4(path),
|
|
4793
|
+
path
|
|
4794
|
+
};
|
|
4795
|
+
}
|
|
4796
|
+
var nodeCarveIo = {
|
|
4797
|
+
readdir: (dir) => readdirSync4(dir),
|
|
4798
|
+
isDirectory: (p) => {
|
|
4799
|
+
try {
|
|
4800
|
+
return statSync4(p).isDirectory();
|
|
4801
|
+
} catch {
|
|
4802
|
+
return false;
|
|
4803
|
+
}
|
|
4804
|
+
},
|
|
4805
|
+
readFile: (p) => readFileSync7(p, "utf8")
|
|
4806
|
+
};
|
|
4807
|
+
var MANIFEST_SCAN_DEPTH = 2;
|
|
4808
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", "cdk.out", ".terraform", ".git"]);
|
|
4809
|
+
function listCarveManifests(dir, io = nodeCarveIo, depth = MANIFEST_SCAN_DEPTH) {
|
|
4810
|
+
const out = [];
|
|
4811
|
+
const walk = (d, level) => {
|
|
4812
|
+
let entries;
|
|
4813
|
+
try {
|
|
4814
|
+
entries = io.readdir(d).sort();
|
|
4815
|
+
} catch {
|
|
4816
|
+
return;
|
|
4817
|
+
}
|
|
4818
|
+
for (const name of entries) {
|
|
4819
|
+
const full = join10(d, name);
|
|
4820
|
+
if (name.endsWith(CARVE_MANIFEST_SUFFIX)) {
|
|
4821
|
+
out.push(full);
|
|
4822
|
+
continue;
|
|
4823
|
+
}
|
|
4824
|
+
if (level >= depth) continue;
|
|
4825
|
+
if (name.startsWith(".") || SKIP_DIRS.has(name)) continue;
|
|
4826
|
+
if (io.isDirectory(full)) walk(full, level + 1);
|
|
4827
|
+
}
|
|
4828
|
+
};
|
|
4829
|
+
walk(dir, 0);
|
|
4830
|
+
return out;
|
|
4831
|
+
}
|
|
4832
|
+
function readCarveStates(dirs, io = nodeCarveIo, depth = MANIFEST_SCAN_DEPTH) {
|
|
4833
|
+
const rank = { emitted: 0, bridged: 1, applied: 2 };
|
|
4834
|
+
const byTarget = /* @__PURE__ */ new Map();
|
|
4835
|
+
for (const dir of dirs) {
|
|
4836
|
+
for (const path of listCarveManifests(dir, io, depth)) {
|
|
4837
|
+
const manifest = readCarveManifest(path, io.readFile);
|
|
4838
|
+
if (!manifest) continue;
|
|
4839
|
+
const state = carveStateOf(manifest, path);
|
|
4840
|
+
const held = byTarget.get(state.target);
|
|
4841
|
+
if (!held || rank[state.stage] > rank[held.stage]) byTarget.set(state.target, state);
|
|
4842
|
+
}
|
|
4843
|
+
}
|
|
4844
|
+
return byTarget;
|
|
4845
|
+
}
|
|
4846
|
+
function carveProgress(states, total) {
|
|
4847
|
+
let applied = 0;
|
|
4848
|
+
let bridged = 0;
|
|
4849
|
+
let emitted = 0;
|
|
4850
|
+
for (const s of states) {
|
|
4851
|
+
if (s.stage === "applied") applied++;
|
|
4852
|
+
else if (s.stage === "bridged") bridged++;
|
|
4853
|
+
else emitted++;
|
|
4854
|
+
}
|
|
4855
|
+
const inFlight = bridged + emitted;
|
|
4856
|
+
const parts = [];
|
|
4857
|
+
if (emitted) parts.push(`${emitted} emitted`);
|
|
4858
|
+
if (bridged) parts.push(`${bridged} bridged`);
|
|
4859
|
+
return {
|
|
4860
|
+
applied,
|
|
4861
|
+
bridged,
|
|
4862
|
+
emitted,
|
|
4863
|
+
inFlight,
|
|
4864
|
+
total: typeof total === "number" ? total : null,
|
|
4865
|
+
label: typeof total === "number" ? `${applied} of ${total} carved` : `${applied} carved`,
|
|
4866
|
+
detail: parts.length ? `${parts.join(", ")} \u2014 apply is yours` : ""
|
|
4867
|
+
};
|
|
4868
|
+
}
|
|
4869
|
+
function applyCommandFor(state, labels = {}) {
|
|
4870
|
+
const env = state.ownership?.env ?? "<env>";
|
|
4871
|
+
const stack = state.ownership?.stack ?? "<stack>";
|
|
4872
|
+
const from = labels.from ?? state.from ?? "<terraform-dir>";
|
|
4873
|
+
const out = labels.out ?? state.outDir;
|
|
4874
|
+
return `chant carve apply --from ${from} --output ${out} --env ${env} --stack ${stack} --write-source`;
|
|
4875
|
+
}
|
|
4876
|
+
function carveStatePayload(states, total, labels = () => ({})) {
|
|
4877
|
+
const list3 = [...states.values()].sort((a, b) => a.target.localeCompare(b.target));
|
|
4878
|
+
return {
|
|
4879
|
+
manifests: list3.length,
|
|
4880
|
+
progress: carveProgress(list3, total),
|
|
4881
|
+
states: list3.map((s) => ({ ...s, note: carveStageNote(s), applyCommand: applyCommandFor(s, labels(s)) })),
|
|
4882
|
+
apply: { human: true, note: APPLY_IS_HUMAN }
|
|
4883
|
+
};
|
|
4884
|
+
}
|
|
4885
|
+
function splitCarveState(tfIr, states, opts = {}) {
|
|
4886
|
+
if (states.size === 0) return { tf: tfIr, graduated: [] };
|
|
4887
|
+
const shorten = opts.shorten ?? ((p) => p);
|
|
4888
|
+
const source = (files) => files.map(shorten).join(", ");
|
|
4889
|
+
const gone = /* @__PURE__ */ new Set();
|
|
4890
|
+
const graduated = [];
|
|
4891
|
+
const nodes = [];
|
|
4892
|
+
for (const node of tfIr.nodes) {
|
|
4893
|
+
const state = states.get(node.id);
|
|
4894
|
+
if (!state) {
|
|
4895
|
+
nodes.push(node);
|
|
4896
|
+
continue;
|
|
4897
|
+
}
|
|
4898
|
+
if (state.graduated) {
|
|
4899
|
+
gone.add(node.id);
|
|
4900
|
+
graduated.push({
|
|
4901
|
+
...node,
|
|
4902
|
+
attrs: {
|
|
4903
|
+
...node.attrs,
|
|
4904
|
+
_status: "good",
|
|
4905
|
+
carve: carveWordFor(state.stage),
|
|
4906
|
+
carveState: state.stage,
|
|
4907
|
+
...state.ownership?.stack ? { ownedStack: state.ownership.stack } : {},
|
|
4908
|
+
...state.ownership?.env ? { ownedEnv: state.ownership.env } : {},
|
|
4909
|
+
...state.at ? { carvedAt: state.at } : {},
|
|
4910
|
+
...state.files.length ? { chantSource: source(state.files) } : {}
|
|
4911
|
+
}
|
|
4912
|
+
});
|
|
4913
|
+
continue;
|
|
4914
|
+
}
|
|
4915
|
+
nodes.push({
|
|
4916
|
+
...node,
|
|
4917
|
+
attrs: {
|
|
4918
|
+
...node.attrs,
|
|
4919
|
+
_status: carveToneFor(state.stage),
|
|
4920
|
+
carve: carveWordFor(state.stage),
|
|
4921
|
+
carveState: state.stage,
|
|
4922
|
+
...state.at ? { carvedAt: state.at } : {},
|
|
4923
|
+
...state.files.length ? { chantSource: source(state.files) } : {},
|
|
4924
|
+
...state.excised.length ? { excisesOnApply: state.excised.join(", ") } : {},
|
|
4925
|
+
...state.deferredInputs.length ? { deferredInputs: state.deferredInputs.join(", ") } : {}
|
|
4926
|
+
}
|
|
4927
|
+
});
|
|
4928
|
+
}
|
|
4929
|
+
const byStack = {};
|
|
4930
|
+
for (const [band, members] of Object.entries(tfIr.groups?.byStack ?? {})) {
|
|
4931
|
+
const left = members.filter((id) => !gone.has(id));
|
|
4932
|
+
if (left.length) byStack[band] = left;
|
|
4933
|
+
}
|
|
4934
|
+
return {
|
|
4935
|
+
tf: {
|
|
4936
|
+
nodes,
|
|
4937
|
+
edges: tfIr.edges.filter((e) => !gone.has(e.from) && !gone.has(e.to)),
|
|
4938
|
+
groups: { ...tfIr.groups, byStack }
|
|
4939
|
+
},
|
|
4940
|
+
graduated
|
|
4941
|
+
};
|
|
4942
|
+
}
|
|
4943
|
+
var GRADUATED_BAND = "carved \u2192 chant";
|
|
4944
|
+
function bandGraduated(tf, graduated) {
|
|
4945
|
+
if (graduated.length === 0) return tf;
|
|
4946
|
+
return {
|
|
4947
|
+
nodes: [...graduated, ...tf.nodes],
|
|
4948
|
+
edges: tf.edges,
|
|
4949
|
+
groups: {
|
|
4950
|
+
...tf.groups,
|
|
4951
|
+
byStack: {
|
|
4952
|
+
[GRADUATED_BAND]: graduated.map((n) => n.id),
|
|
4953
|
+
...tf.groups?.byStack ?? {}
|
|
4954
|
+
}
|
|
4955
|
+
}
|
|
4956
|
+
};
|
|
4957
|
+
}
|
|
4958
|
+
function carveStateNote(progress, states) {
|
|
4959
|
+
if (states.size === 0) return "";
|
|
4960
|
+
const head = ` Carve state (from ${states.size} manifest${states.size === 1 ? "" : "s"}): ${progress.label}`;
|
|
4961
|
+
return progress.detail ? `${head}, ${progress.detail}.` : `${head}.`;
|
|
4962
|
+
}
|
|
4963
|
+
|
|
4964
|
+
// src/ops-lens.ts
|
|
4965
|
+
var SUPPORTED_MAJOR2 = 1;
|
|
4966
|
+
var refuse2 = (error, remedy) => ({ ok: false, refusal: { error, code: "op-ir", remedy } });
|
|
4967
|
+
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.";
|
|
4968
|
+
var isRecord3 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
4969
|
+
function parseOpIr(value) {
|
|
4970
|
+
if (!isRecord3(value)) {
|
|
4971
|
+
return refuse2("That file isn't an op.json \u2014 its top level isn't a JSON object.", HOW_TO_EMIT);
|
|
4972
|
+
}
|
|
4973
|
+
if (typeof value.name !== "string" || !Array.isArray(value.phases)) {
|
|
4974
|
+
return refuse2(
|
|
4975
|
+
"That JSON doesn't look like an op.json \u2014 it needs a string `name` and a `phases` array.",
|
|
4976
|
+
HOW_TO_EMIT
|
|
4977
|
+
);
|
|
4978
|
+
}
|
|
4979
|
+
const bad = value.phases.findIndex((p) => !isRecord3(p) || typeof p.name !== "string" || !Array.isArray(p.steps));
|
|
4980
|
+
if (bad >= 0) {
|
|
4981
|
+
return refuse2(
|
|
4982
|
+
`That JSON has a \`phases\` array, but entry ${bad} isn't a phase (needs a string \`name\` and a \`steps\` array).`,
|
|
4983
|
+
HOW_TO_EMIT
|
|
4984
|
+
);
|
|
4985
|
+
}
|
|
4986
|
+
const version = value.formatVersion;
|
|
4987
|
+
if (version !== void 0) {
|
|
4988
|
+
const major = Number(String(version).split(".")[0]);
|
|
4989
|
+
if (!Number.isFinite(major) || major !== SUPPORTED_MAJOR2) {
|
|
4990
|
+
return refuse2(
|
|
4991
|
+
`This op.json declares format version ${String(version)}; behold reads version ${SUPPORTED_MAJOR2}.`,
|
|
4992
|
+
"Upgrade behold, or render the Op with a behold that speaks its version."
|
|
4993
|
+
);
|
|
4994
|
+
}
|
|
4995
|
+
}
|
|
4996
|
+
return { ok: true, ir: value };
|
|
4997
|
+
}
|
|
4998
|
+
function readOpIr(path, readFile) {
|
|
4999
|
+
let text;
|
|
5000
|
+
try {
|
|
5001
|
+
text = readFile(path);
|
|
5002
|
+
} catch (err) {
|
|
5003
|
+
return refuse2(`Couldn't read ${path}: ${err instanceof Error ? err.message : String(err)}`, HOW_TO_EMIT);
|
|
5004
|
+
}
|
|
5005
|
+
let json;
|
|
5006
|
+
try {
|
|
5007
|
+
json = JSON.parse(text);
|
|
5008
|
+
} catch (err) {
|
|
5009
|
+
return refuse2(`${path} isn't valid JSON: ${err instanceof Error ? err.message : String(err)}`, HOW_TO_EMIT);
|
|
5010
|
+
}
|
|
5011
|
+
return parseOpIr(json);
|
|
5012
|
+
}
|
|
5013
|
+
function receiptName(step) {
|
|
5014
|
+
const r = step.receipt;
|
|
5015
|
+
if (typeof r === "string") return r;
|
|
5016
|
+
return r && typeof r.name === "string" && r.name || "effect";
|
|
5017
|
+
}
|
|
5018
|
+
function stepLabel(step) {
|
|
5019
|
+
if (step.kind === "gate") return `gate:${step.signalName}`;
|
|
5020
|
+
if (step.kind === "effect") return `effect:${receiptName(step)}`;
|
|
5021
|
+
return step.fn;
|
|
5022
|
+
}
|
|
5023
|
+
function stepKind(step) {
|
|
5024
|
+
if (step.kind === "gate") return "Gate";
|
|
5025
|
+
if (step.kind === "effect") return "Effect";
|
|
5026
|
+
return step.fn;
|
|
5027
|
+
}
|
|
5028
|
+
function stepStatus(step) {
|
|
5029
|
+
return step.kind === "gate" ? "warn" : step.kind === "effect" ? "accent" : "neutral";
|
|
5030
|
+
}
|
|
5031
|
+
function argsLine(args) {
|
|
5032
|
+
const entries = Object.entries(args ?? {});
|
|
5033
|
+
if (!entries.length) return void 0;
|
|
5034
|
+
return entries.map(([k, v]) => `${k}=${typeof v === "object" && v !== null ? JSON.stringify(v) : String(v)}`).join(", ");
|
|
5035
|
+
}
|
|
5036
|
+
function profileLine(p) {
|
|
5037
|
+
if (!p) return void 0;
|
|
5038
|
+
const parts = [];
|
|
5039
|
+
if (p.startToCloseTimeout) parts.push(`${p.startToCloseTimeout} start-to-close`);
|
|
5040
|
+
if (p.heartbeatTimeout) parts.push(`${p.heartbeatTimeout} heartbeat`);
|
|
5041
|
+
if (p.retry?.maximumAttempts) parts.push(`${p.retry.maximumAttempts} attempts`);
|
|
5042
|
+
if (p.retry?.initialInterval) parts.push(`from ${p.retry.initialInterval}`);
|
|
5043
|
+
return parts.length ? parts.join(", ") : void 0;
|
|
5044
|
+
}
|
|
5045
|
+
function placeSteps(op) {
|
|
5046
|
+
const out = [];
|
|
5047
|
+
const lists = [
|
|
5048
|
+
[op.phases ?? [], "phases"],
|
|
5049
|
+
[op.onFailure ?? [], "onFailure"]
|
|
5050
|
+
];
|
|
5051
|
+
const used = /* @__PURE__ */ new Set();
|
|
5052
|
+
for (const [phases, when] of lists) {
|
|
5053
|
+
for (const phase of phases) {
|
|
5054
|
+
const box = when === "onFailure" ? `${op.name} \xB7 onFailure: ${phase.name}` : `${op.name} \xB7 ${phase.name}`;
|
|
5055
|
+
for (const step of phase.steps ?? []) {
|
|
5056
|
+
let id = `${op.name}/${phase.name}/${stepLabel(step)}`;
|
|
5057
|
+
for (let n = 2; used.has(id); n += 1) id = `${op.name}/${phase.name}/${stepLabel(step)}#${n}`;
|
|
5058
|
+
used.add(id);
|
|
5059
|
+
out.push({ id, step, phase, when, box });
|
|
5060
|
+
}
|
|
5061
|
+
}
|
|
5062
|
+
}
|
|
5063
|
+
return out;
|
|
5064
|
+
}
|
|
5065
|
+
function opPlacedSteps(op) {
|
|
5066
|
+
return placeSteps(op).map((p) => ({
|
|
5067
|
+
id: p.id,
|
|
5068
|
+
step: p.step,
|
|
5069
|
+
phase: p.phase.name,
|
|
5070
|
+
parallel: Boolean(p.phase.parallel),
|
|
5071
|
+
when: p.when
|
|
5072
|
+
}));
|
|
5073
|
+
}
|
|
5074
|
+
var RULE_BOX = (opName) => `${opName} \xB7 rule table`;
|
|
5075
|
+
var ruleId = (opName, rule) => `${opName}/rules/${rule.id}`;
|
|
5076
|
+
var isPredicate = (v) => isRecord3(v) && typeof v.kind === "string";
|
|
5077
|
+
function isConvergeRule(v) {
|
|
5078
|
+
if (!isRecord3(v)) return false;
|
|
5079
|
+
if (typeof v.id !== "string" || !v.id) return false;
|
|
5080
|
+
if (typeof v.why !== "string" || !v.why) return false;
|
|
5081
|
+
if (!isPredicate(v.when)) return false;
|
|
5082
|
+
const then = v.then;
|
|
5083
|
+
if (!isRecord3(then)) return false;
|
|
5084
|
+
return then.kind === "run" && typeof then.op === "string" || then.kind === "report" && typeof then.reason === "string";
|
|
5085
|
+
}
|
|
5086
|
+
function convergeTable(op) {
|
|
5087
|
+
for (const placed of placeSteps(op)) {
|
|
5088
|
+
const { step } = placed;
|
|
5089
|
+
if (step.kind !== "activity" || step.fn !== "convergeTick") continue;
|
|
5090
|
+
const rules = step.args?.rules;
|
|
5091
|
+
if (!Array.isArray(rules)) return void 0;
|
|
5092
|
+
const kept = rules.filter(isConvergeRule);
|
|
5093
|
+
if (!kept.length) return void 0;
|
|
5094
|
+
return {
|
|
5095
|
+
tickStepId: placed.id,
|
|
5096
|
+
phase: placed.phase.name,
|
|
5097
|
+
rules: kept,
|
|
5098
|
+
...typeof step.args?.dial === "string" ? { dial: step.args.dial } : {},
|
|
5099
|
+
...typeof step.args?.budget === "number" ? { budget: step.args.budget } : {},
|
|
5100
|
+
...typeof step.args?.env === "string" ? { env: step.args.env } : {}
|
|
5101
|
+
};
|
|
5102
|
+
}
|
|
5103
|
+
return void 0;
|
|
5104
|
+
}
|
|
5105
|
+
var COMPARISON_TEXT = {
|
|
5106
|
+
eq: "=",
|
|
5107
|
+
neq: "!=",
|
|
5108
|
+
gt: ">",
|
|
5109
|
+
gte: ">=",
|
|
5110
|
+
lt: "<",
|
|
5111
|
+
lte: "<="
|
|
5112
|
+
};
|
|
5113
|
+
function predicateText(predicate) {
|
|
5114
|
+
switch (predicate.kind) {
|
|
5115
|
+
case "field-comparison": {
|
|
5116
|
+
const op = COMPARISON_TEXT[predicate.op];
|
|
5117
|
+
if (!op) return `(unreadable comparison on ${predicate.field})`;
|
|
5118
|
+
const value = typeof predicate.value === "string" ? JSON.stringify(predicate.value) : String(predicate.value);
|
|
5119
|
+
return `${predicate.field} ${op} ${value}`;
|
|
5120
|
+
}
|
|
5121
|
+
case "field-truthiness":
|
|
5122
|
+
return `${predicate.field} is ${predicate.op}`;
|
|
5123
|
+
case "all-of":
|
|
5124
|
+
case "any-of": {
|
|
5125
|
+
const joiner = predicate.kind === "all-of" ? " and " : " or ";
|
|
5126
|
+
const parts = (predicate.predicates ?? []).filter(isPredicate);
|
|
5127
|
+
if (!parts.length) return predicate.kind === "all-of" ? "(always)" : "(never)";
|
|
5128
|
+
return parts.map((p) => p.kind === "all-of" || p.kind === "any-of" ? `(${predicateText(p)})` : predicateText(p)).join(joiner);
|
|
5129
|
+
}
|
|
5130
|
+
default:
|
|
5131
|
+
return "(predicate outside the kinds behold reads)";
|
|
5132
|
+
}
|
|
5133
|
+
}
|
|
5134
|
+
function actionText(action) {
|
|
5135
|
+
return action.kind === "run" ? `run(${action.op})` : `report: ${action.reason}`;
|
|
5136
|
+
}
|
|
5137
|
+
function ruleStatus(rule, declared) {
|
|
5138
|
+
if (rule.then.kind !== "run") return "neutral";
|
|
5139
|
+
return declared.has(rule.then.op) ? "accent" : "warn";
|
|
5140
|
+
}
|
|
5141
|
+
function ruleNode(op, table, rule, declared) {
|
|
5142
|
+
const dispatches = rule.then.kind === "run" && declared.has(rule.then.op) ? rule.then.op : void 0;
|
|
5143
|
+
const dangling = rule.then.kind === "run" && !declared.has(rule.then.op) ? rule.then.op : void 0;
|
|
5144
|
+
return {
|
|
5145
|
+
id: ruleId(op.name, rule),
|
|
5146
|
+
// The card's kind, as an activity's is its `fn`: a converge rule is what
|
|
5147
|
+
// this is, and the whole table reads as one kind of thing in the track.
|
|
5148
|
+
kind: "ConvergeRule",
|
|
5149
|
+
lexicon: "op",
|
|
5150
|
+
attrs: {
|
|
5151
|
+
_status: ruleStatus(rule, declared),
|
|
5152
|
+
_step: "rule",
|
|
5153
|
+
rule: rule.id,
|
|
5154
|
+
op: op.name,
|
|
5155
|
+
phase: table.phase,
|
|
5156
|
+
// The condition, the action, and the rationale — the three columns a rule
|
|
5157
|
+
// table has. `why` is chant's required field, verbatim: the accessible-ops
|
|
5158
|
+
// promise is that the answer to "why did it do that" is already written
|
|
5159
|
+
// down, so behold's job is to show it, not to summarise it.
|
|
5160
|
+
when: predicateText(rule.when),
|
|
5161
|
+
then: actionText(rule.then),
|
|
5162
|
+
why: rule.why,
|
|
5163
|
+
...dispatches ? { dispatches } : {},
|
|
5164
|
+
// A `then` naming an Op that isn't in this set: stated, not guessed at.
|
|
5165
|
+
// chant's TMP014 refuses a run() naming an Op the PROJECT doesn't declare,
|
|
5166
|
+
// so this is behold's own composition boundary showing through, and saying
|
|
5167
|
+
// which one it is beats an edge into nowhere.
|
|
5168
|
+
...dangling ? { dangling: `${dangling} \u2014 not among the Ops rendered here, so no edge is drawn` } : {},
|
|
5169
|
+
// `flapThreshold` absent means chant's own default applies. Named the way
|
|
5170
|
+
// a gate's unset timeout is, rather than restating a number chant owns.
|
|
5171
|
+
flap: rule.flapThreshold !== void 0 ? `${rule.flapThreshold} consecutive ticks` : "(chant default)",
|
|
5172
|
+
...table.dial ? { dial: table.dial } : {}
|
|
5173
|
+
}
|
|
5174
|
+
};
|
|
5175
|
+
}
|
|
5176
|
+
function nodeFor(op, placed, guards) {
|
|
5177
|
+
const { step, phase, when } = placed;
|
|
5178
|
+
const common = {
|
|
5179
|
+
op: op.name,
|
|
5180
|
+
phase: phase.name,
|
|
5181
|
+
...phase.parallel ? { parallel: true } : {},
|
|
5182
|
+
...when === "onFailure" ? { when: "onFailure" } : {}
|
|
5183
|
+
};
|
|
5184
|
+
if (step.kind === "gate") {
|
|
5185
|
+
return {
|
|
5186
|
+
id: placed.id,
|
|
5187
|
+
kind: stepKind(step),
|
|
5188
|
+
lexicon: "op",
|
|
5189
|
+
attrs: {
|
|
5190
|
+
_status: stepStatus(step),
|
|
5191
|
+
_step: "gate",
|
|
5192
|
+
gate: step.signalName,
|
|
5193
|
+
timeout: step.timeout ?? "(chant default)",
|
|
5194
|
+
...common,
|
|
5195
|
+
...step.description ? { description: step.description } : {},
|
|
5196
|
+
// "gates drawn as their own marks on the step they guard" (#284): the
|
|
5197
|
+
// gate names what it holds back, so the pause reads from the gate card
|
|
5198
|
+
// without hunting for the next one in the track.
|
|
5199
|
+
...guards ? { guards } : {}
|
|
5200
|
+
}
|
|
5201
|
+
};
|
|
5202
|
+
}
|
|
5203
|
+
if (step.kind === "effect") {
|
|
5204
|
+
return {
|
|
5205
|
+
id: placed.id,
|
|
5206
|
+
kind: stepKind(step),
|
|
5207
|
+
lexicon: "op",
|
|
5208
|
+
attrs: {
|
|
5209
|
+
_status: stepStatus(step),
|
|
5210
|
+
_step: "effect",
|
|
5211
|
+
effect: receiptName(step),
|
|
5212
|
+
...common,
|
|
5213
|
+
...step.expectation !== void 0 ? { expectation: String(step.expectation) } : {},
|
|
5214
|
+
...step.description ? { description: step.description } : {},
|
|
5215
|
+
// The nested steps, named but not drawn — see OpIrEffectStep's doc.
|
|
5216
|
+
...step.steps?.length ? { runs: step.steps.map(stepLabel).join(" \u2192 ") } : {}
|
|
4134
5217
|
}
|
|
4135
5218
|
};
|
|
4136
|
-
}
|
|
4137
|
-
const
|
|
4138
|
-
const
|
|
4139
|
-
|
|
4140
|
-
|
|
4141
|
-
|
|
4142
|
-
|
|
4143
|
-
|
|
4144
|
-
|
|
4145
|
-
|
|
4146
|
-
|
|
4147
|
-
|
|
4148
|
-
|
|
4149
|
-
|
|
4150
|
-
|
|
4151
|
-
|
|
4152
|
-
|
|
4153
|
-
|
|
4154
|
-
}
|
|
5219
|
+
}
|
|
5220
|
+
const profile = step.profile;
|
|
5221
|
+
const policy = profileLine(profile ? op.activityProfiles?.[profile] : void 0);
|
|
5222
|
+
const args = argsLine(step.args);
|
|
5223
|
+
return {
|
|
5224
|
+
id: placed.id,
|
|
5225
|
+
kind: stepKind(step),
|
|
5226
|
+
lexicon: "op",
|
|
5227
|
+
attrs: {
|
|
5228
|
+
_status: stepStatus(step),
|
|
5229
|
+
_step: "activity",
|
|
5230
|
+
fn: step.fn,
|
|
5231
|
+
...common,
|
|
5232
|
+
...profile ? { profile } : {},
|
|
5233
|
+
...policy ? { policy } : {},
|
|
5234
|
+
// The resolved args, verbatim from the IR. They carry the Op's SCOPE
|
|
5235
|
+
// (env, stack, template path) and never an estate entity id — see the
|
|
5236
|
+
// module doc on why no edge is drawn from them.
|
|
5237
|
+
...args ? { args } : {},
|
|
5238
|
+
...step.outcomeAttribute ? { outcome: step.outcomeAttribute.from ? `${step.outcomeAttribute.name} \u2190 ${step.outcomeAttribute.from}` : step.outcomeAttribute.name } : {},
|
|
5239
|
+
...op.activityContracts?.[step.fn] ? { contract: "declared (args/returns JSON Schema in op.json)" } : {}
|
|
4155
5240
|
}
|
|
5241
|
+
};
|
|
5242
|
+
}
|
|
5243
|
+
function chainPhases(placed, when) {
|
|
5244
|
+
const byPhase = [];
|
|
5245
|
+
for (const p of placed) {
|
|
5246
|
+
if (p.when !== when) continue;
|
|
5247
|
+
const last = byPhase[byPhase.length - 1];
|
|
5248
|
+
if (last && last.phase === p.phase) last.ids.push(p.id);
|
|
5249
|
+
else byPhase.push({ phase: p.phase, ids: [p.id] });
|
|
4156
5250
|
}
|
|
5251
|
+
return byPhase.map(
|
|
5252
|
+
({ phase, ids }) => phase.parallel ? { heads: ids, tails: ids } : { heads: ids.slice(0, 1), tails: ids.slice(-1) }
|
|
5253
|
+
);
|
|
5254
|
+
}
|
|
5255
|
+
function opsToIr(ops) {
|
|
5256
|
+
const nodes = [];
|
|
5257
|
+
const edges = [];
|
|
4157
5258
|
const byStack = {};
|
|
4158
|
-
|
|
4159
|
-
|
|
4160
|
-
|
|
5259
|
+
const endpoints = /* @__PURE__ */ new Map();
|
|
5260
|
+
const declared = new Set(ops.map((o) => o.name));
|
|
5261
|
+
const dispatches = [];
|
|
5262
|
+
for (const op of ops) {
|
|
5263
|
+
const placed = placeSteps(op);
|
|
5264
|
+
for (let i = 0; i < placed.length; i += 1) {
|
|
5265
|
+
const p = placed[i];
|
|
5266
|
+
const next = placed[i + 1];
|
|
5267
|
+
const guards = p.step.kind === "gate" && next && next.when === p.when ? stepLabel(next.step) : void 0;
|
|
5268
|
+
nodes.push(nodeFor(op, p, guards));
|
|
5269
|
+
(byStack[p.box] ??= []).push(p.id);
|
|
5270
|
+
}
|
|
5271
|
+
for (const when of ["phases", "onFailure"]) {
|
|
5272
|
+
const track = chainPhases(placed, when);
|
|
5273
|
+
for (let i = 0; i + 1 < track.length; i += 1) {
|
|
5274
|
+
for (const from of track[i].tails) {
|
|
5275
|
+
for (const to of track[i + 1].heads) edges.push({ from, to, kind: "ref", viaAttr: "then" });
|
|
5276
|
+
}
|
|
5277
|
+
}
|
|
5278
|
+
if (when === "phases" && track.length) {
|
|
5279
|
+
endpoints.set(op.name, { first: track[0].heads, last: track[track.length - 1].tails });
|
|
5280
|
+
}
|
|
5281
|
+
}
|
|
5282
|
+
const table = convergeTable(op);
|
|
5283
|
+
if (!table) continue;
|
|
5284
|
+
for (const rule of table.rules) {
|
|
5285
|
+
const node = ruleNode(op, table, rule, declared);
|
|
5286
|
+
nodes.push(node);
|
|
5287
|
+
(byStack[RULE_BOX(op.name)] ??= []).push(node.id);
|
|
5288
|
+
edges.push({ from: table.tickStepId, to: node.id, kind: "ref", viaAttr: "rule" });
|
|
5289
|
+
if (rule.then.kind === "run") dispatches.push({ from: node.id, op: rule.then.op });
|
|
5290
|
+
}
|
|
5291
|
+
}
|
|
5292
|
+
for (const op of ops) {
|
|
5293
|
+
const mine = endpoints.get(op.name);
|
|
5294
|
+
if (!mine) continue;
|
|
5295
|
+
for (const dep of op.depends ?? []) {
|
|
5296
|
+
const theirs = endpoints.get(dep);
|
|
5297
|
+
if (!theirs) continue;
|
|
5298
|
+
for (const from of theirs.last) {
|
|
5299
|
+
for (const to of mine.first) edges.push({ from, to, kind: "ref", viaAttr: "depends" });
|
|
5300
|
+
}
|
|
5301
|
+
}
|
|
5302
|
+
}
|
|
5303
|
+
for (const { from, op } of dispatches) {
|
|
5304
|
+
const target = endpoints.get(op);
|
|
5305
|
+
if (!target) continue;
|
|
5306
|
+
for (const to of target.first) edges.push({ from, to, kind: "ref", viaAttr: "run" });
|
|
4161
5307
|
}
|
|
4162
5308
|
return { nodes, edges, groups: { byStack } };
|
|
4163
5309
|
}
|
|
4164
|
-
function
|
|
4165
|
-
const
|
|
4166
|
-
const
|
|
4167
|
-
|
|
4168
|
-
|
|
5310
|
+
function opCardFields(node) {
|
|
5311
|
+
const step = node.attrs._step;
|
|
5312
|
+
const phase = typeof node.attrs.phase === "string" ? node.attrs.phase : void 0;
|
|
5313
|
+
const declared = step === "gate" ? [
|
|
5314
|
+
{ label: "gate", value: String(node.attrs.gate ?? "") },
|
|
5315
|
+
{ label: "timeout", value: String(node.attrs.timeout ?? "") }
|
|
5316
|
+
] : step === "effect" ? [
|
|
5317
|
+
{ label: "effect", value: String(node.attrs.effect ?? "") },
|
|
5318
|
+
...phase ? [{ label: "phase", value: phase }] : []
|
|
5319
|
+
] : step === "activity" ? [
|
|
5320
|
+
...phase ? [{ label: "phase", value: phase }] : [],
|
|
5321
|
+
...typeof node.attrs.profile === "string" ? [{ label: "profile", value: node.attrs.profile }] : []
|
|
5322
|
+
] : (
|
|
5323
|
+
// A converge rule card leads with the two columns that make it a
|
|
5324
|
+
// rule — the condition and what it does about it. `why` is the long
|
|
5325
|
+
// one and belongs in the inspect pane, where it reads in full
|
|
5326
|
+
// rather than being cut to fit a card.
|
|
5327
|
+
step === "rule" ? [
|
|
5328
|
+
{ label: "when", value: String(node.attrs.when ?? "") },
|
|
5329
|
+
{ label: "then", value: String(node.attrs.then ?? "") }
|
|
5330
|
+
] : void 0
|
|
5331
|
+
);
|
|
5332
|
+
if (!declared) return void 0;
|
|
5333
|
+
const run3 = typeof node.attrs.run === "string" ? node.attrs.run : void 0;
|
|
5334
|
+
return run3 ? [{ label: "run", value: run3 }, ...declared.slice(0, 1)] : declared;
|
|
5335
|
+
}
|
|
5336
|
+
function opsNote(ops, ir) {
|
|
5337
|
+
const gates = ir.nodes.filter((n) => n.attrs._step === "gate").length;
|
|
5338
|
+
const rules = ir.nodes.filter((n) => n.attrs._step === "rule");
|
|
5339
|
+
const steps = ir.nodes.length - rules.length;
|
|
5340
|
+
const dangling = rules.filter((n) => n.attrs.dangling !== void 0).length;
|
|
5341
|
+
const counts = [
|
|
5342
|
+
`${ops.length} declared Op${ops.length === 1 ? "" : "s"}`,
|
|
5343
|
+
`${steps} step${steps === 1 ? "" : "s"}`,
|
|
5344
|
+
...gates ? [`${gates} gate${gates === 1 ? "" : "s"}`] : [],
|
|
5345
|
+
...rules.length ? [`${rules.length} converge rule${rules.length === 1 ? "" : "s"}`] : []
|
|
5346
|
+
].join(" \xB7 ");
|
|
5347
|
+
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.` : "") : "";
|
|
5348
|
+
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
5349
|
}
|
|
4170
5350
|
|
|
4171
5351
|
// src/render.ts
|
|
4172
5352
|
registerPack({ lexicon: "k8s", iconFor: k8sIconFor });
|
|
4173
5353
|
registerPack({ lexicon: "helm", iconFor: helmIconFor });
|
|
4174
5354
|
registerPack({ lexicon: "terraform", iconFor: () => void 0, fields: carveCardFields });
|
|
5355
|
+
registerPack({ lexicon: "op", iconFor: () => void 0, fields: opCardFields });
|
|
4175
5356
|
function renderArchitecture(ir, byContainer, opts = {}) {
|
|
4176
5357
|
const spread = Math.min(1.5, ir.edges.length / Math.max(ir.nodes.length, 1));
|
|
4177
5358
|
const layout = layoutArchitecture(ir, byContainer, {
|
|
@@ -4261,7 +5442,8 @@ function bandedPlan(ir, bands) {
|
|
|
4261
5442
|
return { placed, boxes, width: panelW, height: Math.max(1, top - BAND_GAP) };
|
|
4262
5443
|
}
|
|
4263
5444
|
function renderCarveEstate(tfIr, appIr, opts) {
|
|
4264
|
-
const
|
|
5445
|
+
const staged = withCarveState(tfIr, appIr, opts);
|
|
5446
|
+
const comp = composeCarveEstate(staged.tf, staged.appNs, { ...opts, carvedTitle: staged.carvedTitle });
|
|
4265
5447
|
const svg = renderSvg(comp.ir, comp.layout, {
|
|
4266
5448
|
fit: true,
|
|
4267
5449
|
hideTitle: true,
|
|
@@ -4270,6 +5452,17 @@ function renderCarveEstate(tfIr, appIr, opts) {
|
|
|
4270
5452
|
});
|
|
4271
5453
|
return { svg, ir: comp.ir };
|
|
4272
5454
|
}
|
|
5455
|
+
function withCarveState(tfIr, appIr, opts) {
|
|
5456
|
+
const states = opts.carved ?? /* @__PURE__ */ new Map();
|
|
5457
|
+
const { tf, graduated } = splitCarveState(tfIr, states, { ...opts.shorten ? { shorten: opts.shorten } : {} });
|
|
5458
|
+
const ns = namespaceAppIr(appIr, opts.appTitle);
|
|
5459
|
+
const progress = carveProgress(states.values(), tfIr.nodes.length);
|
|
5460
|
+
return {
|
|
5461
|
+
tf,
|
|
5462
|
+
appNs: { ...ns, nodes: [...ns.nodes, ...graduated] },
|
|
5463
|
+
carvedTitle: states.size ? `carved so far \u2014 ${progress.label}` : "carved so far"
|
|
5464
|
+
};
|
|
5465
|
+
}
|
|
4273
5466
|
function namespaceAppIr(appIr, appTitle) {
|
|
4274
5467
|
const prefix = `${appTitle.split(" ")[0]}/`;
|
|
4275
5468
|
const appId = (id) => `${prefix}${id}`;
|
|
@@ -4282,7 +5475,7 @@ function namespaceAppIr(appIr, appTitle) {
|
|
|
4282
5475
|
}
|
|
4283
5476
|
function composeCarveEstate(tfIr, appNs, opts) {
|
|
4284
5477
|
const tfPlan = bandedPlan(tfIr, tfIr.groups.byStack ?? {});
|
|
4285
|
-
const appPlan = bandedPlan(appNs, { "carved so far": appNs.nodes.map((n) => n.id) });
|
|
5478
|
+
const appPlan = bandedPlan(appNs, { [opts.carvedTitle ?? "carved so far"]: appNs.nodes.map((n) => n.id) });
|
|
4286
5479
|
const memberH = (plan) => TITLE + PAD + plan.height + PAD;
|
|
4287
5480
|
const memberW = (plan) => plan.width + PAD * 2;
|
|
4288
5481
|
const appX0 = memberW(tfPlan) + MEMBER_GAP;
|
|
@@ -4321,23 +5514,25 @@ function composeCarveEstate(tfIr, appNs, opts) {
|
|
|
4321
5514
|
};
|
|
4322
5515
|
}
|
|
4323
5516
|
function renderCarveMorph(tfIr, appIr, carved, opts) {
|
|
4324
|
-
const
|
|
4325
|
-
const
|
|
5517
|
+
const staged = withCarveState(tfIr, appIr, opts);
|
|
5518
|
+
const appNs = staged.appNs;
|
|
5519
|
+
const morphOpts = { ...opts, carvedTitle: "carved so far" };
|
|
5520
|
+
const before = composeCarveEstate(staged.tf, appNs, morphOpts);
|
|
4326
5521
|
const gone = new Set(carved);
|
|
4327
|
-
const bands =
|
|
5522
|
+
const bands = staged.tf.groups.byStack ?? {};
|
|
4328
5523
|
const bandsAfter = {};
|
|
4329
5524
|
for (const [band, members] of Object.entries(bands)) {
|
|
4330
5525
|
const left = members.filter((id) => !gone.has(id));
|
|
4331
5526
|
if (left.length) bandsAfter[band] = left;
|
|
4332
5527
|
}
|
|
4333
5528
|
const tfAfter = {
|
|
4334
|
-
nodes:
|
|
4335
|
-
edges:
|
|
5529
|
+
nodes: staged.tf.nodes.filter((n) => !gone.has(n.id)),
|
|
5530
|
+
edges: staged.tf.edges.filter((e) => !gone.has(e.from) && !gone.has(e.to)),
|
|
4336
5531
|
groups: { byStack: bandsAfter }
|
|
4337
5532
|
};
|
|
4338
|
-
const carvedNodes =
|
|
5533
|
+
const carvedNodes = staged.tf.nodes.filter((n) => gone.has(n.id)).map((n) => ({ ...n, attrs: { ...n.attrs, _status: "good", carve: "carved \u2192 chant" } }));
|
|
4339
5534
|
const appAfter = { ...appNs, nodes: [...appNs.nodes, ...carvedNodes] };
|
|
4340
|
-
const after = composeCarveEstate(tfAfter, appAfter,
|
|
5535
|
+
const after = composeCarveEstate(tfAfter, appAfter, morphOpts);
|
|
4341
5536
|
const view = (name, comp) => ({
|
|
4342
5537
|
name,
|
|
4343
5538
|
ir: comp.ir,
|
|
@@ -4599,12 +5794,12 @@ function radializeLayout(layout, groupOf, size = /* @__PURE__ */ new Map()) {
|
|
|
4599
5794
|
}
|
|
4600
5795
|
|
|
4601
5796
|
// src/carve-actions.ts
|
|
4602
|
-
import { existsSync as existsSync8, mkdirSync as mkdirSync3, readFileSync as
|
|
4603
|
-
import { isAbsolute, join as
|
|
5797
|
+
import { existsSync as existsSync8, mkdirSync as mkdirSync3, readFileSync as readFileSync10, readdirSync as readdirSync5, statSync as statSync5 } from "node:fs";
|
|
5798
|
+
import { isAbsolute, join as join13, relative as relative2, resolve as resolve4, sep as sep2 } from "node:path";
|
|
4604
5799
|
|
|
4605
5800
|
// src/layout.ts
|
|
4606
|
-
import { existsSync as existsSync6, mkdirSync as mkdirSync2, readFileSync as
|
|
4607
|
-
import { join as
|
|
5801
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync2, readFileSync as readFileSync8, renameSync, rmSync, writeFileSync as writeFileSync2, accessSync, constants } from "node:fs";
|
|
5802
|
+
import { join as join11 } from "node:path";
|
|
4608
5803
|
var LAYOUT_DIR = ".behold";
|
|
4609
5804
|
var LAYOUT_FILE = "layout.json";
|
|
4610
5805
|
var MAX_BODY_BYTES = 64 * 1024;
|
|
@@ -4645,7 +5840,7 @@ function lensFromQuery(params) {
|
|
|
4645
5840
|
return [slug(zoom), params.get("radial") === "1" ? "radial" : null, stack ? slug(`stack-${stack}`) : null].filter(Boolean).join("+");
|
|
4646
5841
|
}
|
|
4647
5842
|
function layoutPath(projectDir) {
|
|
4648
|
-
return
|
|
5843
|
+
return join11(projectDir, LAYOUT_DIR, LAYOUT_FILE);
|
|
4649
5844
|
}
|
|
4650
5845
|
function unwritableReason(projectDir) {
|
|
4651
5846
|
if (!projectDir || !existsSync6(projectDir)) return `no such project directory: ${projectDir || "(none)"}`;
|
|
@@ -4661,7 +5856,7 @@ function readLayoutFile(projectDir) {
|
|
|
4661
5856
|
const file = layoutPath(projectDir);
|
|
4662
5857
|
try {
|
|
4663
5858
|
if (!existsSync6(file)) return empty;
|
|
4664
|
-
const raw =
|
|
5859
|
+
const raw = readFileSync8(file, "utf8");
|
|
4665
5860
|
if (raw.length > MAX_FILE_BYTES) return empty;
|
|
4666
5861
|
const parsed = JSON.parse(raw);
|
|
4667
5862
|
if (!parsed || typeof parsed !== "object" || !parsed.lenses || typeof parsed.lenses !== "object") return empty;
|
|
@@ -4701,7 +5896,7 @@ function writeLens(projectDir, lens, deltas) {
|
|
|
4701
5896
|
}
|
|
4702
5897
|
const body = JSON.stringify({ version: 1, lenses }, null, 2) + "\n";
|
|
4703
5898
|
if (body.length > MAX_FILE_BYTES) throw new LayoutTooLarge(`a layout sidecar is capped at ${MAX_FILE_BYTES} bytes`);
|
|
4704
|
-
mkdirSync2(
|
|
5899
|
+
mkdirSync2(join11(projectDir, LAYOUT_DIR), { recursive: true });
|
|
4705
5900
|
const tmp = `${file}.tmp`;
|
|
4706
5901
|
writeFileSync2(tmp, body, "utf8");
|
|
4707
5902
|
renameSync(tmp, file);
|
|
@@ -4786,8 +5981,8 @@ function reanchorEdges(svg, moved) {
|
|
|
4786
5981
|
|
|
4787
5982
|
// src/carve-live.ts
|
|
4788
5983
|
import { spawn as spawn3 } from "node:child_process";
|
|
4789
|
-
import { readFileSync as
|
|
4790
|
-
import { join as
|
|
5984
|
+
import { readFileSync as readFileSync9, writeFileSync as writeFileSync3, existsSync as existsSync7 } from "node:fs";
|
|
5985
|
+
import { join as join12 } from "node:path";
|
|
4791
5986
|
var LIVE_CONTAINER = "behold-carve-floci";
|
|
4792
5987
|
var LIVE_PORT = 4602;
|
|
4793
5988
|
var LIVE_IMAGE = "floci/floci:1.5.34";
|
|
@@ -4846,9 +6041,9 @@ async function teardownScratchFloci() {
|
|
|
4846
6041
|
await capture("docker", ["rm", "-f", LIVE_CONTAINER], process.cwd()).catch(() => void 0);
|
|
4847
6042
|
}
|
|
4848
6043
|
async function applyIntoFloci(fromDir, step) {
|
|
4849
|
-
const template =
|
|
6044
|
+
const template = join12(fromDir, "floci-override.tf.disabled");
|
|
4850
6045
|
if (!existsSync7(template)) return `no floci-override.tf.disabled in ${fromDir} \u2014 this estate wasn't authored for the live tier`;
|
|
4851
|
-
writeFileSync3(
|
|
6046
|
+
writeFileSync3(join12(fromDir, "floci_override.tf"), armOverride(readFileSync9(template, "utf8"), LIVE_PORT));
|
|
4852
6047
|
const init = await step("terraform", ["init", "-input=false", "-no-color"], fromDir);
|
|
4853
6048
|
if (init !== 0) return `terraform init exited ${init} (provider downloads need network)`;
|
|
4854
6049
|
const apply = await step(
|
|
@@ -4872,7 +6067,7 @@ ${tail}`, exitCode: r.code };
|
|
|
4872
6067
|
// src/carve-actions.ts
|
|
4873
6068
|
async function runCarvePlan(demo) {
|
|
4874
6069
|
if (!demo?.live) {
|
|
4875
|
-
return
|
|
6070
|
+
return refuse3(
|
|
4876
6071
|
"carve-action",
|
|
4877
6072
|
"terraform plan is the live tier's beat, and this server isn't serving one",
|
|
4878
6073
|
"run `behold demo carve --live` (needs docker + terraform)"
|
|
@@ -4880,7 +6075,7 @@ async function runCarvePlan(demo) {
|
|
|
4880
6075
|
}
|
|
4881
6076
|
const r = await runLivePlan(demo.from);
|
|
4882
6077
|
if ("error" in r) {
|
|
4883
|
-
return
|
|
6078
|
+
return refuse3("carve-action", r.error, "Is the scratch Floci still up? `docker ps` should list " + demo.live.container + ".");
|
|
4884
6079
|
}
|
|
4885
6080
|
return {
|
|
4886
6081
|
ok: true,
|
|
@@ -4892,14 +6087,14 @@ async function runCarvePlan(demo) {
|
|
|
4892
6087
|
}
|
|
4893
6088
|
async function runCarveObserve(demo, select) {
|
|
4894
6089
|
if (!demo.live) {
|
|
4895
|
-
return
|
|
6090
|
+
return refuse3(
|
|
4896
6091
|
"carve-action",
|
|
4897
6092
|
"the observe beat reads the scratch Floci, and this server isn't serving one",
|
|
4898
6093
|
"run `behold demo carve --live` (needs docker + terraform)"
|
|
4899
6094
|
);
|
|
4900
6095
|
}
|
|
4901
|
-
if (!existsSync8(
|
|
4902
|
-
return
|
|
6096
|
+
if (!existsSync8(join13(demo.out, "src"))) {
|
|
6097
|
+
return refuse3("carve-action", "nothing has been emitted yet", "run Emit first \u2014 observe reads the carveout emit writes");
|
|
4903
6098
|
}
|
|
4904
6099
|
const args = ["lifecycle", "diff", "prod", "--live", "--json"];
|
|
4905
6100
|
const run3 = await runChantRaw(args, demo.out, { AWS_ENDPOINT_URL: demo.live.endpoint }).catch((err) => ({
|
|
@@ -4908,18 +6103,26 @@ async function runCarveObserve(demo, select) {
|
|
|
4908
6103
|
stderr: err instanceof Error ? err.message : String(err)
|
|
4909
6104
|
}));
|
|
4910
6105
|
if (run3.code !== 0) {
|
|
4911
|
-
return
|
|
6106
|
+
return refuse3(
|
|
4912
6107
|
"carve-action",
|
|
4913
6108
|
`chant lifecycle diff exited ${run3.code}: ${merge(run3, demo.root) || "(no output)"}`,
|
|
4914
6109
|
"Is the scratch Floci still up? `docker ps` should list " + demo.live.container + "."
|
|
4915
6110
|
);
|
|
4916
6111
|
}
|
|
4917
|
-
let
|
|
6112
|
+
let parsed;
|
|
4918
6113
|
try {
|
|
4919
|
-
|
|
6114
|
+
parsed = JSON.parse(run3.stdout);
|
|
4920
6115
|
} catch {
|
|
4921
|
-
return
|
|
6116
|
+
return refuse3("carve-action", "chant lifecycle diff answered something that isn't JSON", "re-run \u2014 a partial read is not a verdict");
|
|
4922
6117
|
}
|
|
6118
|
+
if (parsed.stacks && !parsed.lexicons) {
|
|
6119
|
+
return refuse3(
|
|
6120
|
+
"carve-action",
|
|
6121
|
+
"chant lifecycle diff answered a stacked shape ({stacks}) \u2014 observe only reads the stack-less {lexicons} shape",
|
|
6122
|
+
"point the target at a stack-less carveout, or file a shape-read gap against observe for the stacked case"
|
|
6123
|
+
);
|
|
6124
|
+
}
|
|
6125
|
+
const aws = parsed.lexicons?.aws ?? {};
|
|
4923
6126
|
const entity = select.split(".").pop() ?? select;
|
|
4924
6127
|
const command = `AWS_ENDPOINT_URL=${demo.live.endpoint} chant ${args.join(" ")} # in ${shortenIn(demo.out, demo.root)}`;
|
|
4925
6128
|
const queried = aws.resources?.queried?.[entity];
|
|
@@ -4954,13 +6157,13 @@ async function runCarveObserve(demo, select) {
|
|
|
4954
6157
|
var MAX_ARTIFACT_BYTES = 64 * 1024;
|
|
4955
6158
|
var MAX_ARTIFACTS = 24;
|
|
4956
6159
|
var MAX_OUTPUT_BYTES = 32 * 1024;
|
|
4957
|
-
var
|
|
6160
|
+
var refuse3 = (code, error, remedy) => ({
|
|
4958
6161
|
ok: false,
|
|
4959
6162
|
refusal: { error, code, remedy }
|
|
4960
6163
|
});
|
|
4961
6164
|
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
6165
|
function insideDemo(root, p) {
|
|
4963
|
-
const rel =
|
|
6166
|
+
const rel = relative2(resolve4(root), resolve4(p));
|
|
4964
6167
|
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
|
4965
6168
|
}
|
|
4966
6169
|
function selectFromReport(report, raw) {
|
|
@@ -4981,17 +6184,17 @@ function readArtifacts(demo, dir, filter) {
|
|
|
4981
6184
|
if (out.length >= MAX_ARTIFACTS || depth > 2) return;
|
|
4982
6185
|
let entries;
|
|
4983
6186
|
try {
|
|
4984
|
-
entries =
|
|
6187
|
+
entries = readdirSync5(d).sort();
|
|
4985
6188
|
} catch {
|
|
4986
6189
|
return;
|
|
4987
6190
|
}
|
|
4988
6191
|
for (const name of entries) {
|
|
4989
6192
|
if (out.length >= MAX_ARTIFACTS) return;
|
|
4990
6193
|
if (name === "node_modules" || name.startsWith(".")) continue;
|
|
4991
|
-
const full =
|
|
6194
|
+
const full = join13(d, name);
|
|
4992
6195
|
let st;
|
|
4993
6196
|
try {
|
|
4994
|
-
st =
|
|
6197
|
+
st = statSync5(full);
|
|
4995
6198
|
} catch {
|
|
4996
6199
|
continue;
|
|
4997
6200
|
}
|
|
@@ -4999,11 +6202,11 @@ function readArtifacts(demo, dir, filter) {
|
|
|
4999
6202
|
walk(full, depth + 1);
|
|
5000
6203
|
continue;
|
|
5001
6204
|
}
|
|
5002
|
-
const rel =
|
|
6205
|
+
const rel = relative2(demo.root, full).split(sep2).join("/");
|
|
5003
6206
|
if (!filter(rel)) continue;
|
|
5004
6207
|
let text = "";
|
|
5005
6208
|
try {
|
|
5006
|
-
text =
|
|
6209
|
+
text = readFileSync10(full, "utf8");
|
|
5007
6210
|
} catch {
|
|
5008
6211
|
continue;
|
|
5009
6212
|
}
|
|
@@ -5025,17 +6228,17 @@ function carveSlug(select) {
|
|
|
5025
6228
|
}
|
|
5026
6229
|
var clip = (s) => s.length > MAX_OUTPUT_BYTES ? s.slice(0, MAX_OUTPUT_BYTES) + "\n\u2026 (truncated)" : s;
|
|
5027
6230
|
function shortenIn(arg, root) {
|
|
5028
|
-
return arg.startsWith(root +
|
|
6231
|
+
return arg.startsWith(root + sep2) ? arg.slice(root.length + 1) : arg === root ? "." : arg;
|
|
5029
6232
|
}
|
|
5030
6233
|
function merge(r, root) {
|
|
5031
6234
|
const joined = stripAnsi([r.stdout, r.stderr].filter((s) => s.trim()).join("\n").trim());
|
|
5032
|
-
return clip(joined.split(root +
|
|
6235
|
+
return clip(joined.split(root + sep2).join("").split(root).join("."));
|
|
5033
6236
|
}
|
|
5034
6237
|
async function runCarveEmit(demo, select) {
|
|
5035
6238
|
const block = carveWriteBlock(demo);
|
|
5036
|
-
if (block) return
|
|
6239
|
+
if (block) return refuse3("read-only", block, "Start the walkthrough with `behold demo carve`.");
|
|
5037
6240
|
mkdirSync3(demo.out, { recursive: true });
|
|
5038
|
-
const reportFile =
|
|
6241
|
+
const reportFile = join13(demo.out, `${carveSlug(select)}-boundary.json`);
|
|
5039
6242
|
const args = [
|
|
5040
6243
|
"carve",
|
|
5041
6244
|
"emit",
|
|
@@ -5056,7 +6259,7 @@ async function runCarveEmit(demo, select) {
|
|
|
5056
6259
|
}));
|
|
5057
6260
|
const output = merge(run3, demo.root);
|
|
5058
6261
|
if (run3.code !== 0) {
|
|
5059
|
-
return
|
|
6262
|
+
return refuse3(
|
|
5060
6263
|
"carve-action",
|
|
5061
6264
|
`chant carve emit exited ${run3.code}: ${output || "(no output)"}`,
|
|
5062
6265
|
"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 +6267,10 @@ async function runCarveEmit(demo, select) {
|
|
|
5064
6267
|
}
|
|
5065
6268
|
let boundary = null;
|
|
5066
6269
|
try {
|
|
5067
|
-
boundary = JSON.parse(
|
|
6270
|
+
boundary = JSON.parse(readFileSync10(reportFile, "utf8"));
|
|
5068
6271
|
} catch {
|
|
5069
6272
|
}
|
|
5070
|
-
const lintPath =
|
|
6273
|
+
const lintPath = relative2(demo.project, join13(demo.out, "src")).split(sep2).join("/");
|
|
5071
6274
|
const lintArgs = ["lint", lintPath];
|
|
5072
6275
|
const lint = await runChantRaw(lintArgs, demo.project).catch((err) => ({
|
|
5073
6276
|
code: 127,
|
|
@@ -5091,7 +6294,7 @@ async function runCarveEmit(demo, select) {
|
|
|
5091
6294
|
}
|
|
5092
6295
|
async function runCarveBridge(demo, select) {
|
|
5093
6296
|
const block = carveWriteBlock(demo);
|
|
5094
|
-
if (block) return
|
|
6297
|
+
if (block) return refuse3("read-only", block, "Start the walkthrough with `behold demo carve`.");
|
|
5095
6298
|
mkdirSync3(demo.out, { recursive: true });
|
|
5096
6299
|
const args = [
|
|
5097
6300
|
"carve",
|
|
@@ -5111,7 +6314,7 @@ async function runCarveBridge(demo, select) {
|
|
|
5111
6314
|
}));
|
|
5112
6315
|
const output = merge(run3, demo.root);
|
|
5113
6316
|
if (run3.code !== 0) {
|
|
5114
|
-
return
|
|
6317
|
+
return refuse3(
|
|
5115
6318
|
"carve-action",
|
|
5116
6319
|
`chant carve bridge exited ${run3.code}: ${output || "(no output)"}`,
|
|
5117
6320
|
"Run the Emit step first \u2014 bridge reads the carve manifest emit leaves in the output directory."
|
|
@@ -5130,8 +6333,8 @@ async function runCarveBridge(demo, select) {
|
|
|
5130
6333
|
}
|
|
5131
6334
|
|
|
5132
6335
|
// src/ops.ts
|
|
5133
|
-
import { readdirSync as
|
|
5134
|
-
import { join as
|
|
6336
|
+
import { readdirSync as readdirSync6, readFileSync as readFileSync11, existsSync as existsSync9 } from "node:fs";
|
|
6337
|
+
import { join as join14 } from "node:path";
|
|
5135
6338
|
var APPLY_TARGET_LEXICON = {
|
|
5136
6339
|
cloudformation: "aws",
|
|
5137
6340
|
kubectl: "k8s",
|
|
@@ -5153,11 +6356,11 @@ function discoverOps(projectDir) {
|
|
|
5153
6356
|
const seen = /* @__PURE__ */ new Set();
|
|
5154
6357
|
const out = [];
|
|
5155
6358
|
for (const sub of ["ops", "src", "."]) {
|
|
5156
|
-
const dir =
|
|
6359
|
+
const dir = join14(projectDir, sub);
|
|
5157
6360
|
if (!existsSync9(dir)) continue;
|
|
5158
|
-
for (const f of
|
|
6361
|
+
for (const f of readdirSync6(dir)) {
|
|
5159
6362
|
if (!f.endsWith(".op.ts")) continue;
|
|
5160
|
-
const content =
|
|
6363
|
+
const content = readFileSync11(join14(dir, f), "utf8");
|
|
5161
6364
|
const name = content.match(/name:\s*["'`]([^"'`]+)["'`]/)?.[1];
|
|
5162
6365
|
if (!name || seen.has(name)) continue;
|
|
5163
6366
|
seen.add(name);
|
|
@@ -5165,29 +6368,607 @@ function discoverOps(projectDir) {
|
|
|
5165
6368
|
const env = content.match(/\benv:\s*["'`]([^"'`]+)["'`]/)?.[1];
|
|
5166
6369
|
const target = content.match(/\btarget:\s*["'`]([^"'`]+)["'`]/)?.[1];
|
|
5167
6370
|
const substrate = target ? APPLY_TARGET_LEXICON[target] : void 0;
|
|
6371
|
+
const irPath = opIrPath(projectDir, name);
|
|
6372
|
+
out.push({
|
|
6373
|
+
name,
|
|
6374
|
+
kind: kindOf(content),
|
|
6375
|
+
dir: projectDir,
|
|
6376
|
+
...gate ? { gate } : {},
|
|
6377
|
+
...env ? { env } : {},
|
|
6378
|
+
...substrate ? { substrate } : {},
|
|
6379
|
+
...existsSync9(irPath) ? { irPath } : {}
|
|
6380
|
+
});
|
|
6381
|
+
}
|
|
6382
|
+
}
|
|
6383
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
6384
|
+
}
|
|
6385
|
+
function opIrPath(projectDir, name) {
|
|
6386
|
+
return join14(projectDir, "dist", "ops", name, "op.json");
|
|
6387
|
+
}
|
|
6388
|
+
function discoverOpIrs(projectDirs) {
|
|
6389
|
+
const seen = /* @__PURE__ */ new Set();
|
|
6390
|
+
const out = [];
|
|
6391
|
+
for (const dir of projectDirs) {
|
|
6392
|
+
const root = join14(dir, "dist", "ops");
|
|
6393
|
+
if (!existsSync9(root)) continue;
|
|
6394
|
+
for (const name of readdirSync6(root)) {
|
|
6395
|
+
if (seen.has(name)) continue;
|
|
6396
|
+
const path = opIrPath(dir, name);
|
|
6397
|
+
if (!existsSync9(path)) continue;
|
|
6398
|
+
seen.add(name);
|
|
6399
|
+
out.push({ name, dir, path });
|
|
6400
|
+
}
|
|
6401
|
+
}
|
|
6402
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
6403
|
+
}
|
|
6404
|
+
function discoverEstateOps(projectDirs) {
|
|
6405
|
+
const seen = /* @__PURE__ */ new Set();
|
|
6406
|
+
const out = [];
|
|
6407
|
+
for (const dir of projectDirs) {
|
|
6408
|
+
for (const op of discoverOps(dir)) {
|
|
6409
|
+
if (seen.has(op.name)) continue;
|
|
6410
|
+
seen.add(op.name);
|
|
6411
|
+
out.push(op);
|
|
6412
|
+
}
|
|
6413
|
+
}
|
|
6414
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
6415
|
+
}
|
|
6416
|
+
|
|
6417
|
+
// src/run-playhead.ts
|
|
6418
|
+
var RECEIPT_READ_FN = "receiptRead";
|
|
6419
|
+
var RECEIPT_WRITE_FN = "receiptWrite";
|
|
6420
|
+
var STEP_STATUSES = /* @__PURE__ */ new Set(["ok", "fail", "skipped"]);
|
|
6421
|
+
var isRecord4 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
6422
|
+
function asStepRecord(value) {
|
|
6423
|
+
if (!isRecord4(value)) return null;
|
|
6424
|
+
if (typeof value.phase !== "string" || typeof value.fn !== "string") return null;
|
|
6425
|
+
if (typeof value.status !== "string" || !STEP_STATUSES.has(value.status)) return null;
|
|
6426
|
+
if (typeof value.durationMs !== "number") return null;
|
|
6427
|
+
return value;
|
|
6428
|
+
}
|
|
6429
|
+
function parseRunProgressLine(line) {
|
|
6430
|
+
let parsed;
|
|
6431
|
+
try {
|
|
6432
|
+
parsed = JSON.parse(line);
|
|
6433
|
+
} catch {
|
|
6434
|
+
return null;
|
|
6435
|
+
}
|
|
6436
|
+
const one = asStepRecord(parsed);
|
|
6437
|
+
if (one) return [one];
|
|
6438
|
+
if (isRecord4(parsed) && typeof parsed.op === "string" && Array.isArray(parsed.records)) {
|
|
6439
|
+
const records = parsed.records.map(asStepRecord).filter((r) => r !== null);
|
|
6440
|
+
return records.length ? records : null;
|
|
6441
|
+
}
|
|
6442
|
+
return null;
|
|
6443
|
+
}
|
|
6444
|
+
function recordLine(record) {
|
|
6445
|
+
const mark = record.status === "ok" ? "\u2713" : record.status === "fail" ? "\u2717" : "\u2022";
|
|
6446
|
+
const took = record.status === "skipped" ? "skipped" : record.durationMs < 1e3 ? `${record.durationMs}ms` : `${(record.durationMs / 1e3).toFixed(1)}s`;
|
|
6447
|
+
return `${mark} ${record.phase} \xB7 ${record.fn} \u2014 ${took}${record.error ? ` \u2014 ${record.error}` : ""}`;
|
|
6448
|
+
}
|
|
6449
|
+
var ANSI = /\x1b\[[0-9;]*m/g;
|
|
6450
|
+
var strip = (s) => s.replace(ANSI, "");
|
|
6451
|
+
function statusFields(stdout) {
|
|
6452
|
+
const out = /* @__PURE__ */ new Map();
|
|
6453
|
+
for (const raw of strip(stdout).split("\n")) {
|
|
6454
|
+
const m = /^\s{2,}([A-Za-z][A-Za-z ]*?)\s*:\s*(.*)$/.exec(raw);
|
|
6455
|
+
if (m) out.set(m[1].trim(), m[2].trim());
|
|
6456
|
+
}
|
|
6457
|
+
return out;
|
|
6458
|
+
}
|
|
6459
|
+
function parseGateLine(line) {
|
|
6460
|
+
const m = /^(.*?)(?: — (.*?))? \(waiting since (.+)\)$/.exec(line.trim());
|
|
6461
|
+
if (!m) return null;
|
|
6462
|
+
const signalName = m[1].trim();
|
|
6463
|
+
if (!signalName) return null;
|
|
6464
|
+
return { signalName, ...m[2] ? { description: m[2].trim() } : {}, since: m[3].trim() };
|
|
6465
|
+
}
|
|
6466
|
+
function readRunStatus(op, run3) {
|
|
6467
|
+
if (run3.code !== 0) {
|
|
6468
|
+
const message = strip(run3.stderr).trim() || strip(run3.stdout).trim() || `chant run status exited ${run3.code}`;
|
|
6469
|
+
if (/not available in local mode|@temporalio\/client is not installed/.test(message)) {
|
|
6470
|
+
return {
|
|
6471
|
+
ok: false,
|
|
6472
|
+
refusal: {
|
|
6473
|
+
error: `No Temporal run state for "${op}" \u2014 ${message.split("\n")[0].replace(/^error:\s*/, "")}`,
|
|
6474
|
+
code: "no-temporal",
|
|
6475
|
+
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."
|
|
6476
|
+
}
|
|
6477
|
+
};
|
|
6478
|
+
}
|
|
6479
|
+
if (/not found|NotFound/.test(message)) {
|
|
6480
|
+
return {
|
|
6481
|
+
ok: false,
|
|
6482
|
+
refusal: {
|
|
6483
|
+
error: `No run of "${op}" to report \u2014 ${message.split("\n")[0].replace(/^error:\s*/, "")}`,
|
|
6484
|
+
code: "no-run",
|
|
6485
|
+
remedy: "Start the Op (Run) and the playhead fills in as each step settles."
|
|
6486
|
+
}
|
|
6487
|
+
};
|
|
6488
|
+
}
|
|
6489
|
+
return {
|
|
6490
|
+
ok: false,
|
|
6491
|
+
refusal: {
|
|
6492
|
+
error: `chant run status ${op} failed: ${message.split("\n")[0].replace(/^error:\s*/, "")}`,
|
|
6493
|
+
code: "run-status",
|
|
6494
|
+
remedy: "Run `chant run status " + op + " --temporal` in the project to see the whole error."
|
|
6495
|
+
}
|
|
6496
|
+
};
|
|
6497
|
+
}
|
|
6498
|
+
const f = statusFields(run3.stdout);
|
|
6499
|
+
const activities = /^(\d+)\/(\d+)/.exec(f.get("Activities") ?? "");
|
|
6500
|
+
const gateLine = f.get("Gate");
|
|
6501
|
+
return {
|
|
6502
|
+
ok: true,
|
|
6503
|
+
read: {
|
|
6504
|
+
op,
|
|
6505
|
+
...f.get("Workflow ID") ? { workflowId: f.get("Workflow ID") } : {},
|
|
6506
|
+
...f.get("Run ID") ? { runId: f.get("Run ID") } : {},
|
|
6507
|
+
...f.get("Status") ? { status: f.get("Status") } : {},
|
|
6508
|
+
...f.get("Started") ? { startedAt: f.get("Started") } : {},
|
|
6509
|
+
...f.get("Closed") ? { closedAt: f.get("Closed") } : {},
|
|
6510
|
+
...activities ? { activities: { completed: Number(activities[1]), scheduled: Number(activities[2]) } } : {},
|
|
6511
|
+
// No Gate line means chant printed none, which it does both when the
|
|
6512
|
+
// query says "nothing pending" and when the Op registers no handler.
|
|
6513
|
+
// Either way: no gate is waiting on a human right now.
|
|
6514
|
+
gate: gateLine ? parseGateLine(gateLine) : null
|
|
6515
|
+
}
|
|
6516
|
+
};
|
|
6517
|
+
}
|
|
6518
|
+
var initialRunState = {
|
|
6519
|
+
op: null,
|
|
6520
|
+
mode: null,
|
|
6521
|
+
status: "idle",
|
|
6522
|
+
records: [],
|
|
6523
|
+
gate: null,
|
|
6524
|
+
gateNote: null,
|
|
6525
|
+
startedAt: null,
|
|
6526
|
+
endedAt: null,
|
|
6527
|
+
lost: null
|
|
6528
|
+
};
|
|
6529
|
+
function runStarted(op, mode, at) {
|
|
6530
|
+
return { ...initialRunState, op, mode, status: "running", startedAt: at };
|
|
6531
|
+
}
|
|
6532
|
+
function runRecord(state, record) {
|
|
6533
|
+
return { ...state, records: [...state.records, record] };
|
|
6534
|
+
}
|
|
6535
|
+
function runGate(state, result) {
|
|
6536
|
+
return result.ok ? { ...state, gate: result.read.gate, gateNote: null } : { ...state, gate: null, gateNote: result.refusal.error };
|
|
6537
|
+
}
|
|
6538
|
+
function runEnded(state, exitCode, at) {
|
|
6539
|
+
if (state.status !== "running") return state;
|
|
6540
|
+
if (exitCode === 0) return { ...state, status: "ok", endedAt: at };
|
|
6541
|
+
if (state.records.some((r) => r.status === "fail")) return { ...state, status: "failed", endedAt: at };
|
|
6542
|
+
return {
|
|
6543
|
+
...state,
|
|
6544
|
+
status: "lost",
|
|
6545
|
+
endedAt: at,
|
|
6546
|
+
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.`
|
|
6547
|
+
};
|
|
6548
|
+
}
|
|
6549
|
+
function slotsFor(placed) {
|
|
6550
|
+
const out = [];
|
|
6551
|
+
for (const p of placed) {
|
|
6552
|
+
if (p.step.kind === "activity") {
|
|
6553
|
+
out.push({ id: p.id, phase: p.phase, fn: p.step.fn });
|
|
6554
|
+
} else if (p.step.kind === "effect") {
|
|
6555
|
+
out.push({ id: p.id, phase: p.phase, fn: RECEIPT_READ_FN });
|
|
6556
|
+
for (const nested of p.step.steps ?? []) {
|
|
6557
|
+
if (nested.kind === "activity") out.push({ id: p.id, phase: p.phase, fn: nested.fn });
|
|
6558
|
+
}
|
|
6559
|
+
out.push({ id: p.id, phase: p.phase, fn: RECEIPT_WRITE_FN });
|
|
6560
|
+
}
|
|
6561
|
+
}
|
|
6562
|
+
return out;
|
|
6563
|
+
}
|
|
6564
|
+
function mergeRun(prev, record) {
|
|
6565
|
+
const rank = { fail: 3, ok: 2, skipped: 1 };
|
|
6566
|
+
const keepPrev = prev && rank[prev.status] >= rank[record.status];
|
|
6567
|
+
return {
|
|
6568
|
+
status: keepPrev ? prev.status : record.status,
|
|
6569
|
+
durationMs: (prev?.durationMs ?? 0) + record.durationMs,
|
|
6570
|
+
...keepPrev && prev.error ? { error: prev.error } : record.error ? { error: record.error } : {},
|
|
6571
|
+
...keepPrev && prev.outcome ? { outcome: prev.outcome } : record.outcome ? { outcome: `${record.outcome.name}=${String(record.outcome.value)}` } : {},
|
|
6572
|
+
records: (prev?.records ?? 0) + 1
|
|
6573
|
+
};
|
|
6574
|
+
}
|
|
6575
|
+
function joinRun(op, records, gate) {
|
|
6576
|
+
const placed = opPlacedSteps(op);
|
|
6577
|
+
const slots = slotsFor(placed);
|
|
6578
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
6579
|
+
for (const s of slots) {
|
|
6580
|
+
const key = `${s.phase}\0${s.fn}`;
|
|
6581
|
+
if (!byKey.has(key)) byKey.set(key, []);
|
|
6582
|
+
byKey.get(key).push({ id: s.id });
|
|
6583
|
+
}
|
|
6584
|
+
const cursor = /* @__PURE__ */ new Map();
|
|
6585
|
+
const byNode = /* @__PURE__ */ new Map();
|
|
6586
|
+
const unplaced = [];
|
|
6587
|
+
let lastSettledIndex = -1;
|
|
6588
|
+
const indexOf = new Map(placed.map((p, i) => [p.id, i]));
|
|
6589
|
+
for (const record of records) {
|
|
6590
|
+
const key = `${record.phase}\0${record.fn}`;
|
|
6591
|
+
const list3 = byKey.get(key);
|
|
6592
|
+
const i = cursor.get(key) ?? 0;
|
|
6593
|
+
const slot = list3?.[i];
|
|
6594
|
+
if (!slot) {
|
|
6595
|
+
unplaced.push(record);
|
|
6596
|
+
continue;
|
|
6597
|
+
}
|
|
6598
|
+
cursor.set(key, i + 1);
|
|
6599
|
+
byNode.set(slot.id, mergeRun(byNode.get(slot.id), record));
|
|
6600
|
+
if (record.status !== "skipped") {
|
|
6601
|
+
lastSettledIndex = Math.max(lastSettledIndex, indexOf.get(slot.id) ?? -1);
|
|
6602
|
+
}
|
|
6603
|
+
}
|
|
6604
|
+
if (gate) {
|
|
6605
|
+
const gateNode = placed.find((p) => p.step.kind === "gate" && p.step.signalName === gate.signalName);
|
|
6606
|
+
if (gateNode) {
|
|
6607
|
+
return {
|
|
6608
|
+
byNode,
|
|
6609
|
+
at: gateNode.id,
|
|
6610
|
+
atReason: `waiting for ${gate.signalName} since ${gate.since}`,
|
|
6611
|
+
unplaced
|
|
6612
|
+
};
|
|
6613
|
+
}
|
|
6614
|
+
}
|
|
6615
|
+
const next = placed.find((p, i) => i > lastSettledIndex && p.when === "phases");
|
|
6616
|
+
return {
|
|
6617
|
+
byNode,
|
|
6618
|
+
at: lastSettledIndex >= 0 && next ? next.id : null,
|
|
6619
|
+
atReason: lastSettledIndex >= 0 && next ? "reached \u2014 not settled yet" : null,
|
|
6620
|
+
unplaced
|
|
6621
|
+
};
|
|
6622
|
+
}
|
|
6623
|
+
var TONE = {
|
|
6624
|
+
ok: "good",
|
|
6625
|
+
fail: "warn",
|
|
6626
|
+
skipped: "neutral"
|
|
6627
|
+
};
|
|
6628
|
+
var RAN = { ok: "ok", fail: "failed", skipped: "skipped" };
|
|
6629
|
+
function tookLabel(ms) {
|
|
6630
|
+
return ms < 1e3 ? `${ms}ms` : `${(ms / 1e3).toFixed(1)}s`;
|
|
6631
|
+
}
|
|
6632
|
+
function paintPlayhead(ir, join21, gate) {
|
|
6633
|
+
if (!join21.byNode.size && !join21.at) return ir;
|
|
6634
|
+
const nodes = ir.nodes.map((node) => {
|
|
6635
|
+
const run3 = join21.byNode.get(node.id);
|
|
6636
|
+
const here = join21.at === node.id;
|
|
6637
|
+
if (!run3 && !here) return node;
|
|
6638
|
+
const isGate = node.attrs?._step === "gate";
|
|
6639
|
+
const attrs = { ...node.attrs };
|
|
6640
|
+
if (run3) {
|
|
6641
|
+
attrs._status = TONE[run3.status];
|
|
6642
|
+
attrs.run = run3.status === "skipped" ? "skipped" : `${RAN[run3.status]} \xB7 ${tookLabel(run3.durationMs)}`;
|
|
6643
|
+
attrs.ran = tookLabel(run3.durationMs);
|
|
6644
|
+
if (run3.error) attrs.error = run3.error;
|
|
6645
|
+
if (run3.outcome) attrs.outcome = run3.outcome;
|
|
6646
|
+
if (run3.records > 1) attrs.runRecords = run3.records;
|
|
6647
|
+
}
|
|
6648
|
+
if (here) {
|
|
6649
|
+
attrs._playhead = true;
|
|
6650
|
+
attrs._status = isGate && gate ? "warn" : "accent";
|
|
6651
|
+
attrs.run = isGate && gate ? `waiting for approval since ${gate.since}` : join21.atReason ?? "here";
|
|
6652
|
+
if (isGate && gate?.description) attrs.gateDescription = gate.description;
|
|
6653
|
+
}
|
|
6654
|
+
return { ...node, attrs };
|
|
6655
|
+
});
|
|
6656
|
+
return { ...ir, nodes };
|
|
6657
|
+
}
|
|
6658
|
+
function pendingGateCard(op, gate, ir) {
|
|
6659
|
+
const node = ir.nodes.find((n) => n.attrs?._step === "gate" && n.attrs?.gate === gate.signalName && n.attrs?.op === op);
|
|
6660
|
+
return {
|
|
6661
|
+
op,
|
|
6662
|
+
signalName: gate.signalName,
|
|
6663
|
+
...gate.description ? { description: gate.description } : {},
|
|
6664
|
+
since: gate.since,
|
|
6665
|
+
...node ? { node: node.id } : {},
|
|
6666
|
+
...node && typeof node.attrs?.guards === "string" ? { guards: node.attrs.guards } : {},
|
|
6667
|
+
approve: {
|
|
6668
|
+
method: "POST",
|
|
6669
|
+
path: `/api/ops/${encodeURIComponent(op)}/signal/${encodeURIComponent(gate.signalName)}`
|
|
6670
|
+
}
|
|
6671
|
+
};
|
|
6672
|
+
}
|
|
6673
|
+
function playheadNote(state, join21) {
|
|
6674
|
+
if (!state.op) {
|
|
6675
|
+
return "No run state: nothing has been run from this behold session, so every step paints as declared.";
|
|
6676
|
+
}
|
|
6677
|
+
const settled = join21 ? join21.byNode.size : state.records.length;
|
|
6678
|
+
const bits = [];
|
|
6679
|
+
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`;
|
|
6680
|
+
if (state.status === "running") {
|
|
6681
|
+
bits.push(
|
|
6682
|
+
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"
|
|
6683
|
+
);
|
|
6684
|
+
}
|
|
6685
|
+
if (state.status === "ok") bits.push("the run exited cleanly");
|
|
6686
|
+
if (state.status === "failed") bits.push("a step failed");
|
|
6687
|
+
if (state.status === "lost") bits.push(`stream lost \u2014 ${state.lost ?? "the progress stream ended without a verdict"}`);
|
|
6688
|
+
if (state.gate) {
|
|
6689
|
+
bits.push(`waiting on gate ${state.gate.signalName} since ${state.gate.since} \u2014 approve is a delegated signal, behold never signals on its own`);
|
|
6690
|
+
} else if (state.gateNote) {
|
|
6691
|
+
bits.push(`gate state unavailable \u2014 ${state.gateNote}`);
|
|
6692
|
+
}
|
|
6693
|
+
if (join21?.unplaced.length) {
|
|
6694
|
+
bits.push(
|
|
6695
|
+
`${join21.unplaced.length} record${join21.unplaced.length === 1 ? "" : "s"} matched no declared step and ${join21.unplaced.length === 1 ? "is" : "are"} not painted`
|
|
6696
|
+
);
|
|
6697
|
+
}
|
|
6698
|
+
if (!bits.length) return `${head}.`;
|
|
6699
|
+
const rest = bits.join("; ");
|
|
6700
|
+
return `${head} \u2014 ${rest}.`;
|
|
6701
|
+
}
|
|
6702
|
+
|
|
6703
|
+
// src/operator.ts
|
|
6704
|
+
var ANSI2 = /\x1b\[[0-9;]*m/g;
|
|
6705
|
+
var strip2 = (s) => s.replace(ANSI2, "");
|
|
6706
|
+
var isRecord5 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
6707
|
+
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).";
|
|
6708
|
+
function readRow(value) {
|
|
6709
|
+
if (!isRecord5(value)) return null;
|
|
6710
|
+
if (typeof value.op !== "string" || typeof value.env !== "string") return null;
|
|
6711
|
+
if (!Array.isArray(value.pendingGates)) return null;
|
|
6712
|
+
const gates = [];
|
|
6713
|
+
for (const g of value.pendingGates) {
|
|
6714
|
+
if (!isRecord5(g) || typeof g.rule !== "string" || typeof g.gate !== "string") return null;
|
|
6715
|
+
gates.push({ rule: g.rule, gate: g.gate, ...typeof g.op === "string" ? { op: g.op } : {} });
|
|
6716
|
+
}
|
|
6717
|
+
const tick = isRecord5(value.lastTick) ? value.lastTick : void 0;
|
|
6718
|
+
const usable = tick && typeof tick.timestamp === "string" && typeof tick.log === "string";
|
|
6719
|
+
const lease = isRecord5(value.lease) ? value.lease : void 0;
|
|
6720
|
+
return {
|
|
6721
|
+
op: value.op,
|
|
6722
|
+
env: value.env,
|
|
6723
|
+
...usable ? { lastTick: tick } : {},
|
|
6724
|
+
pendingGates: gates,
|
|
6725
|
+
...lease && typeof lease.holder === "string" && typeof lease.expiresAt === "string" ? { lease: { holder: lease.holder, expiresAt: lease.expiresAt } } : {}
|
|
6726
|
+
};
|
|
6727
|
+
}
|
|
6728
|
+
function readOperatorStatus(run3) {
|
|
6729
|
+
const err = strip2(run3.stderr);
|
|
6730
|
+
const out = strip2(run3.stdout).trim();
|
|
6731
|
+
if (run3.code !== 0) {
|
|
6732
|
+
const message = err.trim() || out || `chant operator status exited ${run3.code}`;
|
|
6733
|
+
const first = message.split("\n")[0].replace(/^error:\s*/i, "");
|
|
6734
|
+
if (/Unknown command|Unknown operator subcommand/i.test(message)) {
|
|
6735
|
+
return {
|
|
6736
|
+
ok: false,
|
|
6737
|
+
refusal: {
|
|
6738
|
+
error: `This chant has no \`operator status\` \u2014 ${first}`,
|
|
6739
|
+
code: "no-operator-cli",
|
|
6740
|
+
remedy: "The operating loop's status surface landed in chant 0.52 (chant#1485). Upgrade chant to read it."
|
|
6741
|
+
}
|
|
6742
|
+
};
|
|
6743
|
+
}
|
|
6744
|
+
return {
|
|
6745
|
+
ok: false,
|
|
6746
|
+
refusal: {
|
|
6747
|
+
error: `chant operator status failed: ${first}`,
|
|
6748
|
+
code: "operator-status",
|
|
6749
|
+
remedy: "Run `chant operator status --json` in the project to see the whole error."
|
|
6750
|
+
}
|
|
6751
|
+
};
|
|
6752
|
+
}
|
|
6753
|
+
if (!out) {
|
|
6754
|
+
return {
|
|
6755
|
+
ok: false,
|
|
6756
|
+
refusal: {
|
|
6757
|
+
error: "This project declares no operating loop \u2014 chant found no ConvergeOp.",
|
|
6758
|
+
code: "no-operator",
|
|
6759
|
+
remedy: HOW_TO_DECLARE
|
|
6760
|
+
}
|
|
6761
|
+
};
|
|
6762
|
+
}
|
|
6763
|
+
let parsed;
|
|
6764
|
+
try {
|
|
6765
|
+
parsed = JSON.parse(out);
|
|
6766
|
+
} catch (e) {
|
|
6767
|
+
return {
|
|
6768
|
+
ok: false,
|
|
6769
|
+
refusal: {
|
|
6770
|
+
error: `chant operator status --json didn't print JSON: ${e instanceof Error ? e.message : String(e)}`,
|
|
6771
|
+
code: "operator-status",
|
|
6772
|
+
remedy: "Run `chant operator status --json` in the project and check what it printed."
|
|
6773
|
+
}
|
|
6774
|
+
};
|
|
6775
|
+
}
|
|
6776
|
+
if (!Array.isArray(parsed)) {
|
|
6777
|
+
return {
|
|
6778
|
+
ok: false,
|
|
6779
|
+
refusal: {
|
|
6780
|
+
error: "chant operator status --json answered with something that isn't an array of ConvergeOp rows.",
|
|
6781
|
+
code: "operator-status",
|
|
6782
|
+
remedy: "behold reads one row per ConvergeOp: `{op, env, lastTick?, pendingGates[], lease?}`."
|
|
6783
|
+
}
|
|
6784
|
+
};
|
|
6785
|
+
}
|
|
6786
|
+
const rows = [];
|
|
6787
|
+
for (const value of parsed) {
|
|
6788
|
+
const row = readRow(value);
|
|
6789
|
+
if (!row) {
|
|
6790
|
+
return {
|
|
6791
|
+
ok: false,
|
|
6792
|
+
refusal: {
|
|
6793
|
+
error: "chant operator status --json answered with a row behold couldn't read (needs `op`, `env`, `pendingGates[]`).",
|
|
6794
|
+
code: "operator-status",
|
|
6795
|
+
remedy: "Check `chant operator status --json` in the project \u2014 and file the shape change if chant's is the newer one."
|
|
6796
|
+
}
|
|
6797
|
+
};
|
|
6798
|
+
}
|
|
6799
|
+
rows.push(row);
|
|
6800
|
+
}
|
|
6801
|
+
return { ok: true, rows };
|
|
6802
|
+
}
|
|
6803
|
+
function declaredConvergeOps(ops) {
|
|
6804
|
+
return ops.filter((o) => o.searchAttributes?.Converge === "true").map((o) => ({
|
|
6805
|
+
name: o.name,
|
|
6806
|
+
...o.searchAttributes?.Env ? { env: o.searchAttributes.Env } : {},
|
|
6807
|
+
...o.searchAttributes?.Dial ? { dial: o.searchAttributes.Dial } : {}
|
|
6808
|
+
}));
|
|
6809
|
+
}
|
|
6810
|
+
var MANAGED_BY = "app.kubernetes.io/managed-by";
|
|
6811
|
+
var COMPONENT = "app.kubernetes.io/component";
|
|
6812
|
+
var NAME_LABEL = "app.kubernetes.io/name";
|
|
6813
|
+
var CHANT = "chant";
|
|
6814
|
+
var CONVERGE_TICK = "converge-tick";
|
|
6815
|
+
var CRONJOB_KIND = "K8s::Batch::CronJob";
|
|
6816
|
+
var NAMESPACE_KIND = "K8s::Core::Namespace";
|
|
6817
|
+
var rec3 = (v) => isRecord5(v) ? v : void 0;
|
|
6818
|
+
var str3 = (v) => typeof v === "string" && v ? v : void 0;
|
|
6819
|
+
var labelsOf = (n) => rec3(rec3(n.attrs?.metadata)?.labels) ?? {};
|
|
6820
|
+
function operatorHomes(ir) {
|
|
6821
|
+
const byNamespace = /* @__PURE__ */ new Map();
|
|
6822
|
+
const conflicted = /* @__PURE__ */ new Set();
|
|
6823
|
+
for (const n of ir.nodes) {
|
|
6824
|
+
if (n.kind !== CRONJOB_KIND) continue;
|
|
6825
|
+
const labels = labelsOf(n);
|
|
6826
|
+
if (labels[MANAGED_BY] !== CHANT || labels[COMPONENT] !== CONVERGE_TICK) continue;
|
|
6827
|
+
const meta = rec3(n.attrs?.metadata);
|
|
6828
|
+
const ns = str3(meta?.namespace);
|
|
6829
|
+
const op = str3(meta?.name);
|
|
6830
|
+
if (!ns || !op) continue;
|
|
6831
|
+
const home = byNamespace.get(ns) ?? { namespace: ns, ticks: [] };
|
|
6832
|
+
const stack = str3(labels[NAME_LABEL]);
|
|
6833
|
+
if (stack && !conflicted.has(ns)) {
|
|
6834
|
+
if (home.stack === void 0) home.stack = stack;
|
|
6835
|
+
else if (home.stack !== stack) {
|
|
6836
|
+
conflicted.add(ns);
|
|
6837
|
+
delete home.stack;
|
|
6838
|
+
}
|
|
6839
|
+
}
|
|
6840
|
+
const schedule = str3(rec3(n.attrs?.spec)?.schedule);
|
|
6841
|
+
home.ticks.push({ node: n.id, op, ...schedule ? { schedule } : {} });
|
|
6842
|
+
byNamespace.set(ns, home);
|
|
6843
|
+
}
|
|
6844
|
+
if (!byNamespace.size) return [];
|
|
6845
|
+
for (const n of ir.nodes) {
|
|
6846
|
+
if (n.kind !== NAMESPACE_KIND) continue;
|
|
6847
|
+
const name = str3(rec3(n.attrs?.metadata)?.name);
|
|
6848
|
+
const home = name ? byNamespace.get(name) : void 0;
|
|
6849
|
+
if (home && !home.node) home.node = n.id;
|
|
6850
|
+
}
|
|
6851
|
+
for (const home of byNamespace.values()) home.ticks.sort((a, b) => a.op.localeCompare(b.op));
|
|
6852
|
+
return [...byNamespace.values()].sort((a, b) => a.namespace.localeCompare(b.namespace));
|
|
6853
|
+
}
|
|
6854
|
+
function markOperatorHome(ir) {
|
|
6855
|
+
const homes = operatorHomes(ir);
|
|
6856
|
+
if (!homes.length) return ir;
|
|
6857
|
+
const marks = /* @__PURE__ */ new Map();
|
|
6858
|
+
for (const home of homes) {
|
|
6859
|
+
if (home.node) {
|
|
6860
|
+
marks.set(home.node, {
|
|
6861
|
+
role: "home",
|
|
6862
|
+
namespace: home.namespace,
|
|
6863
|
+
...home.stack ? { stack: home.stack } : {},
|
|
6864
|
+
ticks: home.ticks.map((t) => t.op)
|
|
6865
|
+
});
|
|
6866
|
+
}
|
|
6867
|
+
for (const tick of home.ticks) {
|
|
6868
|
+
marks.set(tick.node, {
|
|
6869
|
+
role: "tick",
|
|
6870
|
+
namespace: home.namespace,
|
|
6871
|
+
...home.stack ? { stack: home.stack } : {},
|
|
6872
|
+
op: tick.op,
|
|
6873
|
+
...tick.schedule ? { schedule: tick.schedule } : {}
|
|
6874
|
+
});
|
|
6875
|
+
}
|
|
6876
|
+
}
|
|
6877
|
+
return {
|
|
6878
|
+
...ir,
|
|
6879
|
+
nodes: ir.nodes.map((n) => {
|
|
6880
|
+
const mark = marks.get(n.id);
|
|
6881
|
+
return mark ? { ...n, attrs: { ...n.attrs, _operator: mark } } : n;
|
|
6882
|
+
})
|
|
6883
|
+
};
|
|
6884
|
+
}
|
|
6885
|
+
function operatorStrip(rows, now = Date.now()) {
|
|
6886
|
+
const out = rows.map((r) => {
|
|
6887
|
+
const expiresAt = r.lease ? Date.parse(r.lease.expiresAt) : NaN;
|
|
6888
|
+
const lease = !r.lease ? "free" : Number.isFinite(expiresAt) && expiresAt <= now ? "expired" : "held";
|
|
6889
|
+
return {
|
|
6890
|
+
op: r.op,
|
|
6891
|
+
env: r.env,
|
|
6892
|
+
log: r.lastTick?.log ?? null,
|
|
6893
|
+
at: r.lastTick?.timestamp ?? null,
|
|
6894
|
+
lease,
|
|
6895
|
+
leaseHolder: r.lease?.holder ?? null,
|
|
6896
|
+
leaseExpiresAt: r.lease?.expiresAt ?? null,
|
|
6897
|
+
pendingGates: r.pendingGates.length
|
|
6898
|
+
};
|
|
6899
|
+
});
|
|
6900
|
+
return { rows: out, pendingGates: out.reduce((n, r) => n + r.pendingGates, 0) };
|
|
6901
|
+
}
|
|
6902
|
+
var CONVERGE_GATE_LOOP = "converge";
|
|
6903
|
+
var APPROVE_SEMANTICS = "Approving records a fact in the gate ledger; it does not unblock the dispatch. The next tick reads it.";
|
|
6904
|
+
function convergeGateCards(rows) {
|
|
6905
|
+
const out = [];
|
|
6906
|
+
for (const row of rows) {
|
|
6907
|
+
for (const g of row.pendingGates) {
|
|
6908
|
+
if (!g.op) continue;
|
|
5168
6909
|
out.push({
|
|
5169
|
-
|
|
5170
|
-
|
|
5171
|
-
|
|
5172
|
-
|
|
5173
|
-
|
|
5174
|
-
|
|
6910
|
+
loop: CONVERGE_GATE_LOOP,
|
|
6911
|
+
rule: g.rule,
|
|
6912
|
+
op: g.op,
|
|
6913
|
+
gate: g.gate,
|
|
6914
|
+
convergeOp: row.op,
|
|
6915
|
+
env: row.env,
|
|
6916
|
+
since: row.lastTick?.timestamp ?? null,
|
|
6917
|
+
semantics: APPROVE_SEMANTICS,
|
|
6918
|
+
approve: {
|
|
6919
|
+
method: "POST",
|
|
6920
|
+
path: `/api/operator/approve/${encodeURIComponent(g.op)}/${encodeURIComponent(g.gate)}`,
|
|
6921
|
+
command: `chant approve ${g.op} ${g.gate}`
|
|
6922
|
+
}
|
|
5175
6923
|
});
|
|
5176
6924
|
}
|
|
5177
6925
|
}
|
|
5178
|
-
return out
|
|
6926
|
+
return out;
|
|
5179
6927
|
}
|
|
5180
|
-
|
|
5181
|
-
|
|
5182
|
-
|
|
5183
|
-
|
|
5184
|
-
|
|
5185
|
-
|
|
5186
|
-
|
|
5187
|
-
|
|
5188
|
-
|
|
6928
|
+
var initialOperatorState = {
|
|
6929
|
+
declared: [],
|
|
6930
|
+
strip: null,
|
|
6931
|
+
gates: [],
|
|
6932
|
+
note: null,
|
|
6933
|
+
code: null,
|
|
6934
|
+
readAt: null
|
|
6935
|
+
};
|
|
6936
|
+
function operatorRead(state, result, at, now = Date.now()) {
|
|
6937
|
+
if (!result.ok) {
|
|
6938
|
+
return { ...state, strip: null, gates: [], note: result.refusal.error, code: result.refusal.code, readAt: at };
|
|
5189
6939
|
}
|
|
5190
|
-
return
|
|
6940
|
+
return {
|
|
6941
|
+
...state,
|
|
6942
|
+
strip: operatorStrip(result.rows, now),
|
|
6943
|
+
gates: convergeGateCards(result.rows),
|
|
6944
|
+
note: null,
|
|
6945
|
+
code: null,
|
|
6946
|
+
readAt: at
|
|
6947
|
+
};
|
|
6948
|
+
}
|
|
6949
|
+
function operatorNote(state) {
|
|
6950
|
+
if (!state.declared.length) {
|
|
6951
|
+
return "No operating loop: this project declares no ConvergeOp, so there is no operator strip.";
|
|
6952
|
+
}
|
|
6953
|
+
const loops = `${state.declared.length} ConvergeOp${state.declared.length === 1 ? "" : "s"}`;
|
|
6954
|
+
if (!state.strip) {
|
|
6955
|
+
return state.note ? `Operating loop: ${loops} declared \u2014 status unavailable: ${state.note}` : `Operating loop: ${loops} declared \u2014 not read yet.`;
|
|
6956
|
+
}
|
|
6957
|
+
const bits = [];
|
|
6958
|
+
const ticked = state.strip.rows.filter((r) => r.at).length;
|
|
6959
|
+
bits.push(
|
|
6960
|
+
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`
|
|
6961
|
+
);
|
|
6962
|
+
const held = state.strip.rows.filter((r) => r.lease === "held").length;
|
|
6963
|
+
if (held) bits.push(`${held} lease${held === 1 ? "" : "s"} held`);
|
|
6964
|
+
const expired = state.strip.rows.filter((r) => r.lease === "expired").length;
|
|
6965
|
+
if (expired) bits.push(`${expired} lease${expired === 1 ? "" : "s"} expired \u2014 the next round reclaims it`);
|
|
6966
|
+
if (state.strip.pendingGates) {
|
|
6967
|
+
bits.push(
|
|
6968
|
+
`${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)`
|
|
6969
|
+
);
|
|
6970
|
+
}
|
|
6971
|
+
return `Operating loop: ${loops} \u2014 ${bits.join("; ")}.`;
|
|
5191
6972
|
}
|
|
5192
6973
|
|
|
5193
6974
|
// src/adopt.ts
|
|
@@ -5270,21 +7051,21 @@ function classifyHealth(status) {
|
|
|
5270
7051
|
if (HEALTHY.test(text)) return "healthy";
|
|
5271
7052
|
return "unknown";
|
|
5272
7053
|
}
|
|
5273
|
-
function
|
|
7054
|
+
function rec4(v) {
|
|
5274
7055
|
return typeof v === "object" && v !== null && !Array.isArray(v) ? v : void 0;
|
|
5275
7056
|
}
|
|
5276
|
-
function
|
|
7057
|
+
function str4(v) {
|
|
5277
7058
|
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
5278
7059
|
}
|
|
5279
7060
|
function statusTree(o) {
|
|
5280
|
-
const attrs =
|
|
7061
|
+
const attrs = rec4(o.attributes);
|
|
5281
7062
|
if (!attrs) return void 0;
|
|
5282
|
-
return
|
|
7063
|
+
return rec4(attrs.status) ?? attrs;
|
|
5283
7064
|
}
|
|
5284
7065
|
function specTree(o) {
|
|
5285
|
-
const attrs =
|
|
7066
|
+
const attrs = rec4(o.attributes);
|
|
5286
7067
|
if (!attrs) return void 0;
|
|
5287
|
-
return
|
|
7068
|
+
return rec4(attrs.spec) ?? attrs;
|
|
5288
7069
|
}
|
|
5289
7070
|
var ARGO_HEALTH = {
|
|
5290
7071
|
Healthy: "healthy",
|
|
@@ -5310,9 +7091,9 @@ function argoErrorMessage(tree) {
|
|
|
5310
7091
|
if (m) return m[2];
|
|
5311
7092
|
continue;
|
|
5312
7093
|
}
|
|
5313
|
-
const type =
|
|
7094
|
+
const type = str4(rec4(c)?.type);
|
|
5314
7095
|
if (!type || !ARGO_ERROR_CONDITION.test(type)) continue;
|
|
5315
|
-
const message =
|
|
7096
|
+
const message = str4(rec4(c)?.message);
|
|
5316
7097
|
if (message) return message;
|
|
5317
7098
|
}
|
|
5318
7099
|
return void 0;
|
|
@@ -5320,10 +7101,10 @@ function argoErrorMessage(tree) {
|
|
|
5320
7101
|
function argoVerdict(o) {
|
|
5321
7102
|
if (o.type !== "K8s::Argo::Application") return void 0;
|
|
5322
7103
|
const tree = statusTree(o);
|
|
5323
|
-
let health =
|
|
5324
|
-
let sync =
|
|
7104
|
+
let health = str4(rec4(tree?.health)?.status);
|
|
7105
|
+
let sync = str4(rec4(tree?.sync)?.status);
|
|
5325
7106
|
if (!health && !sync) {
|
|
5326
|
-
const word =
|
|
7107
|
+
const word = str4(o.status);
|
|
5327
7108
|
if (word === "READY") {
|
|
5328
7109
|
health = "Healthy";
|
|
5329
7110
|
sync = "Synced";
|
|
@@ -5349,16 +7130,16 @@ function readyFromConditions(tree) {
|
|
|
5349
7130
|
const conditions = tree?.conditions;
|
|
5350
7131
|
if (!Array.isArray(conditions)) return void 0;
|
|
5351
7132
|
for (const c of conditions) {
|
|
5352
|
-
const cond =
|
|
7133
|
+
const cond = rec4(c);
|
|
5353
7134
|
if (!cond || cond.type !== "Ready") continue;
|
|
5354
|
-
const status =
|
|
7135
|
+
const status = str4(cond.status);
|
|
5355
7136
|
if (status !== "True" && status !== "False" && status !== "Unknown") continue;
|
|
5356
|
-
return { status, reason:
|
|
7137
|
+
return { status, reason: str4(cond.reason) };
|
|
5357
7138
|
}
|
|
5358
7139
|
return void 0;
|
|
5359
7140
|
}
|
|
5360
7141
|
function readyFromChantConditions(o) {
|
|
5361
|
-
const conditions =
|
|
7142
|
+
const conditions = rec4(o.attributes)?.conditions;
|
|
5362
7143
|
if (!Array.isArray(conditions)) return void 0;
|
|
5363
7144
|
for (const c of conditions) {
|
|
5364
7145
|
if (typeof c !== "string") continue;
|
|
@@ -5370,7 +7151,7 @@ function readyFromChantConditions(o) {
|
|
|
5370
7151
|
return void 0;
|
|
5371
7152
|
}
|
|
5372
7153
|
function readyFromStatusWord(o) {
|
|
5373
|
-
const word =
|
|
7154
|
+
const word = str4(o.status);
|
|
5374
7155
|
if (!word || word === "PRESENT") return void 0;
|
|
5375
7156
|
if (word === "READY") return { status: "True" };
|
|
5376
7157
|
if (word === "NOT-READY") return { status: "False" };
|
|
@@ -5380,8 +7161,8 @@ function describeReady(ready) {
|
|
|
5380
7161
|
return `Ready=${ready.status}${ready.reason ? ` (${ready.reason})` : ""}`;
|
|
5381
7162
|
}
|
|
5382
7163
|
function revisionVerdict(tree) {
|
|
5383
|
-
const applied =
|
|
5384
|
-
const attempted =
|
|
7164
|
+
const applied = str4(tree?.lastAppliedRevision);
|
|
7165
|
+
const attempted = str4(tree?.lastAttemptedRevision);
|
|
5385
7166
|
if (!applied || !attempted || applied === attempted) return void 0;
|
|
5386
7167
|
return { health: "progressing", detail: `applied ${applied}, attempted ${attempted}` };
|
|
5387
7168
|
}
|
|
@@ -5394,23 +7175,23 @@ function fluxVerdict(o) {
|
|
|
5394
7175
|
if (ready?.status === "False") return { health: "degraded", detail: describeReady(ready) };
|
|
5395
7176
|
if (ready?.status === "Unknown") return { health: "progressing", detail: describeReady(ready) };
|
|
5396
7177
|
if (ready?.status === "True") return revisionVerdict(tree) ?? { health: "healthy", detail: describeReady(ready) };
|
|
5397
|
-
if (o.type === FLUX_HELMREPOSITORY_TYPE &&
|
|
7178
|
+
if (o.type === FLUX_HELMREPOSITORY_TYPE && str4(specTree(o)?.type) === "oci") {
|
|
5398
7179
|
return { health: "healthy", detail: "spec.type=oci (nothing to reconcile)" };
|
|
5399
7180
|
}
|
|
5400
7181
|
const conditions = tree?.conditions;
|
|
5401
7182
|
const reconciling = Array.isArray(conditions) && conditions.some((c) => {
|
|
5402
|
-
const cond =
|
|
7183
|
+
const cond = rec4(c);
|
|
5403
7184
|
return cond?.type === "Reconciling" && cond.status === "True";
|
|
5404
7185
|
});
|
|
5405
7186
|
if (reconciling) return { health: "progressing", detail: "Reconciling=True" };
|
|
5406
|
-
if (!
|
|
7187
|
+
if (!str4(o.status) || o.status === "PRESENT") {
|
|
5407
7188
|
return { health: "progressing", detail: "no Ready condition yet" };
|
|
5408
7189
|
}
|
|
5409
7190
|
return void 0;
|
|
5410
7191
|
}
|
|
5411
7192
|
var DEPLOYMENT_TYPE = "K8s::Apps::Deployment";
|
|
5412
7193
|
function unhappyCondition(o, type) {
|
|
5413
|
-
const fromStrings =
|
|
7194
|
+
const fromStrings = rec4(o.attributes)?.conditions;
|
|
5414
7195
|
if (Array.isArray(fromStrings)) {
|
|
5415
7196
|
for (const c of fromStrings) {
|
|
5416
7197
|
if (typeof c === "string" && c.startsWith(type)) return c;
|
|
@@ -5420,13 +7201,13 @@ function unhappyCondition(o, type) {
|
|
|
5420
7201
|
const raw = tree?.conditions;
|
|
5421
7202
|
if (Array.isArray(raw)) {
|
|
5422
7203
|
for (const c of raw) {
|
|
5423
|
-
const cond =
|
|
7204
|
+
const cond = rec4(c);
|
|
5424
7205
|
if (!cond || cond.type !== type) continue;
|
|
5425
|
-
const status =
|
|
7206
|
+
const status = str4(cond.status);
|
|
5426
7207
|
const unhappy = type === "ReplicaFailure" ? status === "True" : status === "False";
|
|
5427
7208
|
if (!unhappy) continue;
|
|
5428
|
-
const reason =
|
|
5429
|
-
const message =
|
|
7209
|
+
const reason = str4(cond.reason);
|
|
7210
|
+
const message = str4(cond.message);
|
|
5430
7211
|
return `${type}${reason ? `=${reason}` : ""}${message ? `: ${message}` : ""}`;
|
|
5431
7212
|
}
|
|
5432
7213
|
}
|
|
@@ -5555,6 +7336,19 @@ var OpRunner = class {
|
|
|
5555
7336
|
* before the first event lands so a reload between trigger and first event
|
|
5556
7337
|
* doesn't show the PREVIOUS run's stale terminal state). */
|
|
5557
7338
|
lastApplyProgress = initialApplyProgress;
|
|
7339
|
+
/** The run playhead (#284 item 2): the settled StepRecords of the last Op
|
|
7340
|
+
* `trigger()` started, plus the last `gateState` answer. Kept after the run
|
|
7341
|
+
* ends and across renders for the same reason `lastApplyProgress` is — a
|
|
7342
|
+
* client opening the ops lens mid-run (or after a reload) hydrates the
|
|
7343
|
+
* playhead instead of starting blank. In-process only: a run started
|
|
7344
|
+
* elsewhere is invisible here, and `chant run status` (src/server.ts's
|
|
7345
|
+
* `/api/ops/:name/status`) is what answers for one. */
|
|
7346
|
+
lastRunState = initialRunState;
|
|
7347
|
+
/** The operator strip (#234 join 3): the last `chant operator status --json`
|
|
7348
|
+
* read, plus the ConvergeOps the built project declares. Held here for the
|
|
7349
|
+
* reason `lastRunState` is — it is a durable status read, not a run, and a
|
|
7350
|
+
* client opening the ops lens hydrates from it instead of starting blank. */
|
|
7351
|
+
lastOperatorState = initialOperatorState;
|
|
5558
7352
|
/** #195: re-point delegated writes at a switched project. The runner reads
|
|
5559
7353
|
* `deps.projectDir` at trigger time, so this is the whole retarget. */
|
|
5560
7354
|
retarget(projectDir) {
|
|
@@ -5569,6 +7363,32 @@ var OpRunner = class {
|
|
|
5569
7363
|
get applyProgress() {
|
|
5570
7364
|
return this.lastApplyProgress;
|
|
5571
7365
|
}
|
|
7366
|
+
/** The run playhead's model (#284 item 2) — `initialRunState` until an Op has
|
|
7367
|
+
* been triggered this session. */
|
|
7368
|
+
get runState() {
|
|
7369
|
+
return this.lastRunState;
|
|
7370
|
+
}
|
|
7371
|
+
/**
|
|
7372
|
+
* Fold a `chant run status <name>` read (src/run-playhead.ts's
|
|
7373
|
+
* `readRunStatus`) into the playhead, and broadcast it.
|
|
7374
|
+
*
|
|
7375
|
+
* The gate is a workflow QUERY, not part of the record stream, so it can only
|
|
7376
|
+
* arrive this way. A read for an Op other than the one being watched is
|
|
7377
|
+
* returned to the caller but not folded — one playhead, one Op, and silently
|
|
7378
|
+
* re-pointing it at whatever was last polled would make the picture lie about
|
|
7379
|
+
* which run it shows. A read arriving before anything has run this session
|
|
7380
|
+
* adopts the Op: a gate pending on a run behold didn't start is exactly the
|
|
7381
|
+
* case the pending card exists for.
|
|
7382
|
+
*/
|
|
7383
|
+
noteGate(op, result) {
|
|
7384
|
+
if (this.lastRunState.op !== null && this.lastRunState.op !== op) return this.lastRunState;
|
|
7385
|
+
const base = this.lastRunState.op === op ? this.lastRunState : { ...initialRunState, op, mode: "temporal" };
|
|
7386
|
+
const next = runGate(base, result);
|
|
7387
|
+
const changed = JSON.stringify(next) !== JSON.stringify(this.lastRunState);
|
|
7388
|
+
this.lastRunState = next;
|
|
7389
|
+
if (changed) this.emitRun();
|
|
7390
|
+
return this.lastRunState;
|
|
7391
|
+
}
|
|
5572
7392
|
/**
|
|
5573
7393
|
* Start `chant run <name>` unless one is already running (the Sync/Adopt/auto-
|
|
5574
7394
|
* sync path). `cwd` is the Op's own project dir (#31 multi-estate); defaults to
|
|
@@ -5578,9 +7398,78 @@ var OpRunner = class {
|
|
|
5578
7398
|
* set it from the Op's declared gate: chant refuses a gated Op outright in
|
|
5579
7399
|
* local mode ("gates and schedules need a durable runtime"), so without the
|
|
5580
7400
|
* flag a gated Op could never run from behold at all.
|
|
7401
|
+
*
|
|
7402
|
+
* Either way the run is asked for structured per-step records (#284 item 2,
|
|
7403
|
+
* chant#1676) — `--progress-json` streams one NDJSON `StepRecord` per settled
|
|
7404
|
+
* step on the durable path; `--json` prints the whole `OpRunResult` at the end
|
|
7405
|
+
* on the local one. Same record shape, which is what lets one playhead render
|
|
7406
|
+
* both. `--json` replaces chant's own human per-step summary on the local
|
|
7407
|
+
* path, so each parsed record is re-emitted to the `op` now-line
|
|
7408
|
+
* ({@link recordLine}) and the operator loses no narration.
|
|
5581
7409
|
*/
|
|
5582
7410
|
trigger(name, opEnv, cwd, temporal) {
|
|
5583
|
-
|
|
7411
|
+
if (this.current) return false;
|
|
7412
|
+
const mode = temporal ? "temporal" : "local";
|
|
7413
|
+
const args = temporal ? ["run", name, "--temporal", "--progress-json"] : ["run", name, "--json"];
|
|
7414
|
+
this.lastRunState = runStarted(name, mode, (/* @__PURE__ */ new Date()).toISOString());
|
|
7415
|
+
this.emitRun();
|
|
7416
|
+
return this.start(
|
|
7417
|
+
args,
|
|
7418
|
+
name,
|
|
7419
|
+
opEnv,
|
|
7420
|
+
cwd,
|
|
7421
|
+
(line) => {
|
|
7422
|
+
const records = parseRunProgressLine(line);
|
|
7423
|
+
if (!records) return false;
|
|
7424
|
+
for (const record of records) {
|
|
7425
|
+
this.lastRunState = runRecord(this.lastRunState, record);
|
|
7426
|
+
this.deps.broadcaster.emit("op", recordLine(record));
|
|
7427
|
+
}
|
|
7428
|
+
this.emitRun();
|
|
7429
|
+
return true;
|
|
7430
|
+
},
|
|
7431
|
+
(code) => {
|
|
7432
|
+
this.lastRunState = runEnded(this.lastRunState, code, (/* @__PURE__ */ new Date()).toISOString());
|
|
7433
|
+
this.emitRun();
|
|
7434
|
+
}
|
|
7435
|
+
);
|
|
7436
|
+
}
|
|
7437
|
+
emitRun() {
|
|
7438
|
+
this.deps.broadcaster.emit("run", JSON.stringify(this.lastRunState));
|
|
7439
|
+
}
|
|
7440
|
+
/** The operator strip's model (#234 join 3) — `initialOperatorState` until the
|
|
7441
|
+
* ops lens has been opened once. */
|
|
7442
|
+
get operatorState() {
|
|
7443
|
+
return this.lastOperatorState;
|
|
7444
|
+
}
|
|
7445
|
+
/**
|
|
7446
|
+
* Record which ConvergeOps the built project declares, read from the emitted
|
|
7447
|
+
* op.json (src/operator.ts's `declaredConvergeOps`) — no subprocess, and the
|
|
7448
|
+
* thing that gates the strip. Broadcast on a change so a project rebuilt (or
|
|
7449
|
+
* switched, #195) under an open tab grows or loses its strip without a reload.
|
|
7450
|
+
*/
|
|
7451
|
+
noteDeclaredConvergeOps(declared) {
|
|
7452
|
+
return this.foldOperator({ ...this.lastOperatorState, declared });
|
|
7453
|
+
}
|
|
7454
|
+
/**
|
|
7455
|
+
* Fold a `chant operator status --json` read (src/operator.ts's
|
|
7456
|
+
* `readOperatorStatus`) into the strip, and broadcast it.
|
|
7457
|
+
*
|
|
7458
|
+
* Broadcast only a CHANGED answer, for `noteGate`'s reason: a client asks when
|
|
7459
|
+
* it opens the ops lens and re-pulls the lens on the broadcast, so an
|
|
7460
|
+
* unchanged answer that still broadcast would have the two chasing each other.
|
|
7461
|
+
*/
|
|
7462
|
+
noteOperator(result, at = (/* @__PURE__ */ new Date()).toISOString()) {
|
|
7463
|
+
return this.foldOperator(operatorRead(this.lastOperatorState, result, at));
|
|
7464
|
+
}
|
|
7465
|
+
/** Compare-and-broadcast, ignoring `readAt` — the instant behold asked changes
|
|
7466
|
+
* on every poll and is not itself news; broadcasting on it would make the
|
|
7467
|
+
* interval a repaint loop. */
|
|
7468
|
+
foldOperator(next) {
|
|
7469
|
+
const same = JSON.stringify({ ...next, readAt: null }) === JSON.stringify({ ...this.lastOperatorState, readAt: null });
|
|
7470
|
+
this.lastOperatorState = next;
|
|
7471
|
+
if (!same) this.deps.broadcaster.emit("operator", JSON.stringify(next));
|
|
7472
|
+
return this.lastOperatorState;
|
|
5584
7473
|
}
|
|
5585
7474
|
/**
|
|
5586
7475
|
* Run an arbitrary `chant` invocation through the same guard/stream/PR/capture
|
|
@@ -5712,8 +7601,12 @@ var OpRunner = class {
|
|
|
5712
7601
|
* fully handled that line (apply()'s progress-JSON parsing) — `start()` then
|
|
5713
7602
|
* skips its own `op`/`pr` broadcast for that one line, leaving every other
|
|
5714
7603
|
* line's raw-log fallback untouched.
|
|
7604
|
+
*
|
|
7605
|
+
* `onExit` (the run playhead, #284 item 2) sees the exit code the moment the
|
|
7606
|
+
* process ends — the only signal that separates "the run finished" from "the
|
|
7607
|
+
* stream stopped", and the reason the playhead never has to infer completion.
|
|
5715
7608
|
*/
|
|
5716
|
-
start(args, label, opEnv, cwd, onLine) {
|
|
7609
|
+
start(args, label, opEnv, cwd, onLine, onExit) {
|
|
5717
7610
|
if (this.current) return false;
|
|
5718
7611
|
const { projectDir, broadcaster } = this.deps;
|
|
5719
7612
|
broadcaster.emit("op", `\u25B6 chant ${args.join(" ")}`);
|
|
@@ -5726,6 +7619,7 @@ var OpRunner = class {
|
|
|
5726
7619
|
this.current = label;
|
|
5727
7620
|
void op.done.then((code) => {
|
|
5728
7621
|
broadcaster.emit("op", `\u25A0 ${label} exited ${code}`);
|
|
7622
|
+
onExit?.(code);
|
|
5729
7623
|
this.current = null;
|
|
5730
7624
|
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
7625
|
});
|
|
@@ -5735,24 +7629,24 @@ var OpRunner = class {
|
|
|
5735
7629
|
|
|
5736
7630
|
// src/substrates.ts
|
|
5737
7631
|
import { spawn as spawn4 } from "node:child_process";
|
|
5738
|
-
import { existsSync as existsSync10, readFileSync as
|
|
5739
|
-
import { join as
|
|
7632
|
+
import { existsSync as existsSync10, readFileSync as readFileSync12 } from "node:fs";
|
|
7633
|
+
import { join as join15 } from "node:path";
|
|
5740
7634
|
import { platform } from "node:os";
|
|
5741
7635
|
var DOCKER_DOWN_DETAIL = "docker is down";
|
|
5742
7636
|
function probe(cmd, args) {
|
|
5743
|
-
return new Promise((
|
|
7637
|
+
return new Promise((resolve9) => {
|
|
5744
7638
|
let out = "";
|
|
5745
7639
|
let proc;
|
|
5746
7640
|
try {
|
|
5747
7641
|
proc = spawn4(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
5748
7642
|
} catch {
|
|
5749
|
-
|
|
7643
|
+
resolve9({ code: 127, out: "" });
|
|
5750
7644
|
return;
|
|
5751
7645
|
}
|
|
5752
7646
|
proc.stdout.on("data", (d) => out += d);
|
|
5753
7647
|
proc.stderr.on("data", (d) => out += d);
|
|
5754
|
-
proc.on("error", () =>
|
|
5755
|
-
proc.on("close", (code) =>
|
|
7648
|
+
proc.on("error", () => resolve9({ code: 127, out }));
|
|
7649
|
+
proc.on("close", (code) => resolve9({ code: code ?? 1, out }));
|
|
5756
7650
|
});
|
|
5757
7651
|
}
|
|
5758
7652
|
async function dockerAvailable(run3) {
|
|
@@ -5765,11 +7659,11 @@ async function dockerRunning(run3, nameFilter) {
|
|
|
5765
7659
|
return out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
|
|
5766
7660
|
}
|
|
5767
7661
|
function scriptBringUp(projectDir, relPath, label) {
|
|
5768
|
-
return existsSync10(
|
|
7662
|
+
return existsSync10(join15(projectDir, relPath)) ? { label, cmd: "bash", args: [relPath] } : void 0;
|
|
5769
7663
|
}
|
|
5770
7664
|
function projectLexicons(projectDir) {
|
|
5771
7665
|
try {
|
|
5772
|
-
const src =
|
|
7666
|
+
const src = readFileSync12(join15(projectDir, "chant.config.ts"), "utf-8");
|
|
5773
7667
|
const m = src.match(/lexicons\s*:\s*\[([^\]]*)\]/);
|
|
5774
7668
|
if (!m) return [];
|
|
5775
7669
|
return [...m[1].matchAll(/["']([^"']+)["']/g)].map((x) => x[1]);
|
|
@@ -5821,7 +7715,7 @@ async function detectSubstrates(projectDir, preview = false, boundContext, probe
|
|
|
5821
7715
|
["forgejo", "Forgejo", ".forgejo", "test/forgejo-runtime-e2e.sh"]
|
|
5822
7716
|
];
|
|
5823
7717
|
for (const [name, label, marker, script] of forges) {
|
|
5824
|
-
if (!existsSync10(
|
|
7718
|
+
if (!existsSync10(join15(projectDir, marker))) continue;
|
|
5825
7719
|
const c = docker ? await dockerRunning(run3, name) : [];
|
|
5826
7720
|
const d = dep(c.length > 0, "container up", "on-demand (pipeline run)");
|
|
5827
7721
|
subs.push({
|
|
@@ -5855,7 +7749,7 @@ async function detectSubstrates(projectDir, preview = false, boundContext, probe
|
|
|
5855
7749
|
detail: endpoint ? `targeting ${endpoint}` : "real Fly (FLY_FLAPS_BASE_URL unset)"
|
|
5856
7750
|
});
|
|
5857
7751
|
}
|
|
5858
|
-
if (existsSync10(
|
|
7752
|
+
if (existsSync10(join15(projectDir, ".github", "workflows"))) {
|
|
5859
7753
|
const gh = await run3("gh", ["auth", "status"]);
|
|
5860
7754
|
const ready = gh.code === 0;
|
|
5861
7755
|
subs.push({
|
|
@@ -5868,7 +7762,7 @@ async function detectSubstrates(projectDir, preview = false, boundContext, probe
|
|
|
5868
7762
|
if (lexicons.includes("temporal")) {
|
|
5869
7763
|
let hasProfiles = false;
|
|
5870
7764
|
try {
|
|
5871
|
-
hasProfiles = /temporal\s*:\s*\{[\s\S]{0,400}?profiles\s*:/.test(
|
|
7765
|
+
hasProfiles = /temporal\s*:\s*\{[\s\S]{0,400}?profiles\s*:/.test(readFileSync12(join15(projectDir, "chant.config.ts"), "utf-8"));
|
|
5872
7766
|
} catch {
|
|
5873
7767
|
}
|
|
5874
7768
|
subs.push({
|
|
@@ -5971,185 +7865,113 @@ async function openRollbackBranches(projectDir, env) {
|
|
|
5971
7865
|
}
|
|
5972
7866
|
}
|
|
5973
7867
|
|
|
5974
|
-
// src/estate.ts
|
|
5975
|
-
import { statSync as
|
|
5976
|
-
|
|
5977
|
-
|
|
5978
|
-
|
|
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) => {
|
|
7868
|
+
// src/estate-edges.ts
|
|
7869
|
+
import { statSync as statSync6 } from "node:fs";
|
|
7870
|
+
var rec5 = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : void 0;
|
|
7871
|
+
var str5 = (v) => typeof v === "string" && v.length > 0 ? v : void 0;
|
|
7872
|
+
var realIsDir2 = (p) => {
|
|
6019
7873
|
try {
|
|
6020
|
-
return
|
|
7874
|
+
return statSync6(p).isDirectory();
|
|
6021
7875
|
} catch {
|
|
6022
7876
|
return false;
|
|
6023
7877
|
}
|
|
6024
7878
|
};
|
|
6025
|
-
function
|
|
6026
|
-
const
|
|
7879
|
+
function declaredDeliveries(n) {
|
|
7880
|
+
const spec = rec5(n.attrs?.spec);
|
|
7881
|
+
if (n.kind === "K8s::Flux::Kustomization") {
|
|
7882
|
+
const path = str5(spec?.path);
|
|
7883
|
+
return path ? [{ from: n.id, path, viaAttr: "reconciles" }] : [];
|
|
7884
|
+
}
|
|
7885
|
+
if (n.kind === "K8s::Argo::Application") {
|
|
7886
|
+
const sources = [spec?.source, ...Array.isArray(spec?.sources) ? spec.sources : []];
|
|
7887
|
+
const out = [];
|
|
7888
|
+
for (const s of sources) {
|
|
7889
|
+
const path = str5(rec5(s)?.path);
|
|
7890
|
+
if (path) out.push({ from: n.id, path, viaAttr: "delivers" });
|
|
7891
|
+
}
|
|
7892
|
+
return out;
|
|
7893
|
+
}
|
|
7894
|
+
return [];
|
|
7895
|
+
}
|
|
7896
|
+
function membershipOf(ir, members) {
|
|
7897
|
+
const named = new Set(members.map((m) => m.name));
|
|
7898
|
+
const byStack = ir.groups?.byStack;
|
|
7899
|
+
const out = /* @__PURE__ */ new Map();
|
|
7900
|
+
const grouped = rec5(byStack);
|
|
7901
|
+
if (grouped) {
|
|
7902
|
+
for (const [name, ids] of Object.entries(grouped)) {
|
|
7903
|
+
if (!named.has(name) || !Array.isArray(ids)) continue;
|
|
7904
|
+
for (const id of ids) if (typeof id === "string") out.set(id, name);
|
|
7905
|
+
}
|
|
7906
|
+
}
|
|
7907
|
+
if (out.size > 0) return out;
|
|
7908
|
+
const byLength = [...members].sort((a, b) => b.name.length - a.name.length);
|
|
6027
7909
|
for (const n of ir.nodes) {
|
|
6028
|
-
|
|
6029
|
-
|
|
6030
|
-
const namespace = str3(spec?.targetNamespace);
|
|
6031
|
-
const path = str3(spec?.path);
|
|
6032
|
-
if (namespace && path) out.push({ path, namespace });
|
|
7910
|
+
const m = byLength.find((c) => n.id.startsWith(`${c.name}/`));
|
|
7911
|
+
if (m) out.set(n.id, m.name);
|
|
6033
7912
|
}
|
|
6034
7913
|
return out;
|
|
6035
7914
|
}
|
|
6036
|
-
function
|
|
6037
|
-
|
|
6038
|
-
|
|
6039
|
-
|
|
6040
|
-
|
|
6041
|
-
|
|
6042
|
-
|
|
6043
|
-
|
|
6044
|
-
|
|
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;
|
|
7915
|
+
function deriveEstateMemberEdges(ir, members, isDir = realIsDir2) {
|
|
7916
|
+
if (members.length < 2) return [];
|
|
7917
|
+
const memberOf = membershipOf(ir, members);
|
|
7918
|
+
if (memberOf.size === 0) return [];
|
|
7919
|
+
const present = new Set(ir.nodes.map((n) => n.id));
|
|
7920
|
+
const pointedAt = /* @__PURE__ */ new Set();
|
|
7921
|
+
for (const e of ir.edges) {
|
|
7922
|
+
const from = memberOf.get(e.from);
|
|
7923
|
+
if (from && from === memberOf.get(e.to)) pointedAt.add(e.to);
|
|
6056
7924
|
}
|
|
6057
|
-
|
|
6058
|
-
|
|
6059
|
-
|
|
6060
|
-
|
|
6061
|
-
|
|
6062
|
-
|
|
6063
|
-
|
|
6064
|
-
const
|
|
6065
|
-
|
|
6066
|
-
|
|
6067
|
-
|
|
6068
|
-
|
|
6069
|
-
|
|
6070
|
-
|
|
6071
|
-
|
|
6072
|
-
|
|
6073
|
-
|
|
6074
|
-
|
|
6075
|
-
|
|
6076
|
-
|
|
6077
|
-
|
|
6078
|
-
|
|
7925
|
+
const entryNodes = (name, lexicon) => {
|
|
7926
|
+
const own = ir.nodes.filter((n) => memberOf.get(n.id) === name && n.lexicon === lexicon).map((n) => n.id);
|
|
7927
|
+
const entries = own.filter((id) => !pointedAt.has(id));
|
|
7928
|
+
return entries.length > 0 ? entries : own;
|
|
7929
|
+
};
|
|
7930
|
+
const out = [];
|
|
7931
|
+
const seen = /* @__PURE__ */ new Set();
|
|
7932
|
+
for (const n of ir.nodes) {
|
|
7933
|
+
const home = memberOf.get(n.id);
|
|
7934
|
+
if (!home) continue;
|
|
7935
|
+
for (const delivery of declaredDeliveries(n)) {
|
|
7936
|
+
let winner;
|
|
7937
|
+
let tied = false;
|
|
7938
|
+
for (const m of members) {
|
|
7939
|
+
if (m.name === home) continue;
|
|
7940
|
+
const score = pathAlignment(delivery.path, m.dir, isDir);
|
|
7941
|
+
if (score === 0) continue;
|
|
7942
|
+
if (!winner || score > winner.score) {
|
|
7943
|
+
winner = { name: m.name, score };
|
|
7944
|
+
tied = false;
|
|
7945
|
+
} else if (score === winner.score) {
|
|
7946
|
+
tied = true;
|
|
7947
|
+
}
|
|
7948
|
+
}
|
|
7949
|
+
if (!winner || tied) continue;
|
|
7950
|
+
for (const to of entryNodes(winner.name, n.lexicon)) {
|
|
7951
|
+
if (!present.has(to) || to === delivery.from) continue;
|
|
7952
|
+
const key = `${delivery.from}\0${to}`;
|
|
7953
|
+
if (seen.has(key)) continue;
|
|
7954
|
+
seen.add(key);
|
|
7955
|
+
out.push({ from: delivery.from, to, kind: "ref", viaAttr: delivery.viaAttr, inferred: true });
|
|
6079
7956
|
}
|
|
6080
7957
|
}
|
|
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
7958
|
}
|
|
6089
|
-
return
|
|
7959
|
+
return out;
|
|
6090
7960
|
}
|
|
6091
|
-
|
|
6092
|
-
|
|
6093
|
-
const
|
|
6094
|
-
|
|
6095
|
-
|
|
6096
|
-
|
|
6097
|
-
|
|
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);
|
|
7961
|
+
function addEstateMemberEdges(ir, members, isDir = realIsDir2) {
|
|
7962
|
+
const existing = new Set(ir.edges.map((e) => `${e.from}\0${e.to}`));
|
|
7963
|
+
for (const e of deriveEstateMemberEdges(ir, members, isDir)) {
|
|
7964
|
+
const key = `${e.from}\0${e.to}`;
|
|
7965
|
+
if (existing.has(key)) continue;
|
|
7966
|
+
existing.add(key);
|
|
7967
|
+
ir.edges.push(e);
|
|
6103
7968
|
}
|
|
6104
|
-
return
|
|
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
|
-
};
|
|
7969
|
+
return ir;
|
|
6148
7970
|
}
|
|
6149
7971
|
|
|
6150
7972
|
// src/events.ts
|
|
6151
7973
|
import { watch, existsSync as existsSync11 } from "node:fs";
|
|
6152
|
-
import { join as
|
|
7974
|
+
import { join as join16 } from "node:path";
|
|
6153
7975
|
var Broadcaster = class {
|
|
6154
7976
|
listeners = /* @__PURE__ */ new Set();
|
|
6155
7977
|
subscribe(fn) {
|
|
@@ -6167,7 +7989,7 @@ var Broadcaster = class {
|
|
|
6167
7989
|
};
|
|
6168
7990
|
var IGNORE = /(^|[\\/])(node_modules|dist|\.git)([\\/]|$)/;
|
|
6169
7991
|
function watchSource(projectDir, onChange, debounceMs = 200) {
|
|
6170
|
-
const dir = existsSync11(
|
|
7992
|
+
const dir = existsSync11(join16(projectDir, "src")) ? join16(projectDir, "src") : projectDir;
|
|
6171
7993
|
let timer;
|
|
6172
7994
|
const watcher = watch(dir, { recursive: true }, (_event, file) => {
|
|
6173
7995
|
const name = typeof file === "string" ? file : "";
|
|
@@ -6416,8 +8238,8 @@ function renderLanes(frames, summaries) {
|
|
|
6416
8238
|
status: Object.fromEntries(f.ir.nodes.map((n) => [n.id, n.attrs?._status ?? ""])),
|
|
6417
8239
|
lexicon: Object.fromEntries(f.ir.nodes.map((n) => [n.id, n.lexicon]))
|
|
6418
8240
|
}));
|
|
6419
|
-
const
|
|
6420
|
-
return doc.includes("</body>") ? doc.replace("</body>", `${
|
|
8241
|
+
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);
|
|
8242
|
+
return doc.includes("</body>") ? doc.replace("</body>", `${strip3}</body>`) : doc + strip3;
|
|
6421
8243
|
}
|
|
6422
8244
|
|
|
6423
8245
|
// src/emulator.ts
|
|
@@ -6455,13 +8277,13 @@ async function emulatorDown(projectDir) {
|
|
|
6455
8277
|
}
|
|
6456
8278
|
|
|
6457
8279
|
// src/demos.ts
|
|
6458
|
-
import { readFileSync as
|
|
6459
|
-
import { join as
|
|
8280
|
+
import { readFileSync as readFileSync13, existsSync as existsSync12, cpSync } from "node:fs";
|
|
8281
|
+
import { join as join17, relative as relative3, resolve as resolve5, sep as sep3 } from "node:path";
|
|
6460
8282
|
import { spawn as spawn5, spawnSync } from "node:child_process";
|
|
6461
8283
|
function loadDemoRegistry(pkgRoot2) {
|
|
6462
8284
|
let raw;
|
|
6463
8285
|
try {
|
|
6464
|
-
raw = JSON.parse(
|
|
8286
|
+
raw = JSON.parse(readFileSync13(join17(pkgRoot2, "demos.json"), "utf8"));
|
|
6465
8287
|
} catch {
|
|
6466
8288
|
return [];
|
|
6467
8289
|
}
|
|
@@ -6501,22 +8323,22 @@ function fetchesFromNetwork(entry) {
|
|
|
6501
8323
|
return entry.source === "git";
|
|
6502
8324
|
}
|
|
6503
8325
|
function demoTargetDir(entry, cwd = process.cwd()) {
|
|
6504
|
-
const legacy =
|
|
8326
|
+
const legacy = resolve5(cwd, "behold-demo");
|
|
6505
8327
|
if (entry.name === "writes" && existsSync12(legacy)) return legacy;
|
|
6506
|
-
return
|
|
8328
|
+
return resolve5(cwd, "behold-demos", entry.name);
|
|
6507
8329
|
}
|
|
6508
8330
|
async function loadDemo(entry, opts) {
|
|
6509
8331
|
const { pkgRoot: pkgRoot2, target } = opts;
|
|
6510
8332
|
const say = (line) => opts.log?.(`behold demo ${entry.name} \u2192 ${line}`);
|
|
6511
8333
|
if (!existsSync12(target)) {
|
|
6512
8334
|
if (entry.source === "bundled") {
|
|
6513
|
-
const bundled =
|
|
8335
|
+
const bundled = join17(pkgRoot2, entry.dir);
|
|
6514
8336
|
if (!existsSync12(bundled)) return { ok: false, error: `this install has no bundled ${entry.dir}` };
|
|
6515
8337
|
say(`copying to ${target} (it's yours \u2014 edit it)`);
|
|
6516
8338
|
try {
|
|
6517
8339
|
cpSync(bundled, target, {
|
|
6518
8340
|
recursive: true,
|
|
6519
|
-
filter: (src) => !
|
|
8341
|
+
filter: (src) => !relative3(bundled, src).split(sep3).includes("node_modules")
|
|
6520
8342
|
});
|
|
6521
8343
|
} catch (err) {
|
|
6522
8344
|
return { ok: false, error: `copy failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
@@ -6530,7 +8352,7 @@ async function loadDemo(entry, opts) {
|
|
|
6530
8352
|
} else {
|
|
6531
8353
|
say(`reusing ${target}`);
|
|
6532
8354
|
}
|
|
6533
|
-
if (existsSync12(
|
|
8355
|
+
if (existsSync12(join17(target, "package.json")) && !existsSync12(join17(target, "node_modules"))) {
|
|
6534
8356
|
say("npm install\u2026");
|
|
6535
8357
|
if (await runStep("npm", ["install"], { cwd: target, shell: process.platform === "win32" }) !== 0) {
|
|
6536
8358
|
return { ok: false, error: `npm install failed in ${target}` };
|
|
@@ -6542,7 +8364,7 @@ async function loadDemo(entry, opts) {
|
|
|
6542
8364
|
return { ok: false, error: `setup failed (${entry.setup})` };
|
|
6543
8365
|
}
|
|
6544
8366
|
}
|
|
6545
|
-
return { ok: true, serveDirs: entry.serve.dirs?.length ? entry.serve.dirs.map((d) =>
|
|
8367
|
+
return { ok: true, serveDirs: entry.serve.dirs?.length ? entry.serve.dirs.map((d) => join17(target, d)) : [target] };
|
|
6546
8368
|
}
|
|
6547
8369
|
function runStep(cmd, args, opts = {}) {
|
|
6548
8370
|
return new Promise((res) => {
|
|
@@ -6553,8 +8375,8 @@ function runStep(cmd, args, opts = {}) {
|
|
|
6553
8375
|
}
|
|
6554
8376
|
|
|
6555
8377
|
// src/server.ts
|
|
6556
|
-
var pkgRoot =
|
|
6557
|
-
var webRoot =
|
|
8378
|
+
var pkgRoot = join18(dirname5(fileURLToPath3(import.meta.url)), "..");
|
|
8379
|
+
var webRoot = join18(pkgRoot, "web");
|
|
6558
8380
|
var execFileP = async (cmd, args) => (await promisify3(execFile3)(cmd, args, { encoding: "utf8", timeout: 1e4 })).stdout;
|
|
6559
8381
|
function optsFromQuery(url, tierEnvVar, projectDir) {
|
|
6560
8382
|
const q = url.searchParams;
|
|
@@ -6603,8 +8425,8 @@ function tierFailure(tier, message) {
|
|
|
6603
8425
|
}
|
|
6604
8426
|
function beholdVersion() {
|
|
6605
8427
|
try {
|
|
6606
|
-
const here =
|
|
6607
|
-
return JSON.parse(
|
|
8428
|
+
const here = dirname5(fileURLToPath3(import.meta.url));
|
|
8429
|
+
return JSON.parse(readFileSync14(join18(here, "..", "package.json"), "utf8")).version ?? "unknown";
|
|
6608
8430
|
} catch {
|
|
6609
8431
|
return "unknown";
|
|
6610
8432
|
}
|
|
@@ -6649,7 +8471,9 @@ async function captureFrame(projectDir, env, frames, broadcaster) {
|
|
|
6649
8471
|
}
|
|
6650
8472
|
}
|
|
6651
8473
|
function carveRoutes(app, reportPath, demo) {
|
|
6652
|
-
const load = () => readCarveReport(reportPath, (p) =>
|
|
8474
|
+
const load = () => readCarveReport(reportPath, (p) => readFileSync14(p, "utf8"));
|
|
8475
|
+
const carveDirs = [dirname5(reportPath), ...demo ? [demo.out] : []];
|
|
8476
|
+
const carveStates = () => readCarveStates(carveDirs, nodeCarveIo, 0);
|
|
6653
8477
|
const demoBlock = () => carveWriteBlock(demo);
|
|
6654
8478
|
const demoInfo = () => {
|
|
6655
8479
|
if (!demo) return null;
|
|
@@ -6661,8 +8485,8 @@ function carveRoutes(app, reportPath, demo) {
|
|
|
6661
8485
|
project: demo.project,
|
|
6662
8486
|
out: demo.out,
|
|
6663
8487
|
// Paths the UI can print without leaking the operator's whole home dir.
|
|
6664
|
-
outLabel:
|
|
6665
|
-
fromLabel:
|
|
8488
|
+
outLabel: relative4(demo.root, demo.out).split(sep4).join("/"),
|
|
8489
|
+
fromLabel: relative4(demo.root, demo.from).split(sep4).join("/"),
|
|
6666
8490
|
runnable: !block,
|
|
6667
8491
|
...block ? { reason: block } : {},
|
|
6668
8492
|
// The live tier, when this boot armed it: the scratch endpoint the
|
|
@@ -6679,7 +8503,7 @@ function carveRoutes(app, reportPath, demo) {
|
|
|
6679
8503
|
let appGraph;
|
|
6680
8504
|
const appGraphOnce = () => {
|
|
6681
8505
|
if (!demo) return Promise.resolve(null);
|
|
6682
|
-
appGraph ??= graphIr(demo.project).then((ir) => ir.nodes.length ? { ir, label:
|
|
8506
|
+
appGraph ??= graphIr(demo.project).then((ir) => ir.nodes.length ? { ir, label: relative4(demo.root, demo.project).split(sep4).join("/") } : null).catch(() => null);
|
|
6683
8507
|
return appGraph;
|
|
6684
8508
|
};
|
|
6685
8509
|
app.get("/api/graph", async (c) => {
|
|
@@ -6687,10 +8511,21 @@ function carveRoutes(app, reportPath, demo) {
|
|
|
6687
8511
|
if (!parsed.ok) return c.json(parsed.refusal, 422);
|
|
6688
8512
|
const tfIr = carveReportToIr(parsed.report);
|
|
6689
8513
|
const appSide = await appGraphOnce();
|
|
8514
|
+
const states = carveStates();
|
|
8515
|
+
const shorten = demo ? (p) => shortenIn(p, demo.root) : (p) => p;
|
|
8516
|
+
const single = () => {
|
|
8517
|
+
const split = splitCarveState(tfIr, states, { shorten });
|
|
8518
|
+
return bandGraduated(split.tf, split.graduated);
|
|
8519
|
+
};
|
|
6690
8520
|
const { svg, ir } = appSide ? renderCarveEstate(tfIr, appSide.ir, {
|
|
6691
|
-
tfTitle: `${
|
|
6692
|
-
appTitle: `${appSide.label} \u2014 chant
|
|
6693
|
-
|
|
8521
|
+
tfTitle: `${relative4(demo.root, demo.from).split(sep4).join("/")} \u2014 terraform`,
|
|
8522
|
+
appTitle: `${appSide.label} \u2014 chant`,
|
|
8523
|
+
carved: states,
|
|
8524
|
+
shorten
|
|
8525
|
+
}) : (() => {
|
|
8526
|
+
const banded = single();
|
|
8527
|
+
return { ...renderBanded(banded), ir: banded };
|
|
8528
|
+
})();
|
|
6694
8529
|
return c.json({
|
|
6695
8530
|
ir,
|
|
6696
8531
|
svg,
|
|
@@ -6702,7 +8537,7 @@ function carveRoutes(app, reportPath, demo) {
|
|
|
6702
8537
|
carve: true,
|
|
6703
8538
|
// A demo whose own advisor run failed says so on the statusbar, not
|
|
6704
8539
|
// only in the terminal the viewer isn't looking at.
|
|
6705
|
-
note: carveNote(parsed.report, tfIr) + (demo?.degraded ? ` Degraded: ${demo.degraded}` : "")
|
|
8540
|
+
note: carveNote(parsed.report, tfIr) + carveStateNote(carveProgress(states.values(), tfIr.nodes.length), states) + (demo?.degraded ? ` Degraded: ${demo.degraded}` : "")
|
|
6706
8541
|
}
|
|
6707
8542
|
});
|
|
6708
8543
|
});
|
|
@@ -6734,14 +8569,22 @@ function carveRoutes(app, reportPath, demo) {
|
|
|
6734
8569
|
}
|
|
6735
8570
|
const tfIr = carveReportToIr(parsed.report);
|
|
6736
8571
|
const html = renderCarveMorph(tfIr, appSide.ir, [select], {
|
|
6737
|
-
tfTitle: `${
|
|
8572
|
+
tfTitle: `${relative4(demo.root, demo.from).split(sep4).join("/")} \u2014 terraform`,
|
|
6738
8573
|
appTitle: `${appSide.label} \u2014 chant`,
|
|
6739
|
-
title: `carve \u2014 ${select}
|
|
8574
|
+
title: `carve \u2014 ${select}`,
|
|
8575
|
+
carved: carveStates(),
|
|
8576
|
+
shorten: (p) => shortenIn(p, demo.root)
|
|
6740
8577
|
});
|
|
6741
8578
|
return c.html(html);
|
|
6742
8579
|
});
|
|
6743
8580
|
app.get("/api/project", (c) => {
|
|
6744
8581
|
const parsed = load();
|
|
8582
|
+
const total = parsed.ok ? parsed.report.count ?? parsed.report.resources.length : null;
|
|
8583
|
+
const state = carveStatePayload(
|
|
8584
|
+
carveStates(),
|
|
8585
|
+
total,
|
|
8586
|
+
() => demo ? { from: shortenIn(demo.from, demo.root), out: shortenIn(demo.out, demo.root) } : {}
|
|
8587
|
+
);
|
|
6745
8588
|
return c.json({
|
|
6746
8589
|
projectDir: reportPath,
|
|
6747
8590
|
recents: [],
|
|
@@ -6755,11 +8598,12 @@ function carveRoutes(app, reportPath, demo) {
|
|
|
6755
8598
|
carve: parsed.ok ? {
|
|
6756
8599
|
report: reportPath,
|
|
6757
8600
|
from: parsed.report.from ?? null,
|
|
6758
|
-
count:
|
|
8601
|
+
count: total,
|
|
6759
8602
|
bands: parsed.report.bands ?? {},
|
|
6760
8603
|
advisory: parsed.report.advisory ?? null,
|
|
6761
|
-
demo: demoInfo()
|
|
6762
|
-
|
|
8604
|
+
demo: demoInfo(),
|
|
8605
|
+
state
|
|
8606
|
+
} : { report: reportPath, demo: demoInfo(), state }
|
|
6763
8607
|
});
|
|
6764
8608
|
});
|
|
6765
8609
|
const runStep2 = async (c, run3) => {
|
|
@@ -6869,7 +8713,17 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
6869
8713
|
// of starting blank — the `apply` SSE event (below) carries every update
|
|
6870
8714
|
// after that. `status: "idle"` (initialApplyProgress) when nothing has
|
|
6871
8715
|
// applied yet this session.
|
|
6872
|
-
applyProgress: runner.applyProgress
|
|
8716
|
+
applyProgress: runner.applyProgress,
|
|
8717
|
+
// The run playhead (#284 item 2): the settled StepRecords of the Op this
|
|
8718
|
+
// session last triggered, so a client opening (or reloading) mid-run
|
|
8719
|
+
// hydrates the playhead instead of starting blank — the `run` SSE event
|
|
8720
|
+
// carries every update after that. `status: "idle"` when nothing has run.
|
|
8721
|
+
runState: runner.runState,
|
|
8722
|
+
// #234 join 3: the operator strip's last known model, so a client that
|
|
8723
|
+
// opens (or reloads) hydrates the strip instead of starting blank — same
|
|
8724
|
+
// reason `runState` is here. `declared: []` until the ops lens has been
|
|
8725
|
+
// opened once, since that is where the op.json read happens.
|
|
8726
|
+
operatorState: runner.operatorState
|
|
6873
8727
|
})
|
|
6874
8728
|
);
|
|
6875
8729
|
app.post("/api/ops/:name/run", (c) => {
|
|
@@ -6910,8 +8764,8 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
6910
8764
|
return c.json({ started: true, name, ran: label });
|
|
6911
8765
|
});
|
|
6912
8766
|
app.post("/api/local/reset", (c) => {
|
|
6913
|
-
const down =
|
|
6914
|
-
const up =
|
|
8767
|
+
const down = join18(cfg.projectDir, "scripts/local/local-down.sh");
|
|
8768
|
+
const up = join18(cfg.projectDir, "scripts/local/local-up.sh");
|
|
6915
8769
|
if (!existsSync13(down) || !existsSync13(up)) {
|
|
6916
8770
|
return c.json({ error: "no local-down.sh / local-up.sh in scripts/local \u2014 reset is only for local emulator projects" }, 400);
|
|
6917
8771
|
}
|
|
@@ -6920,6 +8774,62 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
6920
8774
|
}
|
|
6921
8775
|
return c.json({ started: true, ran: "local-down + local-up" });
|
|
6922
8776
|
});
|
|
8777
|
+
app.get("/api/ops/:name/status", async (c) => {
|
|
8778
|
+
const name = c.req.param("name");
|
|
8779
|
+
const info = estateOps().find((o) => o.name === name);
|
|
8780
|
+
const run3 = await runChantRaw(["run", "status", name, "--temporal"], info?.dir ?? cfg.projectDir);
|
|
8781
|
+
const result = readRunStatus(name, run3);
|
|
8782
|
+
const runState = runner.noteGate(name, result);
|
|
8783
|
+
if (!result.ok) return c.json({ ...result.refusal, runState }, 422);
|
|
8784
|
+
return c.json({ ...result.read, runState });
|
|
8785
|
+
});
|
|
8786
|
+
const convergeDirs = () => {
|
|
8787
|
+
const dirs = [];
|
|
8788
|
+
const declared = [];
|
|
8789
|
+
for (const f of discoverOpIrs(estateDirs)) {
|
|
8790
|
+
const parsed = readOpIr(f.path, (p) => readFileSync14(p, "utf8"));
|
|
8791
|
+
if (!parsed.ok) continue;
|
|
8792
|
+
const mine = declaredConvergeOps([parsed.ir]);
|
|
8793
|
+
if (!mine.length) continue;
|
|
8794
|
+
declared.push(...mine);
|
|
8795
|
+
if (!dirs.includes(f.dir)) dirs.push(f.dir);
|
|
8796
|
+
}
|
|
8797
|
+
return { dirs, declared };
|
|
8798
|
+
};
|
|
8799
|
+
app.get("/api/operator/status", async (c) => {
|
|
8800
|
+
const { dirs, declared } = convergeDirs();
|
|
8801
|
+
runner.noteDeclaredConvergeOps(declared);
|
|
8802
|
+
if (!dirs.length) {
|
|
8803
|
+
const state2 = runner.noteOperator({
|
|
8804
|
+
ok: false,
|
|
8805
|
+
refusal: {
|
|
8806
|
+
error: "This project declares no operating loop \u2014 no emitted Op carries chant's `Converge` search attribute.",
|
|
8807
|
+
code: "no-operator",
|
|
8808
|
+
remedy: HOW_TO_DECLARE
|
|
8809
|
+
}
|
|
8810
|
+
});
|
|
8811
|
+
return c.json({ ...state2, operator: state2 }, 200);
|
|
8812
|
+
}
|
|
8813
|
+
const rows = [];
|
|
8814
|
+
for (const dir of dirs) {
|
|
8815
|
+
const run3 = await runChantRaw(["operator", "status", "--json"], dir);
|
|
8816
|
+
const result = readOperatorStatus(run3);
|
|
8817
|
+
if (!result.ok) return c.json({ ...result.refusal, operator: runner.noteOperator(result) }, 422);
|
|
8818
|
+
rows.push(...result.rows);
|
|
8819
|
+
}
|
|
8820
|
+
const state = runner.noteOperator({ ok: true, rows });
|
|
8821
|
+
return c.json({ ...state, operator: state });
|
|
8822
|
+
});
|
|
8823
|
+
app.post("/api/operator/approve/:op/:gate", async (c) => {
|
|
8824
|
+
const { op, gate } = c.req.param();
|
|
8825
|
+
const info = estateOps().find((o) => o.name === op);
|
|
8826
|
+
broadcaster.emit("op", `\u270E approve ${op} ${gate} \u2014 records a fact; the next tick acts on it`);
|
|
8827
|
+
const { code, stderr, stdout } = await runChantRaw(["approve", op, gate], info?.dir ?? cfg.projectDir);
|
|
8828
|
+
if (code !== 0) {
|
|
8829
|
+
return c.json({ error: stderr.trim() || stdout.trim() || `approve exited ${code}`, code: "approve" }, 500);
|
|
8830
|
+
}
|
|
8831
|
+
return c.json({ recorded: true, op, gate, semantics: APPROVE_SEMANTICS });
|
|
8832
|
+
});
|
|
6923
8833
|
app.post("/api/ops/:name/signal/:gate", async (c) => {
|
|
6924
8834
|
const { name, gate } = c.req.param();
|
|
6925
8835
|
const info = estateOps().find((o) => o.name === name);
|
|
@@ -6946,8 +8856,11 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
6946
8856
|
const { environments, lexicons, stacks, k8sProfiles } = await detectProject(cfg.projectDir);
|
|
6947
8857
|
const k8sTarget = lexicons.includes("k8s") ? resolveK8sTarget(k8sProfiles, cfg.env, await loadKubeconfig()) : void 0;
|
|
6948
8858
|
const axes = deployAxes(tierEnvVar, lexicons, k8sTarget);
|
|
8859
|
+
const emittedOps = discoverOpIrs(estateDirs).length;
|
|
8860
|
+
const carveState = carveStatePayload(readCarveStates(estateDirs));
|
|
6949
8861
|
return c.json({
|
|
6950
8862
|
projectDir: cfg.projectDir,
|
|
8863
|
+
...carveState.manifests ? { carve: { state: carveState } } : {},
|
|
6951
8864
|
// #195: the full estate composition (multi-project serves) and the
|
|
6952
8865
|
// switcher's recents, so the SPA's project section can show what's
|
|
6953
8866
|
// loaded and offer where to go. Recents exclude nothing here — the SPA
|
|
@@ -6978,6 +8891,12 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
6978
8891
|
// the names — `graphPath` (chant.ts) resolves a picked name to its `src`
|
|
6979
8892
|
// server-side; the SPA never needs the path.
|
|
6980
8893
|
...stacks?.length ? { stacks: stacks.map((s) => s.name) } : {},
|
|
8894
|
+
// The ops lens stop (#284), gated exactly like `tiers`/`stacks` above:
|
|
8895
|
+
// present only when the estate has actually emitted Ops, so a project on
|
|
8896
|
+
// a chant < 0.50, or one that has never been built, grows no dead zoom
|
|
8897
|
+
// stop and no dead ⌘K entry. The count, not a bare flag — the SPA shows
|
|
8898
|
+
// it, and "ops: 0" is not a thing this can ever say.
|
|
8899
|
+
...emittedOps ? { ops: emittedOps } : {},
|
|
6981
8900
|
targets: deployTargets(lexicons, k8sTarget),
|
|
6982
8901
|
// Where the k8s binding came from, so the SPA never implies behold chose
|
|
6983
8902
|
// it (#106). Absent for a project with no k8s lexicon or no resolvable
|
|
@@ -6989,9 +8908,9 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
6989
8908
|
app.post("/api/project/open", async (c) => {
|
|
6990
8909
|
if (cfg.previewMode) return c.json({ error: "switching projects is locked in preview mode" }, 403);
|
|
6991
8910
|
const body = await c.req.json().catch(() => ({}));
|
|
6992
|
-
const dir = typeof body.dir === "string" && body.dir.trim() ?
|
|
8911
|
+
const dir = typeof body.dir === "string" && body.dir.trim() ? resolve6(body.dir.trim()) : "";
|
|
6993
8912
|
if (!dir || !existsSync13(dir)) return c.json({ error: `no such directory: ${dir || "(no dir given)"}` }, 400);
|
|
6994
|
-
if (!existsSync13(
|
|
8913
|
+
if (!existsSync13(join18(dir, "chant.config.ts"))) {
|
|
6995
8914
|
return c.json({ error: `${dir} doesn't look like a chant project \u2014 no chant.config.ts` }, 400);
|
|
6996
8915
|
}
|
|
6997
8916
|
switchServedProject([dir]);
|
|
@@ -7011,7 +8930,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
7011
8930
|
};
|
|
7012
8931
|
app.post("/api/project/reveal", async (c) => {
|
|
7013
8932
|
const body = await c.req.json().catch(() => ({}));
|
|
7014
|
-
const dir = typeof body.dir === "string" && body.dir.trim() ?
|
|
8933
|
+
const dir = typeof body.dir === "string" && body.dir.trim() ? resolve6(body.dir.trim()) : cfg.projectDir;
|
|
7015
8934
|
const known = /* @__PURE__ */ new Set([cfg.projectDir, ...cfg.projectDirs ?? [], ...listRecents().map((r) => r.dir)]);
|
|
7016
8935
|
if (!known.has(dir)) return c.json({ error: "not a served or recent project directory" }, 400);
|
|
7017
8936
|
const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
|
|
@@ -7079,7 +8998,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
7079
8998
|
const loaded = await loadDemo(entry, { pkgRoot, target, log: (line) => process.stdout.write(line + "\n") });
|
|
7080
8999
|
if (!loaded.ok) return c.json({ error: loaded.error }, 500);
|
|
7081
9000
|
const dirs = loaded.serveDirs;
|
|
7082
|
-
if (!existsSync13(
|
|
9001
|
+
if (!existsSync13(join18(dirs[0], "chant.config.ts"))) {
|
|
7083
9002
|
return c.json({ error: `${entry.name} loaded to ${target} but ${dirs[0]} has no chant.config.ts` }, 500);
|
|
7084
9003
|
}
|
|
7085
9004
|
if (entry.serve.local) {
|
|
@@ -7177,7 +9096,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
7177
9096
|
path: "/api/demos/open",
|
|
7178
9097
|
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
9098
|
},
|
|
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" },
|
|
9099
|
+
{ 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
9100
|
...cfg.carveReport ? [
|
|
7182
9101
|
{ method: "GET", path: "/api/carve", desc: "carve mode: the raw `chant carve advise --json` peelability report this server is rendering" },
|
|
7183
9102
|
{
|
|
@@ -7199,14 +9118,17 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
7199
9118
|
{ method: "GET", path: "/api/resources", desc: "component \u2192 declared resources" },
|
|
7200
9119
|
{ method: "GET", path: "/api/ci", desc: "generated CI pipeline projection {stages, jobs, forge}" },
|
|
7201
9120
|
{ 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" },
|
|
9121
|
+
{ method: "GET", path: "/api/ops", desc: "committed Ops + adopt lexicons + apply progress + run playhead" },
|
|
9122
|
+
{ method: "GET", path: "/api/ops/:name/status", desc: "an Op's durable run status + pending gate (chant run status)" },
|
|
9123
|
+
{ method: "GET", path: "/api/operator/status", desc: "the operating loop's strip: last tick per ConvergeOp + lease + pending converge gates" },
|
|
7203
9124
|
{ method: "GET", path: "/api/history", desc: "recent source commits (rollback targets)" },
|
|
7204
9125
|
{ method: "GET", path: "/api/frames", desc: "captured lanes frames" },
|
|
7205
|
-
{ method: "GET", path: "/api/events", desc: "SSE: changed / op / apply / pr" },
|
|
9126
|
+
{ method: "GET", path: "/api/events", desc: "SSE: changed / op / apply / run / pr" },
|
|
7206
9127
|
{ method: "POST", path: "/api/refresh", desc: "re-observe live now (?env=) \u2014 returns the fresh graph" },
|
|
7207
9128
|
{ method: "POST", path: "/api/apply", desc: "delegated apply: ?env=&component=<name|all> (guarded, preview-locked)" },
|
|
7208
9129
|
{ 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" },
|
|
9130
|
+
{ method: "POST", path: "/api/ops/:name/signal/:gate", desc: "approve an Op's gate (releases the waiting run)" },
|
|
9131
|
+
{ 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
9132
|
{ method: "POST", path: "/api/rollback", desc: "open a rollback PR: ?to=<sha>" },
|
|
7211
9133
|
{ method: "POST", path: "/api/substrates/:name/up", desc: "bring a substrate up" },
|
|
7212
9134
|
{ method: "POST", path: "/api/local/reset", desc: "reset the local emulator" },
|
|
@@ -7218,6 +9140,58 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
7218
9140
|
const url = new URL(c.req.url);
|
|
7219
9141
|
const opts = optsFromQuery(url, tierEnvVar, cfg.projectDir);
|
|
7220
9142
|
try {
|
|
9143
|
+
if (url.searchParams.get("ops") === "1") {
|
|
9144
|
+
const files = discoverOpIrs(estateDirs);
|
|
9145
|
+
if (!files.length) {
|
|
9146
|
+
return c.json(
|
|
9147
|
+
{
|
|
9148
|
+
error: "this project has no emitted Ops \u2014 nothing under dist/ops/<name>/op.json",
|
|
9149
|
+
code: "op-ir",
|
|
9150
|
+
remedy: HOW_TO_EMIT
|
|
9151
|
+
},
|
|
9152
|
+
422
|
|
9153
|
+
);
|
|
9154
|
+
}
|
|
9155
|
+
const parsedOps = [];
|
|
9156
|
+
for (const f of files) {
|
|
9157
|
+
const parsed = readOpIr(f.path, (p) => readFileSync14(p, "utf8"));
|
|
9158
|
+
if (!parsed.ok) return c.json(parsed.refusal, 422);
|
|
9159
|
+
parsedOps.push(parsed.ir);
|
|
9160
|
+
}
|
|
9161
|
+
const declaredIr = opsToIr(parsedOps);
|
|
9162
|
+
const runState = runner.runState;
|
|
9163
|
+
const ranOp = runState.op ? parsedOps.find((o) => o.name === runState.op) : void 0;
|
|
9164
|
+
const join21 = ranOp ? joinRun(ranOp, runState.records, runState.gate) : null;
|
|
9165
|
+
const opsIr = join21 ? paintPlayhead(declaredIr, join21, runState.gate) : declaredIr;
|
|
9166
|
+
const { svg: svg2 } = renderGraph(opsIr, { boxes: "byStack" });
|
|
9167
|
+
const operator = runner.noteDeclaredConvergeOps(declaredConvergeOps(parsedOps));
|
|
9168
|
+
return c.json({
|
|
9169
|
+
ir: opsIr,
|
|
9170
|
+
svg: svg2,
|
|
9171
|
+
meta: {
|
|
9172
|
+
projectDir: cfg.projectDir,
|
|
9173
|
+
env: cfg.env ?? null,
|
|
9174
|
+
tier: null,
|
|
9175
|
+
target: null,
|
|
9176
|
+
mode: "ops",
|
|
9177
|
+
ops: parsedOps.length,
|
|
9178
|
+
note: `${opsNote(parsedOps, opsIr)} ${playheadNote(runState, join21)} ${operatorNote(operator)}`,
|
|
9179
|
+
// The operator strip's own model: the ConvergeOps this project
|
|
9180
|
+
// declares, plus whatever the last status read said. `declared`
|
|
9181
|
+
// without a `strip` is the honest starting state — a loop exists and
|
|
9182
|
+
// behold hasn't asked about it yet.
|
|
9183
|
+
operator,
|
|
9184
|
+
// The playhead's own model, so the SPA renders the pending card and
|
|
9185
|
+
// the run legend from the same answer that painted the graph.
|
|
9186
|
+
run: runState,
|
|
9187
|
+
// #234's pending-approval card, whole: the gate, when it started
|
|
9188
|
+
// waiting, the step it holds back, and the delegated write its
|
|
9189
|
+
// approve button performs. Present only when `gateState` NAMED a
|
|
9190
|
+
// pending gate — never inferred from a stalled track.
|
|
9191
|
+
gate: runState.gate && runState.op ? pendingGateCard(runState.op, runState.gate, opsIr) : null
|
|
9192
|
+
}
|
|
9193
|
+
});
|
|
9194
|
+
}
|
|
7221
9195
|
const components = url.searchParams.get("components") === "1";
|
|
7222
9196
|
const logical = url.searchParams.get("logical") === "1";
|
|
7223
9197
|
const multi = cfg.projectDirs && cfg.projectDirs.length > 1;
|
|
@@ -7226,8 +9200,10 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
7226
9200
|
let metaEnv = cfg.env ?? null;
|
|
7227
9201
|
if (multi) {
|
|
7228
9202
|
ir = await composeEstate(cfg.projectDirs, logical ? { ...opts, detail: 3 } : opts);
|
|
7229
|
-
ir = addValueMatchEdges(ir);
|
|
7230
9203
|
ir = addK8sDeclaredEdges(ir);
|
|
9204
|
+
ir = addValueMatchEdges(ir);
|
|
9205
|
+
ir = addEstateMemberEdges(ir, estateMembers(cfg.projectDirs));
|
|
9206
|
+
ir = markOperatorHome(ir);
|
|
7231
9207
|
const estateContext = await boundK8sContext(metaEnv ?? void 0);
|
|
7232
9208
|
ir = addClusterAnchorEdges(ir, estateContext);
|
|
7233
9209
|
if (logical) {
|
|
@@ -7262,7 +9238,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
7262
9238
|
ir = joinCiProgress(ir, runner.applyProgress);
|
|
7263
9239
|
} else if (logical) {
|
|
7264
9240
|
const raw = mergeClusterRoot(await graphIr(cfg.projectDir, { ...opts, detail: 3 }), await clusterRootGraphIr(cfg.projectDir, opts));
|
|
7265
|
-
const base = addK8sDeclaredEdges(
|
|
9241
|
+
const base = addValueMatchEdges(addK8sDeclaredEdges(raw));
|
|
7266
9242
|
const { ir: projected, byContainer } = projectTopology(base, metaEnv ?? void 0, await boundK8sContext(metaEnv ?? void 0), [await graphPath(cfg.projectDir, opts), cfg.projectDir]);
|
|
7267
9243
|
const { svg: svg2 } = renderArchitecture(projected, byContainer);
|
|
7268
9244
|
const logicalNote = notesFor("logical", projected, void 0, base.nodes.length);
|
|
@@ -7270,8 +9246,9 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
7270
9246
|
} else {
|
|
7271
9247
|
ir = mergeClusterRoot(await graphIr(cfg.projectDir, opts), await clusterRootGraphIr(cfg.projectDir, opts));
|
|
7272
9248
|
if ((opts.detail ?? 2) < 3) ir = pruneImports(ir);
|
|
7273
|
-
ir = addValueMatchEdges(ir);
|
|
7274
9249
|
ir = addK8sDeclaredEdges(ir);
|
|
9250
|
+
ir = addValueMatchEdges(ir);
|
|
9251
|
+
ir = markOperatorHome(ir);
|
|
7275
9252
|
ir = addClusterAnchorEdges(ir, await boundK8sContext(metaEnv ?? void 0));
|
|
7276
9253
|
}
|
|
7277
9254
|
let srcCompositeEdgesAttached;
|
|
@@ -7285,14 +9262,14 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
7285
9262
|
srcCompositeEdgesAttached = 0;
|
|
7286
9263
|
}
|
|
7287
9264
|
}
|
|
7288
|
-
if (!multi && !components && !logical && !opts.lens && ir.nodes.length === 0 && !existsSync13(
|
|
9265
|
+
if (!multi && !components && !logical && !opts.lens && ir.nodes.length === 0 && !existsSync13(join18(cfg.projectDir, "chant.config.ts"))) {
|
|
7289
9266
|
return c.json(noProjectError(cfg.projectDir), 404);
|
|
7290
9267
|
}
|
|
7291
9268
|
const radial = new URL(c.req.url).searchParams.get("radial") === "1";
|
|
7292
9269
|
const { svg } = renderGraph(ir, multi ? { boxes: "byStack" } : { radial });
|
|
7293
9270
|
const srcZoom = components ? "components" : logical ? "logical" : opts.detail === 1 ? "composites" : opts.detail === 3 ? "attributes" : "resources";
|
|
7294
9271
|
const estateLensNote = multi && components ? "the components lens doesn't apply to a composed estate yet \u2014 showing the composed entity graph" : void 0;
|
|
7295
|
-
const srcNote = multi ? estateLensNote : notesFor(srcZoom, ir, srcCompositeEdgesAttached);
|
|
9272
|
+
const srcNote = multi ? estateLensNote : notesFor(srcZoom, ir, srcCompositeEdgesAttached, void 0, opts.detail ?? 2);
|
|
7296
9273
|
return c.json({
|
|
7297
9274
|
ir,
|
|
7298
9275
|
svg,
|
|
@@ -7401,8 +9378,10 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
7401
9378
|
const composed = est.ir;
|
|
7402
9379
|
let ir2 = runtime ? attachRuntimeContainment(composed) : pruneRuntimeChildren(composed);
|
|
7403
9380
|
if ((detail ?? 2) < 3) ir2 = pruneImports(ir2);
|
|
7404
|
-
ir2 = addValueMatchEdges(ir2);
|
|
7405
9381
|
ir2 = addK8sDeclaredEdges(ir2);
|
|
9382
|
+
ir2 = addValueMatchEdges(ir2);
|
|
9383
|
+
ir2 = addEstateMemberEdges(ir2, estateMembers(cfg.projectDirs));
|
|
9384
|
+
ir2 = markOperatorHome(ir2);
|
|
7406
9385
|
const boundContext2 = await boundK8sContext(env);
|
|
7407
9386
|
ir2 = addClusterAnchorEdges(ir2, boundContext2);
|
|
7408
9387
|
const coverNote = est.unobserved.length || est.dropped.length ? `live observe covered ${est.observed} of ${est.total} projects \u2014 ` + [
|
|
@@ -7427,11 +9406,12 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
7427
9406
|
});
|
|
7428
9407
|
}
|
|
7429
9408
|
const { svg: svg2 } = renderGraph(ir2, { boxes: runtime ? "byContainer" : "byStack" });
|
|
9409
|
+
const zoom2 = runtime ? "runtime" : detail === 1 ? "composites" : detail === 3 ? "attributes" : "resources";
|
|
7430
9410
|
const note2 = [
|
|
7431
9411
|
coverNote,
|
|
7432
9412
|
namespaceJoinNote(est.joined),
|
|
7433
9413
|
namespaceMismatchNote(withoutJoinedMembers(ir2.nodes, est.joined)),
|
|
7434
|
-
|
|
9414
|
+
notesFor(zoom2, ir2, void 0, void 0, detail ?? 2)
|
|
7435
9415
|
].filter(Boolean).join(" \xB7 ");
|
|
7436
9416
|
return c.json({
|
|
7437
9417
|
ir: ir2,
|
|
@@ -7448,19 +9428,21 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
7448
9428
|
const observed = await lifecycleDiffLive(cfg.projectDir, env, query).then((d) => d.lexicons?.helm?.observedArtifacts).catch(() => void 0);
|
|
7449
9429
|
applyHelmArtifacts(ir, observed);
|
|
7450
9430
|
synthesizeHelmReleases(ir, observed, discoverReleaseUnits(cfg.projectDir));
|
|
9431
|
+
applyHelmRenderDrift(ir, await readHelmRenderDrift(cfg.projectDir, env, query));
|
|
7451
9432
|
}
|
|
7452
9433
|
ir = runtime ? attachRuntimeContainment(ir) : pruneRuntimeChildren(ir);
|
|
7453
9434
|
if (logical) {
|
|
7454
9435
|
const logicalBefore = ir.nodes.length;
|
|
7455
|
-
const { ir: projected, byContainer } = projectTopology(addK8sDeclaredEdges(
|
|
9436
|
+
const { ir: projected, byContainer } = projectTopology(addValueMatchEdges(addK8sDeclaredEdges(ir)), env, boundContext, [await graphPath(cfg.projectDir, opts), cfg.projectDir]);
|
|
7456
9437
|
const { svg: svg2 } = renderArchitecture(projected, byContainer);
|
|
7457
9438
|
const logicalTierNote = tierMismatchNote(projected, beholdConfig.tiers, query.tier);
|
|
7458
9439
|
const logicalNote = [logicalTierNote, notesFor("logical", projected, void 0, logicalBefore)].filter(Boolean).join(" \xB7 ");
|
|
7459
9440
|
return c.json({ ir: projected, svg: svg2, byContainer, meta: { projectDir: cfg.projectDir, env, mode: "logical", ...logicalNote ? { note: logicalNote } : {} } });
|
|
7460
9441
|
}
|
|
7461
9442
|
if ((query.detail ?? 2) < 3) ir = pruneImports(ir);
|
|
7462
|
-
ir = addValueMatchEdges(ir);
|
|
7463
9443
|
ir = addK8sDeclaredEdges(ir);
|
|
9444
|
+
ir = addValueMatchEdges(ir);
|
|
9445
|
+
ir = markOperatorHome(ir);
|
|
7464
9446
|
ir = addClusterAnchorEdges(ir, boundContext);
|
|
7465
9447
|
let compositeEdgesAttached;
|
|
7466
9448
|
if (query.detail === 1) {
|
|
@@ -7496,6 +9478,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
7496
9478
|
const observed = await lifecycleDiffLive(cfg.projectDir, env).then((d) => d.lexicons?.helm?.observedArtifacts).catch(() => void 0);
|
|
7497
9479
|
applyHelmArtifacts(result.ir, observed);
|
|
7498
9480
|
synthesizeHelmReleases(result.ir, observed, discoverReleaseUnits(cfg.projectDir));
|
|
9481
|
+
applyHelmRenderDrift(result.ir, await readHelmRenderDrift(cfg.projectDir, env));
|
|
7499
9482
|
}
|
|
7500
9483
|
}
|
|
7501
9484
|
if (env) {
|
|
@@ -7503,8 +9486,8 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
7503
9486
|
else pruneRuntimeChildren(result.ir);
|
|
7504
9487
|
}
|
|
7505
9488
|
if ((optsFromQuery(new URL(c.req.url)).detail ?? 2) < 3) pruneImports(result.ir);
|
|
7506
|
-
addValueMatchEdges(result.ir);
|
|
7507
9489
|
addK8sDeclaredEdges(result.ir);
|
|
9490
|
+
addValueMatchEdges(result.ir);
|
|
7508
9491
|
addClusterAnchorEdges(result.ir, await boundK8sContext(env));
|
|
7509
9492
|
const { svg } = renderGraph(result.ir, { boxes: "byContainer" });
|
|
7510
9493
|
return c.json({
|
|
@@ -7623,9 +9606,9 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
7623
9606
|
}
|
|
7624
9607
|
return c.json({ started: true, component, env });
|
|
7625
9608
|
});
|
|
7626
|
-
const rel =
|
|
9609
|
+
const rel = relative4(process.cwd(), webRoot) || ".";
|
|
7627
9610
|
app.use("/*", serveStatic({ root: rel }));
|
|
7628
|
-
app.get("/", serveStatic({ path:
|
|
9611
|
+
app.get("/", serveStatic({ path: join18(rel, "index.html") }));
|
|
7629
9612
|
return app;
|
|
7630
9613
|
}
|
|
7631
9614
|
async function bootLocalEmulators(dir, who) {
|
|
@@ -7654,7 +9637,7 @@ async function bootLocalEmulators(dir, who) {
|
|
|
7654
9637
|
async function startServer(cfg) {
|
|
7655
9638
|
if (cfg.local) cfg.emulators = await bootLocalEmulators(cfg.projectDir, "behold serve --local");
|
|
7656
9639
|
for (const dir of cfg.projectDirs ?? [cfg.projectDir]) {
|
|
7657
|
-
if (existsSync13(
|
|
9640
|
+
if (existsSync13(join18(dir, "chant.config.ts"))) addRecent(dir);
|
|
7658
9641
|
}
|
|
7659
9642
|
const broadcaster = new Broadcaster();
|
|
7660
9643
|
const frames = new FrameBuffer();
|
|
@@ -7670,6 +9653,10 @@ async function startServer(cfg) {
|
|
|
7670
9653
|
broadcaster.emit("changed", memberDir ?? "");
|
|
7671
9654
|
void capture2();
|
|
7672
9655
|
};
|
|
9656
|
+
const onMemberSourceChange = (memberDir) => {
|
|
9657
|
+
invalidateMember(memberDir);
|
|
9658
|
+
onEstateChange(memberDir);
|
|
9659
|
+
};
|
|
7673
9660
|
const memberTag = (dir) => (cfg.projectDirs?.length ?? 0) > 1 ? ` [${basename(dir)}]` : "";
|
|
7674
9661
|
const onPollDrift = (dir, movedLexicons) => {
|
|
7675
9662
|
onEstateChange(dir);
|
|
@@ -7699,7 +9686,7 @@ async function startServer(cfg) {
|
|
|
7699
9686
|
};
|
|
7700
9687
|
const carve = !!cfg.carveReport;
|
|
7701
9688
|
let stopWatch = carve ? () => {
|
|
7702
|
-
} : watchSources(cfg.projectDirs ?? [cfg.projectDir],
|
|
9689
|
+
} : watchSources(cfg.projectDirs ?? [cfg.projectDir], onMemberSourceChange);
|
|
7703
9690
|
let stopPoll = !carve && cfg.env && cfg.pollSecs ? startDriftPoll({
|
|
7704
9691
|
intervalMs: cfg.pollSecs * 1e3,
|
|
7705
9692
|
// Per-member queries, swept sequentially inside the poll (#297): a
|
|
@@ -7716,7 +9703,7 @@ async function startServer(cfg) {
|
|
|
7716
9703
|
};
|
|
7717
9704
|
cfg.onProjectSwitch = (dir) => {
|
|
7718
9705
|
stopWatch();
|
|
7719
|
-
stopWatch = watchSources(cfg.projectDirs ?? [dir],
|
|
9706
|
+
stopWatch = watchSources(cfg.projectDirs ?? [dir], onMemberSourceChange);
|
|
7720
9707
|
stopPoll();
|
|
7721
9708
|
stopPoll = () => {
|
|
7722
9709
|
};
|
|
@@ -7789,12 +9776,12 @@ async function startServer(cfg) {
|
|
|
7789
9776
|
}
|
|
7790
9777
|
|
|
7791
9778
|
// src/export.ts
|
|
7792
|
-
import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync4, copyFileSync, readFileSync as
|
|
7793
|
-
import { join as
|
|
9779
|
+
import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync4, copyFileSync, readFileSync as readFileSync15, readdirSync as readdirSync7 } from "node:fs";
|
|
9780
|
+
import { join as join19, dirname as dirname6, basename as basename2 } from "node:path";
|
|
7794
9781
|
import { fileURLToPath as fileURLToPath4 } from "node:url";
|
|
7795
|
-
var LENS_PARAMS = ["components", "detail", "env", "logical", "radial", "tier"];
|
|
9782
|
+
var LENS_PARAMS = ["components", "detail", "env", "logical", "ops", "radial", "tier"];
|
|
7796
9783
|
function canonicalKey(path, params) {
|
|
7797
|
-
const flat = params.get("components") === "1" || params.get("logical") === "1";
|
|
9784
|
+
const flat = params.get("components") === "1" || params.get("logical") === "1" || params.get("ops") === "1";
|
|
7798
9785
|
const q = LENS_PARAMS.filter((k) => params.has(k) && !(flat && (k === "detail" || k === "radial"))).map((k) => `${k}=${params.get(k)}`).join("&");
|
|
7799
9786
|
return q ? `${path}?${q}` : path;
|
|
7800
9787
|
}
|
|
@@ -7808,6 +9795,7 @@ function captureKeys(axes) {
|
|
|
7808
9795
|
add("/api/project", {});
|
|
7809
9796
|
add("/api/substrates", {});
|
|
7810
9797
|
add("/api/ops", {});
|
|
9798
|
+
if (axes.ops) add("/api/graph", { ops: "1" });
|
|
7811
9799
|
const tiers = axes.tiers && axes.tiers.length ? axes.tiers : [""];
|
|
7812
9800
|
const envs = ["", ...axes.environments];
|
|
7813
9801
|
for (const env of envs) {
|
|
@@ -7838,7 +9826,7 @@ function captureKeys(axes) {
|
|
|
7838
9826
|
return [...keys];
|
|
7839
9827
|
}
|
|
7840
9828
|
function webDir() {
|
|
7841
|
-
return
|
|
9829
|
+
return join19(dirname6(fileURLToPath4(import.meta.url)), "..", "web");
|
|
7842
9830
|
}
|
|
7843
9831
|
function workerName(project, override) {
|
|
7844
9832
|
const raw = override ?? `behold-${basename2(project)}`;
|
|
@@ -7848,8 +9836,8 @@ function workerName(project, override) {
|
|
|
7848
9836
|
async function runExport(cfg, outDir, opts = {}) {
|
|
7849
9837
|
const app = createApp({ ...cfg, layoutWrites: false });
|
|
7850
9838
|
const proj = await (await app.request("/api/project")).json();
|
|
7851
|
-
const axes = { environments: proj.environments ?? [], tiers: proj.tiers ?? [] };
|
|
7852
|
-
const snapDir =
|
|
9839
|
+
const axes = { environments: proj.environments ?? [], tiers: proj.tiers ?? [], ...proj.ops ? { ops: proj.ops } : {} };
|
|
9840
|
+
const snapDir = join19(outDir, "snapshots");
|
|
7853
9841
|
mkdirSync4(snapDir, { recursive: true });
|
|
7854
9842
|
const keyToFile = {};
|
|
7855
9843
|
let ok = 0;
|
|
@@ -7858,7 +9846,7 @@ async function runExport(cfg, outDir, opts = {}) {
|
|
|
7858
9846
|
const res = await app.request(`${key}${key.includes("?") ? "&" : "?"}layout=1`);
|
|
7859
9847
|
const body = await res.text();
|
|
7860
9848
|
const file = slug2(key);
|
|
7861
|
-
writeFileSync4(
|
|
9849
|
+
writeFileSync4(join19(snapDir, file), body);
|
|
7862
9850
|
keyToFile[key] = `snapshots/${file}`;
|
|
7863
9851
|
if (res.ok) ok++;
|
|
7864
9852
|
else failed++;
|
|
@@ -7870,21 +9858,21 @@ async function runExport(cfg, outDir, opts = {}) {
|
|
|
7870
9858
|
axes,
|
|
7871
9859
|
keyToFile
|
|
7872
9860
|
};
|
|
7873
|
-
writeFileSync4(
|
|
7874
|
-
const html =
|
|
9861
|
+
writeFileSync4(join19(outDir, "manifest.json"), JSON.stringify(manifest, null, 2));
|
|
9862
|
+
const html = readFileSync15(join19(webDir(), "index.html"), "utf8").replace(
|
|
7875
9863
|
/<\/head>/i,
|
|
7876
9864
|
` <script>window.__BEHOLD_STATIC__ = true;</script>
|
|
7877
9865
|
</head>`
|
|
7878
9866
|
);
|
|
7879
|
-
writeFileSync4(
|
|
7880
|
-
for (const f of
|
|
9867
|
+
writeFileSync4(join19(outDir, "index.html"), html);
|
|
9868
|
+
for (const f of readdirSync7(webDir())) {
|
|
7881
9869
|
if (f === "index.html") continue;
|
|
7882
|
-
copyFileSync(
|
|
9870
|
+
copyFileSync(join19(webDir(), f), join19(outDir, f));
|
|
7883
9871
|
}
|
|
7884
|
-
writeFileSync4(
|
|
9872
|
+
writeFileSync4(join19(outDir, "README.md"), BUNDLE_README);
|
|
7885
9873
|
const name = workerName(cfg.projectDir, opts.name);
|
|
7886
9874
|
writeFileSync4(
|
|
7887
|
-
|
|
9875
|
+
join19(outDir, "wrangler.jsonc"),
|
|
7888
9876
|
JSON.stringify(
|
|
7889
9877
|
{ $schema: "node_modules/wrangler/config-schema.json", name, compatibility_date: "2025-06-01", assets: { directory: "." } },
|
|
7890
9878
|
null,
|
|
@@ -7932,7 +9920,7 @@ It's just files \u2014 GitHub Pages, S3, nginx, or Cloudflare Pages
|
|
|
7932
9920
|
`;
|
|
7933
9921
|
|
|
7934
9922
|
// src/doctor.ts
|
|
7935
|
-
import { relative as
|
|
9923
|
+
import { relative as relative5, resolve as resolve7 } from "node:path";
|
|
7936
9924
|
var list2 = (xs) => xs.join(", ");
|
|
7937
9925
|
var EMPTY_KUBECONFIG = { contexts: /* @__PURE__ */ new Map(), servers: /* @__PURE__ */ new Map() };
|
|
7938
9926
|
function summarize(xs, max = 4) {
|
|
@@ -7940,7 +9928,7 @@ function summarize(xs, max = 4) {
|
|
|
7940
9928
|
}
|
|
7941
9929
|
function labelFor(root, target, estate) {
|
|
7942
9930
|
if (!estate) return "";
|
|
7943
|
-
const rel =
|
|
9931
|
+
const rel = relative5(root, target);
|
|
7944
9932
|
return `${rel || "."}: `;
|
|
7945
9933
|
}
|
|
7946
9934
|
function chantCheck(root, targets, estate) {
|
|
@@ -7953,7 +9941,7 @@ function chantCheck(root, targets, estate) {
|
|
|
7953
9941
|
name: "chant",
|
|
7954
9942
|
status: "fail",
|
|
7955
9943
|
detail: `${list2(resolutions.map(describe))} \u2014 behold shells the project's own chant`,
|
|
7956
|
-
fix: `Run \`npm install\` in ${list2(missing.map(({ target }) =>
|
|
9944
|
+
fix: `Run \`npm install\` in ${list2(missing.map(({ target }) => relative5(root, target) || target))}`
|
|
7957
9945
|
};
|
|
7958
9946
|
}
|
|
7959
9947
|
const stale = resolutions.filter(({ res }) => !meetsFloor(res.version, floor));
|
|
@@ -8081,7 +10069,7 @@ function opsCheck(root, ops, chantSource, estate) {
|
|
|
8081
10069
|
return { name: "ops", status: "pass", detail: `${ops.length} committed: ${list2(names)}; ${mcp}` };
|
|
8082
10070
|
}
|
|
8083
10071
|
async function diagnose(dir, probes = {}) {
|
|
8084
|
-
const root =
|
|
10072
|
+
const root = resolve7(dir);
|
|
8085
10073
|
const shape = detectProjectShape(root);
|
|
8086
10074
|
const behold = beholdVersion();
|
|
8087
10075
|
if (shape.kind === "none") {
|
|
@@ -8101,16 +10089,16 @@ async function diagnose(dir, probes = {}) {
|
|
|
8101
10089
|
};
|
|
8102
10090
|
}
|
|
8103
10091
|
const estate = shape.kind === "estate";
|
|
8104
|
-
const members = (shape.members ?? []).map((m) =>
|
|
10092
|
+
const members = (shape.members ?? []).map((m) => resolve7(root, m));
|
|
8105
10093
|
const targets = estate ? members : [root];
|
|
8106
10094
|
const primary = targets[0];
|
|
8107
10095
|
const projectCheck = estate ? {
|
|
8108
10096
|
name: "project",
|
|
8109
10097
|
status: "pass",
|
|
8110
10098
|
detail: `estate of ${members.length} projects (${shape.membersFrom === "behold-config" ? ".behold.json members" : "npm workspaces"}): ${list2(
|
|
8111
|
-
members.map((m) =>
|
|
10099
|
+
members.map((m) => relative5(root, m))
|
|
8112
10100
|
)}`
|
|
8113
|
-
} : { name: "project", status: "pass", detail: `chant project (${
|
|
10101
|
+
} : { name: "project", status: "pass", detail: `chant project (${relative5(root, shape.configFile)})` };
|
|
8114
10102
|
const infos = await Promise.all(targets.map(async (t) => ({ target: t, info: await detectProject(t) })));
|
|
8115
10103
|
const declared = new Map(infos.map(({ target, info }) => [target, info.lexicons]));
|
|
8116
10104
|
const lexicons = [...new Set(infos.flatMap(({ info }) => info.lexicons))];
|
|
@@ -8335,7 +10323,7 @@ ${USAGE}`);
|
|
|
8335
10323
|
process.stderr.write("behold serve: --auto-sync needs --env and --poll (it acts on polled drift)\n");
|
|
8336
10324
|
process.exit(2);
|
|
8337
10325
|
}
|
|
8338
|
-
const dirs = projectDirs.map((d) =>
|
|
10326
|
+
const dirs = projectDirs.map((d) => resolve8(d));
|
|
8339
10327
|
for (const d of dirs) warnIfNotChantProject(d);
|
|
8340
10328
|
await startServer({
|
|
8341
10329
|
projectDir: dirs[0],
|
|
@@ -8354,7 +10342,7 @@ function warnIfNotChantProject(dir) {
|
|
|
8354
10342
|
if (shape.kind === "estate") {
|
|
8355
10343
|
process.stderr.write(
|
|
8356
10344
|
`behold: warning \u2014 ${dir} is an estate root, not a chant project itself.
|
|
8357
|
-
Serve its members composed: behold serve ${shape.members.map((m) =>
|
|
10345
|
+
Serve its members composed: behold serve ${shape.members.map((m) => join20(dir, m)).join(" ")}
|
|
8358
10346
|
`
|
|
8359
10347
|
);
|
|
8360
10348
|
return;
|
|
@@ -8389,8 +10377,8 @@ async function runCarve(rest) {
|
|
|
8389
10377
|
);
|
|
8390
10378
|
process.exit(2);
|
|
8391
10379
|
}
|
|
8392
|
-
const reportPath =
|
|
8393
|
-
const parsed = readCarveReport(reportPath, (p) =>
|
|
10380
|
+
const reportPath = resolve8(fileArg);
|
|
10381
|
+
const parsed = readCarveReport(reportPath, (p) => readFileSync16(p, "utf8"));
|
|
8394
10382
|
if (!parsed.ok) {
|
|
8395
10383
|
process.stderr.write(`behold carve: ${parsed.refusal.error}
|
|
8396
10384
|
${parsed.refusal.remedy}
|
|
@@ -8401,7 +10389,7 @@ async function runCarve(rest) {
|
|
|
8401
10389
|
`behold carve \u2014 ${parsed.report.resources.length} resource(s)/module(s) ranked${parsed.report.from ? ` from ${parsed.report.from}` : ""}
|
|
8402
10390
|
`
|
|
8403
10391
|
);
|
|
8404
|
-
await startServer({ projectDir:
|
|
10392
|
+
await startServer({ projectDir: dirname7(reportPath), carveReport: reportPath, port });
|
|
8405
10393
|
}
|
|
8406
10394
|
async function runDoctor(rest) {
|
|
8407
10395
|
let json = false;
|
|
@@ -8417,8 +10405,8 @@ async function runDoctor(rest) {
|
|
|
8417
10405
|
}
|
|
8418
10406
|
}
|
|
8419
10407
|
const dir = dirArg ?? ".";
|
|
8420
|
-
if (!existsSync14(
|
|
8421
|
-
process.stderr.write(`behold doctor: no such directory: ${
|
|
10408
|
+
if (!existsSync14(resolve8(dir))) {
|
|
10409
|
+
process.stderr.write(`behold doctor: no such directory: ${resolve8(dir)}
|
|
8422
10410
|
`);
|
|
8423
10411
|
process.exit(2);
|
|
8424
10412
|
}
|
|
@@ -8427,7 +10415,7 @@ async function runDoctor(rest) {
|
|
|
8427
10415
|
if (!report.ok) process.exitCode = 1;
|
|
8428
10416
|
}
|
|
8429
10417
|
async function runDemo(rest) {
|
|
8430
|
-
const pkgRoot2 =
|
|
10418
|
+
const pkgRoot2 = join20(dirname7(fileURLToPath5(import.meta.url)), "..");
|
|
8431
10419
|
const registry = loadDemoRegistry(pkgRoot2);
|
|
8432
10420
|
let port = 4600;
|
|
8433
10421
|
let live = false;
|
|
@@ -8481,7 +10469,7 @@ async function runDemo(rest) {
|
|
|
8481
10469
|
`);
|
|
8482
10470
|
process.exit(2);
|
|
8483
10471
|
}
|
|
8484
|
-
const target = dirArg ?
|
|
10472
|
+
const target = dirArg ? resolve8(dirArg) : demoTargetDir(entry);
|
|
8485
10473
|
const loaded = await loadDemo(entry, { pkgRoot: pkgRoot2, target, log: (line) => process.stdout.write(line + "\n") });
|
|
8486
10474
|
if (!loaded.ok) {
|
|
8487
10475
|
process.stderr.write(`behold demo ${entry.name}: ${loaded.error}
|
|
@@ -8512,7 +10500,7 @@ function spawnStep(cmd, args, cwd) {
|
|
|
8512
10500
|
});
|
|
8513
10501
|
}
|
|
8514
10502
|
async function serveCarveDemo(target, carve, port, live = false) {
|
|
8515
|
-
const at = (rel) =>
|
|
10503
|
+
const at = (rel) => resolve8(target, rel);
|
|
8516
10504
|
const project = at(carve.project);
|
|
8517
10505
|
const from = at(carve.from);
|
|
8518
10506
|
const state = carve.state ? at(carve.state) : void 0;
|
|
@@ -8552,7 +10540,7 @@ async function serveCarveDemo(target, carve, port, live = false) {
|
|
|
8552
10540
|
};
|
|
8553
10541
|
process.stdout.write("behold demo carve --live \u2192 the tfstate is real now; the advisor reads what terraform wrote.\n");
|
|
8554
10542
|
}
|
|
8555
|
-
if (existsSync14(
|
|
10543
|
+
if (existsSync14(join20(project, "package.json")) && !existsSync14(join20(project, "node_modules"))) {
|
|
8556
10544
|
process.stdout.write(`behold demo carve \u2192 npm install in ${carve.project}/ (the chant this walkthrough shells)\u2026
|
|
8557
10545
|
`);
|
|
8558
10546
|
const code = await spawnStep("npm", ["install"], project);
|
|
@@ -8563,7 +10551,7 @@ async function serveCarveDemo(target, carve, port, live = false) {
|
|
|
8563
10551
|
}
|
|
8564
10552
|
}
|
|
8565
10553
|
let degraded;
|
|
8566
|
-
if (!existsSync14(
|
|
10554
|
+
if (!existsSync14(join20(target, "node_modules", "@cdktf", "hcl2json"))) {
|
|
8567
10555
|
process.stdout.write("behold demo carve \u2192 npm install @cdktf/hcl2json (chant's HCL parser, ~2MB, once)\u2026\n");
|
|
8568
10556
|
const code = await spawnStep("npm", ["install", "--no-save", "--no-package-lock", "@cdktf/hcl2json"], target);
|
|
8569
10557
|
if (code !== 0) degraded = "couldn't install @cdktf/hcl2json (chant's HCL parser) \u2014 no network?";
|
|
@@ -8632,7 +10620,7 @@ async function runPreview(rest) {
|
|
|
8632
10620
|
process.stderr.write("behold preview: --port must be a number\n");
|
|
8633
10621
|
process.exit(2);
|
|
8634
10622
|
}
|
|
8635
|
-
const projectDir =
|
|
10623
|
+
const projectDir = resolve8(dirArg ?? process.cwd());
|
|
8636
10624
|
if (!existsSync14(projectDir)) {
|
|
8637
10625
|
process.stderr.write(`behold preview: project not found at ${projectDir}
|
|
8638
10626
|
`);
|
|
@@ -8653,21 +10641,21 @@ async function runPreview(rest) {
|
|
|
8653
10641
|
await startServer({ projectDir, port, env: "local", previewMode: true });
|
|
8654
10642
|
}
|
|
8655
10643
|
async function runExportCmd(rest) {
|
|
8656
|
-
let outDir =
|
|
10644
|
+
let outDir = resolve8("behold-export");
|
|
8657
10645
|
let env;
|
|
8658
10646
|
let name;
|
|
8659
10647
|
let dirArg;
|
|
8660
10648
|
let emulator = false;
|
|
8661
10649
|
for (let i = 0; i < rest.length; i++) {
|
|
8662
10650
|
const a = rest[i];
|
|
8663
|
-
if (a === "--out") outDir =
|
|
10651
|
+
if (a === "--out") outDir = resolve8(rest[++i]);
|
|
8664
10652
|
else if (a === "--env") env = rest[++i];
|
|
8665
10653
|
else if (a === "--name") name = rest[++i];
|
|
8666
10654
|
else if (a === "--emulator") emulator = true;
|
|
8667
10655
|
else if (a === "-h" || a === "--help") return void process.stdout.write(USAGE);
|
|
8668
10656
|
else if (!a.startsWith("-")) dirArg = a;
|
|
8669
10657
|
}
|
|
8670
|
-
const projectDir =
|
|
10658
|
+
const projectDir = resolve8(dirArg ?? process.cwd());
|
|
8671
10659
|
if (emulator) {
|
|
8672
10660
|
injectEmulatorEnv(env);
|
|
8673
10661
|
env ??= "local";
|