@edgehero/pi-dispatch-admin 1.7.0 → 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 (2) hide show
  1. package/dist/index.mjs +146 -18
  2. package/package.json +1 -1
package/dist/index.mjs CHANGED
@@ -9215,6 +9215,84 @@ var init_queue = __esm({
9215
9215
  }
9216
9216
  });
9217
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
+
9218
9296
  // ../worker/src/git-dirty.mjs
9219
9297
  import { execFileSync } from "node:child_process";
9220
9298
  function gitDirty(folder, { exec: exec4 = execFileSync } = {}) {
@@ -10028,8 +10106,8 @@ var init_graph_model = __esm({
10028
10106
 
10029
10107
  // src/costs.mjs
10030
10108
  function costsSinceMs(windowKey, nowMs) {
10031
- if (windowKey === "7d") return nowMs - 7 * DAY_MS;
10032
- 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;
10033
10111
  const now = new Date(nowMs);
10034
10112
  return Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1);
10035
10113
  }
@@ -10152,7 +10230,7 @@ function foldCosts({ records, subscriptions, pricing, nowMs, piAiPin = null, sin
10152
10230
  const requested = Number.isFinite(sinceMs);
10153
10231
  const fromMs = requested ? Math.min(sinceMs, nowMs) : firstRunMs ?? nowMs;
10154
10232
  const toMs2 = nowMs;
10155
- 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;
10156
10234
  return {
10157
10235
  // `firstRunMs` rides along (null when nothing ran) so a renderer can pad or trim the daily series
10158
10236
  // against the requested edge without re-deriving what the fold already knows.
@@ -10183,7 +10261,7 @@ function buildDaily(runs, fromMs, toMs2) {
10183
10261
  buckets.get(day).push(r.contribution);
10184
10262
  }
10185
10263
  const daily = [];
10186
- 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) {
10187
10265
  const day = utcDay(cursor);
10188
10266
  const contribs = buckets.get(day) ?? [];
10189
10267
  daily.push({ day, cost: combineContributions(contribs), runs: contribs.length });
@@ -10207,7 +10285,7 @@ function buildDailyByFlow(runs, fromMs, toMs2) {
10207
10285
  const rows = [];
10208
10286
  for (const [flow, buckets] of byFlow) {
10209
10287
  const days = [];
10210
- 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) {
10211
10289
  const day = utcDay(cursor);
10212
10290
  const contribs = buckets.get(day) ?? [];
10213
10291
  days.push({ day, cost: combineContributions(contribs), runs: contribs.length });
@@ -10451,7 +10529,7 @@ function peakWindow(w, attributed) {
10451
10529
  let peakRuns = 0;
10452
10530
  let peakTokens = 0;
10453
10531
  if (w.rolling) {
10454
- 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;
10455
10533
  let left = 0;
10456
10534
  let runsInWindow = 0;
10457
10535
  let tokensInWindow = 0;
@@ -10542,11 +10620,11 @@ function medianQuad(quads) {
10542
10620
  }
10543
10621
  return quad;
10544
10622
  }
10545
- 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;
10546
10624
  var init_costs = __esm({
10547
10625
  "src/costs.mjs"() {
10548
10626
  init_budget();
10549
- DAY_MS = 24 * 60 * 60 * 1e3;
10627
+ DAY_MS2 = 24 * 60 * 60 * 1e3;
10550
10628
  HOUR_MS = 60 * 60 * 1e3;
10551
10629
  COSTS_WINDOWS = ["7d", "30d", "mtd"];
10552
10630
  QUAD_KEYS = ["input", "output", "cacheRead", "cacheWrite", "cacheWrite1h"];
@@ -11209,6 +11287,35 @@ function withTimeout(promise, ms, fallback) {
11209
11287
  });
11210
11288
  return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
11211
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
+ }
11212
11319
  function listRuns({ logsDir, limit = 10, fs = nodeFs3 }) {
11213
11320
  const cap = clampLimit(limit);
11214
11321
  let names;
@@ -11251,12 +11358,13 @@ function scanRunRecords({ logsDir, sinceMs, nowMs = Date.now(), fs = nodeFs3 })
11251
11358
  function readRun({ logsDir, jobId, fs = nodeFs3 }) {
11252
11359
  return readJsonFile(join2(logsDir, `${sanitizeJobId(jobId)}.json`), fs);
11253
11360
  }
11254
- function readLogTail({ logsDir, jobId, lines = 200, fs = nodeFs3 }) {
11361
+ function readLogTail({ logsDir, jobId, lines = 200, fs = nodeFs3, host = null, self = null }) {
11255
11362
  let text;
11256
11363
  try {
11257
11364
  text = fs.readFileSync(join2(logsDir, `${sanitizeJobId(jobId)}.log`), "utf8");
11258
11365
  } catch {
11259
- 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 };
11260
11368
  }
11261
11369
  const all = text.split("\n").filter((line) => line !== "");
11262
11370
  const cap = clampLines(lines);
@@ -11772,6 +11880,7 @@ var init_read_model = __esm({
11772
11880
  init_connection();
11773
11881
  init_host_registry();
11774
11882
  init_queue();
11883
+ init_run_mirror();
11775
11884
  init_flow_gate();
11776
11885
  init_git_dirty();
11777
11886
  init_packages();
@@ -40334,6 +40443,7 @@ function createDashboardDeps(paths, {
40334
40443
  return {
40335
40444
  async fetchSnapshot() {
40336
40445
  const queues = await fleetQueues();
40446
+ const merged = await mergedRunsOn(redis, { logsDir: paths.logsDir, limit: RUNS_ON_DASHBOARD }).catch(() => ({ runs: [], hosts: [], mirror: "off" }));
40337
40447
  const [pausedStates, countsPer, workerList, dayRaw, weekRaw, monthRaw, tokenRaw, schedulerLists, activeLists, stallHash] = await Promise.all([
40338
40448
  Promise.all(queues.map((q) => q.isPaused())),
40339
40449
  Promise.all(queues.map((q) => q.getJobCounts("waiting", "active", "paused", "delayed", "failed"))),
@@ -40384,7 +40494,12 @@ function createDashboardDeps(paths, {
40384
40494
  schedulers: mapSchedulers(schedulerList, Date.now()),
40385
40495
  schedulerStalls: stallHash ?? {},
40386
40496
  schedulerStallMax: paths.schedulerStallMax,
40387
- 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,
40388
40503
  settings: readSettingsView({ settingsFile: paths.settingsFile }),
40389
40504
  triggers: readTriggers({ triggersPath: paths.triggersPath }),
40390
40505
  pauseWindows: readPauseWindows({ pauseWindowsPath: paths.pauseWindowsPath }),
@@ -40847,7 +40962,11 @@ function renderPanel(snapshot, width, state, styler) {
40847
40962
  ]
40848
40963
  },
40849
40964
  { title: "TRIGGERS", lines: toLines(renderTriggers({ schedulers: snapshot.schedulers, triggers: snapshot.triggers })) },
40850
- { 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) },
40851
40970
  { title: "SETTINGS", lines: toLines(renderSettingsView(snapshot.settings)) }
40852
40971
  ];
40853
40972
  const scopedText = renderScopedLimits({ limits: snapshot.scopedLimits, scopedBudget: snapshot.scopedBudget });
@@ -41148,6 +41267,13 @@ function limitRow(l, used, inner, styler) {
41148
41267
  if (Number.isInteger(l.concurrent)) bits.push(styler.fg("muted", `\u2264${l.concurrent} at once`));
41149
41268
  return fitLine(bits.join(styler.fg("dim", " ")), inner, styler);
41150
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
+ }
41151
41277
  function runLines(rows, selected, inner, styler) {
41152
41278
  if (!Array.isArray(rows) || rows.length === 0) return [styler.cell("(no runs)", inner, { color: "dim" })];
41153
41279
  const { top, count } = runsWindow(rows.length, selected);
@@ -42121,8 +42247,8 @@ var init_setup_wizard = __esm({
42121
42247
  init_deployment_pointer();
42122
42248
  init_read_model();
42123
42249
  init_index();
42124
- RUNTIME_VERSION = "1.7.0";
42125
- RECEIVER_VERSION = "1.4.0";
42250
+ RUNTIME_VERSION = "1.8.0";
42251
+ RECEIVER_VERSION = "1.5.0";
42126
42252
  RUNTIME_PKG = "@edgehero/pi-dispatch";
42127
42253
  RECEIVER_PKG = "@edgehero/pi-dispatch-receiver";
42128
42254
  NUDGE_MARKER_BASENAME = "pi-dispatch-setup.nudged";
@@ -42309,8 +42435,9 @@ function registerTools(pi) {
42309
42435
  }),
42310
42436
  async execute(_toolCallId, params) {
42311
42437
  const paths = resolvePaths(process.env);
42312
- const data = params.jobId ? readRun({ logsDir: paths.logsDir, jobId: params.jobId }) : listRuns({ logsDir: paths.logsDir, limit: params.limit ?? 10 });
42313
- 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));
42314
42441
  }
42315
42442
  });
42316
42443
  pi.registerTool({
@@ -43597,7 +43724,8 @@ async function showLogs(logsDir, tokens, ctx) {
43597
43724
  return;
43598
43725
  }
43599
43726
  const lines = tokens[2] ? Number(tokens[2]) : void 0;
43600
- 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 });
43601
43729
  const custom = ctx?.ui?.custom;
43602
43730
  if (typeof custom !== "function") {
43603
43731
  const message = "logs viewer unavailable in this pi version -- raw logs are never sent to the model";
@@ -43616,7 +43744,7 @@ function makeLogViewer(jobId, tail) {
43616
43744
  const component = {
43617
43745
  render(_width) {
43618
43746
  if (missing) {
43619
- 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.`, ""];
43620
43748
  }
43621
43749
  const out = [`logs ${jobId} -- ${lines.length} line(s). Up/Down scroll, PgUp/PgDn page, Esc close.`, ""];
43622
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.7.0",
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",