@intentius/behold 0.10.1 → 0.11.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
@@ -1121,7 +1121,7 @@ import { fileURLToPath as fileURLToPath3 } from "node:url";
1121
1121
  import { execFile as execFile3 } from "node:child_process";
1122
1122
  import { promisify as promisify3 } from "node:util";
1123
1123
  import { existsSync as existsSync13, readFileSync as readFileSync13 } from "node:fs";
1124
- import { dirname as dirname4, join as join16, relative as relative3, resolve as resolve5, sep as sep4 } from "node:path";
1124
+ import { basename, dirname as dirname4, join as join16, relative as relative3, resolve as resolve5, sep as sep4 } from "node:path";
1125
1125
 
1126
1126
  // src/recents.ts
1127
1127
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
@@ -1552,8 +1552,15 @@ function parseYAMLArray(lines, startIndex, baseIndent) {
1552
1552
  result.push(obj);
1553
1553
  i = j;
1554
1554
  } else {
1555
- result.push(parseScalar(itemValue));
1556
- i++;
1555
+ const header = blockScalarHeader(itemValue);
1556
+ if (header) {
1557
+ const block = parseBlockScalar(lines, i + 1, indent, header);
1558
+ result.push(block.value);
1559
+ i = block.endIndex;
1560
+ } else {
1561
+ result.push(parseScalar(itemValue));
1562
+ i++;
1563
+ }
1557
1564
  }
