@intentius/behold 0.13.1 → 0.15.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/dist/cli.js CHANGED
@@ -839,6 +839,31 @@ async function createK8sClient(options = {}) {
839
839
  target: refText(ref)
840
840
  });
841
841
  }
842
+ async function selfSubjectReview(signal) {
843
+ for (const version of SELF_SUBJECT_REVIEW_VERSIONS) {
844
+ const path = `/apis/authentication.k8s.io/${version}/selfsubjectreviews`;
845
+ let response;
846
+ try {
847
+ response = await sendJson(path, "POST", {
848
+ body: JSON.stringify({ apiVersion: `authentication.k8s.io/${version}`, kind: "SelfSubjectReview" }),
849
+ signal,
850
+ target: `self subject review (${version})`
851
+ });
852
+ } catch (err) {
853
+ if (err instanceof K8sApiError && err.notFound) continue;
854
+ throw err;
855
+ }
856
+ const user2 = response.status?.userInfo;
857
+ if (user2?.username) {
858
+ return {
859
+ username: user2.username,
860
+ ...user2.uid ? { uid: user2.uid } : {},
861
+ ...user2.groups && user2.groups.length > 0 ? { groups: user2.groups } : {}
862
+ };
863
+ }
864
+ }
865
+ return void 0;
866
+ }
842
867
  async function resourcesOf(apiVersion, signal) {
843
868
  const list4 = await apiResourceList(apiVersion, signal);
844
869
  if (!list4) return [];
@@ -858,6 +883,7 @@ async function createK8sClient(options = {}) {
858
883
  concurrently: (items, fn) => mapConcurrent(items, fn, concurrency),
859
884
  resources: resourcesOf,
860
885
  preferredGroupVersions,
886
+ selfSubjectReview,
861
887
  discoveryCacheKeys: () => [...discoveryCache.keys()].sort()
862
888
  };
863
889
  }
