@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.
- package/README.md +8 -0
- package/dist/index.mjs +415 -64
- 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
|
|
9073
|
-
|
|
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
|
-
|
|
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 *
|
|
9939
|
-
if (windowKey === "30d") return nowMs - 30 *
|
|
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) /
|
|
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 /
|
|
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 /
|
|
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 *
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
10632
|
-
const
|
|
10633
|
-
const
|
|
10634
|
-
|
|
10635
|
-
|
|
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
|
-
|
|
10822
|
+
for (const q of queuesToClose) await q.close().catch(() => {
|
|
10640
10823
|
});
|
|
10641
10824
|
}
|
|
10642
10825
|
}
|
|
10643
|
-
async function
|
|
10644
|
-
|
|
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
|
-
|
|
10647
|
-
|
|
10648
|
-
|
|
10649
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
11233
|
+
const opened = [];
|
|
10996
11234
|
try {
|
|
10997
|
-
|
|
10998
|
-
const
|
|
10999
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
40136
|
-
|
|
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
|
|
40140
|
-
|
|
40141
|
-
|
|
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
|
|
40149
|
-
queue
|
|
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
|
-
|
|
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
|
|
40517
|
+
await fleetQueues();
|
|
40518
|
+
for (const q of await killSwitchQueues()) await q.pause();
|
|
40197
40519
|
},
|
|
40198
40520
|
async resume() {
|
|
40199
|
-
await
|
|
40521
|
+
await fleetQueues();
|
|
40522
|
+
for (const q of await killSwitchQueues()) await q.resume();
|
|
40200
40523
|
},
|
|
40201
40524
|
async dispose() {
|
|
40202
|
-
|
|
40203
|
-
|
|
40204
|
-
|
|
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
|
-
|
|
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.
|
|
41912
|
-
RECEIVER_VERSION = "1.
|
|
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
|
|
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
|
-
|
|
42100
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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?.(
|
|
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 ?
|
|
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
|
|
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.
|
|
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",
|