@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.
- package/dist/index.mjs +146 -18
- 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 *
|
|
10032
|
-
if (windowKey === "30d") return nowMs - 30 *
|
|
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) /
|
|
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 /
|
|
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 /
|
|
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 *
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
42125
|
-
RECEIVER_VERSION = "1.
|
|
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
|
-
|
|
42313
|
-
|
|
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
|
|
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.
|
|
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",
|