@@ -874,7 +900,7 @@ function selectorText(selector) {
874
900
  function refText(ref) {
875
901
  return `${ref.apiVersion} ${ref.kind} ${ref.namespace ? `${ref.namespace}/` : ""}${ref.name}`;
876
902
  }
877
- var clientNodeModule;
903
+ var clientNodeModule, SELF_SUBJECT_REVIEW_VERSIONS;
878
904
  var init_client = __esm({
879
905
  "node_modules/@intentius/chant-k8s-client/src/client.ts"() {
880
906
  init_errors();
@@ -883,6 +909,7 @@ var init_client = __esm({
883
909
  init_field_manager();
884
910
  init_concurrency();
885
911
  init_kubeconfig();
912
+ SELF_SUBJECT_REVIEW_VERSIONS = ["v1", "v1beta1", "v1alpha1"];
886
913
  }
887
914
  });
888
915
 
@@ -2283,1098 +2310,1713 @@ function joinCiProgress(ir, state) {
2283
2310
  return ir;
2284
2311
  }
2285
2312
 
2286
- // src/component-status.ts
2287
- function componentStatusColor(row) {
2288
- if (row.stack && row.stack.healthy === false) {
2289
- if (row.stack.status && /ROLLBACK|FAILED/i.test(row.stack.status)) return "warn";
2290
- return "accent";
2291
- }
2292
- const rollup = row.resources;
2293
- if (rollup && rollup.total > 0) {
2294
- if (rollup.unobserved > 0) return "accent";
2295
- if (rollup.present === rollup.total) return "good";
2296
- if (rollup.present === 0) return "neutral";
2297
- return "warn";
2298
- }
2299
- if (row.live !== void 0) return row.live ? "good" : "neutral";
2300
- switch (row.reconciliation) {
2301
- case "reconciled":
2302
- return "good";
2303
- case "unrecorded":
2304
- return row.detail.startsWith("live") ? "good" : "neutral";
2305
- case "stale":
2306
- case "drifted":
2307
- return "warn";
2308
- case "unknown":
2309
- default:
2310
- return "neutral";
2313
+ // src/operator.ts
2314
+ var ANSI = /\x1b\[[0-9;]*m/g;
2315
+ var strip = (s) => s.replace(ANSI, "");
2316
+ var isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
2317
+ 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).";
2318
+ function readRow(value) {
2319
+ if (!isRecord(value)) return null;
2320
+ if (typeof value.op !== "string" || typeof value.env !== "string") return null;
2321
+ if (!Array.isArray(value.pendingGates)) return null;
2322
+ const gates = [];
2323
+ for (const g of value.pendingGates) {
2324
+ if (!isRecord(g) || typeof g.rule !== "string" || typeof g.gate !== "string") return null;
2325
+ gates.push({
2326
+ rule: g.rule,
2327
+ gate: g.gate,
2328
+ ...typeof g.op === "string" ? { op: g.op } : {},
2329
+ // chant#2028's address, carried through verbatim and only when chant sent
2330
+ // one. The scheme check happens where the link is MINTED
2331
+ // ({@link approvalLink}), not here, so a row whose url behold won't link
2332
+ // still parses rather than refusing the whole strip.
2333
+ ...typeof g.url === "string" && g.url ? { url: g.url } : {}
2334
+ });
2311
2335
  }
2312
- }
2313
- function joinComponentStatus(ir, rows) {
2314
- const byComponent = new Map(rows.map((r) => [r.component, r]));
2336
+ const tick = isRecord(value.lastTick) ? value.lastTick : void 0;
2337
+ const usable = tick && typeof tick.timestamp === "string" && typeof tick.log === "string";
2338
+ const lease = isRecord(value.lease) ? value.lease : void 0;
2315
2339
  return {
2316
- ...ir,
2317
- nodes: ir.nodes.map((n) => {
2318
- const row = byComponent.get(n.id);
2319
- if (!row) return n;
2340
+ op: value.op,
2341
+ env: value.env,
2342
+ ...usable ? { lastTick: tick } : {},
2343
+ pendingGates: gates,
2344
+ ...lease && typeof lease.holder === "string" && typeof lease.expiresAt === "string" ? { lease: { holder: lease.holder, expiresAt: lease.expiresAt } } : {}
2345
+ };
2346
+ }
2347
+ function readOperatorStatus(run3) {
2348
+ const err = strip(run3.stderr);
2349
+ const out = strip(run3.stdout).trim();
2350
+ if (run3.code !== 0) {
2351
+ const message = err.trim() || out || `chant operator status exited ${run3.code}`;
2352
+ const first = message.split("\n")[0].replace(/^error:\s*/i, "");
2353
+ if (/Unknown command|Unknown operator subcommand/i.test(message)) {
2320
2354
  return {
2321
- ...n,
2322
- attrs: {
2323
- ...n.attrs,
2324
- _status: componentStatusColor(row),
2325
- _liveStatus: {
2326
- reconciliation: row.reconciliation,
2327
- detail: row.detail,
2328
- ...row.live !== void 0 ? { live: row.live } : {},
2329
- ...row.stack ? { stack: row.stack } : {},
2330
- ...row.resources ? { resources: row.resources } : {}
2331
- }
2355
+ ok: false,
2356
+ refusal: {
2357
+ error: `This chant has no \`operator status\` \u2014 ${first}`,
2358
+ code: "no-operator-cli",
2359
+ remedy: "The operating loop's status surface landed in chant 0.52 (chant#1485). Upgrade chant to read it."
2332
2360
  }
2333
2361
  };
2334
- })
2335
- };
2336
- }
2337
-
2338
- // src/overlay.ts
2339
- function pruneImports(ir) {
2340
- const drop = new Set((ir.imports ?? []).map((i) => i.node));
2341
- if (drop.size === 0) return ir;
2342
- ir.nodes = ir.nodes.filter((n) => !drop.has(n.id));
2343
- if (ir.edges) ir.edges = ir.edges.filter((e) => !drop.has(e.from) && !drop.has(e.to));
2344
- return ir;
2345
- }
2346
- function componentDir(node) {
2347
- const parts = node.sourceLoc?.file?.split("/") ?? [];
2348
- if (parts[0] !== "src" || parts[1] === "examples" || parts.length < 3) return void 0;
2349
- return parts[1];
2350
- }
2351
- function reclassifyOverlay(ir) {
2352
- const deployedComponents = /* @__PURE__ */ new Set();
2353
- for (const n of ir.nodes) {
2354
- if (n.attrs?._status === "good") {
2355
- const c = componentDir(n);
2356
- if (c) deployedComponents.add(c);
2357
2362
  }
2363
+ return {
2364
+ ok: false,
2365
+ refusal: {
2366
+ error: `chant operator status failed: ${first}`,
2367
+ code: "operator-status",
2368
+ remedy: "Run `chant operator status --json` in the project to see the whole error."
2369
+ }
2370
+ };
2358
2371
  }
2359
- for (const n of ir.nodes) {
2360
- const file = n.sourceLoc?.file ?? "";
2361
- if (file.startsWith("src/examples/")) {
2362
- n.attrs = { ...n.attrs, _byo: true };
2363
- delete n.attrs._status;
2364
- } else if (n.kind === "AWS::CloudFormation::Parameter") {
2365
- const c = componentDir(n);
2366
- if (c && deployedComponents.has(c)) {
2367
- n.attrs = { ...n.attrs, _status: "good" };
2372
+ if (!out) {
2373
+ return {
2374
+ ok: false,
2375
+ refusal: {
2376
+ error: "This project declares no operating loop \u2014 chant found no ConvergeOp.",
2377
+ code: "no-operator",
2378
+ remedy: HOW_TO_DECLARE
2379
+ }
2380
+ };
2381
+ }
2382
+ let parsed;
2383
+ try {
2384
+ parsed = JSON.parse(out);
2385
+ } catch (e) {
2386
+ return {
2387
+ ok: false,
2388
+ refusal: {
2389
+ error: `chant operator status --json didn't print JSON: ${e instanceof Error ? e.message : String(e)}`,
2390
+ code: "operator-status",
2391
+ remedy: "Run `chant operator status --json` in the project and check what it printed."
2392
+ }
2393
+ };
2394
+ }
2395
+ if (!Array.isArray(parsed)) {
2396
+ return {
2397
+ ok: false,
2398
+ refusal: {
2399
+ error: "chant operator status --json answered with something that isn't an array of ConvergeOp rows.",
2400
+ code: "operator-status",
2401
+ remedy: "behold reads one row per ConvergeOp: `{op, env, lastTick?, pendingGates[], lease?}`."
2368
2402
  }
2403
+ };
2404
+ }
2405
+ const rows = [];
2406
+ for (const value of parsed) {
2407
+ const row = readRow(value);
2408
+ if (!row) {
2409
+ return {
2410
+ ok: false,
2411
+ refusal: {
2412
+ error: "chant operator status --json answered with a row behold couldn't read (needs `op`, `env`, `pendingGates[]`).",
2413
+ code: "operator-status",
2414
+ remedy: "Check `chant operator status --json` in the project \u2014 and file the shape change if chant's is the newer one."
2415
+ }
2416
+ };
2369
2417
  }
2418
+ rows.push(row);
2370
2419
  }
2371
- return ir;
2420
+ return { ok: true, rows };
2372
2421
  }
2373
- function pruneRuntimeChildren(ir) {
2374
- const runtime = new Set(ir.nodes.filter((n) => n.runtimeOwner).map((n) => n.id));
2375
- if (runtime.size === 0) return ir;
2376
- ir.nodes = ir.nodes.filter((n) => !runtime.has(n.id));
2377
- ir.edges = ir.edges.filter((e) => !runtime.has(e.from) && !runtime.has(e.to));
2378
- return ir;
2422
+ function declaredConvergeOps(ops) {
2423
+ return ops.filter((o) => o.searchAttributes?.Converge === "true").map((o) => ({
2424
+ name: o.name,
2425
+ ...o.searchAttributes?.Env ? { env: o.searchAttributes.Env } : {},
2426
+ ...o.searchAttributes?.Dial ? { dial: o.searchAttributes.Dial } : {}
2427
+ }));
2379
2428
  }
2380
- function attachRuntimeContainment(ir) {
2381
- const byOwner = /* @__PURE__ */ new Map();
2429
+ var MANAGED_BY = "app.kubernetes.io/managed-by";
2430
+ var COMPONENT = "app.kubernetes.io/component";
2431
+ var NAME_LABEL = "app.kubernetes.io/name";
2432
+ var CHANT = "chant";
2433
+ var CONVERGE_TICK = "converge-tick";
2434
+ var CRONJOB_KIND = "K8s::Batch::CronJob";
2435
+ var NAMESPACE_KIND = "K8s::Core::Namespace";
2436
+ var rec = (v) => isRecord(v) ? v : void 0;
2437
+ var str = (v) => typeof v === "string" && v ? v : void 0;
2438
+ var labelsOf = (n) => rec(rec(n.attrs?.metadata)?.labels) ?? {};
2439
+ function operatorHomes(ir) {
2440
+ const byNamespace = /* @__PURE__ */ new Map();
2441
+ const conflicted = /* @__PURE__ */ new Set();
2382
2442
  for (const n of ir.nodes) {
2383
- if (!n.runtimeOwner) continue;
2384
- if (!byOwner.has(n.runtimeOwner)) byOwner.set(n.runtimeOwner, /* @__PURE__ */ new Set());
2385
- byOwner.get(n.runtimeOwner).add(n.id);
2443
+ if (n.kind !== CRONJOB_KIND) continue;
2444
+ const labels = labelsOf(n);
2445
+ if (labels[MANAGED_BY] !== CHANT || labels[COMPONENT] !== CONVERGE_TICK) continue;
2446
+ const meta = rec(n.attrs?.metadata);
2447
+ const ns = str(meta?.namespace);
2448
+ const op = str(meta?.name);
2449
+ if (!ns || !op) continue;
2450
+ const home = byNamespace.get(ns) ?? { namespace: ns, ticks: [] };
2451
+ const stack = str(labels[NAME_LABEL]);
2452
+ if (stack && !conflicted.has(ns)) {
2453
+ if (home.stack === void 0) home.stack = stack;
2454
+ else if (home.stack !== stack) {
2455
+ conflicted.add(ns);
2456
+ delete home.stack;
2457
+ }
2458
+ }
2459
+ const schedule = str(rec(n.attrs?.spec)?.schedule);
2460
+ home.ticks.push({ node: n.id, op, ...schedule ? { schedule } : {} });
2461
+ byNamespace.set(ns, home);
2386
2462
  }
2387
- if (byOwner.size === 0) return ir;
2388
- const byContainer = { ...ir.groups.byContainer ?? {} };
2389
- for (const [owner, children] of byOwner) {
2390
- byContainer[owner] = [.../* @__PURE__ */ new Set([owner, ...byContainer[owner] ?? [], ...children])].sort();
2463
+ if (!byNamespace.size) return [];
2464
+ for (const n of ir.nodes) {
2465
+ if (n.kind !== NAMESPACE_KIND) continue;
2466
+ const name = str(rec(n.attrs?.metadata)?.name);
2467
+ const home = name ? byNamespace.get(name) : void 0;
2468
+ if (home && !home.node) home.node = n.id;
2391
2469
  }
2392
- ir.groups.byContainer = byContainer;
2393
- return ir;
2394
- }
2395
-
2396
- // src/value-match.ts
2397
- function typeSegment(kind) {
2398
- const parts = kind.split(kind.includes("::") ? "::" : "/");
2399
- return parts[parts.length - 1] ?? kind;
2400
- }
2401
- function isOwnNameAttr(kind, key) {
2402
- if (!/name$/i.test(key)) return false;
2403
- const prefix = key.slice(0, -4).toLowerCase();
2404
- if (prefix === "") return true;
2405
- return typeSegment(kind).toLowerCase().includes(prefix);
2406
- }
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);
2470
+ for (const home of byNamespace.values()) home.ticks.sort((a, b) => a.op.localeCompare(b.op));
2471
+ return [...byNamespace.values()].sort((a, b) => a.namespace.localeCompare(b.namespace));
2426
2472
  }
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;
2473
+ function markOperatorHome(ir) {
2474
+ const homes = operatorHomes(ir);
2475
+ if (!homes.length) return ir;
2476
+ const marks = /* @__PURE__ */ new Map();
2477
+ for (const home of homes) {
2478
+ if (home.node) {
2479
+ marks.set(home.node, {
2480
+ role: "home",
2481
+ namespace: home.namespace,
2482
+ ...home.stack ? { stack: home.stack } : {},
2483
+ ticks: home.ticks.map((t) => t.op)
2484
+ });
2434
2485
  }
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
- }
2486
+ for (const tick of home.ticks) {
2487
+ marks.set(tick.node, {
2488
+ role: "tick",
2489
+ namespace: home.namespace,
2490
+ ...home.stack ? { stack: home.stack } : {},
2491
+ op: tick.op,
2492
+ ...tick.schedule ? { schedule: tick.schedule } : {}
2493
+ });
2445
2494
  }
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
2495
  }
2452
- return out;
2496
+ return {
2497
+ ...ir,
2498
+ nodes: ir.nodes.map((n) => {
2499
+ const mark = marks.get(n.id);
2500
+ return mark ? { ...n, attrs: { ...n.attrs, _operator: mark } } : n;
2501
+ })
2502
+ };
2453
2503
  }
2454
- function addValueMatchEdges(ir) {
2455
- const nodes = ir.nodes;
2456
- const owner = /* @__PURE__ */ new Map();
2457
- for (const n of nodes) {
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);
2462
- }
2463
- }
2464
- if (owner.size === 0) return ir;
2465
- const declared = /* @__PURE__ */ new Set();
2466
- for (const e of ir.edges) declared.add(`${e.from}\0${e.to}`);
2467
- const added = /* @__PURE__ */ new Set();
2468
- for (const a of nodes) {
2469
- for (const { path, value } of stringLeaves(a.attrs)) {
2470
- if (!joinable(value)) continue;
2471
- const b = owner.get(value);
2472
- if (!b || b === a.id) continue;
2473
- const key = `${a.id}\0${b}`;
2474
- if (declared.has(key) || added.has(key)) continue;
2475
- added.add(key);
2476
- ir.edges.push({ from: a.id, to: b, kind: "ref", viaAttr: path, inferred: true });
2477
- }
2504
+ var OPERATOR_HOME_GLYPH = {
2505
+ body: '<path d="M16.5 6.63A7 7 0 1 1 6.64 7.5"/><path d="M16.5 6.63 14.3 5.9M16.5 6.63 15.9 8.1"/>'
2506
+ };
2507
+ function operatorHomeBoxMarks(ir, namespaceBoxes) {
2508
+ const out = {};
2509
+ for (const home of operatorHomes(ir)) {
2510
+ const box = namespaceBoxes[home.namespace];
2511
+ if (box) out[box] = OPERATOR_HOME_GLYPH;
2478
2512
  }
2479
- return ir;
2480
- }
2481
-
2482
- // src/k8s-edges.ts
2483
- var WORKLOAD_KINDS = /* @__PURE__ */ new Set([
2484
- "K8s::Apps::Deployment",
2485
- "K8s::Apps::StatefulSet",
2486
- "K8s::Apps::DaemonSet",
2487
- "K8s::Apps::ReplicaSet"
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
- ];
2494
- function rec(v) {
2495
- return v && typeof v === "object" && !Array.isArray(v) ? v : void 0;
2496
- }
2497
- function str(v) {
2498
- return typeof v === "string" && v.length > 0 ? v : void 0;
2513
+ return out;
2499
2514
  }
2500
- function namespaceOf(n) {
2501
- return str(rec(n.attrs?.metadata)?.namespace) ?? "default";
2515
+ function operatorStrip(rows, now = Date.now()) {
2516
+ const out = rows.map((r) => {
2517
+ const expiresAt = r.lease ? Date.parse(r.lease.expiresAt) : NaN;
2518
+ const lease = !r.lease ? "free" : Number.isFinite(expiresAt) && expiresAt <= now ? "expired" : "held";
2519
+ return {
2520
+ op: r.op,
2521
+ env: r.env,
2522
+ log: r.lastTick?.log ?? null,
2523
+ at: r.lastTick?.timestamp ?? null,
2524
+ tickId: typeof r.lastTick?.id === "string" && r.lastTick.id ? r.lastTick.id : null,
2525
+ lease,
2526
+ leaseHolder: r.lease?.holder ?? null,
2527
+ leaseExpiresAt: r.lease?.expiresAt ?? null,
2528
+ pendingGates: r.pendingGates.length
2529
+ };
2530
+ });
2531
+ return { rows: out, pendingGates: out.reduce((n, r) => n + r.pendingGates, 0) };
2502
2532
  }
2503
- function nameOf(n) {
2504
- return str(rec(n.attrs?.metadata)?.name);
2533
+ var OPERATOR_INTERVAL_MS = 6e4;
2534
+ var TICK_VERDICT_TTL_MS = 15 * OPERATOR_INTERVAL_MS;
2535
+ function tickFreshness(at, now = Date.now()) {
2536
+ const t = at ? Date.parse(at) : NaN;
2537
+ if (!Number.isFinite(t)) return { ageMs: null, fresh: false };
2538
+ const age = now - t;
2539
+ if (age < 0) return { ageMs: null, fresh: false };
2540
+ return { ageMs: age, fresh: age <= TICK_VERDICT_TTL_MS };
2541
+ }
2542
+ var RECONCILIATIONS = /* @__PURE__ */ new Set(["reconciled", "unrecorded", "stale", "drifted", "unknown"]);
2543
+ function readVerdict(value) {
2544
+ if (!isRecord(value)) return null;
2545
+ if (typeof value.component !== "string" || !value.component) return null;
2546
+ if (typeof value.reconciliation !== "string" || !RECONCILIATIONS.has(value.reconciliation)) return null;
2547
+ const unobserved = isRecord(value.unobserved) && typeof value.unobserved.reason === "string" ? value.unobserved : void 0;
2548
+ return {
2549
+ component: value.component,
2550
+ reconciliation: value.reconciliation,
2551
+ detail: typeof value.detail === "string" ? value.detail : "",
2552
+ ...typeof value.live === "boolean" ? { live: value.live } : {},
2553
+ ...unobserved ? {
2554
+ unobserved: {
2555
+ reason: unobserved.reason,
2556
+ ...typeof unobserved.detail === "string" ? { detail: unobserved.detail } : {}
2557
+ }
2558
+ } : {}
2559
+ };
2505
2560
  }
2506
- function labelRecord(v) {
2507
- const r = rec(v);
2508
- if (!r) return void 0;
2509
- const out = {};
2510
- for (const [k, val] of Object.entries(r)) {
2511
- if (typeof val !== "string") return void 0;
2512
- out[k] = val;
2561
+ function tickComponentVerdicts(rows) {
2562
+ const out = [];
2563
+ for (const row of rows) {
2564
+ const tick = row.lastTick;
2565
+ if (!tick || !Array.isArray(tick.components)) continue;
2566
+ const verdicts = [];
2567
+ for (const value of tick.components) {
2568
+ const verdict = readVerdict(value);
2569
+ if (verdict) verdicts.push(verdict);
2570
+ }
2571
+ out.push({
2572
+ op: row.op,
2573
+ env: tick.env || row.env,
2574
+ at: tick.timestamp,
2575
+ tickId: typeof tick.id === "string" && tick.id ? tick.id : null,
2576
+ verdicts
2577
+ });
2513
2578
  }
2514
- return Object.keys(out).length > 0 ? out : void 0;
2579
+ return out;
2515
2580
  }
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;
2581
+ function verdictsForEnv(all, env) {
2582
+ const when = (t) => {
2583
+ const ms = Date.parse(t.at);
2584
+ return Number.isFinite(ms) ? ms : -Infinity;
2585
+ };
2586
+ let best = null;
2587
+ for (const t of all) {
2588
+ if (t.env !== env) continue;
2589
+ if (!best || when(t) > when(best)) best = t;
2590
+ }
2591
+ return best;
2522
2592
  }
2523
- function selects(selector, labels) {
2524
- return Object.entries(selector).every(([k, v]) => labels[k] === v);
2593
+ var CONVERGE_GATE_LOOP = "converge";
2594
+ var APPROVE_SEMANTICS = "Approving records a fact in the gate ledger; it does not unblock the dispatch. The next tick reads it.";
2595
+ function approvalLink(url) {
2596
+ if (!url) return void 0;
2597
+ try {
2598
+ const parsed = new URL(url);
2599
+ return parsed.protocol === "http:" || parsed.protocol === "https:" ? url : void 0;
2600
+ } catch {
2601
+ return void 0;
2602
+ }
2525
2603
  }
2526
- function templateLabels(n) {
2527
- const template = rec(rec(n.attrs?.spec)?.template);
2528
- return labelRecord(rec(template?.metadata)?.labels);
2604
+ function convergeGateCards(rows) {
2605
+ const out = [];
2606
+ for (const row of rows) {
2607
+ for (const g of row.pendingGates) {
2608
+ if (!g.op) continue;
2609
+ out.push({
2610
+ loop: CONVERGE_GATE_LOOP,
2611
+ rule: g.rule,
2612
+ op: g.op,
2613
+ gate: g.gate,
2614
+ convergeOp: row.op,
2615
+ env: row.env,
2616
+ since: row.lastTick?.timestamp ?? null,
2617
+ semantics: APPROVE_SEMANTICS,
2618
+ approve: {
2619
+ method: "POST",
2620
+ path: `/api/operator/approve/${encodeURIComponent(g.op)}/${encodeURIComponent(g.gate)}`,
2621
+ command: `chant approve ${g.op} ${g.gate}`
2622
+ },
2623
+ ...approvalLink(g.url) ? { url: g.url } : {}
2624
+ });
2625
+ }
2626
+ }
2627
+ return out;
2529
2628
  }
2530
- function shortKind(kind) {
2531
- const parts = kind.split("::");
2532
- return parts[parts.length - 1] ?? kind;
2629
+ var OPERATOR_LOG_FLOOR = "0.53.1";
2630
+ var OPERATOR_LOG_LIMIT = 50;
2631
+ var OPERATOR_LOG_MAX_LIMIT = 200;
2632
+ function operatorLogWindow(raw) {
2633
+ let limit = OPERATOR_LOG_LIMIT;
2634
+ if (raw.limit !== void 0 && raw.limit !== null && raw.limit !== "") {
2635
+ const n = Number(raw.limit);
2636
+ if (!Number.isInteger(n) || n < 1) return { error: `limit must be a positive integer (got "${raw.limit}")` };
2637
+ limit = Math.min(n, OPERATOR_LOG_MAX_LIMIT);
2638
+ }
2639
+ if (raw.since !== void 0 && raw.since !== null && raw.since !== "") {
2640
+ if (Number.isNaN(new Date(raw.since).getTime())) {
2641
+ return { error: `since must be an ISO-8601 timestamp (got "${raw.since}")` };
2642
+ }
2643
+ return { limit, since: raw.since };
2644
+ }
2645
+ return { limit };
2533
2646
  }
2534
- var FLUX_SOURCE_KINDS = /* @__PURE__ */ new Set([
2535
- "K8s::Flux::GitRepository",
2536
- "K8s::Flux::OCIRepository",
2537
- "K8s::Flux::HelmRepository",
2538
- "K8s::Flux::HelmChart",
2539
- "K8s::Flux::Bucket"
2540
- ]);
2541
- function sourceRefOf(refObj, referrer, defaultKind) {
2542
- const ref = rec(refObj);
2543
- const kind = str(ref?.kind) ?? defaultKind;
2544
- const name = str(ref?.name);
2545
- if (!kind || !name) return void 0;
2546
- return { kind, name, namespace: str(ref?.namespace) ?? namespaceOf(referrer) };
2647
+ function operatorLogArgs(window) {
2648
+ return [
2649
+ "operator",
2650
+ "log",
2651
+ "--json",
2652
+ "--limit",
2653
+ String(window.limit),
2654
+ ...window.since ? ["--since", window.since] : []
2655
+ ];
2547
2656
  }
2548
- function namedRefOf(refObj, referrer) {
2549
- const ref = rec(refObj);
2550
- const name = str(ref?.name);
2551
- if (!name) return void 0;
2552
- return { name, namespace: str(ref?.namespace) ?? namespaceOf(referrer) };
2657
+ function readTickRecord(value) {
2658
+ if (!isRecord(value)) return null;
2659
+ if (typeof value.op !== "string" || typeof value.timestamp !== "string" || typeof value.log !== "string") return null;
2660
+ return value;
2553
2661
  }
2554
- function list(v) {
2555
- return Array.isArray(v) ? v : [];
2662
+ function readResolutionRecord(value) {
2663
+ if (!isRecord(value)) return null;
2664
+ const needed = ["op", "gate", "resolvedBy", "timestamp"];
2665
+ if (needed.some((k) => typeof value[k] !== "string")) return null;
2666
+ return value;
2556
2667
  }
2557
- function helmReleaseSourceRef(n) {
2558
- return rec(rec(rec(n.attrs?.spec)?.chart)?.spec)?.sourceRef;
2668
+ function readOperatorLog(run3) {
2669
+ const err = strip(run3.stderr);
2670
+ const out = strip(run3.stdout).trim();
2671
+ if (run3.code !== 0) {
2672
+ const message = err.trim() || out || `chant operator log exited ${run3.code}`;
2673
+ const first = message.split("\n")[0].replace(/^error:\s*/i, "");
2674
+ if (/Unknown command|Unknown operator subcommand|Unknown flag/i.test(message)) {
2675
+ return {
2676
+ ok: false,
2677
+ refusal: {
2678
+ error: `This chant has no \`operator log\` \u2014 ${first}`,
2679
+ code: "no-operator-log-cli",
2680
+ remedy: `The converge tick history landed in chant ${OPERATOR_LOG_FLOOR} (chant#2029). Upgrade chant to read it; the strip above needs only 0.52.`
2681
+ }
2682
+ };
2683
+ }
2684
+ return {
2685
+ ok: false,
2686
+ refusal: {
2687
+ error: `chant operator log failed: ${first}`,
2688
+ code: "operator-log",
2689
+ remedy: "Run `chant operator log --json` in the project to see the whole error."
2690
+ }
2691
+ };
2692
+ }
2693
+ if (!out) {
2694
+ return {
2695
+ ok: false,
2696
+ refusal: {
2697
+ error: "This project declares no operating loop \u2014 chant found no ConvergeOp.",
2698
+ code: "no-operator",
2699
+ remedy: HOW_TO_DECLARE
2700
+ }
2701
+ };
2702
+ }
2703
+ let parsed;
2704
+ try {
2705
+ parsed = JSON.parse(out);
2706
+ } catch (e) {
2707
+ return {
2708
+ ok: false,
2709
+ refusal: {
2710
+ error: `chant operator log --json didn't print JSON: ${e instanceof Error ? e.message : String(e)}`,
2711
+ code: "operator-log",
2712
+ remedy: "Run `chant operator log --json` in the project and check what it printed."
2713
+ }
2714
+ };
2715
+ }
2716
+ const unreadable = (why) => ({
2717
+ ok: false,
2718
+ refusal: {
2719
+ error: `chant operator log --json answered with something behold couldn't read: ${why}`,
2720
+ code: "operator-log",
2721
+ remedy: "behold reads `{entries: [{kind, timestamp, record}], malformed: {converge, gates}}`."
2722
+ }
2723
+ });
2724
+ if (!isRecord(parsed) || !Array.isArray(parsed.entries)) return unreadable("no `entries` array");
2725
+ const entries = [];
2726
+ for (const value of parsed.entries) {
2727
+ if (!isRecord(value) || typeof value.timestamp !== "string") return unreadable("an entry with no timestamp");
2728
+ if (value.kind === "tick") {
2729
+ const record = readTickRecord(value.record);
2730
+ if (!record) return unreadable("a tick entry with no `op`/`timestamp`/`log`");
2731
+ entries.push({ kind: "tick", timestamp: value.timestamp, record });
2732
+ } else if (value.kind === "gate-resolution") {
2733
+ const record = readResolutionRecord(value.record);
2734
+ if (!record) return unreadable("a gate-resolution entry with no `op`/`gate`/`resolvedBy`/`timestamp`");
2735
+ entries.push({ kind: "gate-resolution", timestamp: value.timestamp, record });
2736
+ } else {
2737
+ return unreadable(`an entry of an unknown kind "${String(value.kind)}"`);
2738
+ }
2739
+ }
2740
+ const counts = isRecord(parsed.malformed) ? parsed.malformed : {};
2741
+ const count = (v) => typeof v === "number" && Number.isFinite(v) && v > 0 ? v : 0;
2742
+ return { ok: true, entries, malformed: { converge: count(counts.converge), gates: count(counts.gates) } };
2559
2743
  }
2560
- function ingressBackendServices(n) {
2561
- const spec = rec(n.attrs?.spec);
2562
- const out = [];
2563
- const backendName = (b) => str(rec(rec(b)?.service)?.name);
2564
- const fromDefault = backendName(spec?.defaultBackend);
2565
- if (fromDefault) out.push(fromDefault);
2566
- const rules = Array.isArray(spec?.rules) ? spec.rules : [];
2567
- for (const rule of rules) {
2568
- const paths = rec(rec(rule)?.http)?.paths;
2569
- for (const p of Array.isArray(paths) ? paths : []) {
2570
- const name = backendName(rec(p)?.backend);
2571
- if (name) out.push(name);
2744
+ function operatorTimeline(entries) {
2745
+ return entries.map((entry) => {
2746
+ if (entry.kind === "gate-resolution") {
2747
+ const r = entry.record;
2748
+ return {
2749
+ kind: "gate-resolution",
2750
+ at: r.timestamp,
2751
+ op: r.op,
2752
+ gate: r.gate,
2753
+ resolvedBy: r.resolvedBy,
2754
+ note: r.note ?? null,
2755
+ url: approvalLink(r.url) ?? null
2756
+ };
2572
2757
  }
2758
+ const t = entry.record;
2759
+ return {
2760
+ kind: "tick",
2761
+ at: t.timestamp,
2762
+ op: t.op,
2763
+ env: typeof t.env === "string" ? t.env : null,
2764
+ id: typeof t.id === "string" && t.id ? t.id : null,
2765
+ log: t.log,
2766
+ gated: (Array.isArray(t.outcomes) ? t.outcomes : []).filter((o) => o && o.action === "gated" && typeof o.gateName === "string").map((o) => ({
2767
+ rule: o.ruleId,
2768
+ op: typeof o.op === "string" ? o.op : null,
2769
+ gate: o.gateName,
2770
+ url: approvalLink(o.url) ?? null
2771
+ }))
2772
+ };
2773
+ });
2774
+ }
2775
+ function mergeOperatorLogs(answers, limit) {
2776
+ const malformed = answers.reduce(
2777
+ (acc, a) => ({ converge: acc.converge + a.malformed.converge, gates: acc.gates + a.malformed.gates }),
2778
+ { converge: 0, gates: 0 }
2779
+ );
2780
+ if (answers.length === 1) {
2781
+ const only = answers[0].entries;
2782
+ return { entries: only.length > limit ? only.slice(-limit) : only, malformed };
2573
2783
  }
2574
- return out;
2784
+ const all = answers.flatMap((a) => a.entries).sort((a, b) => {
2785
+ const delta = new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime();
2786
+ return delta !== 0 ? delta : a.kind === b.kind ? 0 : a.kind === "tick" ? -1 : 1;
2787
+ });
2788
+ return { entries: all.length > limit ? all.slice(-limit) : all, malformed };
2575
2789
  }
2576
- function deriveK8sEdges(nodes) {
2577
- const k8s = nodes.filter((n) => n.lexicon === "k8s");
2578
- if (k8s.length === 0) return [];
2579
- const out = [];
2580
- const add = (from, to, viaAttr) => {
2581
- if (from !== to) out.push({ from, to, kind: "ref", viaAttr, inferred: true });
2582
- };
2583
- const workloads = k8s.filter((n) => WORKLOAD_KINDS.has(n.kind));
2584
- const byKindNsName = /* @__PURE__ */ new Map();
2585
- const appProjectsByName = /* @__PURE__ */ new Map();
2586
- for (const n of k8s) {
2587
- const name = nameOf(n);
2588
- if (!name) continue;
2589
- byKindNsName.set(`${n.kind}\0${namespaceOf(n)}\0${name}`, n);
2590
- if (n.kind === "K8s::Argo::AppProject") {
2591
- const same = appProjectsByName.get(name) ?? [];
2592
- same.push(n);
2593
- appProjectsByName.set(name, same);
2594
- }
2790
+ var initialOperatorState = {
2791
+ declared: [],
2792
+ strip: null,
2793
+ gates: [],
2794
+ verdicts: [],
2795
+ note: null,
2796
+ code: null,
2797
+ readAt: null
2798
+ };
2799
+ function operatorRead(state, result, at, now = Date.now()) {
2800
+ if (!result.ok) {
2801
+ return { ...state, strip: null, gates: [], verdicts: [], note: result.refusal.error, code: result.refusal.code, readAt: at };
2595
2802
  }
2596
- const lookup = (kind, namespace, name) => byKindNsName.get(`${kind}\0${namespace}\0${name}`);
2597
- const addFluxRef = (n, refObj, viaAttr, allowed, defaultKind) => {
2598
- const ref = sourceRefOf(refObj, n, defaultKind);
2599
- if (!ref || ref.name === "*") return;
2600
- const kind = `K8s::Flux::${ref.kind}`;
2601
- if (allowed && !allowed.has(kind)) return;
2602
- const target = lookup(kind, ref.namespace, ref.name);
2603
- if (target) add(n.id, target.id, viaAttr);
2604
- };
2605
- const addSourceRef = (n, refObj, viaAttr, defaultKind) => addFluxRef(n, refObj, viaAttr, FLUX_SOURCE_KINDS, defaultKind);
2606
- const addNamedRef = (n, refObj, kind, viaAttr) => {
2607
- const ref = namedRefOf(refObj, n);
2608
- if (!ref) return;
2609
- const target = lookup(kind, ref.namespace, ref.name);
2610
- if (target) add(n.id, target.id, viaAttr);
2611
- };
2612
- const addProject = (n, project, viaAttr) => {
2613
- const candidates = project ? appProjectsByName.get(project) ?? [] : [];
2614
- const target = candidates.length === 1 ? candidates[0] : candidates.find((p) => namespaceOf(p) === namespaceOf(n));
2615
- if (target) add(n.id, target.id, viaAttr);
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
- }
2803
+ return {
2804
+ ...state,
2805
+ strip: operatorStrip(result.rows, now),
2806
+ gates: convergeGateCards(result.rows),
2807
+ verdicts: tickComponentVerdicts(result.rows),
2808
+ note: null,
2809
+ code: null,
2810
+ readAt: at
2625
2811
  };
2626
- for (const n of k8s) {
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);
2630
- continue;
2631
- }
2632
- if (n.kind === "K8s::Networking::Ingress") {
2633
- for (const svcName of ingressBackendServices(n)) {
2634
- const svc = lookup("K8s::Core::Service", namespaceOf(n), svcName);
2635
- if (svc) add(n.id, svc.id, "ingress backend");
2636
- }
2637
- continue;
2638
- }
2639
- if (n.kind === "K8s::Autoscaling::HorizontalPodAutoscaler") {
2640
- const ref = rec(rec(n.attrs?.spec)?.scaleTargetRef);
2641
- const refKind = str(ref?.kind);
2642
- const refName = str(ref?.name);
2643
- if (!refKind || !refName) continue;
2644
- for (const w of workloads) {
2645
- if (namespaceOf(w) !== namespaceOf(n)) continue;
2646
- if (shortKind(w.kind) === refKind && nameOf(w) === refName) add(n.id, w.id, "scaleTargetRef");
2647
- }
2648
- continue;
2649
- }
2650
- if (n.kind === "K8s::Flux::Kustomization") {
2651
- addSourceRef(n, rec(n.attrs?.spec)?.sourceRef, "sourceRef");
2652
- for (const dep of list(rec(n.attrs?.spec)?.dependsOn)) {
2653
- addNamedRef(n, dep, "K8s::Flux::Kustomization", "dependsOn");
2654
- }
2655
- continue;
2656
- }
2657
- if (n.kind === "K8s::Flux::HelmRelease") {
2658
- addSourceRef(n, helmReleaseSourceRef(n), "chart sourceRef");
2659
- continue;
2660
- }
2661
- if (n.kind === "K8s::Flux::ImagePolicy") {
2662
- addNamedRef(n, rec(n.attrs?.spec)?.imageRepositoryRef, "K8s::Flux::ImageRepository", "imageRepositoryRef");
2663
- continue;
2664
- }
2665
- if (n.kind === "K8s::Flux::ImageUpdateAutomation") {
2666
- addSourceRef(n, rec(n.attrs?.spec)?.sourceRef, "sourceRef", "GitRepository");
2667
- continue;
2668
- }
2669
- if (n.kind === "K8s::Flux::Alert") {
2670
- addNamedRef(n, rec(n.attrs?.spec)?.providerRef, "K8s::Flux::Provider", "providerRef");
2671
- for (const src of list(rec(n.attrs?.spec)?.eventSources)) {
2672
- addFluxRef(n, src, "eventSource");
2673
- }
2674
- continue;
2675
- }
2676
- if (n.kind === "K8s::Argo::Application") {
2677
- addProject(n, str(rec(n.attrs?.spec)?.project), "project");
2678
- continue;
2679
- }
2680
- if (n.kind === "K8s::Argo::ApplicationSet") {
2681
- addProject(n, str(rec(rec(rec(n.attrs?.spec)?.template)?.spec)?.project), "template project");
2682
- }
2683
- }
2684
- return out;
2685
2812
  }
2686
- function addK8sDeclaredEdges(ir) {
2687
- const existing = new Set(ir.edges.map((e) => `${e.from}\0${e.to}`));
2688
- for (const e of deriveK8sEdges(ir.nodes)) {
2689
- const key = `${e.from}\0${e.to}`;
2690
- if (existing.has(key)) continue;
2691
- existing.add(key);
2692
- ir.edges.push(e);
2813
+ function operatorNote(state, now = Date.now()) {
2814
+ if (!state.declared.length) {
2815
+ return "No operating loop: this project declares no ConvergeOp, so there is no operator strip.";
2693
2816
  }
2694
- return ir;
2695
- }
2696
-
2697
- // src/helm-artifacts.ts
2698
- function chartNameOf(node) {
2699
- const name = node.attrs?.name;
2700
- return typeof name === "string" && name.length > 0 ? name : void 0;
2701
- }
2702
- function releaseMatchesChart(observed, chartName) {
2703
- const chart = observed.attributes?.chart;
2704
- if (typeof chart !== "string") return false;
2705
- return chart === chartName || chart.startsWith(`${chartName}-`);
2706
- }
2707
- function applyHelmArtifacts(ir, observed) {
2708
- let charts = 0;
2709
- let installed = 0;
2710
- for (const node of ir.nodes) {
2711
- if (node.lexicon !== "helm" || node.kind !== "Helm::Chart") continue;
2712
- charts++;
2713
- const attrs = node.attrs ??= {};
2714
- if (observed === void 0) {
2715
- attrs._status = "neutral";
2716
- attrs._unobserved = "artifact-observation-unavailable";
2717
- continue;
2718
- }
2719
- const chartName = chartNameOf(node);
2720
- const entry = chartName ? Object.entries(observed).find(([key2, o2]) => key2.startsWith("release/") && releaseMatchesChart(o2, chartName)) : void 0;
2721
- if (!entry) {
2722
- attrs._status = "accent";
2723
- continue;
2724
- }
2725
- const [key, o] = entry;
2726
- const match = {
2727
- release: key.replace(/^release\//, ""),
2728
- ...o.status ? { status: o.status } : {},
2729
- ...typeof o.attributes?.revision === "string" ? { revision: o.attributes.revision } : {},
2730
- ...typeof o.attributes?.chart === "string" ? { chart: o.attributes.chart } : {}
2731
- };
2732
- attrs._artifact = match;
2733
- attrs._status = o.status === "deployed" ? "good" : "warn";
2734
- if (o.status === "deployed") installed++;
2817
+ const loops = `${state.declared.length} ConvergeOp${state.declared.length === 1 ? "" : "s"}`;
2818
+ if (!state.strip) {
2819
+ return state.note ? `Operating loop: ${loops} declared \u2014 status unavailable: ${state.note}` : `Operating loop: ${loops} declared \u2014 not read yet.`;
2735
2820
  }
2736
- return { charts, installed };
2821
+ const bits = [];
2822
+ const ticked = state.strip.rows.filter((r) => r.at).length;
2823
+ bits.push(
2824
+ ticked ? `last tick per loop \u2014 the status surface answers with one tick each, and the history is its own read (chant#2029) reached from the strip` : `no tick recorded yet on any loop`
2825
+ );
2826
+ const named = state.verdicts.reduce((n, t) => n + t.verdicts.length, 0);
2827
+ if (named) {
2828
+ const stale = state.verdicts.filter((t) => t.verdicts.length && !tickFreshness(t.at, now).fresh);
2829
+ const painted = named - stale.reduce((n, t) => n + t.verdicts.length, 0);
2830
+ bits.push(
2831
+ painted ? `${painted} component verdict${painted === 1 ? "" : "s"} from that tick \u2014 joined onto the component DAG by component name, under the live read (never over it) and only while the tick is younger than ${Math.round(TICK_VERDICT_TTL_MS / 6e4)}m` : `${named} component verdict${named === 1 ? "" : "s"} from that tick, all older than ${Math.round(TICK_VERDICT_TTL_MS / 6e4)}m \u2014 named on the node, not painted: a colour would read as the estate's state now`
2832
+ );
2833
+ }
2834
+ const held = state.strip.rows.filter((r) => r.lease === "held").length;
2835
+ if (held) bits.push(`${held} lease${held === 1 ? "" : "s"} held`);
2836
+ const expired = state.strip.rows.filter((r) => r.lease === "expired").length;
2837
+ if (expired) bits.push(`${expired} lease${expired === 1 ? "" : "s"} expired \u2014 the next round reclaims it`);
2838
+ if (state.strip.pendingGates) {
2839
+ const addressed = state.gates.filter((g) => g.url).length;
2840
+ bits.push(
2841
+ `${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)` + (addressed ? `, and ${addressed} name${addressed === 1 ? "s" : ""} where approval happens (chant#2028)` : "")
2842
+ );
2843
+ }
2844
+ return `Operating loop: ${loops} \u2014 ${bits.join("; ")}.`;
2737
2845
  }
2738
2846
 
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;
2847
+ // src/component-status.ts
2848
+ function componentStatusColor(row) {
2849
+ if (row.stack && row.stack.healthy === false) {
2850
+ if (row.stack.status && /ROLLBACK|FAILED/i.test(row.stack.status)) return "warn";
2851
+ return "accent";
2852
+ }
2853
+ const rollup = row.resources;
2854
+ if (rollup && rollup.total > 0) {
2855
+ if (rollup.unobserved > 0) return "accent";
2856
+ if (rollup.present === rollup.total) return "good";
2857
+ if (rollup.present === 0) return "neutral";
2858
+ return "warn";
2859
+ }
2860
+ if (row.live !== void 0) return row.live ? "good" : "neutral";
2861
+ switch (row.reconciliation) {
2862
+ case "reconciled":
2863
+ return "good";
2864
+ case "unrecorded":
2865
+ return row.detail.startsWith("live") ? "good" : "neutral";
2866
+ case "stale":
2867
+ case "drifted":
2868
+ return "warn";
2869
+ case "unknown":
2870
+ default:
2871
+ return "neutral";
2872
+ }
2753
2873
  }
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
- };
2874
+ function httpUrl(url) {
2875
+ if (typeof url !== "string" || !url) return void 0;
2774
2876
  try {
2775
- walk(root);
2877
+ const p = new URL(url);
2878
+ return p.protocol === "http:" || p.protocol === "https:" ? url : void 0;
2776
2879
  } catch {
2777
2880
  return void 0;
2778
2881
  }
2779
- return any ? h.digest("hex") : void 0;
2780
- }
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}`;
2785
2882
  }
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;
2883
+ function releaseOrigin(rec6) {
2884
+ const origin = rec6.runOrigin;
2885
+ if (origin && typeof origin.forge === "string") {
2886
+ const url = httpUrl(origin.url);
2887
+ return {
2888
+ forge: origin.forge,
2889
+ originSource: "record",
2890
+ ...origin.repo ? { repo: origin.repo } : {},
2891
+ ...url ? { url } : {}
2892
+ };
2793
2893
  }
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);
2894
+ return { forge: rec6.runId.startsWith("local-") ? "local" : "unknown", originSource: "inferred" };
2819
2895
  }
2820
-
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;
2826
- }
2827
- if (zoom === "logical") {
2828
- return empty ? "logical is an AWS projection \u2014 no AWS resources in this estate" : void 0;
2829
- }
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;
2896
+ var FORGE_WORD = {
2897
+ github: "github",
2898
+ gitlab: "gitlab",
2899
+ op: "op",
2900
+ local: "laptop",
2901
+ unknown: "?"
2902
+ };
2903
+ function releaseChip(status) {
2904
+ return `${status.gitSha.slice(0, 7)} via ${FORGE_WORD[status.forge]}`;
2838
2905
  }
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)`;
2906
+ function releaseStatus(rec6) {
2907
+ if (!rec6) return void 0;
2908
+ return {
2909
+ runId: rec6.runId,
2910
+ ...releaseOrigin(rec6),
2911
+ gitSha: rec6.gitSha,
2912
+ digest: rec6.digest,
2913
+ timestamp: rec6.timestamp,
2914
+ actor: rec6.actor,
2915
+ ...rec6.approver ? { approver: rec6.approver } : {}
2916
+ };
2842
2917
  }
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";
2918
+ function joinComponentStatus(ir, rows) {
2919
+ const byComponent = new Map(rows.map((r) => [r.component, r]));
2920
+ return {
2921
+ ...ir,
2922
+ nodes: ir.nodes.map((n) => {
2923
+ const row = byComponent.get(n.id);
2924
+ if (!row) return n;
2925
+ const release = releaseStatus(row.recorded);
2926
+ return {
2927
+ ...n,
2928
+ attrs: {
2929
+ ...n.attrs,
2930
+ ...release ? { release: releaseChip(release), _release: release } : {},
2931
+ _status: componentStatusColor(row),
2932
+ _liveStatus: {
2933
+ reconciliation: row.reconciliation,
2934
+ detail: row.detail,
2935
+ ...row.live !== void 0 ? { live: row.live } : {},
2936
+ ...row.stack ? { stack: row.stack } : {},
2937
+ ...row.resources ? { resources: row.resources } : {}
2938
+ }
2939
+ }
2940
+ };
2941
+ })
2942
+ };
2850
2943
  }
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`;
2944
+ function joinTickVerdicts(ir, tick, now = Date.now()) {
2945
+ if (!tick || !tick.verdicts.length) return ir;
2946
+ const { fresh } = tickFreshness(tick.at, now);
2947
+ const byComponent = new Map(tick.verdicts.map((v) => [v.component, v]));
2948
+ let joined = false;
2949
+ const nodes = ir.nodes.map((n) => {
2950
+ const v = byComponent.get(n.id);
2951
+ if (!v) return n;
2952
+ joined = true;
2953
+ const status = {
2954
+ reconciliation: v.reconciliation,
2955
+ detail: v.detail,
2956
+ ...v.live !== void 0 ? { live: v.live } : {},
2957
+ ...v.unobserved ? { unobserved: v.unobserved } : {},
2958
+ op: tick.op,
2959
+ env: tick.env,
2960
+ at: tick.at,
2961
+ ...tick.tickId ? { tickId: tick.tickId } : {},
2962
+ stale: !fresh
2963
+ };
2964
+ const paint = fresh && n.attrs?._status === void 0;
2965
+ return {
2966
+ ...n,
2967
+ attrs: {
2968
+ ...n.attrs,
2969
+ // The reconciliation tier and nothing else — `live`/`stack`/`resources`
2970
+ // are deliberately not passed, so this can only ever reach
2971
+ // `componentStatusColor`'s last branch.
2972
+ ...paint ? { _status: componentStatusColor({ reconciliation: v.reconciliation, detail: v.detail }) } : {},
2973
+ _tickStatus: status
2974
+ }
2975
+ };
2976
+ });
2977
+ return joined ? { ...ir, nodes } : ir;
2866
2978
  }
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`;
2979
+
2980
+ // src/overlay.ts
2981
+ function pruneImports(ir) {
2982
+ const drop = new Set((ir.imports ?? []).map((i) => i.node));
2983
+ if (drop.size === 0) return ir;
2984
+ ir.nodes = ir.nodes.filter((n) => !drop.has(n.id));
2985
+ if (ir.edges) ir.edges = ir.edges.filter((e) => !drop.has(e.from) && !drop.has(e.to));
2986
+ return ir;
2871
2987
  }
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;
2988
+ function componentDir(node) {
2989
+ const parts = node.sourceLoc?.file?.split("/") ?? [];
2990
+ if (parts[0] !== "src" || parts[1] === "examples" || parts.length < 3) return void 0;
2991
+ return parts[1];
2876
2992
  }
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();
2993
+ function reclassifyOverlay(ir) {
2994
+ const deployedComponents = /* @__PURE__ */ new Set();
2881
2995
  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);
2996
+ if (n.attrs?._status === "good") {
2997
+ const c = componentDir(n);
2998
+ if (c) deployedComponents.add(c);
2910
2999
  }
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));
3000
+ }
2934
3001
  for (const n of ir.nodes) {
2935
- if (n.runtimeOwner && own.has(n.runtimeOwner)) n.runtimeOwner = `${name}/${n.runtimeOwner}`;
3002
+ const file = n.sourceLoc?.file ?? "";
3003
+ if (file.startsWith("src/examples/")) {
3004
+ n.attrs = { ...n.attrs, _byo: true };
3005
+ delete n.attrs._status;
3006
+ } else if (n.kind === "AWS::CloudFormation::Parameter") {
3007
+ const c = componentDir(n);
3008
+ if (c && deployedComponents.has(c)) {
3009
+ n.attrs = { ...n.attrs, _status: "good" };
3010
+ }
3011
+ }
2936
3012
  }
2937
3013
  return ir;
2938
3014
  }
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 = [];
3015
+ function pruneRuntimeChildren(ir) {
3016
+ const runtime = new Set(ir.nodes.filter((n) => n.runtimeOwner).map((n) => n.id));
3017
+ if (runtime.size === 0) return ir;
3018
+ ir.nodes = ir.nodes.filter((n) => !runtime.has(n.id));
3019
+ ir.edges = ir.edges.filter((e) => !runtime.has(e.from) && !runtime.has(e.to));
3020
+ return ir;
3021
+ }
3022
+ function attachRuntimeContainment(ir) {
3023
+ const byOwner = /* @__PURE__ */ new Map();
2951
3024
  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 });
3025
+ if (!n.runtimeOwner) continue;
3026
+ if (!byOwner.has(n.runtimeOwner)) byOwner.set(n.runtimeOwner, /* @__PURE__ */ new Set());
3027
+ byOwner.get(n.runtimeOwner).add(n.id);
2957
3028
  }
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));
3029
+ if (byOwner.size === 0) return ir;
3030
+ const byContainer = { ...ir.groups.byContainer ?? {} };
3031
+ for (const [owner, children] of byOwner) {
3032
+ byContainer[owner] = [.../* @__PURE__ */ new Set([owner, ...byContainer[owner] ?? [], ...children])].sort();
3033
+ }
3034
+ ir.groups.byContainer = byContainer;
3035
+ return ir;
2964
3036
  }
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;
3037
+
3038
+ // src/value-match.ts
3039
+ function typeSegment(kind) {
3040
+ const parts = kind.split(kind.includes("::") ? "::" : "/");
3041
+ return parts[parts.length - 1] ?? kind;
2970
3042
  }
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;
3043
+ function isOwnNameAttr(kind, key) {
3044
+ if (!/name$/i.test(key)) return false;
3045
+ const prefix = key.slice(0, -4).toLowerCase();
3046
+ if (prefix === "") return true;
3047
+ return typeSegment(kind).toLowerCase().includes(prefix);
2982
3048
  }
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}/`)));
3049
+ var MIN_NAME_LEN = 6;
3050
+ var MAX_NAME_LEN = 200;
3051
+ var MAX_DEPTH = 12;
3052
+ var SKIP_KEYS = /* @__PURE__ */ new Set([
3053
+ "status",
3054
+ "annotations",
3055
+ "labels",
3056
+ "matchLabels",
3057
+ "managedFields",
3058
+ "ownerReferences",
3059
+ "creationTimestamp",
3060
+ "resourceVersion",
3061
+ "uid",
3062
+ "selfLink",
3063
+ "generation",
3064
+ "finalizers"
3065
+ ]);
3066
+ function joinable(v) {
3067
+ return v.length >= MIN_NAME_LEN && v.length <= MAX_NAME_LEN && !/\s/.test(v);
3033
3068
  }
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) });
3069
+ function stringLeaves(attrs) {
3070
+ const out = [];
3071
+ const onPath = /* @__PURE__ */ new Set();
3072
+ const step = (v, key, path, depth) => {
3073
+ if (typeof v === "string") {
3074
+ out.push({ path, key, value: v });
3075
+ return;
3076
+ }
3077
+ if (depth >= MAX_DEPTH || !v || typeof v !== "object") return;
3078
+ if (onPath.has(v)) return;
3079
+ onPath.add(v);
3080
+ if (Array.isArray(v)) {
3081
+ for (const item of v) step(item, key, path, depth + 1);
3082
+ } else {
3083
+ for (const [k, val] of Object.entries(v)) {
3084
+ if (k.startsWith("_") || SKIP_KEYS.has(k)) continue;
3085
+ step(val, k, path ? `${path}.${k}` : k, depth + 1);
3058
3086
  }
3059
3087
  }
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
3088
+ onPath.delete(v);
3109
3089
  };
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 };
3090
+ for (const [k, v] of Object.entries(attrs ?? {})) {
3091
+ if (k.startsWith("_") || SKIP_KEYS.has(k)) continue;
3092
+ step(v, k, k, 0);
3119
3093
  }
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;
3094
+ return out;
3135
3095
  }
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]);
3096
+ function addValueMatchEdges(ir) {
3097
+ const nodes = ir.nodes;
3098
+ const owner = /* @__PURE__ */ new Map();
3099
+ for (const n of nodes) {
3100
+ for (const { path, key, value } of stringLeaves(n.attrs)) {
3101
+ if (path !== key && path !== `metadata.${key}`) continue;
3102
+ if (!joinable(value) || !isOwnNameAttr(n.kind, key)) continue;
3103
+ owner.set(value, owner.has(value) ? null : n.id);
3104
+ }
3144
3105
  }
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++;
3106
+ if (owner.size === 0) return ir;
3107
+ const declared = /* @__PURE__ */ new Set();
3108
+ for (const e of ir.edges) declared.add(`${e.from}\0${e.to}`);
3109
+ const added = /* @__PURE__ */ new Set();
3110
+ for (const a of nodes) {
3111
+ for (const { path, value } of stringLeaves(a.attrs)) {
3112
+ if (!joinable(value)) continue;
3113
+ const b = owner.get(value);
3114
+ if (!b || b === a.id) continue;
3115
+ const key = `${a.id}\0${b}`;
3116
+ if (declared.has(key) || added.has(key)) continue;
3117
+ added.add(key);
3118
+ ir.edges.push({ from: a.id, to: b, kind: "ref", viaAttr: path, inferred: true });
3159
3119
  }
3160
3120
  }
3161
- return { charts, drifted };
3121
+ return ir;
3162
3122
  }
3163
3123
 
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"
3124
+ // src/k8s-edges.ts
3125
+ var WORKLOAD_KINDS = /* @__PURE__ */ new Set([
3126
+ "K8s::Apps::Deployment",
3127
+ "K8s::Apps::StatefulSet",
3128
+ "K8s::Apps::DaemonSet",
3129
+ "K8s::Apps::ReplicaSet"
3170
3130
  ]);
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;
3131
+ var SELECTOR_CARRIERS = [
3132
+ { kind: "K8s::Core::Service", read: (spec) => spec?.selector, viaAttr: "selector" },
3133
+ { kind: "K8s::Policy::PodDisruptionBudget", read: (spec) => labelSelector(spec?.selector), viaAttr: "selector" },
3134
+ { kind: "K8s::Networking::NetworkPolicy", read: (spec) => labelSelector(spec?.podSelector), viaAttr: "podSelector" }
3135
+ ];
3136
+ function rec2(v) {
3137
+ return v && typeof v === "object" && !Array.isArray(v) ? v : void 0;
3175
3138
  }
3176
- function metaString(node, key) {
3177
- const v = metadata(node)?.[key];
3139
+ function str2(v) {
3178
3140
  return typeof v === "string" && v.length > 0 ? v : void 0;
3179
3141
  }
3180
- function isK8s(node) {
3181
- return node.lexicon === "k8s";
3142
+ function namespaceOf(n) {
3143
+ return str2(rec2(n.attrs?.metadata)?.namespace) ?? "default";
3182
3144
  }
3183
- function isNamespace(node) {
3184
- return node.kind === "K8s::Core::Namespace";
3145
+ function nameOf(n) {
3146
+ return str2(rec2(n.attrs?.metadata)?.name);
3185
3147
  }
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;
3148
+ function labelRecord(v) {
3149
+ const r = rec2(v);
3150
+ if (!r) return void 0;
3151
+ const out = {};
3152
+ for (const [k, val] of Object.entries(r)) {
3153
+ if (typeof val !== "string") return void 0;
3154
+ out[k] = val;
3192
3155
  }
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;
3156
+ return Object.keys(out).length > 0 ? out : void 0;
3201
3157
  }
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;
3158
+ function labelSelector(v) {
3159
+ const sel = rec2(v);
3160
+ if (!sel) return void 0;
3161
+ const expressions = sel.matchExpressions;
3162
+ if (Array.isArray(expressions) && expressions.length > 0) return void 0;
3163
+ return sel.matchLabels;
3232
3164
  }
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;
3165
+ function selects(selector, labels) {
3166
+ return Object.entries(selector).every(([k, v]) => labels[k] === v);
3283
3167
  }
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);
3168
+ function templateLabels(n) {
3169
+ const template = rec2(rec2(n.attrs?.spec)?.template);
3170
+ return labelRecord(rec2(template?.metadata)?.labels);
3292
3171
  }
3293
- function strAttr(node, key) {
3294
- const v = node.attrs?.[key];
3295
- return typeof v === "string" ? v : void 0;
3172
+ function shortKind(kind) {
3173
+ const parts = kind.split("::");
3174
+ return parts[parts.length - 1] ?? kind;
3296
3175
  }
3297
- function resourceName(node) {
3298
- return strAttr(node, "name");
3176
+ var FLUX_SOURCE_KINDS = /* @__PURE__ */ new Set([
3177
+ "K8s::Flux::GitRepository",
3178
+ "K8s::Flux::OCIRepository",
3179
+ "K8s::Flux::HelmRepository",
3180
+ "K8s::Flux::HelmChart",
3181
+ "K8s::Flux::Bucket"
3182
+ ]);
3183
+ function sourceRefOf(refObj, referrer, defaultKind) {
3184
+ const ref = rec2(refObj);
3185
+ const kind = str2(ref?.kind) ?? defaultKind;
3186
+ const name = str2(ref?.name);
3187
+ if (!kind || !name) return void 0;
3188
+ return { kind, name, namespace: str2(ref?.namespace) ?? namespaceOf(referrer) };
3299
3189
  }
3300
- function subnetVnetName(node) {
3301
- const name = resourceName(node);
3302
- if (!name || !name.includes("/")) return void 0;
3303
- return name.split("/")[0];
3190
+ function namedRefOf(refObj, referrer) {
3191
+ const ref = rec2(refObj);
3192
+ const name = str2(ref?.name);
3193
+ if (!name) return void 0;
3194
+ return { name, namespace: str2(ref?.namespace) ?? namespaceOf(referrer) };
3304
3195
  }
3305
- function subnetShortName(node) {
3306
- const name = resourceName(node);
3307
- return name?.includes("/") ? name.split("/").slice(1).join("/") : name;
3196
+ function list(v) {
3197
+ return Array.isArray(v) ? v : [];
3308
3198
  }
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;
3199
+ function helmReleaseSourceRef(n) {
3200
+ return rec2(rec2(rec2(n.attrs?.spec)?.chart)?.spec)?.sourceRef;
3313
3201
  }
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();
3330
- collectAzureRefs(n.attrs, names);
3331
- const targets = /* @__PURE__ */ new Set();
3332
- for (const name of names) {
3333
- const target = byName.get(name) ?? byId.get(name);
3334
- if (target && target.id !== n.id) targets.add(target.id);
3202
+ function ingressBackendServices(n) {
3203
+ const spec = rec2(n.attrs?.spec);
3204
+ const out = [];
3205
+ const backendName = (b) => str2(rec2(rec2(b)?.service)?.name);
3206
+ const fromDefault = backendName(spec?.defaultBackend);
3207
+ if (fromDefault) out.push(fromDefault);
3208
+ const rules = Array.isArray(spec?.rules) ? spec.rules : [];
3209
+ for (const rule of rules) {
3210
+ const paths = rec2(rec2(rule)?.http)?.paths;
3211
+ for (const p of Array.isArray(paths) ? paths : []) {
3212
+ const name = backendName(rec2(p)?.backend);
3213
+ if (name) out.push(name);
3335
3214
  }
3336
- if (targets.size) refOut.set(n.id, targets);
3337
- }
3338
- for (const e of ir.edges) {
3339
- if (!byId.has(e.from) || !byId.has(e.to)) continue;
3340
- (refOut.get(e.from) ?? refOut.set(e.from, /* @__PURE__ */ new Set()).get(e.from)).add(e.to);
3341
3215
  }
3342
- const RG_TITLE = env ? `resource group ${env}` : "resource group";
3343
- const vnetTitle = (v) => `VNet ${vnetCidr(v) ?? resourceName(v) ?? v.id}`;
3344
- const subnetTitle = (s) => `subnet ${strAttr(s, "addressPrefix") ?? subnetShortName(s) ?? s.id}`;
3345
- const guardedBy = /* @__PURE__ */ new Map();
3346
- for (const s of subnets) {
3347
- const names = /* @__PURE__ */ new Set();
3348
- collectAzureRefs(s.attrs?.networkSecurityGroup, names);
3349
- for (const name of names) {
3350
- if (byName.get(name)?.kind === NSG_KIND) guardedBy.set(s.id, name);
3216
+ return out;
3217
+ }
3218
+ function deriveK8sEdges(nodes) {
3219
+ const k8s = nodes.filter((n) => n.lexicon === "k8s");
3220
+ if (k8s.length === 0) return [];
3221
+ const out = [];
3222
+ const add = (from, to, viaAttr) => {
3223
+ if (from !== to) out.push({ from, to, kind: "ref", viaAttr, inferred: true });
3224
+ };
3225
+ const workloads = k8s.filter((n) => WORKLOAD_KINDS.has(n.kind));
3226
+ const byKindNsName = /* @__PURE__ */ new Map();
3227
+ const appProjectsByName = /* @__PURE__ */ new Map();
3228
+ for (const n of k8s) {
3229
+ const name = nameOf(n);
3230
+ if (!name) continue;
3231
+ byKindNsName.set(`${n.kind}\0${namespaceOf(n)}\0${name}`, n);
3232
+ if (n.kind === "K8s::Argo::AppProject") {
3233
+ const same = appProjectsByName.get(name) ?? [];
3234
+ same.push(n);
3235
+ appProjectsByName.set(name, same);
3351
3236
  }
3352
3237
  }
3353
- const headline = azure.filter((n) => AZURE_HEADLINE_KINDS.has(n.kind));
3354
- const subnetIds = new Set(subnets.map((s) => s.id));
3355
- const vnetIds = new Set(vnets.map((v) => v.id));
3356
- const nearest = (start, want, maxDepth = 6) => {
3357
- const seen = /* @__PURE__ */ new Set([start]);
3358
- let frontier = [...refOut.get(start) ?? []];
3359
- for (let depth = 0; depth < maxDepth && frontier.length; depth++) {
3360
- const next = [];
3361
- for (const id of frontier) {
3362
- if (seen.has(id)) continue;
3363
- seen.add(id);
3364
- if (want.has(id)) return id;
3365
- for (const r of refOut.get(id) ?? []) if (!seen.has(r)) next.push(r);
3366
- }
3367
- frontier = next;
3368
- }
3369
- return void 0;
3238
+ const lookup = (kind, namespace, name) => byKindNsName.get(`${kind}\0${namespace}\0${name}`);
3239
+ const addFluxRef = (n, refObj, viaAttr, allowed, defaultKind) => {
3240
+ const ref = sourceRefOf(refObj, n, defaultKind);
3241
+ if (!ref || ref.name === "*") return;
3242
+ const kind = `K8s::Flux::${ref.kind}`;
3243
+ if (allowed && !allowed.has(kind)) return;
3244
+ const target = lookup(kind, ref.namespace, ref.name);
3245
+ if (target) add(n.id, target.id, viaAttr);
3370
3246
  };
3371
- const byContainer = {};
3372
- const child = (parent, c) => {
3373
- const arr = byContainer[parent] ?? (byContainer[parent] = []);
3374
- if (!arr.includes(c)) arr.push(c);
3247
+ const addSourceRef = (n, refObj, viaAttr, defaultKind) => addFluxRef(n, refObj, viaAttr, FLUX_SOURCE_KINDS, defaultKind);
3248
+ const addNamedRef = (n, refObj, kind, viaAttr) => {
3249
+ const ref = namedRefOf(refObj, n);
3250
+ if (!ref) return;
3251
+ const target = lookup(kind, ref.namespace, ref.name);
3252
+ if (target) add(n.id, target.id, viaAttr);
3375
3253
  };
3376
- for (const v of vnets) child(RG_TITLE, vnetTitle(v));
3377
- for (const s of subnets) {
3254
+ const addProject = (n, project, viaAttr) => {
3255
+ const candidates = project ? appProjectsByName.get(project) ?? [] : [];
3256
+ const target = candidates.length === 1 ? candidates[0] : candidates.find((p) => namespaceOf(p) === namespaceOf(n));
3257
+ if (target) add(n.id, target.id, viaAttr);
3258
+ };
3259
+ const addSelector = (n, raw, viaAttr) => {
3260
+ const selector = labelRecord(raw);
3261
+ if (!selector) return;
3262
+ for (const w of workloads) {
3263
+ if (namespaceOf(w) !== namespaceOf(n)) continue;
3264
+ const labels = templateLabels(w);
3265
+ if (labels && selects(selector, labels)) add(n.id, w.id, viaAttr);
3266
+ }
3267
+ };
3268
+ for (const n of k8s) {
3269
+ const carrier = SELECTOR_CARRIERS.find((c) => c.kind === n.kind);
3270
+ if (carrier) {
3271
+ addSelector(n, carrier.read(rec2(n.attrs?.spec)), carrier.viaAttr);
3272
+ continue;
3273
+ }
3274
+ if (n.kind === "K8s::Networking::Ingress") {
3275
+ for (const svcName of ingressBackendServices(n)) {
3276
+ const svc = lookup("K8s::Core::Service", namespaceOf(n), svcName);
3277
+ if (svc) add(n.id, svc.id, "ingress backend");
3278
+ }
3279
+ continue;
3280
+ }
3281
+ if (n.kind === "K8s::Autoscaling::HorizontalPodAutoscaler") {
3282
+ const ref = rec2(rec2(n.attrs?.spec)?.scaleTargetRef);
3283
+ const refKind = str2(ref?.kind);
3284
+ const refName = str2(ref?.name);
3285
+ if (!refKind || !refName) continue;
3286
+ for (const w of workloads) {
3287
+ if (namespaceOf(w) !== namespaceOf(n)) continue;
3288
+ if (shortKind(w.kind) === refKind && nameOf(w) === refName) add(n.id, w.id, "scaleTargetRef");
3289
+ }
3290
+ continue;
3291
+ }
3292
+ if (n.kind === "K8s::Flux::Kustomization") {
3293
+ addSourceRef(n, rec2(n.attrs?.spec)?.sourceRef, "sourceRef");
3294
+ for (const dep of list(rec2(n.attrs?.spec)?.dependsOn)) {
3295
+ addNamedRef(n, dep, "K8s::Flux::Kustomization", "dependsOn");
3296
+ }
3297
+ continue;
3298
+ }
3299
+ if (n.kind === "K8s::Flux::HelmRelease") {
3300
+ addSourceRef(n, helmReleaseSourceRef(n), "chart sourceRef");
3301
+ continue;
3302
+ }
3303
+ if (n.kind === "K8s::Flux::ImagePolicy") {
3304
+ addNamedRef(n, rec2(n.attrs?.spec)?.imageRepositoryRef, "K8s::Flux::ImageRepository", "imageRepositoryRef");
3305
+ continue;
3306
+ }
3307
+ if (n.kind === "K8s::Flux::ImageUpdateAutomation") {
3308
+ addSourceRef(n, rec2(n.attrs?.spec)?.sourceRef, "sourceRef", "GitRepository");
3309
+ continue;
3310
+ }
3311
+ if (n.kind === "K8s::Flux::Alert") {
3312
+ addNamedRef(n, rec2(n.attrs?.spec)?.providerRef, "K8s::Flux::Provider", "providerRef");
3313
+ for (const src of list(rec2(n.attrs?.spec)?.eventSources)) {
3314
+ addFluxRef(n, src, "eventSource");
3315
+ }
3316
+ continue;
3317
+ }
3318
+ if (n.kind === "K8s::Argo::Application") {
3319
+ addProject(n, str2(rec2(n.attrs?.spec)?.project), "project");
3320
+ continue;
3321
+ }
3322
+ if (n.kind === "K8s::Argo::ApplicationSet") {
3323
+ addProject(n, str2(rec2(rec2(rec2(n.attrs?.spec)?.template)?.spec)?.project), "template project");
3324
+ }
3325
+ }
3326
+ return out;
3327
+ }
3328
+ function addK8sDeclaredEdges(ir) {
3329
+ const existing = new Set(ir.edges.map((e) => `${e.from}\0${e.to}`));
3330
+ for (const e of deriveK8sEdges(ir.nodes)) {
3331
+ const key = `${e.from}\0${e.to}`;
3332
+ if (existing.has(key)) continue;
3333
+ existing.add(key);
3334
+ ir.edges.push(e);
3335
+ }
3336
+ return ir;
3337
+ }
3338
+
3339
+ // src/helm-artifacts.ts
3340
+ function chartNameOf(node) {
3341
+ const name = node.attrs?.name;
3342
+ return typeof name === "string" && name.length > 0 ? name : void 0;
3343
+ }
3344
+ function releaseMatchesChart(observed, chartName) {
3345
+ const chart = observed.attributes?.chart;
3346
+ if (typeof chart !== "string") return false;
3347
+ return chart === chartName || chart.startsWith(`${chartName}-`);
3348
+ }
3349
+ function applyHelmArtifacts(ir, observed) {
3350
+ let charts = 0;
3351
+ let installed = 0;
3352
+ for (const node of ir.nodes) {
3353
+ if (node.lexicon !== "helm" || node.kind !== "Helm::Chart") continue;
3354
+ charts++;
3355
+ const attrs = node.attrs ??= {};
3356
+ if (observed === void 0) {
3357
+ attrs._status = "neutral";
3358
+ attrs._unobserved = "artifact-observation-unavailable";
3359
+ continue;
3360
+ }
3361
+ const chartName = chartNameOf(node);
3362
+ const entry = chartName ? Object.entries(observed).find(([key2, o2]) => key2.startsWith("release/") && releaseMatchesChart(o2, chartName)) : void 0;
3363
+ if (!entry) {
3364
+ attrs._status = "accent";
3365
+ continue;
3366
+ }
3367
+ const [key, o] = entry;
3368
+ const match = {
3369
+ release: key.replace(/^release\//, ""),
3370
+ ...o.status ? { status: o.status } : {},
3371
+ ...typeof o.attributes?.revision === "string" ? { revision: o.attributes.revision } : {},
3372
+ ...typeof o.attributes?.chart === "string" ? { chart: o.attributes.chart } : {}
3373
+ };
3374
+ attrs._artifact = match;
3375
+ attrs._status = o.status === "deployed" ? "good" : "warn";
3376
+ if (o.status === "deployed") installed++;
3377
+ }
3378
+ return { charts, installed };
3379
+ }
3380
+
3381
+ // src/estate.ts
3382
+ import { statSync as statSync3 } from "node:fs";
3383
+ import { availableParallelism } from "node:os";
3384
+ import { join as join7, resolve as resolve3, sep } from "node:path";
3385
+ import { composeStacks, shortStackNames } from "@intentius/pinhole";
3386
+
3387
+ // src/member-ir.ts
3388
+ import { createHash } from "node:crypto";
3389
+ import { readdirSync as readdirSync3, statSync as statSync2 } from "node:fs";
3390
+ import { join as join6, relative, resolve as resolve2 } from "node:path";
3391
+ var SKIP = /* @__PURE__ */ new Set(["node_modules", "dist", ".git"]);
3392
+ function memberIrCacheSize(env = process.env) {
3393
+ const override = Number.parseInt(env.BEHOLD_ESTATE_IR_CACHE ?? "", 10);
3394
+ return Number.isInteger(override) && override >= 0 ? override : 64;
3395
+ }
3396
+ function memberSourceStamp(dir) {
3397
+ const root = resolve2(dir);
3398
+ const h = createHash("sha1");
3399
+ let any = false;
3400
+ const walk = (at) => {
3401
+ const entries = readdirSync3(at, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : 1);
3402
+ for (const e of entries) {
3403
+ if (SKIP.has(e.name)) continue;
3404
+ const path = join6(at, e.name);
3405
+ if (e.isDirectory()) {
3406
+ walk(path);
3407
+ continue;
3408
+ }
3409
+ if (!e.isFile()) continue;
3410
+ const st = statSync2(path);
3411
+ h.update(`${relative(root, path)}\0${st.mtimeMs}\0${st.size}
3412
+ `);
3413
+ any = true;
3414
+ }
3415
+ };
3416
+ try {
3417
+ walk(root);
3418
+ } catch {
3419
+ return void 0;
3420
+ }
3421
+ return any ? h.digest("hex") : void 0;
3422
+ }
3423
+ function cacheKey(dir, opts) {
3424
+ const chant = resolveChant(dir);
3425
+ const canonical = JSON.stringify(opts, Object.keys(opts).sort());
3426
+ return `${resolve2(dir)}\0${chant.bin}\0${chant.version ?? ""}\0${canonical}`;
3427
+ }
3428
+ var cache = /* @__PURE__ */ new Map();
3429
+ var hits = 0;
3430
+ var misses = 0;
3431
+ function invalidateMember(dir) {
3432
+ if (dir === void 0) {
3433
+ cache.clear();
3434
+ return;
3435
+ }
3436
+ const root = resolve2(dir);
3437
+ for (const [key, entry] of cache) if (entry.dir === root) cache.delete(key);
3438
+ }
3439
+ async function memberIr(dir, opts = {}) {
3440
+ const size = memberIrCacheSize();
3441
+ if (opts.live || opts.overlay || size === 0) return graphIr(dir, opts);
3442
+ const stamp = memberSourceStamp(dir);
3443
+ const key = cacheKey(dir, opts);
3444
+ const hit = stamp !== void 0 ? cache.get(key) : void 0;
3445
+ if (hit && hit.stamp === stamp) {
3446
+ hits++;
3447
+ cache.delete(key);
3448
+ cache.set(key, hit);
3449
+ return structuredClone(hit.ir);
3450
+ }
3451
+ misses++;
3452
+ const ir = await graphIr(dir, opts);
3453
+ if (stamp === void 0 || memberSourceStamp(dir) !== stamp) return ir;
3454
+ cache.delete(key);
3455
+ cache.set(key, { dir: resolve2(dir), stamp, ir });
3456
+ for (const oldest of cache.keys()) {
3457
+ if (cache.size <= size) break;
3458
+ cache.delete(oldest);
3459
+ }
3460
+ return structuredClone(ir);
3461
+ }
3462
+
3463
+ // src/zoom-notes.ts
3464
+ function zoomNote(zoom, ir, compositeEdgesAttached) {
3465
+ const empty = ir.nodes.length === 0;
3466
+ if (zoom === "components") {
3467
+ return empty ? "no components discovered \u2014 nothing declares one in this project" : void 0;
3468
+ }
3469
+ if (zoom === "logical") {
3470
+ return empty ? "logical is an AWS projection \u2014 no AWS resources in this estate" : void 0;
3471
+ }
3472
+ if (zoom === "composites") {
3473
+ return compositeEdgesAttached === 0 ? "no component ownership to join \u2014 showing the resource graph unchanged" : void 0;
3474
+ }
3475
+ if (zoom === "runtime") {
3476
+ const children = ir.nodes.filter((n) => n.runtimeOwner).length;
3477
+ return children === 0 ? "nothing below the declaration boundary \u2014 no owner-referenced children on this substrate" : void 0;
3478
+ }
3479
+ return void 0;
3480
+ }
3481
+ function logicalKept(before, after) {
3482
+ if (after > 0 && after * 3 >= before) return void 0;
3483
+ 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)`;
3484
+ }
3485
+ function edgelessNote(zoom, ir, detail) {
3486
+ if (zoom === "components" || zoom === "logical") return void 0;
3487
+ if (ir.nodes.length === 0 || ir.edges.length > 0) return void 0;
3488
+ if (detail !== void 0 && detail < 3) {
3489
+ 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)";
3490
+ }
3491
+ return "no edges \u2014 nothing in this estate references anything else";
3492
+ }
3493
+ var CLUSTER_SCOPED = /* @__PURE__ */ new Set([
3494
+ "K8s::Core::Namespace",
3495
+ "K8s::Rbac::ClusterRole",
3496
+ "K8s::Rbac::ClusterRoleBinding",
3497
+ "K8s::Storage::StorageClass",
3498
+ "K8s::Apiextensions::CustomResourceDefinition"
3499
+ ]);
3500
+ function namespaceMismatchNote(nodes) {
3501
+ const k8s = nodes.filter((n) => n?.lexicon === "k8s");
3502
+ if (k8s.length === 0) return void 0;
3503
+ const pending = k8s.filter((n) => n.attrs?._status === "accent" && !CLUSTER_SCOPED.has(n.kind ?? ""));
3504
+ if (pending.length < 2) return void 0;
3505
+ if (pending.some((n) => typeof n.attrs?.metadata?.namespace === "string" && n.attrs.metadata.namespace)) return void 0;
3506
+ if (!k8s.some((n) => n.attrs?._status === "good")) return void 0;
3507
+ 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`;
3508
+ }
3509
+ function namespaceJoinNote(joined) {
3510
+ if (joined.length === 0) return void 0;
3511
+ const each = joined.map((j) => `${j.name} in "${j.namespace}"`).join(", ");
3512
+ return `read ${each} \u2014 the namespace the estate's own Kustomization targetNamespace binds, not the app project's own declaration`;
3513
+ }
3514
+ function notesFor(zoom, ir, compositeEdgesAttached, logicalBefore, detail) {
3515
+ const primary = zoom === "logical" && logicalBefore !== void 0 ? logicalKept(logicalBefore, ir.nodes.length) : zoomNote(zoom, ir, compositeEdgesAttached);
3516
+ const notes = [primary, edgelessNote(zoom, ir, detail)].filter((n) => n !== void 0);
3517
+ return notes.length ? notes.join(" \xB7 ") : void 0;
3518
+ }
3519
+ function tierMismatchNote(ir, tiers, currentTier) {
3520
+ const values = tiers?.values ?? [];
3521
+ if (values.length < 2) return void 0;
3522
+ const byLexicon = /* @__PURE__ */ new Map();
3523
+ for (const n of ir.nodes) {
3524
+ const status = n.attrs?._status;
3525
+ if (status !== "good" && status !== "accent") continue;
3526
+ const lexicon = n.lexicon ?? "?";
3527
+ const c = byLexicon.get(lexicon) ?? { good: 0, accent: 0 };
3528
+ c[status === "good" ? "good" : "accent"]++;
3529
+ byLexicon.set(lexicon, c);
3530
+ }
3531
+ for (const [lexicon, { good, accent }] of [...byLexicon].sort()) {
3532
+ if (accent < 2 || accent * 2 < good + accent) continue;
3533
+ const here = currentTier ? `tier "${currentTier}"` : "the default tier";
3534
+ const others = values.filter((v) => v !== currentTier).join(", ");
3535
+ 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)`;
3536
+ }
3537
+ return void 0;
3538
+ }
3539
+
3540
+ // src/estate.ts
3541
+ function estateReadPool(members, env = process.env) {
3542
+ const override = Number.parseInt(env.BEHOLD_ESTATE_CONCURRENCY ?? "", 10);
3543
+ const cap = Number.isInteger(override) && override >= 1 ? override : Math.min(4, availableParallelism());
3544
+ return Math.max(1, Math.min(cap, members));
3545
+ }
3546
+ async function mapPool(items, width, fn) {
3547
+ const results = new Array(items.length);
3548
+ let next = 0;
3549
+ const worker = async () => {
3550
+ for (let i = next++; i < items.length; i = next++) {
3551
+ results[i] = await fn(items[i], i);
3552
+ }
3553
+ };
3554
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(width, items.length)) }, () => worker()));
3555
+ return results;
3556
+ }
3557
+ function estateMembers(projectDirs) {
3558
+ const names = shortStackNames([...projectDirs]);
3559
+ return projectDirs.map((dir, i) => ({ name: names[i], dir }));
3560
+ }
3561
+ async function composeEstate(projectDirs, opts = {}) {
3562
+ const names = shortStackNames(projectDirs);
3563
+ const stacks = await mapPool(projectDirs, estateReadPool(projectDirs.length), async (dir, i) => ({
3564
+ name: names[i],
3565
+ ir: await memberIr(dir, opts)
3566
+ }));
3567
+ return composeStacks(stacks);
3568
+ }
3569
+ var firstLine2 = (e) => {
3570
+ const msg = e instanceof Error ? e.message : String(e);
3571
+ const m = msg.match(/exited \d+:\s*([\s\S]*)/);
3572
+ return (m ? m[1] : msg).split("\n")[0].trim().slice(0, 160);
3573
+ };
3574
+ function namespaceRuntimeOwners(name, ir) {
3575
+ const own = new Set(ir.nodes.map((n) => n.id));
3576
+ for (const n of ir.nodes) {
3577
+ if (n.runtimeOwner && own.has(n.runtimeOwner)) n.runtimeOwner = `${name}/${n.runtimeOwner}`;
3578
+ }
3579
+ return ir;
3580
+ }
3581
+ var NAMESPACE_JOIN_FLOOR = "0.44.5";
3582
+ var rec3 = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : void 0;
3583
+ var str3 = (v) => typeof v === "string" && v.length > 0 ? v : void 0;
3584
+ var realIsDir = (p) => {
3585
+ try {
3586
+ return statSync3(p).isDirectory();
3587
+ } catch {
3588
+ return false;
3589
+ }
3590
+ };
3591
+ function declaredNamespaceBindings(ir) {
3592
+ const out = [];
3593
+ for (const n of ir.nodes) {
3594
+ if (n.kind !== "K8s::Flux::Kustomization") continue;
3595
+ const spec = rec3(n.attrs?.spec);
3596
+ const namespace = str3(spec?.targetNamespace);
3597
+ const path = str3(spec?.path);
3598
+ if (namespace && path) out.push({ path, namespace });
3599
+ }
3600
+ return out;
3601
+ }
3602
+ function awaitsNamespaceBinding(ir) {
3603
+ const namespaced = ir.nodes.filter((n) => n.lexicon === "k8s" && !CLUSTER_SCOPED.has(n.kind ?? ""));
3604
+ if (namespaced.length === 0) return false;
3605
+ return namespaced.every((n) => !str3(rec3(n.attrs?.metadata)?.namespace));
3606
+ }
3607
+ function declaredSegments(path) {
3608
+ if (path.startsWith("/")) return void 0;
3609
+ const segs = path.split("/").filter((s) => s !== "" && s !== ".");
3610
+ if (segs.length === 0 || segs.includes("..")) return void 0;
3611
+ return segs;
3612
+ }
3613
+ function pathAlignment(declared, dir, isDir = realIsDir) {
3614
+ const want = declaredSegments(declared);
3615
+ if (!want) return 0;
3616
+ const root = resolve3(dir);
3617
+ const have = root.split(sep).filter(Boolean);
3618
+ for (let k = Math.min(want.length, have.length); k >= 1; k--) {
3619
+ if (!have.slice(-k).every((s, i) => s === want[i])) continue;
3620
+ if (!isDir(join7(root, ...want.slice(k)))) continue;
3621
+ return k;
3622
+ }
3623
+ return 0;
3624
+ }
3625
+ function joinNamespaceBindings(members, isDir = realIsDir) {
3626
+ const bindings = members.flatMap(
3627
+ (m) => m.ir ? declaredNamespaceBindings(m.ir).map((b) => ({ ...b, declaredBy: m.dir })) : []
3628
+ );
3629
+ if (bindings.length === 0) return [];
3630
+ const awaiting = members.filter((m) => m.ir && awaitsNamespaceBinding(m.ir));
3631
+ if (awaiting.length === 0) return [];
3632
+ const best = /* @__PURE__ */ new Map();
3633
+ for (const b of bindings) {
3634
+ let winner;
3635
+ let tied = false;
3636
+ for (const m of awaiting) {
3637
+ if (m.dir === b.declaredBy) continue;
3638
+ const score = pathAlignment(b.path, m.dir, isDir);
3639
+ if (score === 0) continue;
3640
+ if (!winner || score > winner.score) {
3641
+ winner = { dir: m.dir, score };
3642
+ tied = false;
3643
+ } else if (score === winner.score) {
3644
+ tied = true;
3645
+ }
3646
+ }
3647
+ if (!winner || tied) continue;
3648
+ const prev = best.get(winner.dir);
3649
+ if (prev === "ambiguous") continue;
3650
+ const join21 = { dir: winner.dir, namespace: b.namespace, path: b.path, declaredBy: b.declaredBy };
3651
+ if (!prev) best.set(winner.dir, { score: winner.score, join: join21 });
3652
+ else if (prev.join.namespace !== b.namespace) best.set(winner.dir, "ambiguous");
3653
+ else if (winner.score > prev.score) best.set(winner.dir, { score: winner.score, join: join21 });
3654
+ }
3655
+ return [...best.values()].filter((v) => v !== "ambiguous").map((v) => v.join);
3656
+ }
3657
+ async function estateNamespaceScopes(projectDirs, opts, isDir = realIsDir) {
3658
+ if (projectDirs.length < 2) return /* @__PURE__ */ new Map();
3659
+ const { live: _live, overlay: _overlay, namespace: _namespace, ...src } = opts;
3660
+ const irs = await mapPool(
3661
+ projectDirs,
3662
+ estateReadPool(projectDirs.length),
3663
+ (dir) => memberIr(dir, { ...src, detail: 3 }).catch(() => void 0)
3664
+ );
3665
+ const scopes = /* @__PURE__ */ new Map();
3666
+ for (const j of joinNamespaceBindings(projectDirs.map((dir, i) => ({ dir, ir: irs[i] })), isDir)) {
3667
+ if (!meetsFloor(resolveChant(j.dir).version, NAMESPACE_JOIN_FLOOR)) continue;
3668
+ scopes.set(j.dir, j.namespace);
3669
+ }
3670
+ return scopes;
3671
+ }
3672
+ function withoutJoinedMembers(nodes, joined) {
3673
+ if (joined.length === 0) return [...nodes];
3674
+ return nodes.filter((n) => !joined.some((j) => n.id.startsWith(`${j.name}/`)));
3675
+ }
3676
+ async function composeEstateOverlay(projectDirs, opts, classify) {
3677
+ const names = shortStackNames(projectDirs);
3678
+ const unobserved = [];
3679
+ const dropped = [];
3680
+ const joined = [];
3681
+ const stacks = new Array(projectDirs.length);
3682
+ const scopes = await estateNamespaceScopes(projectDirs, opts);
3683
+ await mapPool(projectDirs, estateReadPool(projectDirs.length), async (dir, i) => {
3684
+ const name = names[i];
3685
+ const namespace = scopes.get(dir);
3686
+ try {
3687
+ const live = { ...opts, live: true, overlay: true, ...namespace ? { namespace } : {} };
3688
+ stacks[i] = { name, ir: namespaceRuntimeOwners(name, classify(await graphIr(dir, live))) };
3689
+ if (namespace) joined.push({ name, namespace });
3690
+ } catch (err) {
3691
+ const reason = firstLine2(err);
3692
+ try {
3693
+ const { env: _env, live: _live, overlay: _overlay, ...srcOpts } = opts;
3694
+ const src = await memberIr(dir, srcOpts);
3695
+ for (const n of src.nodes) n.attrs = { ...n.attrs, _status: "neutral", _unobserved: reason };
3696
+ stacks[i] = { name, ir: src };
3697
+ unobserved.push({ name, reason });
3698
+ } catch (err2) {
3699
+ dropped.push({ name, reason: firstLine2(err2) });
3700
+ }
3701
+ }
3702
+ });
3703
+ const present = stacks.filter((s) => !!s);
3704
+ return {
3705
+ ir: composeStacks(present),
3706
+ observed: present.length - unobserved.length,
3707
+ total: projectDirs.length,
3708
+ unobserved,
3709
+ dropped,
3710
+ // Concurrent reads finish in whatever order the clusters answer; the note
3711
+ // this feeds should read the same twice.
3712
+ joined: joined.sort((a, b) => a.name.localeCompare(b.name))
3713
+ };
3714
+ }
3715
+
3716
+ // src/helm-drift.ts
3717
+ function countChanges(docs) {
3718
+ return docs.reduce((n, d) => n + d.changes.length, 0);
3719
+ }
3720
+ function provenanceOf(live) {
3721
+ const m = live.manifest;
3722
+ return {
3723
+ contentDigest: m.contentDigest,
3724
+ inputDigest: m.inputDigest,
3725
+ valuesDigest: m.valuesDigest,
3726
+ chart: m.chart,
3727
+ chartVersion: m.chartVersion,
3728
+ repo: m.repo,
3729
+ releaseName: m.releaseName,
3730
+ namespace: m.namespace,
3731
+ profile: m.capabilityProfile?.cluster ?? "",
3732
+ kubeVersion: m.capabilityProfile?.kubeVersion ?? "",
3733
+ renderedAt: m.renderedAt,
3734
+ helmVersion: m.helmVersion,
3735
+ chantVersion: m.chantVersion,
3736
+ sourceRef: m.sourceRef
3737
+ };
3738
+ }
3739
+ function helmRenderReport(record, live) {
3740
+ const base = { render: record.name, chart: record.chart };
3741
+ if (!live) {
3742
+ return { ...base, verdict: "unobserved", reason: record.contentDigest ? "no-stored-render" : "unpinned" };
3743
+ }
3744
+ const d = live.diff;
3745
+ const counts = {
3746
+ drifted: d.drifted.length,
3747
+ changes: countChanges(d.drifted),
3748
+ unchanged: d.unchanged.length,
3749
+ unobserved: d.unobserved.length,
3750
+ undeclared: d.undeclaredEntities.length
3751
+ };
3752
+ const provenance = provenanceOf(live);
3753
+ if (d.drifted.length > 0) {
3754
+ return { ...base, verdict: "drifted", counts, provenance, drifted: d.drifted };
3755
+ }
3756
+ if (d.drifted.length + d.accepted.length + d.unchanged.length === 0) {
3757
+ return { ...base, verdict: "unobserved", reason: "nothing-observed", counts, provenance };
3758
+ }
3759
+ if (d.unobserved.length > 0) {
3760
+ return { ...base, verdict: "unobserved", reason: "partly-observed", counts, provenance };
3761
+ }
3762
+ return { ...base, verdict: "in-sync", counts, provenance };
3763
+ }
3764
+ async function readHelmRenderDrift(projectDir, env, opts = {}) {
3765
+ const report = await helmRenders(projectDir, opts).catch(() => void 0);
3766
+ const records = report?.records ?? [];
3767
+ if (records.length === 0) return [];
3768
+ return mapPool(records, estateReadPool(records.length), async (record) => {
3769
+ if (!record.contentDigest) return helmRenderReport(record, void 0);
3770
+ const live = await helmRenderDiffLive(projectDir, record.contentDigest, env, opts).catch(() => void 0);
3771
+ return helmRenderReport(record, live);
3772
+ });
3773
+ }
3774
+ function chartNameOf2(node) {
3775
+ const name = node.attrs?.name;
3776
+ return typeof name === "string" && name.length > 0 ? name : void 0;
3777
+ }
3778
+ var SEVERITY = { drifted: 2, unobserved: 1, "in-sync": 0 };
3779
+ function applyHelmRenderDrift(ir, reports) {
3780
+ if (reports.length === 0) return { charts: 0, drifted: 0 };
3781
+ const byChart = /* @__PURE__ */ new Map();
3782
+ for (const r of reports) {
3783
+ const list3 = byChart.get(r.chart);
3784
+ if (list3) list3.push(r);
3785
+ else byChart.set(r.chart, [r]);
3786
+ }
3787
+ let charts = 0;
3788
+ let drifted = 0;
3789
+ for (const node of ir.nodes) {
3790
+ if (node.lexicon !== "helm" || node.kind !== "Helm::Chart") continue;
3791
+ const chartName = chartNameOf2(node);
3792
+ const matched = chartName ? byChart.get(chartName) : void 0;
3793
+ if (!matched || matched.length === 0) continue;
3794
+ const worst = matched.reduce((a, b) => SEVERITY[b.verdict] > SEVERITY[a.verdict] ? b : a);
3795
+ const attrs = node.attrs ??= {};
3796
+ attrs._renderDrift = matched.length === 1 ? worst : { ...worst, renders: matched };
3797
+ charts++;
3798
+ if (worst.verdict === "drifted") {
3799
+ attrs._status = "warn";
3800
+ drifted++;
3801
+ }
3802
+ }
3803
+ return { charts, drifted };
3804
+ }
3805
+
3806
+ // src/cluster-anchor.ts
3807
+ var MANAGED_CLUSTER_KINDS = /* @__PURE__ */ new Set([
3808
+ "AWS::EKS::Cluster",
3809
+ "GCP::Container::Cluster",
3810
+ "Microsoft.ContainerService/managedClusters",
3811
+ "K3d::Cluster"
3812
+ ]);
3813
+ var ANCHOR_VIA = "runs-on";
3814
+ function metadata(node) {
3815
+ const m = node.attrs?.metadata;
3816
+ return m && typeof m === "object" && !Array.isArray(m) ? m : void 0;
3817
+ }
3818
+ function metaString(node, key) {
3819
+ const v = metadata(node)?.[key];
3820
+ return typeof v === "string" && v.length > 0 ? v : void 0;
3821
+ }
3822
+ function isK8s(node) {
3823
+ return node.lexicon === "k8s";
3824
+ }
3825
+ function isNamespace(node) {
3826
+ return node.kind === "K8s::Core::Namespace";
3827
+ }
3828
+ function managedClusterName(node) {
3829
+ const metaName = metaString(node, "name");
3830
+ if (metaName) return metaName;
3831
+ for (const key of ["name", "clusterName"]) {
3832
+ const v = node.attrs?.[key];
3833
+ if (typeof v === "string" && v.length > 0) return v;
3834
+ }
3835
+ return node.id;
3836
+ }
3837
+ function boundManagedCluster(nodes, boundContext) {
3838
+ const clusters = nodes.filter((n) => MANAGED_CLUSTER_KINDS.has(n.kind));
3839
+ if (clusters.length === 1) return clusters[0];
3840
+ if (clusters.length === 0 || !boundContext) return void 0;
3841
+ const bound = clusters.filter((n) => contextBindsCluster(boundContext, managedClusterName(n)));
3842
+ return bound.length === 1 ? bound[0] : void 0;
3843
+ }
3844
+ function addClusterAnchorEdges(ir, boundContext) {
3845
+ const nodes = ir.nodes;
3846
+ const cluster = boundManagedCluster(ir.nodes, boundContext);
3847
+ if (!cluster) return ir;
3848
+ const k8sNodes = nodes.filter(isK8s);
3849
+ if (k8sNodes.length === 0) return ir;
3850
+ const namespaceByName = /* @__PURE__ */ new Map();
3851
+ for (const n of k8sNodes) {
3852
+ if (!isNamespace(n)) continue;
3853
+ const name = metaString(n, "name");
3854
+ if (name && !namespaceByName.has(name)) namespaceByName.set(name, n.id);
3855
+ }
3856
+ const existing = new Set(ir.edges.map((e) => `${e.from}\0${e.to}`));
3857
+ const add = (from, to) => {
3858
+ if (from === to) return;
3859
+ const key = `${from}\0${to}`;
3860
+ if (existing.has(key)) return;
3861
+ existing.add(key);
3862
+ ir.edges.push({ from, to, kind: "ref", viaAttr: ANCHOR_VIA, inferred: true });
3863
+ };
3864
+ for (const n of k8sNodes) {
3865
+ if (isNamespace(n)) {
3866
+ add(cluster.id, n.id);
3867
+ continue;
3868
+ }
3869
+ const ns = metaString(n, "namespace");
3870
+ const nsNode = ns ? namespaceByName.get(ns) : void 0;
3871
+ add(nsNode ?? cluster.id, n.id);
3872
+ }
3873
+ return ir;
3874
+ }
3875
+
3876
+ // src/logical-azure.ts
3877
+ var AZURE_HEADLINE_KINDS = /* @__PURE__ */ new Set([
3878
+ // compute / workloads
3879
+ "Microsoft.Compute/virtualMachines",
3880
+ "Microsoft.Compute/virtualMachineScaleSets",
3881
+ "Microsoft.ContainerService/managedClusters",
3882
+ "Microsoft.ContainerInstance/containerGroups",
3883
+ "Microsoft.Web/sites",
3884
+ // network edge
3885
+ "Microsoft.Network/publicIPAddresses",
3886
+ "Microsoft.Network/loadBalancers",
3887
+ "Microsoft.Network/applicationGateways",
3888
+ "Microsoft.Network/natGateways",
3889
+ "Microsoft.Network/dnsZones",
3890
+ // data stores
3891
+ "Microsoft.Storage/storageAccounts",
3892
+ "Microsoft.Sql/servers",
3893
+ "Microsoft.DocumentDB/databaseAccounts",
3894
+ "Microsoft.Cache/redis",
3895
+ "Microsoft.DBforPostgreSQL/flexibleServers",
3896
+ // registry / identity edge
3897
+ "Microsoft.ContainerRegistry/registries"
3898
+ ]);
3899
+ var AZURE_FABRIC_KINDS = /* @__PURE__ */ new Set([
3900
+ "Microsoft.Network/virtualNetworks",
3901
+ "Microsoft.Network/virtualNetworks_subnets",
3902
+ "Microsoft.Network/networkSecurityGroups",
3903
+ "Microsoft.Network/routeTables",
3904
+ "Microsoft.Network/networkInterfaces",
3905
+ // identity/authorization hubs — a role assignment or managed identity is
3906
+ // referenced by half the estate, so contracting through one invents edges
3907
+ // between unrelated resources.
3908
+ "Microsoft.Authorization/roleAssignments",
3909
+ "Microsoft.ManagedIdentity/userAssignedIdentities",
3910
+ "Microsoft.KeyVault/vaults"
3911
+ ]);
3912
+ var VNET_KIND = "Microsoft.Network/virtualNetworks";
3913
+ var SUBNET_KIND = "Microsoft.Network/virtualNetworks_subnets";
3914
+ var NSG_KIND = "Microsoft.Network/networkSecurityGroups";
3915
+ var RESOURCE_ID_RE = /resourceId\(\s*([^)]*)\)/g;
3916
+ var QUOTED_RE = /'([^']*)'/g;
3917
+ function resourceIdNames(value) {
3918
+ if (typeof value !== "string" || !value.includes("resourceId(")) return [];
3919
+ const out = [];
3920
+ for (const call of value.matchAll(RESOURCE_ID_RE)) {
3921
+ const args = [...call[1].matchAll(QUOTED_RE)].map((m) => m[1]);
3922
+ if (args.length >= 2) out.push(args[args.length - 1]);
3923
+ }
3924
+ return out;
3925
+ }
3926
+ function collectAzureRefs(value, out) {
3927
+ if (value === null || typeof value !== "object") {
3928
+ for (const name of resourceIdNames(value)) out.add(name);
3929
+ return;
3930
+ }
3931
+ const ref = value.$ref;
3932
+ if (typeof ref === "string") out.add(ref.split(".")[0]);
3933
+ for (const v of Object.values(value)) collectAzureRefs(v, out);
3934
+ }
3935
+ function strAttr(node, key) {
3936
+ const v = node.attrs?.[key];
3937
+ return typeof v === "string" ? v : void 0;
3938
+ }
3939
+ function resourceName(node) {
3940
+ return strAttr(node, "name");
3941
+ }
3942
+ function subnetVnetName(node) {
3943
+ const name = resourceName(node);
3944
+ if (!name || !name.includes("/")) return void 0;
3945
+ return name.split("/")[0];
3946
+ }
3947
+ function subnetShortName(node) {
3948
+ const name = resourceName(node);
3949
+ return name?.includes("/") ? name.split("/").slice(1).join("/") : name;
3950
+ }
3951
+ function vnetCidr(node) {
3952
+ const space = node.attrs?.addressSpace;
3953
+ const prefixes = space?.addressPrefixes;
3954
+ return Array.isArray(prefixes) && typeof prefixes[0] === "string" ? prefixes[0] : void 0;
3955
+ }
3956
+ function projectAzureLogical(ir, env) {
3957
+ const azure = ir.nodes.filter((n) => n.lexicon === "azure");
3958
+ if (azure.length === 0) return { ir: { nodes: [], edges: [], groups: {} }, byContainer: {} };
3959
+ const componentOf = componentNamer(azure);
3960
+ const byId = new Map(azure.map((n) => [n.id, n]));
3961
+ const byName = /* @__PURE__ */ new Map();
3962
+ for (const n of azure) {
3963
+ const name = resourceName(n);
3964
+ if (name && !byName.has(name)) byName.set(name, n);
3965
+ }
3966
+ const vnets = azure.filter((n) => n.kind === VNET_KIND);
3967
+ const subnets = azure.filter((n) => n.kind === SUBNET_KIND);
3968
+ const vnetByName = new Map(vnets.map((v) => [resourceName(v) ?? v.id, v]));
3969
+ const refOut = /* @__PURE__ */ new Map();
3970
+ for (const n of azure) {
3971
+ const names = /* @__PURE__ */ new Set();
3972
+ collectAzureRefs(n.attrs, names);
3973
+ const targets = /* @__PURE__ */ new Set();
3974
+ for (const name of names) {
3975
+ const target = byName.get(name) ?? byId.get(name);
3976
+ if (target && target.id !== n.id) targets.add(target.id);
3977
+ }
3978
+ if (targets.size) refOut.set(n.id, targets);
3979
+ }
3980
+ for (const e of ir.edges) {
3981
+ if (!byId.has(e.from) || !byId.has(e.to)) continue;
3982
+ (refOut.get(e.from) ?? refOut.set(e.from, /* @__PURE__ */ new Set()).get(e.from)).add(e.to);
3983
+ }
3984
+ const RG_TITLE = env ? `resource group ${env}` : "resource group";
3985
+ const vnetTitle = (v) => `VNet ${vnetCidr(v) ?? resourceName(v) ?? v.id}`;
3986
+ const subnetTitle = (s) => `subnet ${strAttr(s, "addressPrefix") ?? subnetShortName(s) ?? s.id}`;
3987
+ const guardedBy = /* @__PURE__ */ new Map();
3988
+ for (const s of subnets) {
3989
+ const names = /* @__PURE__ */ new Set();
3990
+ collectAzureRefs(s.attrs?.networkSecurityGroup, names);
3991
+ for (const name of names) {
3992
+ if (byName.get(name)?.kind === NSG_KIND) guardedBy.set(s.id, name);
3993
+ }
3994
+ }
3995
+ const headline = azure.filter((n) => AZURE_HEADLINE_KINDS.has(n.kind));
3996
+ const subnetIds = new Set(subnets.map((s) => s.id));
3997
+ const vnetIds = new Set(vnets.map((v) => v.id));
3998
+ const nearest = (start, want, maxDepth = 6) => {
3999
+ const seen = /* @__PURE__ */ new Set([start]);
4000
+ let frontier = [...refOut.get(start) ?? []];
4001
+ for (let depth = 0; depth < maxDepth && frontier.length; depth++) {
4002
+ const next = [];
4003
+ for (const id of frontier) {
4004
+ if (seen.has(id)) continue;
4005
+ seen.add(id);
4006
+ if (want.has(id)) return id;
4007
+ for (const r of refOut.get(id) ?? []) if (!seen.has(r)) next.push(r);
4008
+ }
4009
+ frontier = next;
4010
+ }
4011
+ return void 0;
4012
+ };
4013
+ const byContainer = {};
4014
+ const child = (parent, c) => {
4015
+ const arr = byContainer[parent] ?? (byContainer[parent] = []);
4016
+ if (!arr.includes(c)) arr.push(c);
4017
+ };
4018
+ for (const v of vnets) child(RG_TITLE, vnetTitle(v));
4019
+ for (const s of subnets) {
3378
4020
  const parentVnet = subnetVnetName(s);
3379
4021
  const v = parentVnet ? vnetByName.get(parentVnet) : void 0;
3380
4022
  child(v ? vnetTitle(v) : RG_TITLE, subnetTitle(s));
@@ -4534,9 +5176,9 @@ function helmIconFor(kind) {
4534
5176
  var SUPPORTED_MAJOR = 1;
4535
5177
  var refuse = (error, remedy) => ({ ok: false, refusal: { error, code: "carve-report", remedy } });
4536
5178
  var HOW_TO_GET_ONE = "Generate one with `chant carve advise --from <terraform-dir> --report report.json` (chant's read-only Terraform peelability advisor), then `behold carve report.json`.";
4537
- var isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
5179
+ var isRecord2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
4538
5180
  function parseCarveReport(value) {
4539
- if (!isRecord(value)) {
5181
+ if (!isRecord2(value)) {
4540
5182
  return refuse("That file isn't a carve report \u2014 its top level isn't a JSON object.", HOW_TO_GET_ONE);
4541
5183
  }
4542
5184
  if (!Array.isArray(value.resources)) {
@@ -4546,7 +5188,7 @@ function parseCarveReport(value) {
4546
5188
  );
4547
5189
  }
4548
5190
  const bad = value.resources.findIndex(
4549
- (r) => !isRecord(r) || typeof r.address !== "string" || typeof r.score !== "number" || typeof r.band !== "string"
5191
+ (r) => !isRecord2(r) || typeof r.address !== "string" || typeof r.score !== "number" || typeof r.band !== "string"
4550
5192
  );
4551
5193
  if (bad >= 0) {
4552
5194
  return refuse(
@@ -4612,7 +5254,7 @@ function boundaryEdgesOf(r) {
4612
5254
  const b = r.boundary;
4613
5255
  if (!b) return [];
4614
5256
  const list3 = Array.isArray(b) ? b : [...b.inbound ?? [], ...b.outbound ?? []];
4615
- return list3.filter((e) => isRecord(e) && typeof e.survivor === "string" && typeof e.carved === "string");
5257
+ return list3.filter((e) => isRecord2(e) && typeof e.survivor === "string" && typeof e.carved === "string");
4616
5258
  }
4617
5259
  function scoreArithmetic(r) {
4618
5260
  const b = r.breakdown;
@@ -4728,10 +5370,10 @@ import { readdirSync as readdirSync4, readFileSync as readFileSync7, statSync as
4728
5370
  import { dirname as dirname4, join as join10 } from "node:path";
4729
5371
  var CARVE_MANIFEST_SUFFIX = ".carve.json";
4730
5372
  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.";
4731
- var isRecord2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
5373
+ var isRecord3 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
4732
5374
  var strings = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
4733
5375
  function parseCarveManifest(value) {
4734
- if (!isRecord2(value)) return null;
5376
+ if (!isRecord3(value)) return null;
4735
5377
  if (value.version !== 1) return null;
4736
5378
  if (typeof value.target !== "string" || !value.target) return null;
4737
5379
  return value;
@@ -4975,9 +5617,9 @@ function carveStateNote(progress, states) {
4975
5617
  var SUPPORTED_MAJOR2 = 1;
4976
5618
  var refuse2 = (error, remedy) => ({ ok: false, refusal: { error, code: "op-ir", remedy } });
4977
5619
  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.";
4978
- var isRecord3 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
5620
+ var isRecord4 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
4979
5621
  function parseOpIr(value) {
4980
- if (!isRecord3(value)) {
5622
+ if (!isRecord4(value)) {
4981
5623
  return refuse2("That file isn't an op.json \u2014 its top level isn't a JSON object.", HOW_TO_EMIT);
4982
5624
  }
4983
5625
  if (typeof value.name !== "string" || !Array.isArray(value.phases)) {
@@ -4986,7 +5628,7 @@ function parseOpIr(value) {
4986
5628
  HOW_TO_EMIT
4987
5629
  );
4988
5630
  }
4989
- const bad = value.phases.findIndex((p) => !isRecord3(p) || typeof p.name !== "string" || !Array.isArray(p.steps));
5631
+ const bad = value.phases.findIndex((p) => !isRecord4(p) || typeof p.name !== "string" || !Array.isArray(p.steps));
4990
5632
  if (bad >= 0) {
4991
5633
  return refuse2(
4992
5634
  `That JSON has a \`phases\` array, but entry ${bad} isn't a phase (needs a string \`name\` and a \`steps\` array).`,
@@ -5083,14 +5725,14 @@ function opPlacedSteps(op) {
5083
5725
  }
5084
5726
  var RULE_BOX = (opName) => `${opName} \xB7 rule table`;
5085
5727
  var ruleId = (opName, rule) => `${opName}/rules/${rule.id}`;
5086
- var isPredicate = (v) => isRecord3(v) && typeof v.kind === "string";
5728
+ var isPredicate = (v) => isRecord4(v) && typeof v.kind === "string";
5087
5729
  function isConvergeRule(v) {
5088
- if (!isRecord3(v)) return false;
5730
+ if (!isRecord4(v)) return false;
5089
5731
  if (typeof v.id !== "string" || !v.id) return false;
5090
5732
  if (typeof v.why !== "string" || !v.why) return false;
5091
5733
  if (!isPredicate(v.when)) return false;
5092
5734
  const then = v.then;
5093
- if (!isRecord3(then)) return false;
5735
+ if (!isRecord4(then)) return false;
5094
5736
  return then.kind === "run" && typeof then.op === "string" || then.kind === "report" && typeof then.reason === "string";
5095
5737
  }
5096
5738
  function convergeTable(op) {
@@ -5999,6 +6641,34 @@ function reanchorEdges(svg, moved) {
5999
6641
  import { spawn as spawn3 } from "node:child_process";
6000
6642
  import { readFileSync as readFileSync9, writeFileSync as writeFileSync3, existsSync as existsSync7 } from "node:fs";
6001
6643
  import { join as join12 } from "node:path";
6644
+
6645
+ // src/scratch.ts
6646
+ var PROTECTED_INFRA = [
6647
+ /^floci(-|$)/,
6648
+ /^chant-floci(-|$)/,
6649
+ /^k3d-kubemicrovm-local$/,
6650
+ /^k3d-fountain-local$/,
6651
+ /^kubemicrovm-local$/,
6652
+ /^fountain-local$/
6653
+ ];
6654
+ var PROTECTED_PORTS = [4566, 4577, 4588];
6655
+ var SCRATCH_PREFIX = "behold-";
6656
+ function scratchNameProblem(name) {
6657
+ if (!name.startsWith(SCRATCH_PREFIX)) return `scratch infra must be named ${SCRATCH_PREFIX}*, got ${JSON.stringify(name)}`;
6658
+ const hit = PROTECTED_INFRA.find((re) => re.test(name));
6659
+ if (hit) return `${JSON.stringify(name)} is protected infrastructure (${hit}) \u2014 behold never touches it`;
6660
+ return void 0;
6661
+ }
6662
+ function scratchPortProblem(port) {
6663
+ if (PROTECTED_PORTS.includes(port)) return `port ${port} belongs to a shared emulator \u2014 a scratch substrate gets its own`;
6664
+ return void 0;
6665
+ }
6666
+ function assertScratch(name, port) {
6667
+ const p = scratchNameProblem(name) ?? (port === void 0 ? void 0 : scratchPortProblem(port));
6668
+ if (p) throw new Error(`scratch discipline: ${p}`);
6669
+ }
6670
+
6671
+ // src/carve-live.ts
6002
6672
  var LIVE_CONTAINER = "behold-carve-floci";
6003
6673
  var LIVE_PORT = 4602;
6004
6674
  var LIVE_IMAGE = "floci/floci:1.5.34";
@@ -6029,973 +6699,692 @@ function capture(cmd, args, cwd) {
6029
6699
  child.on("error", () => res({ code: -1, stdout: out }));
6030
6700
  child.on("close", (code) => res({ code: code ?? 1, stdout: out }));
6031
6701
  });
6032
- }
6033
- async function bootScratchFloci() {
6034
- const ps = await capture("docker", ["ps", "-a", "--format", "{{.Names}}"], process.cwd());
6035
- if (ps.code !== 0) return "docker isn't answering \u2014 is the daemon up?";
6036
- if (ps.stdout.split("\n").includes(LIVE_CONTAINER)) {
6037
- return `container ${LIVE_CONTAINER} already exists \u2014 a previous run didn't tear down. \`docker rm -f ${LIVE_CONTAINER}\` and re-run.`;
6038
- }
6039
- const run3 = await capture(
6040
- "docker",
6041
- ["run", "-d", "--rm", "-p", `${LIVE_PORT}:4566`, "--name", LIVE_CONTAINER, LIVE_IMAGE],
6042
- process.cwd()
6043
- );
6044
- if (run3.code !== 0) return `docker run ${LIVE_IMAGE} failed (${run3.code})`;
6045
- for (let i = 0; i < 30; i++) {
6046
- try {
6047
- const health = await fetch(`http://localhost:${LIVE_PORT}/_localstack/health`);
6048
- if (health.ok && (await health.text()).includes("cloudformation")) return void 0;
6049
- } catch {
6050
- }
6051
- await new Promise((r) => setTimeout(r, 1e3));
6052
- }
6053
- await teardownScratchFloci();
6054
- return `Floci never reported healthy on :${LIVE_PORT} after 30s`;
6055
- }
6056
- async function teardownScratchFloci() {
6057
- await capture("docker", ["rm", "-f", LIVE_CONTAINER], process.cwd()).catch(() => void 0);
6058
- }
6059
- async function applyIntoFloci(fromDir, step) {
6060
- const template = join12(fromDir, "floci-override.tf.disabled");
6061
- if (!existsSync7(template)) return `no floci-override.tf.disabled in ${fromDir} \u2014 this estate wasn't authored for the live tier`;
6062
- writeFileSync3(join12(fromDir, "floci_override.tf"), armOverride(readFileSync9(template, "utf8"), LIVE_PORT));
6063
- const init = await step("terraform", ["init", "-input=false", "-no-color"], fromDir);
6064
- if (init !== 0) return `terraform init exited ${init} (provider downloads need network)`;
6065
- const apply = await step(
6066
- "terraform",
6067
- ["apply", "-input=false", "-auto-approve", "-no-color", ...LIVE_TARGETS.map((t) => `-target=${t}`)],
6068
- fromDir
6069
- );
6070
- if (apply !== 0) return `terraform apply exited ${apply}`;
6071
- return void 0;
6072
- }
6073
- async function runLivePlan(fromDir) {
6074
- const r = await capture("terraform", ["plan", "-input=false", "-no-color", "-detailed-exitcode", ...LIVE_TARGETS.map((t) => `-target=${t}`)], fromDir);
6075
- if (r.code !== 0 && r.code !== 2) {
6076
- const tail = r.stdout.trim().split("\n").slice(-8).join("\n");
6077
- return { error: `terraform plan failed (exit ${r.code}):
6078
- ${tail}`, exitCode: r.code };
6079
- }
6080
- return { ...parsePlanOutput(r.stdout, r.code), exitCode: r.code };
6081
- }
6082
-
6083
- // src/carve-actions.ts
6084
- async function runCarvePlan(demo) {
6085
- if (!demo?.live) {
6086
- return refuse3(
6087
- "carve-action",
6088
- "terraform plan is the live tier's beat, and this server isn't serving one",
6089
- "run `behold demo carve --live` (needs docker + terraform)"
6090
- );
6091
- }
6092
- const r = await runLivePlan(demo.from);
6093
- if ("error" in r) {
6094
- return refuse3("carve-action", r.error, "Is the scratch Floci still up? `docker ps` should list " + demo.live.container + ".");
6095
- }
6096
- return {
6097
- ok: true,
6098
- command: "terraform plan -detailed-exitcode (targeted, in the demo copy)",
6099
- planLine: r.planLine,
6100
- changes: r.changes,
6101
- noDestroy: r.noDestroy
6102
- };
6103
- }
6104
- async function runCarveObserve(demo, select) {
6105
- if (!demo.live) {
6106
- return refuse3(
6107
- "carve-action",
6108
- "the observe beat reads the scratch Floci, and this server isn't serving one",
6109
- "run `behold demo carve --live` (needs docker + terraform)"
6110
- );
6111
- }
6112
- if (!existsSync8(join13(demo.out, "src"))) {
6113
- return refuse3("carve-action", "nothing has been emitted yet", "run Emit first \u2014 observe reads the carveout emit writes");
6114
- }
6115
- const args = ["lifecycle", "diff", "prod", "--live", "--json"];
6116
- const run3 = await runChantRaw(args, demo.out, { AWS_ENDPOINT_URL: demo.live.endpoint }).catch((err) => ({
6117
- code: 127,
6118
- stdout: "",
6119
- stderr: err instanceof Error ? err.message : String(err)
6120
- }));
6121
- if (run3.code !== 0) {
6122
- return refuse3(
6123
- "carve-action",
6124
- `chant lifecycle diff exited ${run3.code}: ${merge(run3, demo.root) || "(no output)"}`,
6125
- "Is the scratch Floci still up? `docker ps` should list " + demo.live.container + "."
6126
- );
6127
- }
6128
- let parsed;
6129
- try {
6130
- parsed = JSON.parse(run3.stdout);
6131
- } catch {
6132
- return refuse3("carve-action", "chant lifecycle diff answered something that isn't JSON", "re-run \u2014 a partial read is not a verdict");
6133
- }
6134
- if (parsed.stacks && !parsed.lexicons) {
6135
- return refuse3(
6136
- "carve-action",
6137
- "chant lifecycle diff answered a stacked shape ({stacks}) \u2014 observe only reads the stack-less {lexicons} shape",
6138
- "point the target at a stack-less carveout, or file a shape-read gap against observe for the stacked case"
6139
- );
6140
- }
6141
- const aws = parsed.lexicons?.aws ?? {};
6142
- const entity = select.split(".").pop() ?? select;
6143
- const command = `AWS_ENDPOINT_URL=${demo.live.endpoint} chant ${args.join(" ")} # in ${shortenIn(demo.out, demo.root)}`;
6144
- const queried = aws.resources?.queried?.[entity];
6145
- const meta = aws.observed?.[entity];
6146
- if (meta) {
6147
- return {
6148
- ok: true,
6149
- select,
6150
- command,
6151
- entity,
6152
- verdict: "observed",
6153
- ...meta.status ? { status: meta.status } : {},
6154
- ...meta.ownership ? { ownership: meta.ownership } : {},
6155
- ...meta.physicalId ? { physicalId: meta.physicalId } : {},
6156
- ...queried ? { queried } : {}
6157
- };
6158
- }
6159
- const hole = aws.resources?.unobserved?.find((u) => u.name === entity);
6160
- if (hole) {
6161
- return {
6162
- ok: true,
6163
- select,
6164
- command,
6165
- entity,
6166
- verdict: "unobserved",
6167
- ...hole.detail || hole.reason ? { detail: [hole.reason, hole.detail].filter(Boolean).join(": ") } : {},
6168
- ...queried ? { queried } : {}
6169
- };
6170
- }
6171
- return { ok: true, select, command, entity, verdict: "missing", ...queried ? { queried } : {} };
6172
- }
6173
- var MAX_ARTIFACT_BYTES = 64 * 1024;
6174
- var MAX_ARTIFACTS = 24;
6175
- var MAX_OUTPUT_BYTES = 32 * 1024;
6176
- var refuse3 = (code, error, remedy) => ({
6177
- ok: false,
6178
- refusal: { error, code, remedy }
6179
- });
6180
- 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.";
6181
- function insideDemo(root, p) {
6182
- const rel = relative2(resolve4(root), resolve4(p));
6183
- return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
6184
- }
6185
- function selectFromReport(report, raw) {
6186
- if (typeof raw !== "string" || !raw || raw.length > 512) return null;
6187
- return (report?.resources ?? []).some((r) => r.address === raw) ? raw : null;
6188
- }
6189
- function carveWriteBlock(demo) {
6190
- if (!demo) {
6191
- return "this server isn't running a carve demo \u2014 the carve actions only exist inside a `behold demo carve` copy";
6192
- }
6193
- if (!existsSync8(demo.from)) return `the demo copy has no Terraform estate at ${demo.from}`;
6194
- if (!insideDemo(demo.root, demo.out)) return "the carve output directory is outside the demo copy";
6195
- return unwritableReason(demo.root);
6196
- }
6197
- function readArtifacts(demo, dir, filter) {
6198
- const out = [];
6199
- const walk = (d, depth) => {
6200
- if (out.length >= MAX_ARTIFACTS || depth > 2) return;
6201
- let entries;
6702
+ }
6703
+ async function bootScratchFloci() {
6704
+ assertScratch(LIVE_CONTAINER, LIVE_PORT);
6705
+ const ps = await capture("docker", ["ps", "-a", "--format", "{{.Names}}"], process.cwd());
6706
+ if (ps.code !== 0) return "docker isn't answering \u2014 is the daemon up?";
6707
+ if (ps.stdout.split("\n").includes(LIVE_CONTAINER)) {
6708
+ return `container ${LIVE_CONTAINER} already exists \u2014 a previous run didn't tear down. \`docker rm -f ${LIVE_CONTAINER}\` and re-run.`;
6709
+ }
6710
+ const run3 = await capture(
6711
+ "docker",
6712
+ ["run", "-d", "--rm", "-p", `${LIVE_PORT}:4566`, "--name", LIVE_CONTAINER, LIVE_IMAGE],
6713
+ process.cwd()
6714
+ );
6715
+ if (run3.code !== 0) return `docker run ${LIVE_IMAGE} failed (${run3.code})`;
6716
+ for (let i = 0; i < 30; i++) {
6202
6717
  try {
6203
- entries = readdirSync5(d).sort();
6718
+ const health = await fetch(`http://localhost:${LIVE_PORT}/_localstack/health`);
6719
+ if (health.ok && (await health.text()).includes("cloudformation")) return void 0;
6204
6720
  } catch {
6205
- return;
6206
- }
6207
- for (const name of entries) {
6208
- if (out.length >= MAX_ARTIFACTS) return;
6209
- if (name === "node_modules" || name.startsWith(".")) continue;
6210
- const full = join13(d, name);
6211
- let st;
6212
- try {
6213
- st = statSync5(full);
6214
- } catch {
6215
- continue;
6216
- }
6217
- if (st.isDirectory()) {
6218
- walk(full, depth + 1);
6219
- continue;
6220
- }
6221
- const rel = relative2(demo.root, full).split(sep2).join("/");
6222
- if (!filter(rel)) continue;
6223
- let text = "";
6224
- try {
6225
- text = readFileSync10(full, "utf8");
6226
- } catch {
6227
- continue;
6228
- }
6229
- const truncated = text.length > MAX_ARTIFACT_BYTES;
6230
- out.push({
6231
- path: rel,
6232
- kind: (name.split(".").pop() ?? "").toLowerCase(),
6233
- bytes: st.size,
6234
- text: truncated ? text.slice(0, MAX_ARTIFACT_BYTES) : text,
6235
- truncated
6236
- });
6237
6721
  }
6238
- };
6239
- walk(dir, 0);
6240
- return out;
6722
+ await new Promise((r) => setTimeout(r, 1e3));
6723
+ }
6724
+ await teardownScratchFloci();
6725
+ return `Floci never reported healthy on :${LIVE_PORT} after 30s`;
6241
6726
  }
6242
- function carveSlug(select) {
6243
- return select.replace(/[^A-Za-z0-9_]+/g, "-");
6727
+ async function teardownScratchFloci() {
6728
+ await capture("docker", ["rm", "-f", LIVE_CONTAINER], process.cwd()).catch(() => void 0);
6244
6729
  }
6245
- var clip = (s) => s.length > MAX_OUTPUT_BYTES ? s.slice(0, MAX_OUTPUT_BYTES) + "\n\u2026 (truncated)" : s;
6246
- function shortenIn(arg, root) {
6247
- return arg.startsWith(root + sep2) ? arg.slice(root.length + 1) : arg === root ? "." : arg;
6730
+ async function applyIntoFloci(fromDir, step) {
6731
+ const template = join12(fromDir, "floci-override.tf.disabled");
6732
+ if (!existsSync7(template)) return `no floci-override.tf.disabled in ${fromDir} \u2014 this estate wasn't authored for the live tier`;
6733
+ writeFileSync3(join12(fromDir, "floci_override.tf"), armOverride(readFileSync9(template, "utf8"), LIVE_PORT));
6734
+ const init = await step("terraform", ["init", "-input=false", "-no-color"], fromDir);
6735
+ if (init !== 0) return `terraform init exited ${init} (provider downloads need network)`;
6736
+ const apply = await step(
6737
+ "terraform",
6738
+ ["apply", "-input=false", "-auto-approve", "-no-color", ...LIVE_TARGETS.map((t) => `-target=${t}`)],
6739
+ fromDir
6740
+ );
6741
+ if (apply !== 0) return `terraform apply exited ${apply}`;
6742
+ return void 0;
6248
6743
  }
6249
- function merge(r, root) {
6250
- const joined = stripAnsi([r.stdout, r.stderr].filter((s) => s.trim()).join("\n").trim());
6251
- return clip(joined.split(root + sep2).join("").split(root).join("."));
6744
+ async function runLivePlan(fromDir) {
6745
+ const r = await capture("terraform", ["plan", "-input=false", "-no-color", "-detailed-exitcode", ...LIVE_TARGETS.map((t) => `-target=${t}`)], fromDir);
6746
+ if (r.code !== 0 && r.code !== 2) {
6747
+ const tail = r.stdout.trim().split("\n").slice(-8).join("\n");
6748
+ return { error: `terraform plan failed (exit ${r.code}):
6749
+ ${tail}`, exitCode: r.code };
6750
+ }
6751
+ return { ...parsePlanOutput(r.stdout, r.code), exitCode: r.code };
6252
6752
  }
6253
- async function runCarveEmit(demo, select) {
6254
- const block = carveWriteBlock(demo);
6255
- if (block) return refuse3("read-only", block, "Start the walkthrough with `behold demo carve`.");
6256
- mkdirSync3(demo.out, { recursive: true });
6257
- const reportFile = join13(demo.out, `${carveSlug(select)}-boundary.json`);
6258
- const args = [
6259
- "carve",
6260
- "emit",
6261
- "--from",
6262
- demo.from,
6263
- ...demo.state ? ["--state", demo.state] : [],
6264
- "--select",
6265
- select,
6266
- "--output",
6267
- demo.out,
6268
- "--report",
6269
- reportFile
6270
- ];
6271
- const run3 = await runChantRaw(args, demo.project).catch((err) => ({
6272
- code: 127,
6273
- stdout: "",
6274
- stderr: err instanceof Error ? err.message : String(err)
6275
- }));
6276
- const output = merge(run3, demo.root);
6277
- if (run3.code !== 0) {
6753
+
6754
+ // src/carve-actions.ts
6755
+ async function runCarvePlan(demo) {
6756
+ if (!demo?.live) {
6278
6757
  return refuse3(
6279
6758
  "carve-action",
6280
- `chant carve emit exited ${run3.code}: ${output || "(no output)"}`,
6281
- "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`."
6759
+ "terraform plan is the live tier's beat, and this server isn't serving one",
6760
+ "run `behold demo carve --live` (needs docker + terraform)"
6282
6761
  );
6283
6762
  }
6284
- let boundary = null;
6285
- try {
6286
- boundary = JSON.parse(readFileSync10(reportFile, "utf8"));
6287
- } catch {
6763
+ const r = await runLivePlan(demo.from);
6764
+ if ("error" in r) {
6765
+ return refuse3("carve-action", r.error, "Is the scratch Floci still up? `docker ps` should list " + demo.live.container + ".");
6288
6766
  }
6289
- const lintPath = relative2(demo.project, join13(demo.out, "src")).split(sep2).join("/");
6290
- const lintArgs = ["lint", lintPath];
6291
- const lint = await runChantRaw(lintArgs, demo.project).catch((err) => ({
6292
- code: 127,
6293
- stdout: "",
6294
- stderr: err instanceof Error ? err.message : String(err)
6295
- }));
6296
6767
  return {
6297
6768
  ok: true,
6298
- select,
6299
- command: `chant ${args.map((a) => shortenIn(a, demo.root)).join(" ")}`,
6300
- output,
6301
- // Emitted source first, scaffolding after: `src/assets.ts` is the thing the
6302
- // step is about, and a package.json sorting above it buries the answer.
6303
- artifacts: readArtifacts(demo, demo.out, (rel) => /\.(ts|json)$/.test(rel) && !rel.endsWith("tsconfig.json")).sort(
6304
- (a, b) => Number(!/\/src\//.test(a.path)) - Number(!/\/src\//.test(b.path))
6305
- ),
6306
- boundary,
6307
- lint: { ok: lint.code === 0, code: lint.code, command: `chant ${lintArgs.join(" ")}`, output: merge(lint, demo.root) },
6308
- buildCaveat: BUILD_CAVEAT
6769
+ command: "terraform plan -detailed-exitcode (targeted, in the demo copy)",
6770
+ planLine: r.planLine,
6771
+ changes: r.changes,
6772
+ noDestroy: r.noDestroy
6309
6773
  };
6310
6774
  }
6311
- async function runCarveBridge(demo, select) {
6312
- const block = carveWriteBlock(demo);
6313
- if (block) return refuse3("read-only", block, "Start the walkthrough with `behold demo carve`.");
6314
- mkdirSync3(demo.out, { recursive: true });
6315
- const args = [
6316
- "carve",
6317
- "bridge",
6318
- "--from",
6319
- demo.from,
6320
- ...demo.state ? ["--state", demo.state] : [],
6321
- "--select",
6322
- select,
6323
- "--output",
6324
- demo.out
6325
- ];
6326
- const run3 = await runChantRaw(args, demo.project).catch((err) => ({
6327
- code: 127,
6328
- stdout: "",
6329
- stderr: err instanceof Error ? err.message : String(err)
6330
- }));
6331
- const output = merge(run3, demo.root);
6332
- if (run3.code !== 0) {
6775
+ async function runCarveObserve(demo, select) {
6776
+ if (!demo.live) {
6333
6777
  return refuse3(
6334
6778
  "carve-action",
6335
- `chant carve bridge exited ${run3.code}: ${output || "(no output)"}`,
6336
- "Run the Emit step first \u2014 bridge reads the carve manifest emit leaves in the output directory."
6779
+ "the observe beat reads the scratch Floci, and this server isn't serving one",
6780
+ "run `behold demo carve --live` (needs docker + terraform)"
6337
6781
  );
6338
6782
  }
6339
- const slug3 = carveSlug(select);
6340
- const all = readArtifacts(demo, demo.out, (rel) => /\.(tf|md|patch)$/.test(rel));
6341
- return {
6342
- ok: true,
6343
- select,
6344
- command: `chant ${args.map((a) => shortenIn(a, demo.root)).join(" ")}`,
6345
- output,
6346
- runbook: all.find((a) => a.path.endsWith(`${slug3}-runbook.md`)) ?? null,
6347
- proposals: all.filter((a) => !a.path.endsWith(`${slug3}-runbook.md`))
6348
- };
6349
- }
6350
-
6351
- // src/ops.ts
6352
- import { readdirSync as readdirSync6, readFileSync as readFileSync11, existsSync as existsSync9 } from "node:fs";
6353
- import { join as join14 } from "node:path";
6354
- var APPLY_TARGET_LEXICON = {
6355
- cloudformation: "aws",
6356
- kubectl: "k8s",
6357
- // kustomize renders then applies through the same k8s pipeline
6358
- // (chant#1548), so it scopes to the same lexicon. Note the auto-sync
6359
- // wrinkle this creates on purpose: a kustomize Op AND a kubectl Op in one
6360
- // project both exact-match `k8s`, and two exact matches decline — two Ops
6361
- // claiming the k8s half genuinely is ambiguous.
6362
- kustomize: "k8s",
6363
- arm: "azure"
6364
- };
6365
- function kindOf(content) {
6366
- if (/\bApplyOp\b/.test(content)) return "apply";
6367
- if (/\bReconcileOp\b/.test(content)) return "reconcile";
6368
- if (/AuditOp\b/.test(content)) return "audit";
6369
- return "op";
6370
- }
6371
- function discoverOps(projectDir) {
6372
- const seen = /* @__PURE__ */ new Set();
6373
- const out = [];
6374
- for (const sub of ["ops", "src", "."]) {
6375
- const dir = join14(projectDir, sub);
6376
- if (!existsSync9(dir)) continue;
6377
- for (const f of readdirSync6(dir)) {
6378
- if (!f.endsWith(".op.ts")) continue;
6379
- const content = readFileSync11(join14(dir, f), "utf8");
6380
- const name = content.match(/name:\s*["'`]([^"'`]+)["'`]/)?.[1];
6381
- if (!name || seen.has(name)) continue;
6382
- seen.add(name);
6383
- const gate = content.match(/signalName:\s*["'`]([^"'`]+)["'`]/)?.[1];
6384
- const env = content.match(/\benv:\s*["'`]([^"'`]+)["'`]/)?.[1];
6385
- const target = content.match(/\btarget:\s*["'`]([^"'`]+)["'`]/)?.[1];
6386
- const substrate = target ? APPLY_TARGET_LEXICON[target] : void 0;
6387
- const irPath = opIrPath(projectDir, name);
6388
- out.push({
6389
- name,
6390
- kind: kindOf(content),
6391
- dir: projectDir,
6392
- ...gate ? { gate } : {},
6393
- ...env ? { env } : {},
6394
- ...substrate ? { substrate } : {},
6395
- ...existsSync9(irPath) ? { irPath } : {}
6396
- });
6397
- }
6398
- }
6399
- return out.sort((a, b) => a.name.localeCompare(b.name));
6400
- }
6401
- function opIrPath(projectDir, name) {
6402
- return join14(projectDir, "dist", "ops", name, "op.json");
6403
- }
6404
- function discoverOpIrs(projectDirs) {
6405
- const seen = /* @__PURE__ */ new Set();
6406
- const out = [];
6407
- for (const dir of projectDirs) {
6408
- const root = join14(dir, "dist", "ops");
6409
- if (!existsSync9(root)) continue;
6410
- for (const name of readdirSync6(root)) {
6411
- if (seen.has(name)) continue;
6412
- const path = opIrPath(dir, name);
6413
- if (!existsSync9(path)) continue;
6414
- seen.add(name);
6415
- out.push({ name, dir, path });
6416
- }
6417
- }
6418
- return out.sort((a, b) => a.name.localeCompare(b.name));
6419
- }
6420
- function discoverEstateOps(projectDirs) {
6421
- const seen = /* @__PURE__ */ new Set();
6422
- const out = [];
6423
- for (const dir of projectDirs) {
6424
- for (const op of discoverOps(dir)) {
6425
- if (seen.has(op.name)) continue;
6426
- seen.add(op.name);
6427
- out.push(op);
6428
- }
6783
+ if (!existsSync8(join13(demo.out, "src"))) {
6784
+ return refuse3("carve-action", "nothing has been emitted yet", "run Emit first \u2014 observe reads the carveout emit writes");
6785
+ }
6786
+ const args = ["lifecycle", "diff", "prod", "--live", "--json"];
6787
+ const run3 = await runChantRaw(args, demo.out, { AWS_ENDPOINT_URL: demo.live.endpoint }).catch((err) => ({
6788
+ code: 127,
6789
+ stdout: "",
6790
+ stderr: err instanceof Error ? err.message : String(err)
6791
+ }));
6792
+ if (run3.code !== 0) {
6793
+ return refuse3(
6794
+ "carve-action",
6795
+ `chant lifecycle diff exited ${run3.code}: ${merge(run3, demo.root) || "(no output)"}`,
6796
+ "Is the scratch Floci still up? `docker ps` should list " + demo.live.container + "."
6797
+ );
6429
6798
  }
6430
- return out.sort((a, b) => a.name.localeCompare(b.name));
6431
- }
6432
-
6433
- // src/run-playhead.ts
6434
- var RECEIPT_READ_FN = "receiptRead";
6435
- var RECEIPT_WRITE_FN = "receiptWrite";
6436
- var STEP_STATUSES = /* @__PURE__ */ new Set(["ok", "fail", "skipped"]);
6437
- var isRecord4 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
6438
- function asStepRecord(value) {
6439
- if (!isRecord4(value)) return null;
6440
- if (typeof value.phase !== "string" || typeof value.fn !== "string") return null;
6441
- if (typeof value.status !== "string" || !STEP_STATUSES.has(value.status)) return null;
6442
- if (typeof value.durationMs !== "number") return null;
6443
- return value;
6444
- }
6445
- function parseRunProgressLine(line) {
6446
6799
  let parsed;
6447
6800
  try {
6448
- parsed = JSON.parse(line);
6801
+ parsed = JSON.parse(run3.stdout);
6449
6802
  } catch {
6450
- return null;
6803
+ return refuse3("carve-action", "chant lifecycle diff answered something that isn't JSON", "re-run \u2014 a partial read is not a verdict");
6451
6804
  }
6452
- const one = asStepRecord(parsed);
6453
- if (one) return [one];
6454
- if (isRecord4(parsed) && typeof parsed.op === "string" && Array.isArray(parsed.records)) {
6455
- const records = parsed.records.map(asStepRecord).filter((r) => r !== null);
6456
- return records.length ? records : null;
6805
+ if (parsed.stacks && !parsed.lexicons) {
6806
+ return refuse3(
6807
+ "carve-action",
6808
+ "chant lifecycle diff answered a stacked shape ({stacks}) \u2014 observe only reads the stack-less {lexicons} shape",
6809
+ "point the target at a stack-less carveout, or file a shape-read gap against observe for the stacked case"
6810
+ );
6457
6811
  }
6458
- return null;
6459
- }
6460
- function recordLine(record) {
6461
- const mark = record.status === "ok" ? "\u2713" : record.status === "fail" ? "\u2717" : "\u2022";
6462
- const took = record.status === "skipped" ? "skipped" : record.durationMs < 1e3 ? `${record.durationMs}ms` : `${(record.durationMs / 1e3).toFixed(1)}s`;
6463
- return `${mark} ${record.phase} \xB7 ${record.fn} \u2014 ${took}${record.error ? ` \u2014 ${record.error}` : ""}`;
6464
- }
6465
- var ANSI = /\x1b\[[0-9;]*m/g;
6466
- var strip = (s) => s.replace(ANSI, "");
6467
- function statusFields(stdout) {
6468
- const out = /* @__PURE__ */ new Map();
6469
- for (const raw of strip(stdout).split("\n")) {
6470
- const m = /^\s{2,}([A-Za-z][A-Za-z ]*?)\s*:\s*(.*)$/.exec(raw);
6471
- if (m) out.set(m[1].trim(), m[2].trim());
6812
+ const aws = parsed.lexicons?.aws ?? {};
6813
+ const entity = select.split(".").pop() ?? select;
6814
+ const command = `AWS_ENDPOINT_URL=${demo.live.endpoint} chant ${args.join(" ")} # in ${shortenIn(demo.out, demo.root)}`;
6815
+ const queried = aws.resources?.queried?.[entity];
6816
+ const meta = aws.observed?.[entity];
6817
+ if (meta) {
6818
+ return {
6819
+ ok: true,
6820
+ select,
6821
+ command,
6822
+ entity,
6823
+ verdict: "observed",
6824
+ ...meta.status ? { status: meta.status } : {},
6825
+ ...meta.ownership ? { ownership: meta.ownership } : {},
6826
+ ...meta.physicalId ? { physicalId: meta.physicalId } : {},
6827
+ ...queried ? { queried } : {}
6828
+ };
6472
6829
  }
6473
- return out;
6474
- }
6475
- function parseGateLine(line) {
6476
- const m = /^(.*?)(?: — (.*?))? \(waiting since (.+)\)$/.exec(line.trim());
6477
- if (!m) return null;
6478
- const signalName = m[1].trim();
6479
- if (!signalName) return null;
6480
- return { signalName, ...m[2] ? { description: m[2].trim() } : {}, since: m[3].trim() };
6481
- }
6482
- function readRunStatus(op, run3) {
6483
- if (run3.code !== 0) {
6484
- const message = strip(run3.stderr).trim() || strip(run3.stdout).trim() || `chant run status exited ${run3.code}`;
6485
- if (/not available in local mode|@temporalio\/client is not installed/.test(message)) {
6486
- return {
6487
- ok: false,
6488
- refusal: {
6489
- error: `No Temporal run state for "${op}" \u2014 ${message.split("\n")[0].replace(/^error:\s*/, "")}`,
6490
- code: "no-temporal",
6491
- 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."
6492
- }
6493
- };
6494
- }
6495
- if (/not found|NotFound/.test(message)) {
6496
- return {
6497
- ok: false,
6498
- refusal: {
6499
- error: `No run of "${op}" to report \u2014 ${message.split("\n")[0].replace(/^error:\s*/, "")}`,
6500
- code: "no-run",
6501
- remedy: "Start the Op (Run) and the playhead fills in as each step settles."
6502
- }
6503
- };
6504
- }
6830
+ const hole = aws.resources?.unobserved?.find((u) => u.name === entity);
6831
+ if (hole) {
6505
6832
  return {
6506
- ok: false,
6507
- refusal: {
6508
- error: `chant run status ${op} failed: ${message.split("\n")[0].replace(/^error:\s*/, "")}`,
6509
- code: "run-status",
6510
- remedy: "Run `chant run status " + op + " --temporal` in the project to see the whole error."
6511
- }
6833
+ ok: true,
6834
+ select,
6835
+ command,
6836
+ entity,
6837
+ verdict: "unobserved",
6838
+ ...hole.detail || hole.reason ? { detail: [hole.reason, hole.detail].filter(Boolean).join(": ") } : {},
6839
+ ...queried ? { queried } : {}
6512
6840
  };
6513
6841
  }
6514
- const f = statusFields(run3.stdout);
6515
- const activities = /^(\d+)\/(\d+)/.exec(f.get("Activities") ?? "");
6516
- const gateLine = f.get("Gate");
6517
- return {
6518
- ok: true,
6519
- read: {
6520
- op,
6521
- ...f.get("Workflow ID") ? { workflowId: f.get("Workflow ID") } : {},
6522
- ...f.get("Run ID") ? { runId: f.get("Run ID") } : {},
6523
- ...f.get("Status") ? { status: f.get("Status") } : {},
6524
- ...f.get("Started") ? { startedAt: f.get("Started") } : {},
6525
- ...f.get("Closed") ? { closedAt: f.get("Closed") } : {},
6526
- ...activities ? { activities: { completed: Number(activities[1]), scheduled: Number(activities[2]) } } : {},
6527
- // No Gate line means chant printed none, which it does both when the
6528
- // query says "nothing pending" and when the Op registers no handler.
6529
- // Either way: no gate is waiting on a human right now.
6530
- gate: gateLine ? parseGateLine(gateLine) : null
6531
- }
6532
- };
6533
- }
6534
- var initialRunState = {
6535
- op: null,
6536
- mode: null,
6537
- status: "idle",
6538
- records: [],
6539
- gate: null,
6540
- gateNote: null,
6541
- startedAt: null,
6542
- endedAt: null,
6543
- lost: null
6544
- };
6545
- function runStarted(op, mode, at) {
6546
- return { ...initialRunState, op, mode, status: "running", startedAt: at };
6842
+ return { ok: true, select, command, entity, verdict: "missing", ...queried ? { queried } : {} };
6547
6843
  }
6548
- function runRecord(state, record) {
6549
- return { ...state, records: [...state.records, record] };
6844
+ var MAX_ARTIFACT_BYTES = 64 * 1024;
6845
+ var MAX_ARTIFACTS = 24;
6846
+ var MAX_OUTPUT_BYTES = 32 * 1024;
6847
+ var refuse3 = (code, error, remedy) => ({
6848
+ ok: false,
6849
+ refusal: { error, code, remedy }
6850
+ });
6851
+ 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.";
6852
+ function insideDemo(root, p) {
6853
+ const rel = relative2(resolve4(root), resolve4(p));
6854
+ return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
6550
6855
  }
6551
- function runGate(state, result) {
6552
- return result.ok ? { ...state, gate: result.read.gate, gateNote: null } : { ...state, gate: null, gateNote: result.refusal.error };
6856
+ function selectFromReport(report, raw) {
6857
+ if (typeof raw !== "string" || !raw || raw.length > 512) return null;
6858
+ return (report?.resources ?? []).some((r) => r.address === raw) ? raw : null;
6553
6859
  }
6554
- function runEnded(state, exitCode, at) {
6555
- if (state.status !== "running") return state;
6556
- if (exitCode === 0) return { ...state, status: "ok", endedAt: at };
6557
- if (state.records.some((r) => r.status === "fail")) return { ...state, status: "failed", endedAt: at };
6558
- return {
6559
- ...state,
6560
- status: "lost",
6561
- endedAt: at,
6562
- 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.`
6563
- };
6860
+ function carveWriteBlock(demo) {
6861
+ if (!demo) {
6862
+ return "this server isn't running a carve demo \u2014 the carve actions only exist inside a `behold demo carve` copy";
6863
+ }
6864
+ if (!existsSync8(demo.from)) return `the demo copy has no Terraform estate at ${demo.from}`;
6865
+ if (!insideDemo(demo.root, demo.out)) return "the carve output directory is outside the demo copy";
6866
+ return unwritableReason(demo.root);
6564
6867
  }
6565
- function slotsFor(placed) {
6868
+ function readArtifacts(demo, dir, filter) {
6566
6869
  const out = [];
6567
- for (const p of placed) {
6568
- if (p.step.kind === "activity") {
6569
- out.push({ id: p.id, phase: p.phase, fn: p.step.fn });
6570
- } else if (p.step.kind === "effect") {
6571
- out.push({ id: p.id, phase: p.phase, fn: RECEIPT_READ_FN });
6572
- for (const nested of p.step.steps ?? []) {
6573
- if (nested.kind === "activity") out.push({ id: p.id, phase: p.phase, fn: nested.fn });
6574
- }
6575
- out.push({ id: p.id, phase: p.phase, fn: RECEIPT_WRITE_FN });
6870
+ const walk = (d, depth) => {
6871
+ if (out.length >= MAX_ARTIFACTS || depth > 2) return;
6872
+ let entries;
6873
+ try {
6874
+ entries = readdirSync5(d).sort();
6875
+ } catch {
6876
+ return;
6877
+ }
6878
+ for (const name of entries) {
6879
+ if (out.length >= MAX_ARTIFACTS) return;
6880
+ if (name === "node_modules" || name.startsWith(".")) continue;
6881
+ const full = join13(d, name);
6882
+ let st;
6883
+ try {
6884
+ st = statSync5(full);
6885
+ } catch {
6886
+ continue;
6887
+ }
6888
+ if (st.isDirectory()) {
6889
+ walk(full, depth + 1);
6890
+ continue;
6891
+ }
6892
+ const rel = relative2(demo.root, full).split(sep2).join("/");
6893
+ if (!filter(rel)) continue;
6894
+ let text = "";
6895
+ try {
6896
+ text = readFileSync10(full, "utf8");
6897
+ } catch {
6898
+ continue;
6899
+ }
6900
+ const truncated = text.length > MAX_ARTIFACT_BYTES;
6901
+ out.push({
6902
+ path: rel,
6903
+ kind: (name.split(".").pop() ?? "").toLowerCase(),
6904
+ bytes: st.size,
6905
+ text: truncated ? text.slice(0, MAX_ARTIFACT_BYTES) : text,
6906
+ truncated
6907
+ });
6576
6908
  }
6577
- }
6578
- return out;
6579
- }
6580
- function mergeRun(prev, record) {
6581
- const rank = { fail: 3, ok: 2, skipped: 1 };
6582
- const keepPrev = prev && rank[prev.status] >= rank[record.status];
6583
- return {
6584
- status: keepPrev ? prev.status : record.status,
6585
- durationMs: (prev?.durationMs ?? 0) + record.durationMs,
6586
- ...keepPrev && prev.error ? { error: prev.error } : record.error ? { error: record.error } : {},
6587
- ...keepPrev && prev.outcome ? { outcome: prev.outcome } : record.outcome ? { outcome: `${record.outcome.name}=${String(record.outcome.value)}` } : {},
6588
- records: (prev?.records ?? 0) + 1
6589
6909
  };
6910
+ walk(dir, 0);
6911
+ return out;
6590
6912
  }
6591
- function joinRun(op, records, gate) {
6592
- const placed = opPlacedSteps(op);
6593
- const slots = slotsFor(placed);
6594
- const byKey = /* @__PURE__ */ new Map();
6595
- for (const s of slots) {
6596
- const key = `${s.phase}\0${s.fn}`;
6597
- if (!byKey.has(key)) byKey.set(key, []);
6598
- byKey.get(key).push({ id: s.id });
6913
+ function carveSlug(select) {
6914
+ return select.replace(/[^A-Za-z0-9_]+/g, "-");
6915
+ }
6916
+ var clip = (s) => s.length > MAX_OUTPUT_BYTES ? s.slice(0, MAX_OUTPUT_BYTES) + "\n\u2026 (truncated)" : s;
6917
+ function shortenIn(arg, root) {
6918
+ return arg.startsWith(root + sep2) ? arg.slice(root.length + 1) : arg === root ? "." : arg;
6919
+ }
6920
+ function merge(r, root) {
6921
+ const joined = stripAnsi([r.stdout, r.stderr].filter((s) => s.trim()).join("\n").trim());
6922
+ return clip(joined.split(root + sep2).join("").split(root).join("."));
6923
+ }
6924
+ async function runCarveEmit(demo, select) {
6925
+ const block = carveWriteBlock(demo);
6926
+ if (block) return refuse3("read-only", block, "Start the walkthrough with `behold demo carve`.");
6927
+ mkdirSync3(demo.out, { recursive: true });
6928
+ const reportFile = join13(demo.out, `${carveSlug(select)}-boundary.json`);
6929
+ const args = [
6930
+ "carve",
6931
+ "emit",
6932
+ "--from",
6933
+ demo.from,
6934
+ ...demo.state ? ["--state", demo.state] : [],
6935
+ "--select",
6936
+ select,
6937
+ "--output",
6938
+ demo.out,
6939
+ "--report",
6940
+ reportFile
6941
+ ];
6942
+ const run3 = await runChantRaw(args, demo.project).catch((err) => ({
6943
+ code: 127,
6944
+ stdout: "",
6945
+ stderr: err instanceof Error ? err.message : String(err)
6946
+ }));
6947
+ const output = merge(run3, demo.root);
6948
+ if (run3.code !== 0) {
6949
+ return refuse3(
6950
+ "carve-action",
6951
+ `chant carve emit exited ${run3.code}: ${output || "(no output)"}`,
6952
+ "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`."
6953
+ );
6599
6954
  }
6600
- const cursor = /* @__PURE__ */ new Map();
6601
- const byNode = /* @__PURE__ */ new Map();
6602
- const unplaced = [];
6603
- let lastSettledIndex = -1;
6604
- const indexOf = new Map(placed.map((p, i) => [p.id, i]));
6605
- for (const record of records) {
6606
- const key = `${record.phase}\0${record.fn}`;
6607
- const list3 = byKey.get(key);
6608
- const i = cursor.get(key) ?? 0;
6609
- const slot = list3?.[i];
6610
- if (!slot) {
6611
- unplaced.push(record);
6612
- continue;
6613
- }
6614
- cursor.set(key, i + 1);
6615
- byNode.set(slot.id, mergeRun(byNode.get(slot.id), record));
6616
- if (record.status !== "skipped") {
6617
- lastSettledIndex = Math.max(lastSettledIndex, indexOf.get(slot.id) ?? -1);
6618
- }
6955
+ let boundary = null;
6956
+ try {
6957
+ boundary = JSON.parse(readFileSync10(reportFile, "utf8"));
6958
+ } catch {
6619
6959
  }
6620
- if (gate) {
6621
- const gateNode = placed.find((p) => p.step.kind === "gate" && p.step.signalName === gate.signalName);
6622
- if (gateNode) {
6623
- return {
6624
- byNode,
6625
- at: gateNode.id,
6626
- atReason: `waiting for ${gate.signalName} since ${gate.since}`,
6627
- unplaced
6628
- };
6629
- }
6960
+ const lintPath = relative2(demo.project, join13(demo.out, "src")).split(sep2).join("/");
6961
+ const lintArgs = ["lint", lintPath];
6962
+ const lint = await runChantRaw(lintArgs, demo.project).catch((err) => ({
6963
+ code: 127,
6964
+ stdout: "",
6965
+ stderr: err instanceof Error ? err.message : String(err)
6966
+ }));
6967
+ return {
6968
+ ok: true,
6969
+ select,
6970
+ command: `chant ${args.map((a) => shortenIn(a, demo.root)).join(" ")}`,
6971
+ output,
6972
+ // Emitted source first, scaffolding after: `src/assets.ts` is the thing the
6973
+ // step is about, and a package.json sorting above it buries the answer.
6974
+ artifacts: readArtifacts(demo, demo.out, (rel) => /\.(ts|json)$/.test(rel) && !rel.endsWith("tsconfig.json")).sort(
6975
+ (a, b) => Number(!/\/src\//.test(a.path)) - Number(!/\/src\//.test(b.path))
6976
+ ),
6977
+ boundary,
6978
+ lint: { ok: lint.code === 0, code: lint.code, command: `chant ${lintArgs.join(" ")}`, output: merge(lint, demo.root) },
6979
+ buildCaveat: BUILD_CAVEAT
6980
+ };
6981
+ }
6982
+ async function runCarveBridge(demo, select) {
6983
+ const block = carveWriteBlock(demo);
6984
+ if (block) return refuse3("read-only", block, "Start the walkthrough with `behold demo carve`.");
6985
+ mkdirSync3(demo.out, { recursive: true });
6986
+ const args = [
6987
+ "carve",
6988
+ "bridge",
6989
+ "--from",
6990
+ demo.from,
6991
+ ...demo.state ? ["--state", demo.state] : [],
6992
+ "--select",
6993
+ select,
6994
+ "--output",
6995
+ demo.out
6996
+ ];
6997
+ const run3 = await runChantRaw(args, demo.project).catch((err) => ({
6998
+ code: 127,
6999
+ stdout: "",
7000
+ stderr: err instanceof Error ? err.message : String(err)
7001
+ }));
7002
+ const output = merge(run3, demo.root);
7003
+ if (run3.code !== 0) {
7004
+ return refuse3(
7005
+ "carve-action",
7006
+ `chant carve bridge exited ${run3.code}: ${output || "(no output)"}`,
7007
+ "Run the Emit step first \u2014 bridge reads the carve manifest emit leaves in the output directory."
7008
+ );
6630
7009
  }
6631
- const next = placed.find((p, i) => i > lastSettledIndex && p.when === "phases");
7010
+ const slug3 = carveSlug(select);
7011
+ const all = readArtifacts(demo, demo.out, (rel) => /\.(tf|md|patch)$/.test(rel));
6632
7012
  return {
6633
- byNode,
6634
- at: lastSettledIndex >= 0 && next ? next.id : null,
6635
- atReason: lastSettledIndex >= 0 && next ? "reached \u2014 not settled yet" : null,
6636
- unplaced
7013
+ ok: true,
7014
+ select,
7015
+ command: `chant ${args.map((a) => shortenIn(a, demo.root)).join(" ")}`,
7016
+ output,
7017
+ runbook: all.find((a) => a.path.endsWith(`${slug3}-runbook.md`)) ?? null,
7018
+ proposals: all.filter((a) => !a.path.endsWith(`${slug3}-runbook.md`))
6637
7019
  };
6638
7020
  }
6639
- var TONE = {
6640
- ok: "good",
6641
- fail: "warn",
6642
- skipped: "neutral"
7021
+
7022
+ // src/ops.ts
7023
+ import { readdirSync as readdirSync6, readFileSync as readFileSync11, existsSync as existsSync9 } from "node:fs";
7024
+ import { join as join14 } from "node:path";
7025
+ var APPLY_TARGET_LEXICON = {
7026
+ cloudformation: "aws",
7027
+ kubectl: "k8s",
7028
+ // kustomize renders then applies through the same k8s pipeline
7029
+ // (chant#1548), so it scopes to the same lexicon. Note the auto-sync
7030
+ // wrinkle this creates on purpose: a kustomize Op AND a kubectl Op in one
7031
+ // project both exact-match `k8s`, and two exact matches decline — two Ops
7032
+ // claiming the k8s half genuinely is ambiguous.
7033
+ kustomize: "k8s",
7034
+ arm: "azure"
6643
7035
  };
6644
- var RAN = { ok: "ok", fail: "failed", skipped: "skipped" };
6645
- function tookLabel(ms) {
6646
- return ms < 1e3 ? `${ms}ms` : `${(ms / 1e3).toFixed(1)}s`;
7036
+ function kindOf(content) {
7037
+ if (/\bApplyOp\b/.test(content)) return "apply";
7038
+ if (/\bReconcileOp\b/.test(content)) return "reconcile";
7039
+ if (/AuditOp\b/.test(content)) return "audit";
7040
+ return "op";
6647
7041
  }
6648
- function paintPlayhead(ir, join21, gate) {
6649
- if (!join21.byNode.size && !join21.at) return ir;
6650
- const nodes = ir.nodes.map((node) => {
6651
- const run3 = join21.byNode.get(node.id);
6652
- const here = join21.at === node.id;
6653
- if (!run3 && !here) return node;
6654
- const isGate = node.attrs?._step === "gate";
6655
- const attrs = { ...node.attrs };
6656
- if (run3) {
6657
- attrs._status = TONE[run3.status];
6658
- attrs.run = run3.status === "skipped" ? "skipped" : `${RAN[run3.status]} \xB7 ${tookLabel(run3.durationMs)}`;
6659
- attrs.ran = tookLabel(run3.durationMs);
6660
- if (run3.error) attrs.error = run3.error;
6661
- if (run3.outcome) attrs.outcome = run3.outcome;
6662
- if (run3.records > 1) attrs.runRecords = run3.records;
6663
- }
6664
- if (here) {
6665
- attrs._playhead = true;
6666
- attrs._status = isGate && gate ? "warn" : "accent";
6667
- attrs.run = isGate && gate ? `waiting for approval since ${gate.since}` : join21.atReason ?? "here";
6668
- if (isGate && gate?.description) attrs.gateDescription = gate.description;
7042
+ function discoverOps(projectDir) {
7043
+ const seen = /* @__PURE__ */ new Set();
7044
+ const out = [];
7045
+ for (const sub of ["ops", "src", "."]) {
7046
+ const dir = join14(projectDir, sub);
7047
+ if (!existsSync9(dir)) continue;
7048
+ for (const f of readdirSync6(dir)) {
7049
+ if (!f.endsWith(".op.ts")) continue;
7050
+ const content = readFileSync11(join14(dir, f), "utf8");
7051
+ const name = content.match(/name:\s*["'`]([^"'`]+)["'`]/)?.[1];
7052
+ if (!name || seen.has(name)) continue;
7053
+ seen.add(name);
7054
+ const gate = content.match(/signalName:\s*["'`]([^"'`]+)["'`]/)?.[1];
7055
+ const env = content.match(/\benv:\s*["'`]([^"'`]+)["'`]/)?.[1];
7056
+ const target = content.match(/\btarget:\s*["'`]([^"'`]+)["'`]/)?.[1];
7057
+ const substrate = target ? APPLY_TARGET_LEXICON[target] : void 0;
7058
+ const irPath = opIrPath(projectDir, name);
7059
+ out.push({
7060
+ name,
7061
+ kind: kindOf(content),
7062
+ dir: projectDir,
7063
+ ...gate ? { gate } : {},
7064
+ ...env ? { env } : {},
7065
+ ...substrate ? { substrate } : {},
7066
+ ...existsSync9(irPath) ? { irPath } : {}
7067
+ });
6669
7068
  }
6670
- return { ...node, attrs };
6671
- });
6672
- return { ...ir, nodes };
7069
+ }
7070
+ return out.sort((a, b) => a.name.localeCompare(b.name));
6673
7071
  }
6674
- function pendingGateCard(op, gate, ir) {
6675
- const node = ir.nodes.find((n) => n.attrs?._step === "gate" && n.attrs?.gate === gate.signalName && n.attrs?.op === op);
6676
- return {
6677
- op,
6678
- signalName: gate.signalName,
6679
- ...gate.description ? { description: gate.description } : {},
6680
- since: gate.since,
6681
- ...node ? { node: node.id } : {},
6682
- ...node && typeof node.attrs?.guards === "string" ? { guards: node.attrs.guards } : {},
6683
- approve: {
6684
- method: "POST",
6685
- path: `/api/ops/${encodeURIComponent(op)}/signal/${encodeURIComponent(gate.signalName)}`
7072
+ function opIrPath(projectDir, name) {
7073
+ return join14(projectDir, "dist", "ops", name, "op.json");
7074
+ }
7075
+ function discoverOpIrs(projectDirs) {
7076
+ const seen = /* @__PURE__ */ new Set();
7077
+ const out = [];
7078
+ for (const dir of projectDirs) {
7079
+ const root = join14(dir, "dist", "ops");
7080
+ if (!existsSync9(root)) continue;
7081
+ for (const name of readdirSync6(root)) {
7082
+ if (seen.has(name)) continue;
7083
+ const path = opIrPath(dir, name);
7084
+ if (!existsSync9(path)) continue;
7085
+ seen.add(name);
7086
+ out.push({ name, dir, path });
6686
7087
  }
6687
- };
6688
- }
6689
- function playheadNote(state, join21) {
6690
- if (!state.op) {
6691
- return "No run state: nothing has been run from this behold session, so every step paints as declared.";
6692
7088
  }
6693
- const settled = join21 ? join21.byNode.size : state.records.length;
6694
- const bits = [];
6695
- 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`;
6696
- if (state.status === "running") {
6697
- bits.push(
6698
- 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"
6699
- );
7089
+ return out.sort((a, b) => a.name.localeCompare(b.name));
7090
+ }
7091
+ function discoverEstateOps(projectDirs) {
7092
+ const seen = /* @__PURE__ */ new Set();
7093
+ const out = [];
7094
+ for (const dir of projectDirs) {
7095
+ for (const op of discoverOps(dir)) {
7096
+ if (seen.has(op.name)) continue;
7097
+ seen.add(op.name);
7098
+ out.push(op);
7099
+ }
6700
7100
  }
6701
- if (state.status === "ok") bits.push("the run exited cleanly");
6702
- if (state.status === "failed") bits.push("a step failed");
6703
- if (state.status === "lost") bits.push(`stream lost \u2014 ${state.lost ?? "the progress stream ended without a verdict"}`);
6704
- if (state.gate) {
6705
- bits.push(`waiting on gate ${state.gate.signalName} since ${state.gate.since} \u2014 approve is a delegated signal, behold never signals on its own`);
6706
- } else if (state.gateNote) {
6707
- bits.push(`gate state unavailable \u2014 ${state.gateNote}`);
7101
+ return out.sort((a, b) => a.name.localeCompare(b.name));
7102
+ }
7103
+
7104
+ // src/run-playhead.ts
7105
+ var RECEIPT_READ_FN = "receiptRead";
7106
+ var RECEIPT_WRITE_FN = "receiptWrite";
7107
+ var STEP_STATUSES = /* @__PURE__ */ new Set(["ok", "fail", "skipped"]);
7108
+ var isRecord5 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
7109
+ function asStepRecord(value) {
7110
+ if (!isRecord5(value)) return null;
7111
+ if (typeof value.phase !== "string" || typeof value.fn !== "string") return null;
7112
+ if (typeof value.status !== "string" || !STEP_STATUSES.has(value.status)) return null;
7113
+ if (typeof value.durationMs !== "number") return null;
7114
+ return value;
7115
+ }
7116
+ function parseRunProgressLine(line) {
7117
+ let parsed;
7118
+ try {
7119
+ parsed = JSON.parse(line);
7120
+ } catch {
7121
+ return null;
6708
7122
  }
6709
- if (join21?.unplaced.length) {
6710
- bits.push(
6711
- `${join21.unplaced.length} record${join21.unplaced.length === 1 ? "" : "s"} matched no declared step and ${join21.unplaced.length === 1 ? "is" : "are"} not painted`
6712
- );
7123
+ const one = asStepRecord(parsed);
7124
+ if (one) return [one];
7125
+ if (isRecord5(parsed) && typeof parsed.op === "string" && Array.isArray(parsed.records)) {
7126
+ const records = parsed.records.map(asStepRecord).filter((r) => r !== null);
7127
+ return records.length ? records : null;
6713
7128
  }
6714
- if (!bits.length) return `${head}.`;
6715
- const rest = bits.join("; ");
6716
- return `${head} \u2014 ${rest}.`;
7129
+ return null;
7130
+ }
7131
+ function recordLine(record) {
7132
+ const mark = record.status === "ok" ? "\u2713" : record.status === "fail" ? "\u2717" : "\u2022";
7133
+ const took = record.status === "skipped" ? "skipped" : record.durationMs < 1e3 ? `${record.durationMs}ms` : `${(record.durationMs / 1e3).toFixed(1)}s`;
7134
+ return `${mark} ${record.phase} \xB7 ${record.fn} \u2014 ${took}${record.error ? ` \u2014 ${record.error}` : ""}`;
6717
7135
  }
6718
-
6719
- // src/operator.ts
6720
7136
  var ANSI2 = /\x1b\[[0-9;]*m/g;
6721
7137
  var strip2 = (s) => s.replace(ANSI2, "");
6722
- var isRecord5 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
6723
- 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).";
6724
- function readRow(value) {
6725
- if (!isRecord5(value)) return null;
6726
- if (typeof value.op !== "string" || typeof value.env !== "string") return null;
6727
- if (!Array.isArray(value.pendingGates)) return null;
6728
- const gates = [];
6729
- for (const g of value.pendingGates) {
6730
- if (!isRecord5(g) || typeof g.rule !== "string" || typeof g.gate !== "string") return null;
6731
- gates.push({ rule: g.rule, gate: g.gate, ...typeof g.op === "string" ? { op: g.op } : {} });
7138
+ function statusFields(stdout) {
7139
+ const out = /* @__PURE__ */ new Map();
7140
+ for (const raw of strip2(stdout).split("\n")) {
7141
+ const m = /^\s{2,}([A-Za-z][A-Za-z ]*?)\s*:\s*(.*)$/.exec(raw);
7142
+ if (m) out.set(m[1].trim(), m[2].trim());
6732
7143
  }
6733
- const tick = isRecord5(value.lastTick) ? value.lastTick : void 0;
6734
- const usable = tick && typeof tick.timestamp === "string" && typeof tick.log === "string";
6735
- const lease = isRecord5(value.lease) ? value.lease : void 0;
6736
- return {
6737
- op: value.op,
6738
- env: value.env,
6739
- ...usable ? { lastTick: tick } : {},
6740
- pendingGates: gates,
6741
- ...lease && typeof lease.holder === "string" && typeof lease.expiresAt === "string" ? { lease: { holder: lease.holder, expiresAt: lease.expiresAt } } : {}
6742
- };
7144
+ return out;
6743
7145
  }
6744
- function readOperatorStatus(run3) {
6745
- const err = strip2(run3.stderr);
6746
- const out = strip2(run3.stdout).trim();
7146
+ function parseGateLine(line) {
7147
+ const m = /^(.*?)(?: — (.*?))? \(waiting since (.+)\)$/.exec(line.trim());
7148
+ if (!m) return null;
7149
+ const signalName = m[1].trim();
7150
+ if (!signalName) return null;
7151
+ return { signalName, ...m[2] ? { description: m[2].trim() } : {}, since: m[3].trim() };
7152
+ }
7153
+ function readRunStatus(op, run3) {
6747
7154
  if (run3.code !== 0) {
6748
- const message = err.trim() || out || `chant operator status exited ${run3.code}`;
6749
- const first = message.split("\n")[0].replace(/^error:\s*/i, "");
6750
- if (/Unknown command|Unknown operator subcommand/i.test(message)) {
7155
+ const message = strip2(run3.stderr).trim() || strip2(run3.stdout).trim() || `chant run status exited ${run3.code}`;
7156
+ if (/not available in local mode|@temporalio\/client is not installed/.test(message)) {
6751
7157
  return {
6752
7158
  ok: false,
6753
7159
  refusal: {
6754
- error: `This chant has no \`operator status\` \u2014 ${first}`,
6755
- code: "no-operator-cli",
6756
- remedy: "The operating loop's status surface landed in chant 0.52 (chant#1485). Upgrade chant to read it."
7160
+ error: `No Temporal run state for "${op}" \u2014 ${message.split("\n")[0].replace(/^error:\s*/, "")}`,
7161
+ code: "no-temporal",
7162
+ 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."
6757
7163
  }
6758
7164
  };
6759
7165
  }
6760
- return {
6761
- ok: false,
6762
- refusal: {
6763
- error: `chant operator status failed: ${first}`,
6764
- code: "operator-status",
6765
- remedy: "Run `chant operator status --json` in the project to see the whole error."
6766
- }
6767
- };
6768
- }
6769
- if (!out) {
6770
- return {
6771
- ok: false,
6772
- refusal: {
6773
- error: "This project declares no operating loop \u2014 chant found no ConvergeOp.",
6774
- code: "no-operator",
6775
- remedy: HOW_TO_DECLARE
6776
- }
6777
- };
6778
- }
6779
- let parsed;
6780
- try {
6781
- parsed = JSON.parse(out);
6782
- } catch (e) {
6783
- return {
6784
- ok: false,
6785
- refusal: {
6786
- error: `chant operator status --json didn't print JSON: ${e instanceof Error ? e.message : String(e)}`,
6787
- code: "operator-status",
6788
- remedy: "Run `chant operator status --json` in the project and check what it printed."
6789
- }
6790
- };
6791
- }
6792
- if (!Array.isArray(parsed)) {
6793
- return {
6794
- ok: false,
6795
- refusal: {
6796
- error: "chant operator status --json answered with something that isn't an array of ConvergeOp rows.",
6797
- code: "operator-status",
6798
- remedy: "behold reads one row per ConvergeOp: `{op, env, lastTick?, pendingGates[], lease?}`."
6799
- }
6800
- };
6801
- }
6802
- const rows = [];
6803
- for (const value of parsed) {
6804
- const row = readRow(value);
6805
- if (!row) {
7166
+ if (/not found|NotFound/.test(message)) {
6806
7167
  return {
6807
7168
  ok: false,
6808
7169
  refusal: {
6809
- error: "chant operator status --json answered with a row behold couldn't read (needs `op`, `env`, `pendingGates[]`).",
6810
- code: "operator-status",
6811
- remedy: "Check `chant operator status --json` in the project \u2014 and file the shape change if chant's is the newer one."
7170
+ error: `No run of "${op}" to report \u2014 ${message.split("\n")[0].replace(/^error:\s*/, "")}`,
7171
+ code: "no-run",
7172
+ remedy: "Start the Op (Run) and the playhead fills in as each step settles."
6812
7173
  }
6813
7174
  };
6814
7175
  }
6815
- rows.push(row);
7176
+ return {
7177
+ ok: false,
7178
+ refusal: {
7179
+ error: `chant run status ${op} failed: ${message.split("\n")[0].replace(/^error:\s*/, "")}`,
7180
+ code: "run-status",
7181
+ remedy: "Run `chant run status " + op + " --temporal` in the project to see the whole error."
7182
+ }
7183
+ };
6816
7184
  }
6817
- return { ok: true, rows };
7185
+ const f = statusFields(run3.stdout);
7186
+ const activities = /^(\d+)\/(\d+)/.exec(f.get("Activities") ?? "");
7187
+ const gateLine = f.get("Gate");
7188
+ return {
7189
+ ok: true,
7190
+ read: {
7191
+ op,
7192
+ ...f.get("Workflow ID") ? { workflowId: f.get("Workflow ID") } : {},
7193
+ ...f.get("Run ID") ? { runId: f.get("Run ID") } : {},
7194
+ ...f.get("Status") ? { status: f.get("Status") } : {},
7195
+ ...f.get("Started") ? { startedAt: f.get("Started") } : {},
7196
+ ...f.get("Closed") ? { closedAt: f.get("Closed") } : {},
7197
+ ...activities ? { activities: { completed: Number(activities[1]), scheduled: Number(activities[2]) } } : {},
7198
+ // No Gate line means chant printed none, which it does both when the
7199
+ // query says "nothing pending" and when the Op registers no handler.
7200
+ // Either way: no gate is waiting on a human right now.
7201
+ gate: gateLine ? parseGateLine(gateLine) : null
7202
+ }
7203
+ };
6818
7204
  }
6819
- function declaredConvergeOps(ops) {
6820
- return ops.filter((o) => o.searchAttributes?.Converge === "true").map((o) => ({
6821
- name: o.name,
6822
- ...o.searchAttributes?.Env ? { env: o.searchAttributes.Env } : {},
6823
- ...o.searchAttributes?.Dial ? { dial: o.searchAttributes.Dial } : {}
6824
- }));
7205
+ var initialRunState = {
7206
+ op: null,
7207
+ mode: null,
7208
+ status: "idle",
7209
+ records: [],
7210
+ gate: null,
7211
+ gateNote: null,
7212
+ startedAt: null,
7213
+ endedAt: null,
7214
+ lost: null
7215
+ };
7216
+ function runStarted(op, mode, at) {
7217
+ return { ...initialRunState, op, mode, status: "running", startedAt: at };
6825
7218
  }
6826
- var MANAGED_BY = "app.kubernetes.io/managed-by";
6827
- var COMPONENT = "app.kubernetes.io/component";
6828
- var NAME_LABEL = "app.kubernetes.io/name";
6829
- var CHANT = "chant";
6830
- var CONVERGE_TICK = "converge-tick";
6831
- var CRONJOB_KIND = "K8s::Batch::CronJob";
6832
- var NAMESPACE_KIND = "K8s::Core::Namespace";
6833
- var rec3 = (v) => isRecord5(v) ? v : void 0;
6834
- var str3 = (v) => typeof v === "string" && v ? v : void 0;
6835
- var labelsOf = (n) => rec3(rec3(n.attrs?.metadata)?.labels) ?? {};
6836
- function operatorHomes(ir) {
6837
- const byNamespace = /* @__PURE__ */ new Map();
6838
- const conflicted = /* @__PURE__ */ new Set();
6839
- for (const n of ir.nodes) {
6840
- if (n.kind !== CRONJOB_KIND) continue;
6841
- const labels = labelsOf(n);
6842
- if (labels[MANAGED_BY] !== CHANT || labels[COMPONENT] !== CONVERGE_TICK) continue;
6843
- const meta = rec3(n.attrs?.metadata);
6844
- const ns = str3(meta?.namespace);
6845
- const op = str3(meta?.name);
6846
- if (!ns || !op) continue;
6847
- const home = byNamespace.get(ns) ?? { namespace: ns, ticks: [] };
6848
- const stack = str3(labels[NAME_LABEL]);
6849
- if (stack && !conflicted.has(ns)) {
6850
- if (home.stack === void 0) home.stack = stack;
6851
- else if (home.stack !== stack) {
6852
- conflicted.add(ns);
6853
- delete home.stack;
7219
+ function runRecord(state, record) {
7220
+ return { ...state, records: [...state.records, record] };
7221
+ }
7222
+ function runGate(state, result) {
7223
+ return result.ok ? { ...state, gate: result.read.gate, gateNote: null } : { ...state, gate: null, gateNote: result.refusal.error };
7224
+ }
7225
+ function runEnded(state, exitCode, at) {
7226
+ if (state.status !== "running") return state;
7227
+ if (exitCode === 0) return { ...state, status: "ok", endedAt: at };
7228
+ if (state.records.some((r) => r.status === "fail")) return { ...state, status: "failed", endedAt: at };
7229
+ return {
7230
+ ...state,
7231
+ status: "lost",
7232
+ endedAt: at,
7233
+ 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.`
7234
+ };
7235
+ }
7236
+ function slotsFor(placed) {
7237
+ const out = [];
7238
+ for (const p of placed) {
7239
+ if (p.step.kind === "activity") {
7240
+ out.push({ id: p.id, phase: p.phase, fn: p.step.fn });
7241
+ } else if (p.step.kind === "effect") {
7242
+ out.push({ id: p.id, phase: p.phase, fn: RECEIPT_READ_FN });
7243
+ for (const nested of p.step.steps ?? []) {
7244
+ if (nested.kind === "activity") out.push({ id: p.id, phase: p.phase, fn: nested.fn });
6854
7245
  }
7246
+ out.push({ id: p.id, phase: p.phase, fn: RECEIPT_WRITE_FN });
6855
7247
  }
6856
- const schedule = str3(rec3(n.attrs?.spec)?.schedule);
6857
- home.ticks.push({ node: n.id, op, ...schedule ? { schedule } : {} });
6858
- byNamespace.set(ns, home);
6859
- }
6860
- if (!byNamespace.size) return [];
6861
- for (const n of ir.nodes) {
6862
- if (n.kind !== NAMESPACE_KIND) continue;
6863
- const name = str3(rec3(n.attrs?.metadata)?.name);
6864
- const home = name ? byNamespace.get(name) : void 0;
6865
- if (home && !home.node) home.node = n.id;
6866
7248
  }
6867
- for (const home of byNamespace.values()) home.ticks.sort((a, b) => a.op.localeCompare(b.op));
6868
- return [...byNamespace.values()].sort((a, b) => a.namespace.localeCompare(b.namespace));
7249
+ return out;
6869
7250
  }
6870
- function markOperatorHome(ir) {
6871
- const homes = operatorHomes(ir);
6872
- if (!homes.length) return ir;
6873
- const marks = /* @__PURE__ */ new Map();
6874
- for (const home of homes) {
6875
- if (home.node) {
6876
- marks.set(home.node, {
6877
- role: "home",
6878
- namespace: home.namespace,
6879
- ...home.stack ? { stack: home.stack } : {},
6880
- ticks: home.ticks.map((t) => t.op)
6881
- });
7251
+ function mergeRun(prev, record) {
7252
+ const rank = { fail: 3, ok: 2, skipped: 1 };
7253
+ const keepPrev = prev && rank[prev.status] >= rank[record.status];
7254
+ return {
7255
+ status: keepPrev ? prev.status : record.status,
7256
+ durationMs: (prev?.durationMs ?? 0) + record.durationMs,
7257
+ ...keepPrev && prev.error ? { error: prev.error } : record.error ? { error: record.error } : {},
7258
+ ...keepPrev && prev.outcome ? { outcome: prev.outcome } : record.outcome ? { outcome: `${record.outcome.name}=${String(record.outcome.value)}` } : {},
7259
+ records: (prev?.records ?? 0) + 1
7260
+ };
7261
+ }
7262
+ function joinRun(op, records, gate) {
7263
+ const placed = opPlacedSteps(op);
7264
+ const slots = slotsFor(placed);
7265
+ const byKey = /* @__PURE__ */ new Map();
7266
+ for (const s of slots) {
7267
+ const key = `${s.phase}\0${s.fn}`;
7268
+ if (!byKey.has(key)) byKey.set(key, []);
7269
+ byKey.get(key).push({ id: s.id });
7270
+ }
7271
+ const cursor = /* @__PURE__ */ new Map();
7272
+ const byNode = /* @__PURE__ */ new Map();
7273
+ const unplaced = [];
7274
+ let lastSettledIndex = -1;
7275
+ const indexOf = new Map(placed.map((p, i) => [p.id, i]));
7276
+ for (const record of records) {
7277
+ const key = `${record.phase}\0${record.fn}`;
7278
+ const list3 = byKey.get(key);
7279
+ const i = cursor.get(key) ?? 0;
7280
+ const slot = list3?.[i];
7281
+ if (!slot) {
7282
+ unplaced.push(record);
7283
+ continue;
6882
7284
  }
6883
- for (const tick of home.ticks) {
6884
- marks.set(tick.node, {
6885
- role: "tick",
6886
- namespace: home.namespace,
6887
- ...home.stack ? { stack: home.stack } : {},
6888
- op: tick.op,
6889
- ...tick.schedule ? { schedule: tick.schedule } : {}
6890
- });
7285
+ cursor.set(key, i + 1);
7286
+ byNode.set(slot.id, mergeRun(byNode.get(slot.id), record));
7287
+ if (record.status !== "skipped") {
7288
+ lastSettledIndex = Math.max(lastSettledIndex, indexOf.get(slot.id) ?? -1);
7289
+ }
7290
+ }
7291
+ if (gate) {
7292
+ const gateNode = placed.find((p) => p.step.kind === "gate" && p.step.signalName === gate.signalName);
7293
+ if (gateNode) {
7294
+ return {
7295
+ byNode,
7296
+ at: gateNode.id,
7297
+ atReason: `waiting for ${gate.signalName} since ${gate.since}`,
7298
+ unplaced
7299
+ };
6891
7300
  }
6892
7301
  }
7302
+ const next = placed.find((p, i) => i > lastSettledIndex && p.when === "phases");
6893
7303
  return {
6894
- ...ir,
6895
- nodes: ir.nodes.map((n) => {
6896
- const mark = marks.get(n.id);
6897
- return mark ? { ...n, attrs: { ...n.attrs, _operator: mark } } : n;
6898
- })
7304
+ byNode,
7305
+ at: lastSettledIndex >= 0 && next ? next.id : null,
7306
+ atReason: lastSettledIndex >= 0 && next ? "reached \u2014 not settled yet" : null,
7307
+ unplaced
6899
7308
  };
6900
7309
  }
6901
- var OPERATOR_HOME_GLYPH = {
6902
- body: '<path d="M16.5 6.63A7 7 0 1 1 6.64 7.5"/><path d="M16.5 6.63 14.3 5.9M16.5 6.63 15.9 8.1"/>'
7310
+ var TONE = {
7311
+ ok: "good",
7312
+ fail: "warn",
7313
+ skipped: "neutral"
6903
7314
  };
6904
- function operatorHomeBoxMarks(ir, namespaceBoxes) {
6905
- const out = {};
6906
- for (const home of operatorHomes(ir)) {
6907
- const box = namespaceBoxes[home.namespace];
6908
- if (box) out[box] = OPERATOR_HOME_GLYPH;
6909
- }
6910
- return out;
6911
- }
6912
- function operatorStrip(rows, now = Date.now()) {
6913
- const out = rows.map((r) => {
6914
- const expiresAt = r.lease ? Date.parse(r.lease.expiresAt) : NaN;
6915
- const lease = !r.lease ? "free" : Number.isFinite(expiresAt) && expiresAt <= now ? "expired" : "held";
6916
- return {
6917
- op: r.op,
6918
- env: r.env,
6919
- log: r.lastTick?.log ?? null,
6920
- at: r.lastTick?.timestamp ?? null,
6921
- lease,
6922
- leaseHolder: r.lease?.holder ?? null,
6923
- leaseExpiresAt: r.lease?.expiresAt ?? null,
6924
- pendingGates: r.pendingGates.length
6925
- };
6926
- });
6927
- return { rows: out, pendingGates: out.reduce((n, r) => n + r.pendingGates, 0) };
7315
+ var RAN = { ok: "ok", fail: "failed", skipped: "skipped" };
7316
+ function tookLabel(ms) {
7317
+ return ms < 1e3 ? `${ms}ms` : `${(ms / 1e3).toFixed(1)}s`;
6928
7318
  }
6929
- var CONVERGE_GATE_LOOP = "converge";
6930
- var APPROVE_SEMANTICS = "Approving records a fact in the gate ledger; it does not unblock the dispatch. The next tick reads it.";
6931
- function convergeGateCards(rows) {
6932
- const out = [];
6933
- for (const row of rows) {
6934
- for (const g of row.pendingGates) {
6935
- if (!g.op) continue;
6936
- out.push({
6937
- loop: CONVERGE_GATE_LOOP,
6938
- rule: g.rule,
6939
- op: g.op,
6940
- gate: g.gate,
6941
- convergeOp: row.op,
6942
- env: row.env,
6943
- since: row.lastTick?.timestamp ?? null,
6944
- semantics: APPROVE_SEMANTICS,
6945
- approve: {
6946
- method: "POST",
6947
- path: `/api/operator/approve/${encodeURIComponent(g.op)}/${encodeURIComponent(g.gate)}`,
6948
- command: `chant approve ${g.op} ${g.gate}`
6949
- }
6950
- });
7319
+ function paintPlayhead(ir, join21, gate) {
7320
+ if (!join21.byNode.size && !join21.at) return ir;
7321
+ const nodes = ir.nodes.map((node) => {
7322
+ const run3 = join21.byNode.get(node.id);
7323
+ const here = join21.at === node.id;
7324
+ if (!run3 && !here) return node;
7325
+ const isGate = node.attrs?._step === "gate";
7326
+ const attrs = { ...node.attrs };
7327
+ if (run3) {
7328
+ attrs._status = TONE[run3.status];
7329
+ attrs.run = run3.status === "skipped" ? "skipped" : `${RAN[run3.status]} \xB7 ${tookLabel(run3.durationMs)}`;
7330
+ attrs.ran = tookLabel(run3.durationMs);
7331
+ if (run3.error) attrs.error = run3.error;
7332
+ if (run3.outcome) attrs.outcome = run3.outcome;
7333
+ if (run3.records > 1) attrs.runRecords = run3.records;
6951
7334
  }
6952
- }
6953
- return out;
7335
+ if (here) {
7336
+ attrs._playhead = true;
7337
+ attrs._status = isGate && gate ? "warn" : "accent";
7338
+ attrs.run = isGate && gate ? `waiting for approval since ${gate.since}` : join21.atReason ?? "here";
7339
+ if (isGate && gate?.description) attrs.gateDescription = gate.description;
7340
+ }
7341
+ return { ...node, attrs };
7342
+ });
7343
+ return { ...ir, nodes };
6954
7344
  }
6955
- var initialOperatorState = {
6956
- declared: [],
6957
- strip: null,
6958
- gates: [],
6959
- note: null,
6960
- code: null,
6961
- readAt: null
6962
- };
6963
- function operatorRead(state, result, at, now = Date.now()) {
6964
- if (!result.ok) {
6965
- return { ...state, strip: null, gates: [], note: result.refusal.error, code: result.refusal.code, readAt: at };
6966
- }
7345
+ function pendingGateCard(op, gate, ir) {
7346
+ const node = ir.nodes.find((n) => n.attrs?._step === "gate" && n.attrs?.gate === gate.signalName && n.attrs?.op === op);
6967
7347
  return {
6968
- ...state,
6969
- strip: operatorStrip(result.rows, now),
6970
- gates: convergeGateCards(result.rows),
6971
- note: null,
6972
- code: null,
6973
- readAt: at
7348
+ op,
7349
+ signalName: gate.signalName,
7350
+ ...gate.description ? { description: gate.description } : {},
7351
+ since: gate.since,
7352
+ ...node ? { node: node.id } : {},
7353
+ ...node && typeof node.attrs?.guards === "string" ? { guards: node.attrs.guards } : {},
7354
+ approve: {
7355
+ method: "POST",
7356
+ path: `/api/ops/${encodeURIComponent(op)}/signal/${encodeURIComponent(gate.signalName)}`
7357
+ }
6974
7358
  };
6975
7359
  }
6976
- function operatorNote(state) {
6977
- if (!state.declared.length) {
6978
- return "No operating loop: this project declares no ConvergeOp, so there is no operator strip.";
6979
- }
6980
- const loops = `${state.declared.length} ConvergeOp${state.declared.length === 1 ? "" : "s"}`;
6981
- if (!state.strip) {
6982
- return state.note ? `Operating loop: ${loops} declared \u2014 status unavailable: ${state.note}` : `Operating loop: ${loops} declared \u2014 not read yet.`;
7360
+ function playheadNote(state, join21) {
7361
+ if (!state.op) {
7362
+ return "No run state: nothing has been run from this behold session, so every step paints as declared.";
6983
7363
  }
7364
+ const settled = join21 ? join21.byNode.size : state.records.length;
6984
7365
  const bits = [];
6985
- const ticked = state.strip.rows.filter((r) => r.at).length;
6986
- bits.push(
6987
- 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`
6988
- );
6989
- const held = state.strip.rows.filter((r) => r.lease === "held").length;
6990
- if (held) bits.push(`${held} lease${held === 1 ? "" : "s"} held`);
6991
- const expired = state.strip.rows.filter((r) => r.lease === "expired").length;
6992
- if (expired) bits.push(`${expired} lease${expired === 1 ? "" : "s"} expired \u2014 the next round reclaims it`);
6993
- if (state.strip.pendingGates) {
7366
+ 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`;
7367
+ if (state.status === "running") {
6994
7368
  bits.push(
6995
- `${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)`
7369
+ 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"
6996
7370
  );
6997
7371
  }
6998
- return `Operating loop: ${loops} \u2014 ${bits.join("; ")}.`;
7372
+ if (state.status === "ok") bits.push("the run exited cleanly");
7373
+ if (state.status === "failed") bits.push("a step failed");
7374
+ if (state.status === "lost") bits.push(`stream lost \u2014 ${state.lost ?? "the progress stream ended without a verdict"}`);
7375
+ if (state.gate) {
7376
+ bits.push(`waiting on gate ${state.gate.signalName} since ${state.gate.since} \u2014 approve is a delegated signal, behold never signals on its own`);
7377
+ } else if (state.gateNote) {
7378
+ bits.push(`gate state unavailable \u2014 ${state.gateNote}`);
7379
+ }
7380
+ if (join21?.unplaced.length) {
7381
+ bits.push(
7382
+ `${join21.unplaced.length} record${join21.unplaced.length === 1 ? "" : "s"} matched no declared step and ${join21.unplaced.length === 1 ? "is" : "are"} not painted`
7383
+ );
7384
+ }
7385
+ if (!bits.length) return `${head}.`;
7386
+ const rest = bits.join("; ");
7387
+ return `${head} \u2014 ${rest}.`;
6999
7388
  }
7000
7389
 
7001
7390
  // src/adopt.ts
@@ -8847,6 +9236,51 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
8847
9236
  const state = runner.noteOperator({ ok: true, rows });
8848
9237
  return c.json({ ...state, operator: state });
8849
9238
  });
9239
+ app.get("/api/operator/log", async (c) => {
9240
+ const { dirs } = convergeDirs();
9241
+ if (!dirs.length) {
9242
+ return c.json(
9243
+ {
9244
+ error: "This project declares no operating loop \u2014 no emitted Op carries chant's `Converge` search attribute.",
9245
+ code: "no-operator",
9246
+ remedy: HOW_TO_DECLARE,
9247
+ history: null
9248
+ },
9249
+ 200
9250
+ );
9251
+ }
9252
+ const window = operatorLogWindow({ limit: c.req.query("limit") ?? null, since: c.req.query("since") ?? null });
9253
+ if ("error" in window) {
9254
+ return c.json({ error: window.error, code: "operator-log", remedy: "Ask for a bounded window: `?limit=50&since=<iso>`.", history: null }, 400);
9255
+ }
9256
+ const answers = [];
9257
+ for (const dir of dirs) {
9258
+ const version = resolveChant(dir).version;
9259
+ if (!meetsFloor(version, OPERATOR_LOG_FLOOR)) {
9260
+ return c.json(
9261
+ {
9262
+ error: `This chant has no \`operator log\` \u2014 ${version ? `the project resolves chant ${version}` : "the project's chant"}, and the tick history landed in ${OPERATOR_LOG_FLOOR}.`,
9263
+ code: "no-operator-log-cli",
9264
+ remedy: `Upgrade the project's chant to ${OPERATOR_LOG_FLOOR} or newer (chant#2029). The strip itself needs only 0.52, so it keeps reading.`,
9265
+ history: null
9266
+ },
9267
+ 422
9268
+ );
9269
+ }
9270
+ const result = readOperatorLog(await runChantRaw(operatorLogArgs(window), dir));
9271
+ if (!result.ok) return c.json({ ...result.refusal, history: null }, result.refusal.code === "no-operator" ? 200 : 422);
9272
+ answers.push({ entries: result.entries, malformed: result.malformed });
9273
+ }
9274
+ const merged = mergeOperatorLogs(answers, window.limit);
9275
+ const history = {
9276
+ entries: operatorTimeline(merged.entries),
9277
+ malformed: merged.malformed,
9278
+ window: { limit: window.limit, since: window.since ?? null },
9279
+ more: merged.entries.length >= window.limit,
9280
+ readAt: (/* @__PURE__ */ new Date()).toISOString()
9281
+ };
9282
+ return c.json({ history });
9283
+ });
8850
9284
  app.post("/api/operator/approve/:op/:gate", async (c) => {
8851
9285
  const { op, gate } = c.req.param();
8852
9286
  const info = estateOps().find((o) => o.name === op);
@@ -9148,6 +9582,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
9148
9582
  { method: "GET", path: "/api/ops", desc: "committed Ops + adopt lexicons + apply progress + run playhead" },
9149
9583
  { method: "GET", path: "/api/ops/:name/status", desc: "an Op's durable run status + pending gate (chant run status)" },
9150
9584
  { method: "GET", path: "/api/operator/status", desc: "the operating loop's strip: last tick per ConvergeOp + lease + pending converge gates" },
9585
+ { method: "GET", path: "/api/operator/log", desc: "the operating loop's timeline: converge ticks + gate resolutions, newest ?limit=n (max 200) from ?since=<iso>" },
9151
9586
  { method: "GET", path: "/api/history", desc: "recent source commits (rollback targets)" },
9152
9587
  { method: "GET", path: "/api/frames", desc: "captured lanes frames" },
9153
9588
  { method: "GET", path: "/api/events", desc: "SSE: changed / op / apply / run / pr" },
@@ -9259,6 +9694,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
9259
9694
  if (env) {
9260
9695
  const rows = await componentStatus(cfg.projectDir, env, opts);
9261
9696
  ir = joinComponentStatus(ir, rows);
9697
+ ir = joinTickVerdicts(ir, verdictsForEnv(runner.operatorState.verdicts, env));
9262
9698
  mode = "component-status";
9263
9699
  metaEnv = env;
9264
9700
  }