1558
1565
  } else {
1559
1566
  break;
@@ -4187,6 +4194,7 @@ function renderGraph(ir, opts = {}) {
4187
4194
  const layout = layoutIr(ir, { fit: true, ...boxes ? { groups: boxes } : {} });
4188
4195
  if (opts.radial && !boxes) radializeLayout(layout, groupKeyByNode(ir), footprints(ir));
4189
4196
  else if (!boxes) packComponents(layout, ir);
4197
+ else if (boxKey === "byStack" && boxes) packMemberBoxes(layout, ir, boxes);
4190
4198
  const svg = renderSvg(ir, layout, {
4191
4199
  fit: true,
4192
4200
  hideTitle: true,
@@ -4409,6 +4417,92 @@ function packComponents(layout, ir) {
4409
4417
  layout.width = Math.max(...nodes.map((n) => n.x + halfW(n))) + pad;
4410
4418
  layout.height = Math.max(...nodes.map((n) => n.y + halfH(n))) + pad;
4411
4419
  }
4420
+ function packMemberBoxes(layout, ir, members) {
4421
+ const boxes = layout.groups ?? [];
4422
+ if (boxes.length < 4) return;
4423
+ const nodes = layout.nodes;
4424
+ const size = footprints(ir);
4425
+ const halfW = (n) => (size.get(n.id)?.w ?? NODE_W) / 2;
4426
+ const halfH = (n) => (size.get(n.id)?.h ?? NODE_H) / 2;
4427
+ for (const n of nodes) n.y = layout.height - n.y;
4428
+ for (const b of boxes) b.y = layout.height - b.y;
4429
+ const byId = new Map(nodes.map((n) => [n.id, n]));
4430
+ const claimed = /* @__PURE__ */ new Set();
4431
+ const blocks = boxes.map((box) => {
4432
+ const ns = (members[box.id ?? box.title] ?? members[box.title] ?? []).flatMap((id) => byId.get(id) ?? []);
4433
+ for (const n of ns) claimed.add(n.id);
4434
+ let minX = box.x - box.w / 2;
4435
+ let maxX2 = box.x + box.w / 2;
4436
+ let minY = box.y - box.h / 2;
4437
+ let maxY2 = box.y + box.h / 2;
4438
+ for (const n of ns) {
4439
+ minX = Math.min(minX, n.x - halfW(n));
4440
+ maxX2 = Math.max(maxX2, n.x + halfW(n));
4441
+ minY = Math.min(minY, n.y - halfH(n));
4442
+ maxY2 = Math.max(maxY2, n.y + halfH(n));
4443
+ }
4444
+ return { ns, box, minX, minY, w: maxX2 - minX, h: maxY2 - minY };
4445
+ });
4446
+ const rest = nodes.filter((n) => !claimed.has(n.id));
4447
+ if (rest.length) {
4448
+ const idxOf = new Map(rest.map((n, i) => [n.id, i]));
4449
+ const parent = rest.map((_, i) => i);
4450
+ const find = (x) => {
4451
+ while (parent[x] !== x) x = parent[x] = parent[parent[x]];
4452
+ return x;
4453
+ };
4454
+ for (const e of ir.edges) {
4455
+ const a = idxOf.get(e.from);
4456
+ const b = idxOf.get(e.to);
4457
+ if (a != null && b != null) parent[find(a)] = find(b);
4458
+ }
4459
+ const comps = /* @__PURE__ */ new Map();
4460
+ rest.forEach((n, i) => (comps.get(find(i)) ?? comps.set(find(i), []).get(find(i))).push(n));
4461
+ for (const ns of comps.values()) {
4462
+ const minX = Math.min(...ns.map((n) => n.x - halfW(n)));
4463
+ const minY = Math.min(...ns.map((n) => n.y - halfH(n)));
4464
+ blocks.push({
4465
+ ns,
4466
+ minX,
4467
+ minY,
4468
+ w: Math.max(...ns.map((n) => n.x + halfW(n))) - minX,
4469
+ h: Math.max(...ns.map((n) => n.y + halfH(n))) - minY
4470
+ });
4471
+ }
4472
+ }
4473
+ const totalArea = blocks.reduce((s, b) => s + (b.w + MEMBER_GAP) * (b.h + MEMBER_GAP), 0);
4474
+ const targetW = Math.max(Math.max(...blocks.map((b) => b.w)), Math.sqrt(totalArea) * 1.3);
4475
+ let shelfX = 0;
4476
+ let shelfY = 0;
4477
+ let shelfH = 0;
4478
+ let maxX = 0;
4479
+ let maxY = 0;
4480
+ for (const b of blocks) {
4481
+ if (shelfX > 0 && shelfX + b.w > targetW) {
4482
+ shelfX = 0;
4483
+ shelfY += shelfH + MEMBER_GAP;
4484
+ shelfH = 0;
4485
+ }
4486
+ const dx = shelfX - b.minX;
4487
+ const dy = shelfY - b.minY;
4488
+ for (const n of b.ns) {
4489
+ n.x += dx;
4490
+ n.y += dy;
4491
+ }
4492
+ if (b.box) {
4493
+ b.box.x += dx;
4494
+ b.box.y += dy;
4495
+ }
4496
+ maxX = Math.max(maxX, shelfX + b.w);
4497
+ maxY = Math.max(maxY, shelfY + b.h);
4498
+ shelfX += b.w + MEMBER_GAP;
4499
+ shelfH = Math.max(shelfH, b.h);
4500
+ }
4501
+ layout.width = maxX;
4502
+ layout.height = maxY;
4503
+ for (const n of nodes) n.y = maxY - n.y;
4504
+ for (const b of boxes) b.y = maxY - b.y;
4505
+ }
4412
4506
  function groupKeyByNode(ir) {
4413
4507
  const out = /* @__PURE__ */ new Map();
4414
4508
  for (const n of ir.nodes) {
@@ -4796,6 +4890,67 @@ async function runCarvePlan(demo) {
4796
4890
  noDestroy: r.noDestroy
4797
4891
  };
4798
4892
  }
4893
+ async function runCarveObserve(demo, select) {
4894
+ if (!demo.live) {
4895
+ return refuse2(
4896
+ "carve-action",
4897
+ "the observe beat reads the scratch Floci, and this server isn't serving one",
4898
+ "run `behold demo carve --live` (needs docker + terraform)"
4899
+ );
4900
+ }
4901
+ if (!existsSync8(join10(demo.out, "src"))) {
4902
+ return refuse2("carve-action", "nothing has been emitted yet", "run Emit first \u2014 observe reads the carveout emit writes");
4903
+ }
4904
+ const args = ["lifecycle", "diff", "prod", "--live", "--json"];
4905
+ const run3 = await runChantRaw(args, demo.out, { AWS_ENDPOINT_URL: demo.live.endpoint }).catch((err) => ({
4906
+ code: 127,
4907
+ stdout: "",
4908
+ stderr: err instanceof Error ? err.message : String(err)
4909
+ }));
4910
+ if (run3.code !== 0) {
4911
+ return refuse2(
4912
+ "carve-action",
4913
+ `chant lifecycle diff exited ${run3.code}: ${merge(run3, demo.root) || "(no output)"}`,
4914
+ "Is the scratch Floci still up? `docker ps` should list " + demo.live.container + "."
4915
+ );
4916
+ }
4917
+ let aws;
4918
+ try {
4919
+ aws = JSON.parse(run3.stdout).lexicons?.aws ?? {};
4920
+ } catch {
4921
+ return refuse2("carve-action", "chant lifecycle diff answered something that isn't JSON", "re-run \u2014 a partial read is not a verdict");
4922
+ }
4923
+ const entity = select.split(".").pop() ?? select;
4924
+ const command = `AWS_ENDPOINT_URL=${demo.live.endpoint} chant ${args.join(" ")} # in ${shortenIn(demo.out, demo.root)}`;
4925
+ const queried = aws.resources?.queried?.[entity];
4926
+ const meta = aws.observed?.[entity];
4927
+ if (meta) {
4928
+ return {
4929
+ ok: true,
4930
+ select,
4931
+ command,
4932
+ entity,
4933
+ verdict: "observed",
4934
+ ...meta.status ? { status: meta.status } : {},
4935
+ ...meta.ownership ? { ownership: meta.ownership } : {},
4936
+ ...meta.physicalId ? { physicalId: meta.physicalId } : {},
4937
+ ...queried ? { queried } : {}
4938
+ };
4939
+ }
4940
+ const hole = aws.resources?.unobserved?.find((u) => u.name === entity);
4941
+ if (hole) {
4942
+ return {
4943
+ ok: true,
4944
+ select,
4945
+ command,
4946
+ entity,
4947
+ verdict: "unobserved",
4948
+ ...hole.detail || hole.reason ? { detail: [hole.reason, hole.detail].filter(Boolean).join(": ") } : {},
4949
+ ...queried ? { queried } : {}
4950
+ };
4951
+ }
4952
+ return { ok: true, select, command, entity, verdict: "missing", ...queried ? { queried } : {} };
4953
+ }
4799
4954
  var MAX_ARTIFACT_BYTES = 64 * 1024;
4800
4955
  var MAX_ARTIFACTS = 24;
4801
4956
  var MAX_OUTPUT_BYTES = 32 * 1024;
@@ -5126,6 +5281,11 @@ function statusTree(o) {
5126
5281
  if (!attrs) return void 0;
5127
5282
  return rec2(attrs.status) ?? attrs;
5128
5283
  }
5284
+ function specTree(o) {
5285
+ const attrs = rec2(o.attributes);
5286
+ if (!attrs) return void 0;
5287
+ return rec2(attrs.spec) ?? attrs;
5288
+ }
5129
5289
  var ARGO_HEALTH = {
5130
5290
  Healthy: "healthy",
5131
5291
  Degraded: "degraded",
@@ -5226,6 +5386,7 @@ function revisionVerdict(tree) {
5226
5386
  return { health: "progressing", detail: `applied ${applied}, attempted ${attempted}` };
5227
5387
  }
5228
5388
  var FLUX_TYPE_PREFIX = "K8s::Flux::";
5389
+ var FLUX_HELMREPOSITORY_TYPE = "K8s::Flux::HelmRepository";
5229
5390
  function fluxVerdict(o) {
5230
5391
  if (!o.type?.startsWith(FLUX_TYPE_PREFIX)) return void 0;
5231
5392
  const tree = statusTree(o);
@@ -5233,6 +5394,9 @@ function fluxVerdict(o) {
5233
5394
  if (ready?.status === "False") return { health: "degraded", detail: describeReady(ready) };
5234
5395
  if (ready?.status === "Unknown") return { health: "progressing", detail: describeReady(ready) };
5235
5396
  if (ready?.status === "True") return revisionVerdict(tree) ?? { health: "healthy", detail: describeReady(ready) };
5397
+ if (o.type === FLUX_HELMREPOSITORY_TYPE && str2(specTree(o)?.type) === "oci") {
5398
+ return { health: "healthy", detail: "spec.type=oci (nothing to reconcile)" };
5399
+ }
5236
5400
  const conditions = tree?.conditions;
5237
5401
  const reconciling = Array.isArray(conditions) && conditions.some((c) => {
5238
5402
  const cond = rec2(c);
@@ -5244,9 +5408,41 @@ function fluxVerdict(o) {
5244
5408
  }
5245
5409
  return void 0;
5246
5410
  }
5411
+ var DEPLOYMENT_TYPE = "K8s::Apps::Deployment";
5412
+ function unhappyCondition(o, type) {
5413
+ const fromStrings = rec2(o.attributes)?.conditions;
5414
+ if (Array.isArray(fromStrings)) {
5415
+ for (const c of fromStrings) {
5416
+ if (typeof c === "string" && c.startsWith(type)) return c;
5417
+ }
5418
+ }
5419
+ const tree = statusTree(o);
5420
+ const raw = tree?.conditions;
5421
+ if (Array.isArray(raw)) {
5422
+ for (const c of raw) {
5423
+ const cond = rec2(c);
5424
+ if (!cond || cond.type !== type) continue;
5425
+ const status = str2(cond.status);
5426
+ const unhappy = type === "ReplicaFailure" ? status === "True" : status === "False";
5427
+ if (!unhappy) continue;
5428
+ const reason = str2(cond.reason);
5429
+ const message = str2(cond.message);
5430
+ return `${type}${reason ? `=${reason}` : ""}${message ? `: ${message}` : ""}`;
5431
+ }
5432
+ }
5433
+ return void 0;
5434
+ }
5435
+ function deploymentVerdict(o) {
5436
+ if (o.type !== DEPLOYMENT_TYPE) return void 0;
5437
+ const damage = unhappyCondition(o, "ReplicaFailure") ?? unhappyCondition(o, "Progressing");
5438
+ if (damage) return { health: "degraded", detail: damage };
5439
+ const available = unhappyCondition(o, "Available");
5440
+ if (available) return { health: "progressing", detail: available };
5441
+ return void 0;
5442
+ }
5247
5443
  function classifyObservedHealth(observed) {
5248
5444
  if (!observed) return { health: "unknown" };
5249
- return argoVerdict(observed) ?? fluxVerdict(observed) ?? { health: classifyHealth(observed.status) };
5445
+ return argoVerdict(observed) ?? fluxVerdict(observed) ?? deploymentVerdict(observed) ?? { health: classifyHealth(observed.status) };
5250
5446
  }
5251
5447
 
5252
5448
  // src/apply.ts
@@ -5377,9 +5573,14 @@ var OpRunner = class {
5377
5573
  * Start `chant run <name>` unless one is already running (the Sync/Adopt/auto-
5378
5574
  * sync path). `cwd` is the Op's own project dir (#31 multi-estate); defaults to
5379
5575
  * the primary. Returns true if it started, false if busy.
5576
+ *
5577
+ * `temporal` — pass `--temporal` so the run gets the durable runtime. Callers
5578
+ * set it from the Op's declared gate: chant refuses a gated Op outright in
5579
+ * local mode ("gates and schedules need a durable runtime"), so without the
5580
+ * flag a gated Op could never run from behold at all.
5380
5581
  */
5381
- trigger(name, opEnv, cwd) {
5382
- return this.start(["run", name], name, opEnv, cwd);
5582
+ trigger(name, opEnv, cwd, temporal) {
5583
+ return this.start(temporal ? ["run", name, "--temporal"] : ["run", name], name, opEnv, cwd);
5383
5584
  }
5384
5585
  /**
5385
5586
  * Run an arbitrary `chant` invocation through the same guard/stream/PR/capture
@@ -5772,13 +5973,31 @@ async function openRollbackBranches(projectDir, env) {
5772
5973
 
5773
5974
  // src/estate.ts
5774
5975
  import { statSync as statSync3 } from "node:fs";
5976
+ import { availableParallelism } from "node:os";
5775
5977
  import { join as join13, resolve as resolve3, sep as sep2 } from "node:path";
5776
5978
  import { composeStacks, shortStackNames } from "@intentius/pinhole";
5979
+ function estateReadPool(members, env = process.env) {
5980
+ const override = Number.parseInt(env.BEHOLD_ESTATE_CONCURRENCY ?? "", 10);
5981
+ const cap = Number.isInteger(override) && override >= 1 ? override : Math.min(4, availableParallelism());
5982
+ return Math.max(1, Math.min(cap, members));
5983
+ }
5984
+ async function mapPool(items, width, fn) {
5985
+ const results = new Array(items.length);
5986
+ let next = 0;
5987
+ const worker = async () => {
5988
+ for (let i = next++; i < items.length; i = next++) {
5989
+ results[i] = await fn(items[i], i);
5990
+ }
5991
+ };
5992
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(width, items.length)) }, () => worker()));
5993
+ return results;
5994
+ }
5777
5995
  async function composeEstate(projectDirs, opts = {}) {
5778
5996
  const names = shortStackNames(projectDirs);
5779
- const stacks = await Promise.all(
5780
- projectDirs.map(async (dir, i) => ({ name: names[i], ir: await graphIr(dir, opts) }))
5781
- );
5997
+ const stacks = await mapPool(projectDirs, estateReadPool(projectDirs.length), async (dir, i) => ({
5998
+ name: names[i],
5999
+ ir: await graphIr(dir, opts)
6000
+ }));
5782
6001
  return composeStacks(stacks);
5783
6002
  }
5784
6003
  var firstLine2 = (e) => {
@@ -5872,7 +6091,11 @@ function joinNamespaceBindings(members, isDir = realIsDir) {
5872
6091
  async function estateNamespaceScopes(projectDirs, opts, isDir = realIsDir) {
5873
6092
  if (projectDirs.length < 2) return /* @__PURE__ */ new Map();
5874
6093
  const { live: _live, overlay: _overlay, namespace: _namespace, ...src } = opts;
5875
- const irs = await Promise.all(projectDirs.map((dir) => graphIr(dir, { ...src, detail: 3 }).catch(() => void 0)));
6094
+ const irs = await mapPool(
6095
+ projectDirs,
6096
+ estateReadPool(projectDirs.length),
6097
+ (dir) => graphIr(dir, { ...src, detail: 3 }).catch(() => void 0)
6098
+ );
5876
6099
  const scopes = /* @__PURE__ */ new Map();
5877
6100
  for (const j of joinNamespaceBindings(projectDirs.map((dir, i) => ({ dir, ir: irs[i] })), isDir)) {
5878
6101
  if (!meetsFloor(resolveChant(j.dir).version, NAMESPACE_JOIN_FLOOR)) continue;
@@ -5891,28 +6114,26 @@ async function composeEstateOverlay(projectDirs, opts, classify) {
5891
6114
  const joined = [];
5892
6115
  const stacks = new Array(projectDirs.length);
5893
6116
  const scopes = await estateNamespaceScopes(projectDirs, opts);
5894
- await Promise.all(
5895
- projectDirs.map(async (dir, i) => {
5896
- const name = names[i];
5897
- const namespace = scopes.get(dir);
6117
+ await mapPool(projectDirs, estateReadPool(projectDirs.length), async (dir, i) => {
6118
+ const name = names[i];
6119
+ const namespace = scopes.get(dir);
6120
+ try {
6121
+ const live = { ...opts, live: true, overlay: true, ...namespace ? { namespace } : {} };
6122
+ stacks[i] = { name, ir: namespaceRuntimeOwners(name, classify(await graphIr(dir, live))) };
6123
+ if (namespace) joined.push({ name, namespace });
6124
+ } catch (err) {
6125
+ const reason = firstLine2(err);
5898
6126
  try {
5899
- const live = { ...opts, live: true, overlay: true, ...namespace ? { namespace } : {} };
5900
- stacks[i] = { name, ir: namespaceRuntimeOwners(name, classify(await graphIr(dir, live))) };
5901
- if (namespace) joined.push({ name, namespace });
5902
- } catch (err) {
5903
- const reason = firstLine2(err);
5904
- try {
5905
- const { env: _env, live: _live, overlay: _overlay, ...srcOpts } = opts;
5906
- const src = await graphIr(dir, srcOpts);
5907
- for (const n of src.nodes) n.attrs = { ...n.attrs, _status: "neutral", _unobserved: reason };
5908
- stacks[i] = { name, ir: src };
5909
- unobserved.push({ name, reason });
5910
- } catch (err2) {
5911
- dropped.push({ name, reason: firstLine2(err2) });
5912
- }
6127
+ const { env: _env, live: _live, overlay: _overlay, ...srcOpts } = opts;
6128
+ const src = await graphIr(dir, srcOpts);
6129
+ for (const n of src.nodes) n.attrs = { ...n.attrs, _status: "neutral", _unobserved: reason };
6130
+ stacks[i] = { name, ir: src };
6131
+ unobserved.push({ name, reason });
6132
+ } catch (err2) {
6133
+ dropped.push({ name, reason: firstLine2(err2) });
5913
6134
  }
5914
- })
5915
- );
6135
+ }
6136
+ });
5916
6137
  const present = stacks.filter((s) => !!s);
5917
6138
  return {
5918
6139
  ir: composeStacks(present),
@@ -5959,6 +6180,12 @@ function watchSource(projectDir, onChange, debounceMs = 200) {
5959
6180
  watcher.close();
5960
6181
  };
5961
6182
  }
6183
+ function watchSources(projectDirs, onChange, debounceMs = 200) {
6184
+ const stops = projectDirs.map((dir) => watchSource(dir, () => onChange(dir), debounceMs));
6185
+ return () => {
6186
+ for (const stop of stops) stop();
6187
+ };
6188
+ }
5962
6189
 
5963
6190
  // src/poll.ts
5964
6191
  function digestOf(nodes) {
@@ -5986,18 +6213,22 @@ function changedLexicons(prev, next) {
5986
6213
  }
5987
6214
  function startDriftPoll(opts) {
5988
6215
  let stopped = false;
5989
- let last;
6216
+ const last = /* @__PURE__ */ new Map();
5990
6217
  let timer;
5991
6218
  const tick = async () => {
5992
- try {
5993
- const digests = driftDigestsByLexicon(await opts.query());
5994
- if (last !== void 0) {
5995
- const moved = changedLexicons(last, digests);
5996
- if (moved.length) opts.onChange(moved);
6219
+ for (const member of opts.members) {
6220
+ if (stopped) return;
6221
+ try {
6222
+ const digests = driftDigestsByLexicon(await member.query());
6223
+ const prev = last.get(member.dir);
6224
+ if (prev !== void 0) {
6225
+ const moved = changedLexicons(prev, digests);
6226
+ if (moved.length) opts.onChange(member.dir, moved);
6227
+ }
6228
+ last.set(member.dir, digests);
6229
+ } catch (err) {
6230
+ opts.onError?.(member.dir, err);
5997
6231
  }
5998
- last = digests;
5999
- } catch (err) {
6000
- opts.onError?.(err);
6001
6232
  }
6002
6233
  if (!stopped) timer = setTimeout(tick, opts.intervalMs);
6003
6234
  };
@@ -6571,6 +6802,7 @@ function carveRoutes(app, reportPath, demo) {
6571
6802
  };
6572
6803
  app.post("/api/carve/emit", (c) => runStep2(c, runCarveEmit));
6573
6804
  app.post("/api/carve/bridge", (c) => runStep2(c, runCarveBridge));
6805
+ app.post("/api/carve/observe", (c) => runStep2(c, runCarveObserve));
6574
6806
  app.post("/api/carve/plan", async (c) => {
6575
6807
  const block = demoBlock();
6576
6808
  if (block) {
@@ -6647,7 +6879,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
6647
6879
  if (!info) {
6648
6880
  return c.json({ error: `no Op named "${name}" in the estate` }, 404);
6649
6881
  }
6650
- if (!runner.trigger(name, info.env, info.dir)) {
6882
+ if (!runner.trigger(name, info.env, info.dir, Boolean(info.gate))) {
6651
6883
  return c.json({ error: `an Op is already running (${runner.running})` }, 409);
6652
6884
  }
6653
6885
  return c.json({ started: true, name });
@@ -6692,7 +6924,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
6692
6924
  const { name, gate } = c.req.param();
6693
6925
  const info = estateOps().find((o) => o.name === name);
6694
6926
  broadcaster.emit("op", `\u270E signal ${name} ${gate}`);
6695
- const { code, stderr } = await runChantRaw(["run", "signal", name, gate], info?.dir ?? cfg.projectDir);
6927
+ const { code, stderr } = await runChantRaw(["run", "signal", name, gate, "--temporal"], info?.dir ?? cfg.projectDir);
6696
6928
  if (code !== 0) return c.json({ error: stderr.trim() || `signal exited ${code}` }, 500);
6697
6929
  return c.json({ signalled: true });
6698
6930
  });
@@ -7434,17 +7666,18 @@ async function startServer(cfg) {
7434
7666
  const app = createApp(cfg, broadcaster, frames, runner);
7435
7667
  const autoSync = cfg.autoSync ?? "off";
7436
7668
  const capture2 = () => captureFrame(cfg.projectDir, cfg.env, frames, broadcaster);
7437
- const onEstateChange = () => {
7438
- broadcaster.emit("changed");
7669
+ const onEstateChange = (memberDir) => {
7670
+ broadcaster.emit("changed", memberDir ?? "");
7439
7671
  void capture2();
7440
7672
  };
7441
- const onPollDrift = (movedLexicons) => {
7442
- onEstateChange();
7673
+ const memberTag = (dir) => (cfg.projectDirs?.length ?? 0) > 1 ? ` [${basename(dir)}]` : "";
7674
+ const onPollDrift = (dir, movedLexicons) => {
7675
+ onEstateChange(dir);
7443
7676
  if (autoSync === "off") return;
7444
- void routeAutoSync(movedLexicons);
7677
+ void routeAutoSync(dir, movedLexicons);
7445
7678
  };
7446
- const routeAutoSync = async (movedLexicons) => {
7447
- const suspended = autoSync === "pull-request" ? suspendedByRollback(await openRollbackBranches(cfg.projectDir, cfg.env), movedLexicons) : /* @__PURE__ */ new Set();
7679
+ const routeAutoSync = async (dir, movedLexicons) => {
7680
+ const suspended = autoSync === "pull-request" ? suspendedByRollback(await openRollbackBranches(dir, cfg.env), movedLexicons) : /* @__PURE__ */ new Set();
7448
7681
  const { picks, declined } = pickAutoSyncOps(
7449
7682
  autoSync,
7450
7683
  discoverEstateOps(cfg.projectDirs ?? [cfg.projectDir]),
@@ -7453,31 +7686,37 @@ async function startServer(cfg) {
7453
7686
  suspended
7454
7687
  );
7455
7688
  for (const d of declined) {
7456
- broadcaster.emit("op", `\u27F3 auto-sync (${autoSync}) declined ${d.lexicon}: ${d.reason}`);
7689
+ broadcaster.emit("op", `\u27F3 auto-sync (${autoSync})${memberTag(dir)} declined ${d.lexicon}: ${d.reason}`);
7457
7690
  }
7458
7691
  for (const { op, lexicons } of picks) {
7459
7692
  const scope = lexicons.join("+");
7460
- if (runner.trigger(op.name, op.env, op.dir)) {
7461
- broadcaster.emit("op", `\u27F3 auto-sync (${autoSync}) ${scope} \u2192 ${op.name}`);
7693
+ if (runner.trigger(op.name, op.env, op.dir, Boolean(op.gate))) {
7694
+ broadcaster.emit("op", `\u27F3 auto-sync (${autoSync})${memberTag(dir)} ${scope} \u2192 ${op.name}`);
7462
7695
  } else {
7463
- broadcaster.emit("op", `\u27F3 auto-sync (${autoSync}) ${scope} \u2192 ${op.name} waiting \u2014 ${runner.running} is running`);
7696
+ broadcaster.emit("op", `\u27F3 auto-sync (${autoSync})${memberTag(dir)} ${scope} \u2192 ${op.name} waiting \u2014 ${runner.running} is running`);
7464
7697
  }
7465
7698
  }
7466
7699
  };
7467
7700
  const carve = !!cfg.carveReport;
7468
7701
  let stopWatch = carve ? () => {
7469
- } : watchSource(cfg.projectDir, onEstateChange);
7702
+ } : watchSources(cfg.projectDirs ?? [cfg.projectDir], onEstateChange);
7470
7703
  let stopPoll = !carve && cfg.env && cfg.pollSecs ? startDriftPoll({
7471
7704
  intervalMs: cfg.pollSecs * 1e3,
7472
- query: () => graphIr(cfg.projectDir, { live: true, overlay: true, env: cfg.env }),
7705
+ // Per-member queries, swept sequentially inside the poll (#297): a
7706
+ // member read can take seconds (#295), so an estate-wide tick must
7707
+ // stretch, not stampede N describes at once.
7708
+ members: (cfg.projectDirs ?? [cfg.projectDir]).map((dir) => ({
7709
+ dir,
7710
+ query: () => graphIr(dir, { live: true, overlay: true, env: cfg.env })
7711
+ })),
7473
7712
  onChange: onPollDrift,
7474
- onError: (err) => process.stderr.write(`poll: ${err instanceof Error ? err.message : String(err)}
7713
+ onError: (dir, err) => process.stderr.write(`poll${memberTag(dir)}: ${err instanceof Error ? err.message : String(err)}
7475
7714
  `)
7476
7715
  }) : () => {
7477
7716
  };
7478
7717
  cfg.onProjectSwitch = (dir) => {
7479
7718
  stopWatch();
7480
- stopWatch = watchSource(dir, onEstateChange);
7719
+ stopWatch = watchSources(cfg.projectDirs ?? [dir], onEstateChange);
7481
7720
  stopPoll();
7482
7721
  stopPoll = () => {
7483
7722
  };
@@ -7551,7 +7790,7 @@ async function startServer(cfg) {
7551
7790
 
7552
7791
  // src/export.ts
7553
7792
  import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync4, copyFileSync, readFileSync as readFileSync14, readdirSync as readdirSync5 } from "node:fs";
7554
- import { join as join17, dirname as dirname5, basename } from "node:path";
7793
+ import { join as join17, dirname as dirname5, basename as basename2 } from "node:path";
7555
7794
  import { fileURLToPath as fileURLToPath4 } from "node:url";
7556
7795
  var LENS_PARAMS = ["components", "detail", "env", "logical", "radial", "tier"];
7557
7796
  function canonicalKey(path, params) {
@@ -7602,7 +7841,7 @@ function webDir() {
7602
7841
  return join17(dirname5(fileURLToPath4(import.meta.url)), "..", "web");
7603
7842
  }
7604
7843
  function workerName(project, override) {
7605
- const raw = override ?? `behold-${basename(project)}`;
7844
+ const raw = override ?? `behold-${basename2(project)}`;
7606
7845
  const name = raw.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 63);
7607
7846
  return name || "behold-export";
7608
7847
  }