@edgehero/pi-dispatch-admin 1.6.1 → 1.8.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.
Files changed (3) hide show
  1. package/README.md +8 -0
  2. package/dist/index.mjs +415 -64
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  > This npm package, **`@edgehero/pi-dispatch-admin`**, is the **operator console** (a pi extension). The service itself is `@edgehero/pi-dispatch` (worker + CLI) and `@edgehero/pi-dispatch-receiver` (the webhook edge); the [main repo](https://github.com/edgehero/pi-dispatch) has the container image, docs, and SECURITY.md.
10
10
 
11
+
11
12
  ## How it works
12
13
 
13
14
  Every trigger produces the same job, through the same path: one queue, one container, one budget.
@@ -73,6 +74,13 @@ One command puts a live TUI over the whole deployment:
73
74
  - **AI-operable, with a human gate.** Model-callable tools let an agent change limits (global and scoped) and manage triggers and pause windows, and every **config** write pops an operator confirmation the model cannot answer, refusing outright when no operator is present. Two tools sit outside that gate on purpose: `dispatch_pause` and `dispatch_resume` write durable queue state but are reversible and spend nothing, so they carry no confirm. One more sits outside it and is **not** money-safe: `dispatch_run` enqueues a **paid** run that edits a local folder in place with no undo, bounded instead by six independent limits (the `PI_DISPATCH_RUN_ROOTS` folder allowlist, a committed per-flow `ai-trigger: allow` opt-in read at a pre-agent SHA, a dirty-tree refusal with no force option, no spend knobs on the tool, a per-hour rate limit, and the worker's daily cap). Commands sit outside its reach entirely: `dispatch_run` speaks flows only, and a chained job's request naming a `command` is refused outright, with no opt-in. Read [`SECURITY.md`](https://github.com/edgehero/pi-dispatch/blob/main/SECURITY.md) on that one before you enable it. The bundled `operate-pi-dispatch` skill teaches the agent those gates.
74
75
  - **Logs stay put.** Raw container output renders only in the overlay viewer, never into model context.
75
76
 
77
+ ## More than one machine
78
+
79
+ The console reads the whole deployment, not one host. The status line names the workers when they have
80
+ been named, `RUN_DETAIL` names the machine that ran a job, the pause switch stops every queue rather than
81
+ the shared one, and the scheduler view spans hosts. On a single host none of that is visible, because
82
+ there is nothing to distinguish. See [`docs/multi-host.md`](../docs/multi-host.md).
83
+
76
84
  ## Install
77
85
 
78
86
  ```bash
package/dist/index.mjs CHANGED
@@ -8326,6 +8326,57 @@ var init_connection = __esm({
8326
8326
  }
8327
8327
  });
8328
8328
 
8329
+ // ../worker/src/host-registry.mjs
8330
+ function bounded(promise, ms = REGISTRY_OP_TIMEOUT_MS) {
8331
+ return new Promise((resolve3, reject) => {
8332
+ const t = setTimeout(() => reject(new Error("registry timeout")), ms);
8333
+ Promise.resolve(promise).then(
8334
+ (v) => (clearTimeout(t), resolve3(v)),
8335
+ (e) => (clearTimeout(t), reject(e))
8336
+ );
8337
+ });
8338
+ }
8339
+ async function readLiveHosts(redis, { now = () => Date.now(), timeoutMs = REGISTRY_OP_TIMEOUT_MS } = {}) {
8340
+ let names;
8341
+ try {
8342
+ names = await bounded(redis.smembers(HOST_SET), timeoutMs);
8343
+ if (names !== null && names !== void 0 && !Array.isArray(names)) throw new Error("smembers did not return a list");
8344
+ } catch (err) {
8345
+ return { unreachable: err?.message ?? "registry unreadable" };
8346
+ }
8347
+ const hosts = [];
8348
+ for (const member of names ?? []) {
8349
+ try {
8350
+ const row = await bounded(redis.hgetall(hostKey(member)), timeoutMs);
8351
+ if (!row || Object.keys(row).length === 0) {
8352
+ await bounded(redis.srem(HOST_SET, member), timeoutMs).catch(() => {
8353
+ });
8354
+ continue;
8355
+ }
8356
+ const beatAt = typeof row.beatAt === "string" && row.beatAt.trim() !== "" ? Number(row.beatAt) : NaN;
8357
+ hosts.push({
8358
+ ...row,
8359
+ name: row.name || member,
8360
+ // Derived rather than stored, so the panel can say "stale 2m" about a row that still lives.
8361
+ // A row whose clock is AHEAD of ours reads as 0 rather than negative: the difference is the
8362
+ // skew between two machines, which is its own signal and not this field's to report.
8363
+ staleMs: Number.isFinite(beatAt) ? Math.max(0, now() - beatAt) : null
8364
+ });
8365
+ } catch {
8366
+ }
8367
+ }
8368
+ hosts.sort((a, b) => String(a.name).localeCompare(String(b.name)));
8369
+ return { hosts };
8370
+ }
8371
+ var HOST_SET, hostKey, REGISTRY_OP_TIMEOUT_MS;
8372
+ var init_host_registry = __esm({
8373
+ "../worker/src/host-registry.mjs"() {
8374
+ HOST_SET = "host:live";
8375
+ hostKey = (name) => `host:h:${name}`;
8376
+ REGISTRY_OP_TIMEOUT_MS = 2e3;
8377
+ }
8378
+ });
8379
+
8329
8380
  // ../worker/src/job-id.mjs
8330
8381
  import { createHash as createHash2 } from "node:crypto";
8331
8382
  function localJobId({ folder, flow, task, minute }) {
@@ -9069,8 +9120,18 @@ var init_triggers = __esm({
9069
9120
 
9070
9121
  // ../worker/src/queue.mjs
9071
9122
  import { Queue } from "bullmq";
9072
- function makeQueue(connection) {
9073
- return new Queue(QUEUE, { connection });
9123
+ function fleetQueueNames(hosts) {
9124
+ const seen = /* @__PURE__ */ new Set();
9125
+ for (const h of hosts ?? []) {
9126
+ if (h?.routes !== true && h?.routes !== "true") continue;
9127
+ const name = h?.name;
9128
+ if (typeof name !== "string" || !WORKER_NAME_RE.test(name)) continue;
9129
+ seen.add(name);
9130
+ }
9131
+ return [QUEUE, ...[...seen].sort().map(hostQueueName)];
9132
+ }
9133
+ function makeQueue(connection, { name = QUEUE } = {}) {
9134
+ return new Queue(name, { connection });
9074
9135
  }
9075
9136
  async function enqueueLocalJob(queue, { folder, flow, task, command, provider, model, maxTurns, image, skillsDir, secrets, secretsProfile, chainDepth, parentJobId, jobId, now = /* @__PURE__ */ new Date() }) {
9076
9137
  const minute = now.toISOString().slice(0, 16);
@@ -9110,7 +9171,37 @@ async function enqueueLocalJob(queue, { folder, flow, task, command, provider, m
9110
9171
  });
9111
9172
  return id;
9112
9173
  }
9113
- var PR_CLOSE_WORDS, QUEUE, SEMANTIC_WINDOW_MS;
9174
+ async function discoverHostQueues(redis, { timeoutMs = 2e3, count = 500 } = {}) {
9175
+ const prefix = `bull:${QUEUE}@`;
9176
+ const names = /* @__PURE__ */ new Set();
9177
+ try {
9178
+ const deadline = Date.now() + timeoutMs;
9179
+ let cursor = "0";
9180
+ do {
9181
+ let timer;
9182
+ const [next, keys] = await Promise.race([
9183
+ redis.scan(cursor, "MATCH", `${prefix}*:meta`, "COUNT", count),
9184
+ new Promise((_, reject) => {
9185
+ timer = setTimeout(() => reject(new Error("scan timed out")), Math.max(1, deadline - Date.now()));
9186
+ })
9187
+ ]).finally(() => clearTimeout(timer));
9188
+ cursor = next;
9189
+ for (const key of keys ?? []) {
9190
+ const name = String(key).slice(prefix.length, -":meta".length);
9191
+ if (WORKER_NAME_RE.test(name)) names.add(name);
9192
+ }
9193
+ } while (cursor !== "0" && Date.now() < deadline);
9194
+ } catch {
9195
+ return [];
9196
+ }
9197
+ return [...names].sort().map(hostQueueName);
9198
+ }
9199
+ function unionQueueNames(fromRegistry, fromKeyspace) {
9200
+ const seen = /* @__PURE__ */ new Set([...fromRegistry ?? [], ...fromKeyspace ?? []]);
9201
+ seen.delete(QUEUE);
9202
+ return [QUEUE, ...[...seen].sort()];
9203
+ }
9204
+ var PR_CLOSE_WORDS, QUEUE, hostQueueName, WORKER_NAME_RE, SEMANTIC_WINDOW_MS;
9114
9205
  var init_queue = __esm({
9115
9206
  "../worker/src/queue.mjs"() {
9116
9207
  init_job_id();
@@ -9118,10 +9209,90 @@ var init_queue = __esm({
9118
9209
  init_triggers();
9119
9210
  PR_CLOSE_WORDS = new Set(Object.values(PR_CLOSE_ACTIONS));
9120
9211
  QUEUE = "pi-jobs";
9212
+ hostQueueName = (worker) => `${QUEUE}@${worker}`;
9213
+ WORKER_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
9121
9214
  SEMANTIC_WINDOW_MS = 10 * 60 * 1e3;
9122
9215
  }
9123
9216
  });
9124
9217
 
9218
+ // ../worker/src/run-mirror.mjs
9219
+ function bounded2(promise, ms) {
9220
+ let timer;
9221
+ return Promise.race([
9222
+ promise,
9223
+ new Promise((_, reject) => {
9224
+ timer = setTimeout(() => reject(new Error("mirror timeout")), ms);
9225
+ })
9226
+ ]).finally(() => clearTimeout(timer));
9227
+ }
9228
+ async function readMirroredRuns(redis, { limit = 50, sinceMs = 0, now = () => Date.now(), timeoutMs = OP_TIMEOUT_MS } = {}) {
9229
+ if (!redis) return { runs: [], degraded: "off" };
9230
+ let ids;
9231
+ try {
9232
+ ids = await bounded2(redis.zrevrangebyscore(RUNS_INDEX, "+inf", `(${sinceMs}`, "LIMIT", 0, Math.max(1, limit)), timeoutMs);
9233
+ } catch (err) {
9234
+ return { runs: [], degraded: `unreachable (${err?.message ?? "?"})` };
9235
+ }
9236
+ if (!Array.isArray(ids) || ids.length === 0) return { runs: [], degraded: "off" };
9237
+ let bodies;
9238
+ try {
9239
+ bodies = await bounded2(redis.mget(...ids.map(runRecordKey)), timeoutMs);
9240
+ } catch (err) {
9241
+ return { runs: [], degraded: `unreachable (${err?.message ?? "?"})` };
9242
+ }
9243
+ const runs = [];
9244
+ const stale = [];
9245
+ for (let i = 0; i < ids.length; i++) {
9246
+ const raw = bodies?.[i];
9247
+ if (typeof raw !== "string" || raw === "") {
9248
+ stale.push(ids[i]);
9249
+ continue;
9250
+ }
9251
+ try {
9252
+ const rec = JSON.parse(raw);
9253
+ if (rec && typeof rec === "object") runs.push(rec);
9254
+ } catch {
9255
+ stale.push(ids[i]);
9256
+ }
9257
+ }
9258
+ if (stale.length > 0) {
9259
+ try {
9260
+ await bounded2(redis.zrem(RUNS_INDEX, ...stale), timeoutMs);
9261
+ } catch {
9262
+ }
9263
+ }
9264
+ return { runs, degraded: runs.length >= limit ? "truncated" : "ok" };
9265
+ }
9266
+ function mergeRuns(local, mirrored, { limit = 50 } = {}) {
9267
+ const by = /* @__PURE__ */ new Map();
9268
+ const at = (r) => {
9269
+ const t = Date.parse(r?.endedAt ?? r?.startedAt ?? "");
9270
+ return Number.isFinite(t) ? t : -Infinity;
9271
+ };
9272
+ for (const r of Array.isArray(mirrored) ? mirrored : []) if (r?.jobId) by.set(r.jobId, r);
9273
+ for (const r of Array.isArray(local) ? local : []) {
9274
+ if (!r?.jobId) continue;
9275
+ const seen = by.get(r.jobId);
9276
+ if (!seen || at(r) >= at(seen)) by.set(r.jobId, r);
9277
+ }
9278
+ const out = [...by.values()].sort((a, b) => at(b) - at(a));
9279
+ return out.slice(0, Math.max(0, limit));
9280
+ }
9281
+ function hostsIn(runs) {
9282
+ const names = /* @__PURE__ */ new Set();
9283
+ for (const r of runs ?? []) if (typeof r?.host === "string" && r.host !== "") names.add(r.host);
9284
+ return [...names].sort();
9285
+ }
9286
+ var RUNS_INDEX, runRecordKey, DAY_MS, OP_TIMEOUT_MS;
9287
+ var init_run_mirror = __esm({
9288
+ "../worker/src/run-mirror.mjs"() {
9289
+ RUNS_INDEX = "runs:index";
9290
+ runRecordKey = (sanitizedJobId) => `runs:rec:${sanitizedJobId}`;
9291
+ DAY_MS = 24 * 60 * 60 * 1e3;
9292
+ OP_TIMEOUT_MS = 2e3;
9293
+ }
9294
+ });
9295
+
9125
9296
  // ../worker/src/git-dirty.mjs
9126
9297
  import { execFileSync } from "node:child_process";
9127
9298
  function gitDirty(folder, { exec: exec4 = execFileSync } = {}) {
@@ -9935,8 +10106,8 @@ var init_graph_model = __esm({
9935
10106
 
9936
10107
  // src/costs.mjs
9937
10108
  function costsSinceMs(windowKey, nowMs) {
9938
- if (windowKey === "7d") return nowMs - 7 * DAY_MS;
9939
- if (windowKey === "30d") return nowMs - 30 * DAY_MS;
10109
+ if (windowKey === "7d") return nowMs - 7 * DAY_MS2;
10110
+ if (windowKey === "30d") return nowMs - 30 * DAY_MS2;
9940
10111
  const now = new Date(nowMs);
9941
10112
  return Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1);
9942
10113
  }
@@ -10059,7 +10230,7 @@ function foldCosts({ records, subscriptions, pricing, nowMs, piAiPin = null, sin
10059
10230
  const requested = Number.isFinite(sinceMs);
10060
10231
  const fromMs = requested ? Math.min(sinceMs, nowMs) : firstRunMs ?? nowMs;
10061
10232
  const toMs2 = nowMs;
10062
- const days = requested || runs.length > 0 ? Math.max(1, Math.ceil((toMs2 - fromMs) / DAY_MS)) : 0;
10233
+ const days = requested || runs.length > 0 ? Math.max(1, Math.ceil((toMs2 - fromMs) / DAY_MS2)) : 0;
10063
10234
  return {
10064
10235
  // `firstRunMs` rides along (null when nothing ran) so a renderer can pad or trim the daily series
10065
10236
  // against the requested edge without re-deriving what the fold already knows.
@@ -10090,7 +10261,7 @@ function buildDaily(runs, fromMs, toMs2) {
10090
10261
  buckets.get(day).push(r.contribution);
10091
10262
  }
10092
10263
  const daily = [];
10093
- for (let cursor = Math.floor(fromMs / DAY_MS) * DAY_MS; cursor <= toMs2; cursor += DAY_MS) {
10264
+ for (let cursor = Math.floor(fromMs / DAY_MS2) * DAY_MS2; cursor <= toMs2; cursor += DAY_MS2) {
10094
10265
  const day = utcDay(cursor);
10095
10266
  const contribs = buckets.get(day) ?? [];
10096
10267
  daily.push({ day, cost: combineContributions(contribs), runs: contribs.length });
@@ -10114,7 +10285,7 @@ function buildDailyByFlow(runs, fromMs, toMs2) {
10114
10285
  const rows = [];
10115
10286
  for (const [flow, buckets] of byFlow) {
10116
10287
  const days = [];
10117
- for (let cursor = Math.floor(fromMs / DAY_MS) * DAY_MS; cursor <= toMs2; cursor += DAY_MS) {
10288
+ for (let cursor = Math.floor(fromMs / DAY_MS2) * DAY_MS2; cursor <= toMs2; cursor += DAY_MS2) {
10118
10289
  const day = utcDay(cursor);
10119
10290
  const contribs = buckets.get(day) ?? [];
10120
10291
  days.push({ day, cost: combineContributions(contribs), runs: contribs.length });
@@ -10358,7 +10529,7 @@ function peakWindow(w, attributed) {
10358
10529
  let peakRuns = 0;
10359
10530
  let peakTokens = 0;
10360
10531
  if (w.rolling) {
10361
- const span = w.per === "5h" ? 5 * HOUR_MS : w.per === "7d" ? 7 * DAY_MS : 30 * DAY_MS;
10532
+ const span = w.per === "5h" ? 5 * HOUR_MS : w.per === "7d" ? 7 * DAY_MS2 : 30 * DAY_MS2;
10362
10533
  let left = 0;
10363
10534
  let runsInWindow = 0;
10364
10535
  let tokensInWindow = 0;
@@ -10449,11 +10620,11 @@ function medianQuad(quads) {
10449
10620
  }
10450
10621
  return quad;
10451
10622
  }
10452
- var DAY_MS, HOUR_MS, COSTS_WINDOWS, QUAD_KEYS, TRIGGER_TAIL, TRIGGER_TAIL_LABELS;
10623
+ var DAY_MS2, HOUR_MS, COSTS_WINDOWS, QUAD_KEYS, TRIGGER_TAIL, TRIGGER_TAIL_LABELS;
10453
10624
  var init_costs = __esm({
10454
10625
  "src/costs.mjs"() {
10455
10626
  init_budget();
10456
- DAY_MS = 24 * 60 * 60 * 1e3;
10627
+ DAY_MS2 = 24 * 60 * 60 * 1e3;
10457
10628
  HOUR_MS = 60 * 60 * 1e3;
10458
10629
  COSTS_WINDOWS = ["7d", "30d", "mtd"];
10459
10630
  QUAD_KEYS = ["input", "output", "cacheRead", "cacheWrite", "cacheWrite1h"];
@@ -10625,32 +10796,73 @@ function parseNonNegInt(raw, fallback) {
10625
10796
  const n = Number.parseInt(raw, 10);
10626
10797
  return Number.isInteger(n) && n >= 0 && String(n) === String(raw).trim() ? n : fallback;
10627
10798
  }
10628
- async function readQueueState({ url, makeQueueFn = makeQueue, parseConnectionFn = parseConnection } = {}) {
10799
+ async function readQueueState({ url, makeQueueFn = makeQueue, parseConnectionFn = parseConnection, redisFn = makeRedisClient, timeoutMs = 2500 } = {}) {
10629
10800
  let queue;
10801
+ const queuesToClose = [];
10630
10802
  try {
10631
- queue = makeQueueFn(parseConnectionFn(url, { failFast: true }));
10632
- const pausedState = await queue.isPaused();
10633
- const counts = await queue.getJobCounts("waiting", "active", "paused", "delayed", "failed");
10634
- const workers = await readWorkerCount(queue);
10635
- return { pausedState, counts, workers };
10803
+ const { names, hosts, blind } = await readFleetQueues({ url, redisFn, timeoutMs });
10804
+ const fleet = blind ? { unreachable: blind } : { hosts };
10805
+ const queues = names.map((name) => makeQueueFn(parseConnectionFn(url, { failFast: true }), { name }));
10806
+ queuesToClose.push(...queues);
10807
+ queue = queues[0];
10808
+ const pausedStates = await Promise.all(queues.map((q) => q.isPaused()));
10809
+ const pausedState = pausedStates.every(Boolean);
10810
+ const pausedPartial = pausedStates.some(Boolean) && !pausedState;
10811
+ const perQueue = await Promise.all(queues.map((q) => q.getJobCounts("waiting", "active", "paused", "delayed", "failed")));
10812
+ const counts = perQueue.reduce((acc, c) => {
10813
+ for (const [k, v] of Object.entries(c ?? {})) acc[k] = (acc[k] ?? 0) + (Number(v) || 0);
10814
+ return acc;
10815
+ }, {});
10816
+ const workerCount = await readWorkerCount(queue);
10817
+ const resolved = resolveWorkerCount({ hosts: fleet.hosts ?? [], workerCount: typeof workerCount === "number" ? workerCount : 0 });
10818
+ return { pausedState, ...pausedPartial && { pausedPartial }, counts, workers: resolved.count, workerNames: resolved.names };
10636
10819
  } catch (err) {
10637
10820
  return { unreachable: err?.message ?? String(err) };
10638
10821
  } finally {
10639
- if (queue) await queue.close().catch(() => {
10822
+ for (const q of queuesToClose) await q.close().catch(() => {
10640
10823
  });
10641
10824
  }
10642
10825
  }
10643
- async function setQueuePaused({ url, paused, makeQueueFn = makeQueue, parseConnectionFn = parseConnection } = {}) {
10644
- let queue;
10826
+ async function readFleetQueues({ url, redisFn = makeRedisClient, timeoutMs = 2500 } = {}) {
10827
+ const redis = redisFn(url);
10828
+ redis.on?.("error", () => {
10829
+ });
10830
+ try {
10831
+ const [fleet, existing] = await Promise.all([
10832
+ withTimeout(readLiveHosts(redis, { timeoutMs: Math.max(250, Math.floor(timeoutMs / 2)) }), timeoutMs, { unreachable: "timed out reaching the registry" }),
10833
+ discoverHostQueues(redis, { timeoutMs })
10834
+ ]);
10835
+ return { names: unionQueueNames(fleetQueueNames(fleet?.hosts), existing), blind: fleet?.unreachable ?? null, hosts: fleet?.hosts ?? [] };
10836
+ } catch (err) {
10837
+ return { names: [QUEUE], blind: err?.message ?? String(err), hosts: [] };
10838
+ } finally {
10839
+ try {
10840
+ redis.disconnect?.();
10841
+ } catch {
10842
+ }
10843
+ }
10844
+ }
10845
+ async function setQueuePaused({ url, paused, makeQueueFn = makeQueue, parseConnectionFn = parseConnection, redisFn = makeRedisClient, timeoutMs = 2500 } = {}) {
10846
+ const opened = [];
10645
10847
  try {
10646
- queue = makeQueueFn(parseConnectionFn(url, { failFast: true }));
10647
- if (paused) await queue.pause();
10648
- else await queue.resume();
10649
- return { ok: true, paused };
10848
+ const { names, blind } = await readFleetQueues({ url, redisFn, timeoutMs });
10849
+ const done = [];
10850
+ for (const name of names) {
10851
+ const q = makeQueueFn(parseConnectionFn(url, { failFast: true }), { name });
10852
+ opened.push(q);
10853
+ try {
10854
+ if (paused) await q.pause();
10855
+ else await q.resume();
10856
+ } catch (err) {
10857
+ return { unreachable: err?.message ?? String(err), partial: { done, failed: name, error: err?.message ?? String(err) }, blind };
10858
+ }
10859
+ done.push(name);
10860
+ }
10861
+ return { ok: true, paused, queues: done, ...blind ? { blind } : {} };
10650
10862
  } catch (err) {
10651
10863
  return { unreachable: err?.message ?? String(err) };
10652
10864
  } finally {
10653
- if (queue) await queue.close().catch(() => {
10865
+ for (const q of opened) await q.close().catch(() => {
10654
10866
  });
10655
10867
  }
10656
10868
  }
@@ -10725,7 +10937,8 @@ async function enqueueDispatchRun({
10725
10937
  }
10726
10938
  let queue;
10727
10939
  try {
10728
- queue = makeQueueFn(parseConnectionFn(valkeyUrl, { failFast: true }));
10940
+ const workerName = process.env.PI_WORKER_NAME;
10941
+ queue = makeQueueFn(parseConnectionFn(valkeyUrl, { failFast: true }), { ...workerName ? { name: hostQueueName(workerName) } : {} });
10729
10942
  const jobId = await enqueueLocalJob(queue, { folder, flow, task });
10730
10943
  return { ok: true, jobId };
10731
10944
  } catch (err) {
@@ -10872,6 +11085,29 @@ async function readScopedBudget({ url, limits, redisFn = makeRedisClient, timeou
10872
11085
  }
10873
11086
  }
10874
11087
  }
11088
+ async function readHosts({ url, redisFn = makeRedisClient, timeoutMs = 2500, now = () => Date.now() } = {}) {
11089
+ let redis;
11090
+ try {
11091
+ parseConnection(url, { failFast: true });
11092
+ redis = redisFn(url);
11093
+ const res = await withTimeout(readLiveHosts(redis, { now, timeoutMs: Math.max(250, Math.floor(timeoutMs / 2)) }), timeoutMs, { unreachable: "timed out reaching the registry" });
11094
+ if (res.unreachable) return { unreachable: res.unreachable };
11095
+ return { hosts: res.hosts };
11096
+ } catch (err) {
11097
+ return { unreachable: err?.message ?? String(err) };
11098
+ } finally {
11099
+ if (redis) redis.disconnect?.();
11100
+ }
11101
+ }
11102
+ function resolveWorkerCount({ hosts, workerCount }) {
11103
+ if (Array.isArray(hosts) && hosts.length > 0) {
11104
+ const declared = hosts.filter((h) => h?.routes === true || h?.routes === "true");
11105
+ const names = declared.map((h) => h.name).filter(Boolean).sort();
11106
+ return { count: hosts.length, names };
11107
+ }
11108
+ if (typeof workerCount === "number" && workerCount > 0) return { count: workerCount, names: [] };
11109
+ return { count: "unknown", names: [] };
11110
+ }
10875
11111
  async function readHeldJobs({ url, limit = 20, redisFn = makeRedisClient, timeoutMs = 2500, now = () => Date.now() } = {}) {
10876
11112
  let redis;
10877
11113
  try {
@@ -10990,17 +11226,22 @@ async function readSchedulers({
10990
11226
  url,
10991
11227
  makeQueueFn = makeQueue,
10992
11228
  parseConnectionFn = parseConnection,
11229
+ redisFn = makeRedisClient,
11230
+ timeoutMs = 2500,
10993
11231
  now = Date.now
10994
11232
  } = {}) {
10995
- let queue;
11233
+ const opened = [];
10996
11234
  try {
10997
- queue = makeQueueFn(parseConnectionFn(url, { failFast: true }));
10998
- const list = await queue.getJobSchedulers(0, -1, true);
10999
- return mapSchedulers(list, now());
11235
+ const fleet = await readHosts({ url, redisFn, timeoutMs });
11236
+ const queues = fleetQueueNames(fleet.hosts).map((name) => makeQueueFn(parseConnectionFn(url, { failFast: true }), { name }));
11237
+ opened.push(...queues);
11238
+ const [primary, ...rest] = queues;
11239
+ const lists = [await primary.getJobSchedulers(0, -1, true), ...await Promise.all(rest.map((q) => q.getJobSchedulers(0, -1, true).catch(() => [])))];
11240
+ return mapSchedulers(lists.flat(), now());
11000
11241
  } catch (err) {
11001
11242
  return { unreachable: err?.message ?? String(err) };
11002
11243
  } finally {
11003
- if (queue) await queue.close().catch(() => {
11244
+ for (const q of opened) await q.close().catch(() => {
11004
11245
  });
11005
11246
  }
11006
11247
  }
@@ -11046,6 +11287,35 @@ function withTimeout(promise, ms, fallback) {
11046
11287
  });
11047
11288
  return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
11048
11289
  }
11290
+ async function mergedRunsOn(redis, { logsDir, limit = 10, fs = nodeFs3, timeoutMs = 2500 } = {}) {
11291
+ const local = listRuns({ logsDir, limit, fs });
11292
+ if (!Array.isArray(local)) return { runs: [], hosts: [], mirror: "off", ...local };
11293
+ try {
11294
+ const { runs: mirrored, degraded } = await readMirroredRuns(redis, { limit: Math.max(limit * 2, 50), timeoutMs });
11295
+ const runs = mergeRuns(local, mirrored, { limit });
11296
+ return { runs, hosts: hostsIn(runs), mirror: degraded };
11297
+ } catch (err) {
11298
+ return { runs: local, hosts: hostsIn(local), mirror: `unreachable (${err?.message ?? "?"})` };
11299
+ }
11300
+ }
11301
+ async function listRunsMerged({ logsDir, limit = 10, url, fs = nodeFs3, redisFn = makeRedisClient, timeoutMs = 2500 } = {}) {
11302
+ let redis;
11303
+ try {
11304
+ redis = redisFn(url);
11305
+ redis.on?.("error", () => {
11306
+ });
11307
+ return await mergedRunsOn(redis, { logsDir, limit, fs, timeoutMs });
11308
+ } catch (err) {
11309
+ const local = listRuns({ logsDir, limit, fs });
11310
+ const runs = Array.isArray(local) ? local : [];
11311
+ return { runs, hosts: hostsIn(runs), mirror: `unreachable (${err?.message ?? "?"})` };
11312
+ } finally {
11313
+ try {
11314
+ redis?.disconnect?.();
11315
+ } catch {
11316
+ }
11317
+ }
11318
+ }
11049
11319
  function listRuns({ logsDir, limit = 10, fs = nodeFs3 }) {
11050
11320
  const cap = clampLimit(limit);
11051
11321
  let names;
@@ -11088,12 +11358,13 @@ function scanRunRecords({ logsDir, sinceMs, nowMs = Date.now(), fs = nodeFs3 })
11088
11358
  function readRun({ logsDir, jobId, fs = nodeFs3 }) {
11089
11359
  return readJsonFile(join2(logsDir, `${sanitizeJobId(jobId)}.json`), fs);
11090
11360
  }
11091
- function readLogTail({ logsDir, jobId, lines = 200, fs = nodeFs3 }) {
11361
+ function readLogTail({ logsDir, jobId, lines = 200, fs = nodeFs3, host = null, self = null }) {
11092
11362
  let text;
11093
11363
  try {
11094
11364
  text = fs.readFileSync(join2(logsDir, `${sanitizeJobId(jobId)}.log`), "utf8");
11095
11365
  } catch {
11096
- return { missing: true };
11366
+ const foreign = typeof host === "string" && host !== "" && typeof self === "string" && self !== "" && host !== self;
11367
+ return foreign ? { missing: true, elsewhere: host } : { missing: true };
11097
11368
  }
11098
11369
  const all = text.split("\n").filter((line) => line !== "");
11099
11370
  const cap = clampLines(lines);
@@ -11607,7 +11878,9 @@ var init_read_model = __esm({
11607
11878
  init_wait_state();
11608
11879
  init_subscriptions();
11609
11880
  init_connection();
11881
+ init_host_registry();
11610
11882
  init_queue();
11883
+ init_run_mirror();
11611
11884
  init_flow_gate();
11612
11885
  init_git_dirty();
11613
11886
  init_packages();
@@ -39692,7 +39965,9 @@ function renderStatus(queue) {
39692
39965
  const counts = queue.counts ?? {};
39693
39966
  const line = ["waiting", "active", "paused", "delayed", "failed"].map((k) => `${k} ${counts[k] ?? 0}`).join(" ");
39694
39967
  const workers = queue.workers === void 0 ? "unknown" : queue.workers;
39695
- return [`Queue: ${queue.pausedState ? "paused" : "running"}`, ` ${line}`, ` workers: ${workers}`].join("\n");
39968
+ const named = Array.isArray(queue.workerNames) && queue.workerNames.length > 0 ? ` (${queue.workerNames.join(", ")})` : "";
39969
+ const state = queue.pausedPartial ? "PARTIALLY paused" : queue.pausedState ? "paused" : "running";
39970
+ return [`Queue: ${state}`, ` ${line}`, ` workers: ${workers}${named}`].join("\n");
39696
39971
  }
39697
39972
  function renderRuns(runs) {
39698
39973
  if (runs && runs.unreachable) return `Runs: unreachable (${runs.unreachable})`;
@@ -40131,27 +40406,68 @@ async function heldJobs(redis) {
40131
40406
  rows.sort((a, b) => (b.waitedMs ?? -1) - (a.waitedMs ?? -1));
40132
40407
  return { rows: rows.slice(0, HELD_LIMIT), more: Math.max(0, rows.length - HELD_LIMIT), truncated: ids.length > HELD_HYDRATE_MAX2 };
40133
40408
  }
40134
- function createDashboardDeps(paths) {
40135
- const queue = makeQueue(parseConnection(paths.valkeyUrl, { failFast: true }));
40136
- const redis = makeRedisClient(paths.valkeyUrl);
40409
+ function createDashboardDeps(paths, {
40410
+ makeQueueFn = makeQueue,
40411
+ parseConnectionFn = parseConnection,
40412
+ redisFn = makeRedisClient,
40413
+ readLiveHostsFn = readLiveHosts,
40414
+ discoverHostQueuesFn = discoverHostQueues
40415
+ } = {}) {
40416
+ const queue = makeQueueFn(parseConnectionFn(paths.valkeyUrl, { failFast: true }));
40417
+ const redis = redisFn(paths.valkeyUrl);
40418
+ const pool = /* @__PURE__ */ new Map([[queue.name, queue]]);
40419
+ let lastNames = [queue.name];
40420
+ let fleetDegraded = null;
40421
+ const fleetQueues = async () => {
40422
+ const fleet = await readLiveHostsFn(redis, { timeoutMs: FLEET_READ_TIMEOUT_MS }).catch((err) => ({
40423
+ unreachable: err?.message ?? String(err)
40424
+ }));
40425
+ if (fleet?.unreachable || !Array.isArray(fleet?.hosts)) {
40426
+ fleetDegraded = fleet?.unreachable ?? "registry unreadable";
40427
+ } else {
40428
+ fleetDegraded = null;
40429
+ lastNames = fleetQueueNames(fleet.hosts);
40430
+ }
40431
+ for (const name of lastNames) {
40432
+ if (!pool.has(name)) pool.set(name, makeQueueFn(parseConnectionFn(paths.valkeyUrl, { failFast: true }), { name }));
40433
+ }
40434
+ return lastNames.map((n) => pool.get(n));
40435
+ };
40436
+ const killSwitchQueues = async () => {
40437
+ const existing = await discoverHostQueuesFn(redis, { timeoutMs: FLEET_READ_TIMEOUT_MS });
40438
+ for (const name of unionQueueNames(lastNames, existing)) {
40439
+ if (!pool.has(name)) pool.set(name, makeQueueFn(parseConnectionFn(paths.valkeyUrl, { failFast: true }), { name }));
40440
+ }
40441
+ return unionQueueNames(lastNames, existing).map((n) => pool.get(n));
40442
+ };
40137
40443
  return {
40138
40444
  async fetchSnapshot() {
40139
- const [pausedState, counts, workerList, dayRaw, weekRaw, monthRaw, tokenRaw, schedulerList, activeList, stallHash] = await Promise.all([
40140
- queue.isPaused(),
40141
- queue.getJobCounts("waiting", "active", "paused", "delayed", "failed"),
40445
+ const queues = await fleetQueues();
40446
+ const merged = await mergedRunsOn(redis, { logsDir: paths.logsDir, limit: RUNS_ON_DASHBOARD }).catch(() => ({ runs: [], hosts: [], mirror: "off" }));
40447
+ const [pausedStates, countsPer, workerList, dayRaw, weekRaw, monthRaw, tokenRaw, schedulerLists, activeLists, stallHash] = await Promise.all([
40448
+ Promise.all(queues.map((q) => q.isPaused())),
40449
+ Promise.all(queues.map((q) => q.getJobCounts("waiting", "active", "paused", "delayed", "failed"))),
40142
40450
  queue.getWorkers().catch(() => []),
40143
40451
  redis.get(dayKey()),
40144
40452
  redis.get(weekKey()),
40145
40453
  redis.get(monthKey()),
40146
40454
  redis.get(tokenDayKey()),
40147
40455
  // issue #25 daily token spend (budget:t:YYYY-MM-DD)
40148
- queue.getJobSchedulers(0, -1, true),
40149
- queue.getActive(0, 0).catch(() => []),
40456
+ // The shared queue's failure is UNCAUGHT (the panel must not draw a scheduler list it could not
40457
+ // read), a host queue's is caught: one unreachable host degrades one host's triggers, never the view.
40458
+ Promise.all(queues.map((q, i) => i === 0 ? q.getJobSchedulers(0, -1, true) : q.getJobSchedulers(0, -1, true).catch(() => []))),
40459
+ Promise.all(queues.map((q) => q.getActive(0, 0).catch(() => []))),
40150
40460
  // Per-scheduler stall counts (money backstop) for the cron drill-in; reuses the held client like the
40151
40461
  // budget GETs. HGETALL of an absent key is `{}`, so a never-stalled deployment shows 0 stalls.
40152
40462
  redis.hgetall(STALL_KEY).catch(() => ({}))
40153
40463
  ]);
40154
40464
  const workers = Array.isArray(workerList) && workerList.length > 0 ? workerList.length : "unknown";
40465
+ const counts = {};
40466
+ for (const c of countsPer) for (const [k, v] of Object.entries(c ?? {})) counts[k] = (counts[k] ?? 0) + Number(v ?? 0);
40467
+ const pausedState = pausedStates.every(Boolean);
40468
+ const pausedPartial = !pausedState && pausedStates.some(Boolean);
40469
+ const schedulerList = schedulerLists.flat();
40470
+ const activeList = activeLists.flat();
40155
40471
  const scopedLimits = readScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath });
40156
40472
  const limitRows = Array.isArray(scopedLimits?.limits) ? scopedLimits.limits : [];
40157
40473
  const scopedNow = /* @__PURE__ */ new Date();
@@ -40173,12 +40489,17 @@ function createDashboardDeps(paths) {
40173
40489
  held,
40174
40490
  scopedLimits,
40175
40491
  scopedBudget,
40176
- queue: { pausedState, counts, workers },
40492
+ queue: { pausedState, pausedPartial, counts, workers, queues: queues.length, fleetDegraded },
40177
40493
  budget: { day: Number(dayRaw ?? 0), week: Number(weekRaw ?? 0), month: Number(monthRaw ?? 0), tokensToday: Number(tokenRaw ?? 0) },
40178
40494
  schedulers: mapSchedulers(schedulerList, Date.now()),
40179
40495
  schedulerStalls: stallHash ?? {},
40180
40496
  schedulerStallMax: paths.schedulerStallMax,
40181
- runs: listRuns({ logsDir: paths.logsDir, limit: RUNS_ON_DASHBOARD }),
40497
+ // Merged across the fleet (issue #57, Gap 3). This host's files stay the truth and the single-host
40498
+ // path; the mirror adds the other hosts' records. On a shared `PI_LOGS_DIR` the local read is
40499
+ // already the merged read and the mirror adds nothing, which is why there is no second code path.
40500
+ runs: merged.runs,
40501
+ runHosts: merged.hosts,
40502
+ runMirror: merged.mirror,
40182
40503
  settings: readSettingsView({ settingsFile: paths.settingsFile }),
40183
40504
  triggers: readTriggers({ triggersPath: paths.triggersPath }),
40184
40505
  pauseWindows: readPauseWindows({ pauseWindowsPath: paths.pauseWindowsPath }),
@@ -40193,15 +40514,19 @@ function createDashboardDeps(paths) {
40193
40514
  };
40194
40515
  },
40195
40516
  async pause() {
40196
- await queue.pause();
40517
+ await fleetQueues();
40518
+ for (const q of await killSwitchQueues()) await q.pause();
40197
40519
  },
40198
40520
  async resume() {
40199
- await queue.resume();
40521
+ await fleetQueues();
40522
+ for (const q of await killSwitchQueues()) await q.resume();
40200
40523
  },
40201
40524
  async dispose() {
40202
- try {
40203
- await queue.close();
40204
- } catch {
40525
+ for (const q of pool.values()) {
40526
+ try {
40527
+ await q.close();
40528
+ } catch {
40529
+ }
40205
40530
  }
40206
40531
  try {
40207
40532
  redis.disconnect();
@@ -40637,7 +40962,11 @@ function renderPanel(snapshot, width, state, styler) {
40637
40962
  ]
40638
40963
  },
40639
40964
  { title: "TRIGGERS", lines: toLines(renderTriggers({ schedulers: snapshot.schedulers, triggers: snapshot.triggers })) },
40640
- { title: "RUNS", lines: renderRunList(buildRows(snapshot, runSort), selected, 24) },
40965
+ // The section TITLE carries the fleet fact, not a seventh cell in each row (issue #57, Gap 3). A cell
40966
+ // would compete with jobId and target inside one `fitLine(..., inner)` and clip silently at width 80;
40967
+ // the title answers the completeness question once rather than fifty times. Absent on a single host,
40968
+ // so that output is byte-identical.
40969
+ { title: runsTitle(snapshot), lines: renderRunList(buildRows(snapshot, runSort), selected, 24) },
40641
40970
  { title: "SETTINGS", lines: toLines(renderSettingsView(snapshot.settings)) }
40642
40971
  ];
40643
40972
  const scopedText = renderScopedLimits({ limits: snapshot.scopedLimits, scopedBudget: snapshot.scopedBudget });
@@ -40743,11 +41072,11 @@ function statusHeader(queue, inner, styler) {
40743
41072
  return styler.cell(`queue unreachable (${queue?.unreachable ?? "?"})`, inner, { color: "error" });
40744
41073
  }
40745
41074
  const c = queue.counts ?? {};
40746
- const running = !queue.pausedState;
41075
+ const running = !queue.pausedState && !queue.pausedPartial;
40747
41076
  const failed = Number(c.failed ?? 0);
40748
41077
  const stateColor = running ? "success" : "warning";
40749
41078
  const dot = styler.fg(stateColor, "\u25CF");
40750
- const word = styler.bold(styler.fg(stateColor, running ? "RUNNING" : "PAUSED"));
41079
+ const word = styler.bold(styler.fg(stateColor, running ? "RUNNING" : queue.pausedPartial ? "PART PAUSED" : "PAUSED"));
40751
41080
  const sep2 = styler.fg("dim", " \xB7 ");
40752
41081
  const delayed = Number(c.delayed ?? 0);
40753
41082
  const vitals = `${c.waiting ?? 0} waiting` + sep2 + `${c.active ?? 0} active` + sep2 + (delayed > 0 ? `${delayed} delayed` + sep2 : "") + (failed > 0 ? styler.fg("error", `${failed} failed`) : `${failed} failed`) + sep2 + `${queue.workers ?? "?"} workers`;
@@ -40938,6 +41267,13 @@ function limitRow(l, used, inner, styler) {
40938
41267
  if (Number.isInteger(l.concurrent)) bits.push(styler.fg("muted", `\u2264${l.concurrent} at once`));
40939
41268
  return fitLine(bits.join(styler.fg("dim", " ")), inner, styler);
40940
41269
  }
41270
+ function runsTitle(snapshot) {
41271
+ const hosts = Array.isArray(snapshot?.runHosts) ? snapshot.runHosts : [];
41272
+ if (hosts.length > 1) return `RUNS \xB7 ${hosts.length} hosts`;
41273
+ const mirror = snapshot?.runMirror;
41274
+ if (typeof mirror === "string" && mirror.startsWith("unreachable")) return "RUNS \xB7 this host only";
41275
+ return "RUNS";
41276
+ }
40941
41277
  function runLines(rows, selected, inner, styler) {
40942
41278
  if (!Array.isArray(rows) || rows.length === 0) return [styler.cell("(no runs)", inner, { color: "dim" })];
40943
41279
  const { top, count } = runsWindow(rows.length, selected);
@@ -41267,6 +41603,7 @@ function renderRunDetail(record, inner, styler, allRuns = [], sandbox = null) {
41267
41603
  const endMs = toMs(r.endedAt);
41268
41604
  const dur = Number.isFinite(startMs) && Number.isFinite(endMs) && endMs >= startMs ? ` (${formatDuration(endMs - startMs)})` : "";
41269
41605
  out.push(kv("timing", `${fmtStamp(r.startedAt)} \u2192 ${fmtStamp(r.endedAt)}${dur}`));
41606
+ if (r.host) out.push(kv("host", String(r.host)));
41270
41607
  const turnBits = [`${show(r.turns)} turns`, `exit ${show(r.exitCode)}`];
41271
41608
  if (r.budgetReserved !== null && r.budgetReserved !== void 0) turnBits.push(`${r.budgetReserved} budget slot`);
41272
41609
  if (r.attempt !== null && r.attempt !== void 0) turnBits.push(`attempt ${r.attempt}`);
@@ -41346,12 +41683,13 @@ function runDetailHints(inner, styler, canOpen = false, canCopy = false, copiedN
41346
41683
  if (copiedNote) return fitLine(styler.fg("success", copiedNote) + styler.fg("dim", " \xB7 ") + line, inner, styler);
41347
41684
  return fitLine(line, inner, styler);
41348
41685
  }
41349
- var KEY_HINTS, RUNS_ON_DASHBOARD, RUNS_VIEWPORT, RUN_SORTS, REFRESH_MS, TAIL_LINES, TAIL_VIEWPORT, MIN_WIDTH2, DRILL_WIDTH, HELD_LIMIT, HELD_HYDRATE_MAX2, KIND_COLOR, KIND_WIDTH, FRAME_CHROME_ROWS, HELD_ON_DASHBOARD;
41686
+ var KEY_HINTS, RUNS_ON_DASHBOARD, RUNS_VIEWPORT, RUN_SORTS, REFRESH_MS, TAIL_LINES, TAIL_VIEWPORT, MIN_WIDTH2, DRILL_WIDTH, HELD_LIMIT, HELD_HYDRATE_MAX2, FLEET_READ_TIMEOUT_MS, KIND_COLOR, KIND_WIDTH, FRAME_CHROME_ROWS, HELD_ON_DASHBOARD;
41350
41687
  var init_dashboard = __esm({
41351
41688
  "src/dashboard.ts"() {
41352
41689
  init_budget();
41353
41690
  init_connection();
41354
41691
  init_queue();
41692
+ init_host_registry();
41355
41693
  init_scheduler_stall_guard();
41356
41694
  init_pause_windows();
41357
41695
  init_read_model();
@@ -41371,6 +41709,7 @@ var init_dashboard = __esm({
41371
41709
  DRILL_WIDTH = 70;
41372
41710
  HELD_LIMIT = 20;
41373
41711
  HELD_HYDRATE_MAX2 = 200;
41712
+ FLEET_READ_TIMEOUT_MS = 2e3;
41374
41713
  KIND_COLOR = { cron: "accent", label: "syntaxType", comment: "syntaxKeyword", pull_request: "syntaxFunction", issue: "syntaxString" };
41375
41714
  KIND_WIDTH = 13;
41376
41715
  FRAME_CHROME_ROWS = 4;
@@ -41908,8 +42247,8 @@ var init_setup_wizard = __esm({
41908
42247
  init_deployment_pointer();
41909
42248
  init_read_model();
41910
42249
  init_index();
41911
- RUNTIME_VERSION = "1.6.1";
41912
- RECEIVER_VERSION = "1.4.0";
42250
+ RUNTIME_VERSION = "1.8.0";
42251
+ RECEIVER_VERSION = "1.5.0";
41913
42252
  RUNTIME_PKG = "@edgehero/pi-dispatch";
41914
42253
  RECEIVER_PKG = "@edgehero/pi-dispatch-receiver";
41915
42254
  NUDGE_MARKER_BASENAME = "pi-dispatch-setup.nudged";
@@ -42089,15 +42428,16 @@ function registerTools(pi) {
42089
42428
  pi.registerTool({
42090
42429
  name: "dispatch_runs",
42091
42430
  label: "pi-dispatch runs",
42092
- description: "Read-only. Returns structured, PII-free run records from the durable run history. Raw job logs are not available to tools \u2014 ask the user to run /dispatch logs.",
42431
+ description: "Read-only. Returns structured run records (PII-free by construction, plus the worker name when the operator declared one) from the durable run history. Raw job logs are not available to tools \u2014 ask the user to run /dispatch logs.",
42093
42432
  parameters: typebox_exports.Object({
42094
42433
  limit: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1, maximum: 50 })),
42095
42434
  jobId: typebox_exports.Optional(typebox_exports.String())
42096
42435
  }),
42097
42436
  async execute(_toolCallId, params) {
42098
42437
  const paths = resolvePaths(process.env);
42099
- const data = params.jobId ? readRun({ logsDir: paths.logsDir, jobId: params.jobId }) : listRuns({ logsDir: paths.logsDir, limit: params.limit ?? 10 });
42100
- return toolText(JSON.stringify(data));
42438
+ if (params.jobId) return toolText(JSON.stringify(readRun({ logsDir: paths.logsDir, jobId: params.jobId })));
42439
+ const merged = await listRunsMerged({ logsDir: paths.logsDir, limit: params.limit ?? 10, url: paths.valkeyUrl });
42440
+ return toolText(JSON.stringify(merged));
42101
42441
  }
42102
42442
  });
42103
42443
  pi.registerTool({
@@ -42128,7 +42468,10 @@ function registerTools(pi) {
42128
42468
  if (res.unreachable) {
42129
42469
  throw new Error(`could not reach the queue at ${paths.valkeyUrl}: ${res.unreachable}`);
42130
42470
  }
42131
- return toolText("paused");
42471
+ if (res.partial) {
42472
+ throw new Error(`paused ${res.partial.done.join(", ")} but FAILED at ${res.partial.failed} \u2014 the deployment is half paused: ${res.partial.error}`);
42473
+ }
42474
+ return toolText(`paused${res.queues.length > 1 ? ` (${res.queues.length} queues)` : ""}`);
42132
42475
  }
42133
42476
  });
42134
42477
  pi.registerTool({
@@ -42143,7 +42486,10 @@ function registerTools(pi) {
42143
42486
  if (res.unreachable) {
42144
42487
  throw new Error(`could not reach the queue at ${paths.valkeyUrl}: ${res.unreachable}`);
42145
42488
  }
42146
- return toolText("resumed");
42489
+ if (res.partial) {
42490
+ throw new Error(`resumed ${res.partial.done.join(", ")} but FAILED at ${res.partial.failed} \u2014 the deployment is half resumed: ${res.partial.error}`);
42491
+ }
42492
+ return toolText(`resumed${res.queues.length > 1 ? ` (${res.queues.length} queues)` : ""}`);
42147
42493
  }
42148
42494
  });
42149
42495
  pi.registerTool({
@@ -42758,11 +43104,15 @@ ${scopedText}` : ""}`);
42758
43104
  const paused = sub === "pause";
42759
43105
  const res = await setQueuePaused({ url: paths.valkeyUrl, paused });
42760
43106
  if (res.unreachable) {
42761
- notify?.(`could not reach Valkey at ${paths.valkeyUrl} \u2014 is it running? (docker compose up)`, "error");
43107
+ notify?.(
43108
+ res.partial ? `${paused ? "paused" : "resumed"} ${res.partial.done.join(", ")} but FAILED at ${res.partial.failed} \u2014 the deployment is half ${paused ? "paused" : "resumed"}` : `could not reach Valkey at ${paths.valkeyUrl} \u2014 is it running? (docker compose up)`,
43109
+ "error"
43110
+ );
42762
43111
  return;
42763
43112
  }
43113
+ const span = res.blind ? ` (fleet unknown: ${res.blind}, only ${res.queues[0]})` : res.queues.length > 1 ? ` (${res.queues.length} queues)` : "";
42764
43114
  notify?.(
42765
- paused ? "paused \u2014 worker will stop taking new jobs (jobs still enqueue; durable, survives restart)" : "resumed",
43115
+ paused ? `paused \u2014 worker will stop taking new jobs (jobs still enqueue; durable, survives restart)${span}` : `resumed${span}`,
42766
43116
  "info"
42767
43117
  );
42768
43118
  return;
@@ -43374,7 +43724,8 @@ async function showLogs(logsDir, tokens, ctx) {
43374
43724
  return;
43375
43725
  }
43376
43726
  const lines = tokens[2] ? Number(tokens[2]) : void 0;
43377
- const tail = readLogTail({ logsDir, jobId, lines });
43727
+ const record = readRun({ logsDir, jobId });
43728
+ const tail = readLogTail({ logsDir, jobId, lines, host: record?.host ?? null, self: process.env.PI_WORKER_NAME ?? null });
43378
43729
  const custom = ctx?.ui?.custom;
43379
43730
  if (typeof custom !== "function") {
43380
43731
  const message = "logs viewer unavailable in this pi version -- raw logs are never sent to the model";
@@ -43393,7 +43744,7 @@ function makeLogViewer(jobId, tail) {
43393
43744
  const component = {
43394
43745
  render(_width) {
43395
43746
  if (missing) {
43396
- return [`logs ${jobId} -- no captured log (PI_CAPTURE_JOB_LOGS off or not found). Esc to close.`, ""];
43747
+ return tail.elsewhere ? [`logs ${jobId} -- this run happened on ${tail.elsewhere}; its raw log stays on that host. Esc to close.`, ""] : [`logs ${jobId} -- no captured log (PI_CAPTURE_JOB_LOGS off or not found). Esc to close.`, ""];
43397
43748
  }
43398
43749
  const out = [`logs ${jobId} -- ${lines.length} line(s). Up/Down scroll, PgUp/PgDn page, Esc close.`, ""];
43399
43750
  for (const line of lines.slice(top, top + VIEWPORT_LINES)) out.push(line);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edgehero/pi-dispatch-admin",
3
- "version": "1.6.1",
3
+ "version": "1.8.0",
4
4
  "description": "Operator console (a pi extension) + skill for pi-dispatch: run the pi coding agent as a self-hosted service. A /dispatch TUI for the queue, spend caps, run history, editable GitHub, GitLab, Forgejo and Azure DevOps triggers (cron/label/comment/PR/MR/work item), and scheduled pause windows — plus AI-operable, human-confirmed controls. Runs against a live pi-dispatch deployment.",
5
5
  "keywords": [
6
6
  "pi-package",