@edgehero/pi-dispatch-admin 1.6.0 → 1.7.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 +270 -47
- 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,6 +9209,8 @@ 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
|
});
|
|
@@ -10625,32 +10718,73 @@ function parseNonNegInt(raw, fallback) {
|
|
|
10625
10718
|
const n = Number.parseInt(raw, 10);
|
|
10626
10719
|
return Number.isInteger(n) && n >= 0 && String(n) === String(raw).trim() ? n : fallback;
|
|
10627
10720
|
}
|
|
10628
|
-
async function readQueueState({ url, makeQueueFn = makeQueue, parseConnectionFn = parseConnection } = {}) {
|
|
10721
|
+
async function readQueueState({ url, makeQueueFn = makeQueue, parseConnectionFn = parseConnection, redisFn = makeRedisClient, timeoutMs = 2500 } = {}) {
|
|
10629
10722
|
let queue;
|
|
10723
|
+
const queuesToClose = [];
|
|
10630
10724
|
try {
|
|
10631
|
-
|
|
10632
|
-
const
|
|
10633
|
-
const
|
|
10634
|
-
|
|
10635
|
-
|
|
10725
|
+
const { names, hosts, blind } = await readFleetQueues({ url, redisFn, timeoutMs });
|
|
10726
|
+
const fleet = blind ? { unreachable: blind } : { hosts };
|
|
10727
|
+
const queues = names.map((name) => makeQueueFn(parseConnectionFn(url, { failFast: true }), { name }));
|
|
10728
|
+
queuesToClose.push(...queues);
|
|
10729
|
+
queue = queues[0];
|
|
10730
|
+
const pausedStates = await Promise.all(queues.map((q) => q.isPaused()));
|
|
10731
|
+
const pausedState = pausedStates.every(Boolean);
|
|
10732
|
+
const pausedPartial = pausedStates.some(Boolean) && !pausedState;
|
|
10733
|
+
const perQueue = await Promise.all(queues.map((q) => q.getJobCounts("waiting", "active", "paused", "delayed", "failed")));
|
|
10734
|
+
const counts = perQueue.reduce((acc, c) => {
|
|
10735
|
+
for (const [k, v] of Object.entries(c ?? {})) acc[k] = (acc[k] ?? 0) + (Number(v) || 0);
|
|
10736
|
+
return acc;
|
|
10737
|
+
}, {});
|
|
10738
|
+
const workerCount = await readWorkerCount(queue);
|
|
10739
|
+
const resolved = resolveWorkerCount({ hosts: fleet.hosts ?? [], workerCount: typeof workerCount === "number" ? workerCount : 0 });
|
|
10740
|
+
return { pausedState, ...pausedPartial && { pausedPartial }, counts, workers: resolved.count, workerNames: resolved.names };
|
|
10636
10741
|
} catch (err) {
|
|
10637
10742
|
return { unreachable: err?.message ?? String(err) };
|
|
10638
10743
|
} finally {
|
|
10639
|
-
|
|
10744
|
+
for (const q of queuesToClose) await q.close().catch(() => {
|
|
10640
10745
|
});
|
|
10641
10746
|
}
|
|
10642
10747
|
}
|
|
10643
|
-
async function
|
|
10644
|
-
|
|
10748
|
+
async function readFleetQueues({ url, redisFn = makeRedisClient, timeoutMs = 2500 } = {}) {
|
|
10749
|
+
const redis = redisFn(url);
|
|
10750
|
+
redis.on?.("error", () => {
|
|
10751
|
+
});
|
|
10752
|
+
try {
|
|
10753
|
+
const [fleet, existing] = await Promise.all([
|
|
10754
|
+
withTimeout(readLiveHosts(redis, { timeoutMs: Math.max(250, Math.floor(timeoutMs / 2)) }), timeoutMs, { unreachable: "timed out reaching the registry" }),
|
|
10755
|
+
discoverHostQueues(redis, { timeoutMs })
|
|
10756
|
+
]);
|
|
10757
|
+
return { names: unionQueueNames(fleetQueueNames(fleet?.hosts), existing), blind: fleet?.unreachable ?? null, hosts: fleet?.hosts ?? [] };
|
|
10758
|
+
} catch (err) {
|
|
10759
|
+
return { names: [QUEUE], blind: err?.message ?? String(err), hosts: [] };
|
|
10760
|
+
} finally {
|
|
10761
|
+
try {
|
|
10762
|
+
redis.disconnect?.();
|
|
10763
|
+
} catch {
|
|
10764
|
+
}
|
|
10765
|
+
}
|
|
10766
|
+
}
|
|
10767
|
+
async function setQueuePaused({ url, paused, makeQueueFn = makeQueue, parseConnectionFn = parseConnection, redisFn = makeRedisClient, timeoutMs = 2500 } = {}) {
|
|
10768
|
+
const opened = [];
|
|
10645
10769
|
try {
|
|
10646
|
-
|
|
10647
|
-
|
|
10648
|
-
|
|
10649
|
-
|
|
10770
|
+
const { names, blind } = await readFleetQueues({ url, redisFn, timeoutMs });
|
|
10771
|
+
const done = [];
|
|
10772
|
+
for (const name of names) {
|
|
10773
|
+
const q = makeQueueFn(parseConnectionFn(url, { failFast: true }), { name });
|
|
10774
|
+
opened.push(q);
|
|
10775
|
+
try {
|
|
10776
|
+
if (paused) await q.pause();
|
|
10777
|
+
else await q.resume();
|
|
10778
|
+
} catch (err) {
|
|
10779
|
+
return { unreachable: err?.message ?? String(err), partial: { done, failed: name, error: err?.message ?? String(err) }, blind };
|
|
10780
|
+
}
|
|
10781
|
+
done.push(name);
|
|
10782
|
+
}
|
|
10783
|
+
return { ok: true, paused, queues: done, ...blind ? { blind } : {} };
|
|
10650
10784
|
} catch (err) {
|
|
10651
10785
|
return { unreachable: err?.message ?? String(err) };
|
|
10652
10786
|
} finally {
|
|
10653
|
-
|
|
10787
|
+
for (const q of opened) await q.close().catch(() => {
|
|
10654
10788
|
});
|
|
10655
10789
|
}
|
|
10656
10790
|
}
|
|
@@ -10725,7 +10859,8 @@ async function enqueueDispatchRun({
|
|
|
10725
10859
|
}
|
|
10726
10860
|
let queue;
|
|
10727
10861
|
try {
|
|
10728
|
-
|
|
10862
|
+
const workerName = process.env.PI_WORKER_NAME;
|
|
10863
|
+
queue = makeQueueFn(parseConnectionFn(valkeyUrl, { failFast: true }), { ...workerName ? { name: hostQueueName(workerName) } : {} });
|
|
10729
10864
|
const jobId = await enqueueLocalJob(queue, { folder, flow, task });
|
|
10730
10865
|
return { ok: true, jobId };
|
|
10731
10866
|
} catch (err) {
|
|
@@ -10872,6 +11007,29 @@ async function readScopedBudget({ url, limits, redisFn = makeRedisClient, timeou
|
|
|
10872
11007
|
}
|
|
10873
11008
|
}
|
|
10874
11009
|
}
|
|
11010
|
+
async function readHosts({ url, redisFn = makeRedisClient, timeoutMs = 2500, now = () => Date.now() } = {}) {
|
|
11011
|
+
let redis;
|
|
11012
|
+
try {
|
|
11013
|
+
parseConnection(url, { failFast: true });
|
|
11014
|
+
redis = redisFn(url);
|
|
11015
|
+
const res = await withTimeout(readLiveHosts(redis, { now, timeoutMs: Math.max(250, Math.floor(timeoutMs / 2)) }), timeoutMs, { unreachable: "timed out reaching the registry" });
|
|
11016
|
+
if (res.unreachable) return { unreachable: res.unreachable };
|
|
11017
|
+
return { hosts: res.hosts };
|
|
11018
|
+
} catch (err) {
|
|
11019
|
+
return { unreachable: err?.message ?? String(err) };
|
|
11020
|
+
} finally {
|
|
11021
|
+
if (redis) redis.disconnect?.();
|
|
11022
|
+
}
|
|
11023
|
+
}
|
|
11024
|
+
function resolveWorkerCount({ hosts, workerCount }) {
|
|
11025
|
+
if (Array.isArray(hosts) && hosts.length > 0) {
|
|
11026
|
+
const declared = hosts.filter((h) => h?.routes === true || h?.routes === "true");
|
|
11027
|
+
const names = declared.map((h) => h.name).filter(Boolean).sort();
|
|
11028
|
+
return { count: hosts.length, names };
|
|
11029
|
+
}
|
|
11030
|
+
if (typeof workerCount === "number" && workerCount > 0) return { count: workerCount, names: [] };
|
|
11031
|
+
return { count: "unknown", names: [] };
|
|
11032
|
+
}
|
|
10875
11033
|
async function readHeldJobs({ url, limit = 20, redisFn = makeRedisClient, timeoutMs = 2500, now = () => Date.now() } = {}) {
|
|
10876
11034
|
let redis;
|
|
10877
11035
|
try {
|
|
@@ -10990,17 +11148,22 @@ async function readSchedulers({
|
|
|
10990
11148
|
url,
|
|
10991
11149
|
makeQueueFn = makeQueue,
|
|
10992
11150
|
parseConnectionFn = parseConnection,
|
|
11151
|
+
redisFn = makeRedisClient,
|
|
11152
|
+
timeoutMs = 2500,
|
|
10993
11153
|
now = Date.now
|
|
10994
11154
|
} = {}) {
|
|
10995
|
-
|
|
11155
|
+
const opened = [];
|
|
10996
11156
|
try {
|
|
10997
|
-
|
|
10998
|
-
const
|
|
10999
|
-
|
|
11157
|
+
const fleet = await readHosts({ url, redisFn, timeoutMs });
|
|
11158
|
+
const queues = fleetQueueNames(fleet.hosts).map((name) => makeQueueFn(parseConnectionFn(url, { failFast: true }), { name }));
|
|
11159
|
+
opened.push(...queues);
|
|
11160
|
+
const [primary, ...rest] = queues;
|
|
11161
|
+
const lists = [await primary.getJobSchedulers(0, -1, true), ...await Promise.all(rest.map((q) => q.getJobSchedulers(0, -1, true).catch(() => [])))];
|
|
11162
|
+
return mapSchedulers(lists.flat(), now());
|
|
11000
11163
|
} catch (err) {
|
|
11001
11164
|
return { unreachable: err?.message ?? String(err) };
|
|
11002
11165
|
} finally {
|
|
11003
|
-
|
|
11166
|
+
for (const q of opened) await q.close().catch(() => {
|
|
11004
11167
|
});
|
|
11005
11168
|
}
|
|
11006
11169
|
}
|
|
@@ -11607,6 +11770,7 @@ var init_read_model = __esm({
|
|
|
11607
11770
|
init_wait_state();
|
|
11608
11771
|
init_subscriptions();
|
|
11609
11772
|
init_connection();
|
|
11773
|
+
init_host_registry();
|
|
11610
11774
|
init_queue();
|
|
11611
11775
|
init_flow_gate();
|
|
11612
11776
|
init_git_dirty();
|
|
@@ -39692,7 +39856,9 @@ function renderStatus(queue) {
|
|
|
39692
39856
|
const counts = queue.counts ?? {};
|
|
39693
39857
|
const line = ["waiting", "active", "paused", "delayed", "failed"].map((k) => `${k} ${counts[k] ?? 0}`).join(" ");
|
|
39694
39858
|
const workers = queue.workers === void 0 ? "unknown" : queue.workers;
|
|
39695
|
-
|
|
39859
|
+
const named = Array.isArray(queue.workerNames) && queue.workerNames.length > 0 ? ` (${queue.workerNames.join(", ")})` : "";
|
|
39860
|
+
const state = queue.pausedPartial ? "PARTIALLY paused" : queue.pausedState ? "paused" : "running";
|
|
39861
|
+
return [`Queue: ${state}`, ` ${line}`, ` workers: ${workers}${named}`].join("\n");
|
|
39696
39862
|
}
|
|
39697
39863
|
function renderRuns(runs) {
|
|
39698
39864
|
if (runs && runs.unreachable) return `Runs: unreachable (${runs.unreachable})`;
|
|
@@ -40131,27 +40297,67 @@ async function heldJobs(redis) {
|
|
|
40131
40297
|
rows.sort((a, b) => (b.waitedMs ?? -1) - (a.waitedMs ?? -1));
|
|
40132
40298
|
return { rows: rows.slice(0, HELD_LIMIT), more: Math.max(0, rows.length - HELD_LIMIT), truncated: ids.length > HELD_HYDRATE_MAX2 };
|
|
40133
40299
|
}
|
|
40134
|
-
function createDashboardDeps(paths
|
|
40135
|
-
|
|
40136
|
-
|
|
40300
|
+
function createDashboardDeps(paths, {
|
|
40301
|
+
makeQueueFn = makeQueue,
|
|
40302
|
+
parseConnectionFn = parseConnection,
|
|
40303
|
+
redisFn = makeRedisClient,
|
|
40304
|
+
readLiveHostsFn = readLiveHosts,
|
|
40305
|
+
discoverHostQueuesFn = discoverHostQueues
|
|
40306
|
+
} = {}) {
|
|
40307
|
+
const queue = makeQueueFn(parseConnectionFn(paths.valkeyUrl, { failFast: true }));
|
|
40308
|
+
const redis = redisFn(paths.valkeyUrl);
|
|
40309
|
+
const pool = /* @__PURE__ */ new Map([[queue.name, queue]]);
|
|
40310
|
+
let lastNames = [queue.name];
|
|
40311
|
+
let fleetDegraded = null;
|
|
40312
|
+
const fleetQueues = async () => {
|
|
40313
|
+
const fleet = await readLiveHostsFn(redis, { timeoutMs: FLEET_READ_TIMEOUT_MS }).catch((err) => ({
|
|
40314
|
+
unreachable: err?.message ?? String(err)
|
|
40315
|
+
}));
|
|
40316
|
+
if (fleet?.unreachable || !Array.isArray(fleet?.hosts)) {
|
|
40317
|
+
fleetDegraded = fleet?.unreachable ?? "registry unreadable";
|
|
40318
|
+
} else {
|
|
40319
|
+
fleetDegraded = null;
|
|
40320
|
+
lastNames = fleetQueueNames(fleet.hosts);
|
|
40321
|
+
}
|
|
40322
|
+
for (const name of lastNames) {
|
|
40323
|
+
if (!pool.has(name)) pool.set(name, makeQueueFn(parseConnectionFn(paths.valkeyUrl, { failFast: true }), { name }));
|
|
40324
|
+
}
|
|
40325
|
+
return lastNames.map((n) => pool.get(n));
|
|
40326
|
+
};
|
|
40327
|
+
const killSwitchQueues = async () => {
|
|
40328
|
+
const existing = await discoverHostQueuesFn(redis, { timeoutMs: FLEET_READ_TIMEOUT_MS });
|
|
40329
|
+
for (const name of unionQueueNames(lastNames, existing)) {
|
|
40330
|
+
if (!pool.has(name)) pool.set(name, makeQueueFn(parseConnectionFn(paths.valkeyUrl, { failFast: true }), { name }));
|
|
40331
|
+
}
|
|
40332
|
+
return unionQueueNames(lastNames, existing).map((n) => pool.get(n));
|
|
40333
|
+
};
|
|
40137
40334
|
return {
|
|
40138
40335
|
async fetchSnapshot() {
|
|
40139
|
-
const
|
|
40140
|
-
|
|
40141
|
-
|
|
40336
|
+
const queues = await fleetQueues();
|
|
40337
|
+
const [pausedStates, countsPer, workerList, dayRaw, weekRaw, monthRaw, tokenRaw, schedulerLists, activeLists, stallHash] = await Promise.all([
|
|
40338
|
+
Promise.all(queues.map((q) => q.isPaused())),
|
|
40339
|
+
Promise.all(queues.map((q) => q.getJobCounts("waiting", "active", "paused", "delayed", "failed"))),
|
|
40142
40340
|
queue.getWorkers().catch(() => []),
|
|
40143
40341
|
redis.get(dayKey()),
|
|
40144
40342
|
redis.get(weekKey()),
|
|
40145
40343
|
redis.get(monthKey()),
|
|
40146
40344
|
redis.get(tokenDayKey()),
|
|
40147
40345
|
// issue #25 daily token spend (budget:t:YYYY-MM-DD)
|
|
40148
|
-
queue
|
|
40149
|
-
queue
|
|
40346
|
+
// The shared queue's failure is UNCAUGHT (the panel must not draw a scheduler list it could not
|
|
40347
|
+
// read), a host queue's is caught: one unreachable host degrades one host's triggers, never the view.
|
|
40348
|
+
Promise.all(queues.map((q, i) => i === 0 ? q.getJobSchedulers(0, -1, true) : q.getJobSchedulers(0, -1, true).catch(() => []))),
|
|
40349
|
+
Promise.all(queues.map((q) => q.getActive(0, 0).catch(() => []))),
|
|
40150
40350
|
// Per-scheduler stall counts (money backstop) for the cron drill-in; reuses the held client like the
|
|
40151
40351
|
// budget GETs. HGETALL of an absent key is `{}`, so a never-stalled deployment shows 0 stalls.
|
|
40152
40352
|
redis.hgetall(STALL_KEY).catch(() => ({}))
|
|
40153
40353
|
]);
|
|
40154
40354
|
const workers = Array.isArray(workerList) && workerList.length > 0 ? workerList.length : "unknown";
|
|
40355
|
+
const counts = {};
|
|
40356
|
+
for (const c of countsPer) for (const [k, v] of Object.entries(c ?? {})) counts[k] = (counts[k] ?? 0) + Number(v ?? 0);
|
|
40357
|
+
const pausedState = pausedStates.every(Boolean);
|
|
40358
|
+
const pausedPartial = !pausedState && pausedStates.some(Boolean);
|
|
40359
|
+
const schedulerList = schedulerLists.flat();
|
|
40360
|
+
const activeList = activeLists.flat();
|
|
40155
40361
|
const scopedLimits = readScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath });
|
|
40156
40362
|
const limitRows = Array.isArray(scopedLimits?.limits) ? scopedLimits.limits : [];
|
|
40157
40363
|
const scopedNow = /* @__PURE__ */ new Date();
|
|
@@ -40173,7 +40379,7 @@ function createDashboardDeps(paths) {
|
|
|
40173
40379
|
held,
|
|
40174
40380
|
scopedLimits,
|
|
40175
40381
|
scopedBudget,
|
|
40176
|
-
queue: { pausedState, counts, workers },
|
|
40382
|
+
queue: { pausedState, pausedPartial, counts, workers, queues: queues.length, fleetDegraded },
|
|
40177
40383
|
budget: { day: Number(dayRaw ?? 0), week: Number(weekRaw ?? 0), month: Number(monthRaw ?? 0), tokensToday: Number(tokenRaw ?? 0) },
|
|
40178
40384
|
schedulers: mapSchedulers(schedulerList, Date.now()),
|
|
40179
40385
|
schedulerStalls: stallHash ?? {},
|
|
@@ -40193,15 +40399,19 @@ function createDashboardDeps(paths) {
|
|
|
40193
40399
|
};
|
|
40194
40400
|
},
|
|
40195
40401
|
async pause() {
|
|
40196
|
-
await
|
|
40402
|
+
await fleetQueues();
|
|
40403
|
+
for (const q of await killSwitchQueues()) await q.pause();
|
|
40197
40404
|
},
|
|
40198
40405
|
async resume() {
|
|
40199
|
-
await
|
|
40406
|
+
await fleetQueues();
|
|
40407
|
+
for (const q of await killSwitchQueues()) await q.resume();
|
|
40200
40408
|
},
|
|
40201
40409
|
async dispose() {
|
|
40202
|
-
|
|
40203
|
-
|
|
40204
|
-
|
|
40410
|
+
for (const q of pool.values()) {
|
|
40411
|
+
try {
|
|
40412
|
+
await q.close();
|
|
40413
|
+
} catch {
|
|
40414
|
+
}
|
|
40205
40415
|
}
|
|
40206
40416
|
try {
|
|
40207
40417
|
redis.disconnect();
|
|
@@ -40743,11 +40953,11 @@ function statusHeader(queue, inner, styler) {
|
|
|
40743
40953
|
return styler.cell(`queue unreachable (${queue?.unreachable ?? "?"})`, inner, { color: "error" });
|
|
40744
40954
|
}
|
|
40745
40955
|
const c = queue.counts ?? {};
|
|
40746
|
-
const running = !queue.pausedState;
|
|
40956
|
+
const running = !queue.pausedState && !queue.pausedPartial;
|
|
40747
40957
|
const failed = Number(c.failed ?? 0);
|
|
40748
40958
|
const stateColor = running ? "success" : "warning";
|
|
40749
40959
|
const dot = styler.fg(stateColor, "\u25CF");
|
|
40750
|
-
const word = styler.bold(styler.fg(stateColor, running ? "RUNNING" : "PAUSED"));
|
|
40960
|
+
const word = styler.bold(styler.fg(stateColor, running ? "RUNNING" : queue.pausedPartial ? "PART PAUSED" : "PAUSED"));
|
|
40751
40961
|
const sep2 = styler.fg("dim", " \xB7 ");
|
|
40752
40962
|
const delayed = Number(c.delayed ?? 0);
|
|
40753
40963
|
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`;
|
|
@@ -41267,6 +41477,7 @@ function renderRunDetail(record, inner, styler, allRuns = [], sandbox = null) {
|
|
|
41267
41477
|
const endMs = toMs(r.endedAt);
|
|
41268
41478
|
const dur = Number.isFinite(startMs) && Number.isFinite(endMs) && endMs >= startMs ? ` (${formatDuration(endMs - startMs)})` : "";
|
|
41269
41479
|
out.push(kv("timing", `${fmtStamp(r.startedAt)} \u2192 ${fmtStamp(r.endedAt)}${dur}`));
|
|
41480
|
+
if (r.host) out.push(kv("host", String(r.host)));
|
|
41270
41481
|
const turnBits = [`${show(r.turns)} turns`, `exit ${show(r.exitCode)}`];
|
|
41271
41482
|
if (r.budgetReserved !== null && r.budgetReserved !== void 0) turnBits.push(`${r.budgetReserved} budget slot`);
|
|
41272
41483
|
if (r.attempt !== null && r.attempt !== void 0) turnBits.push(`attempt ${r.attempt}`);
|
|
@@ -41346,12 +41557,13 @@ function runDetailHints(inner, styler, canOpen = false, canCopy = false, copiedN
|
|
|
41346
41557
|
if (copiedNote) return fitLine(styler.fg("success", copiedNote) + styler.fg("dim", " \xB7 ") + line, inner, styler);
|
|
41347
41558
|
return fitLine(line, inner, styler);
|
|
41348
41559
|
}
|
|
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;
|
|
41560
|
+
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
41561
|
var init_dashboard = __esm({
|
|
41351
41562
|
"src/dashboard.ts"() {
|
|
41352
41563
|
init_budget();
|
|
41353
41564
|
init_connection();
|
|
41354
41565
|
init_queue();
|
|
41566
|
+
init_host_registry();
|
|
41355
41567
|
init_scheduler_stall_guard();
|
|
41356
41568
|
init_pause_windows();
|
|
41357
41569
|
init_read_model();
|
|
@@ -41371,6 +41583,7 @@ var init_dashboard = __esm({
|
|
|
41371
41583
|
DRILL_WIDTH = 70;
|
|
41372
41584
|
HELD_LIMIT = 20;
|
|
41373
41585
|
HELD_HYDRATE_MAX2 = 200;
|
|
41586
|
+
FLEET_READ_TIMEOUT_MS = 2e3;
|
|
41374
41587
|
KIND_COLOR = { cron: "accent", label: "syntaxType", comment: "syntaxKeyword", pull_request: "syntaxFunction", issue: "syntaxString" };
|
|
41375
41588
|
KIND_WIDTH = 13;
|
|
41376
41589
|
FRAME_CHROME_ROWS = 4;
|
|
@@ -41908,7 +42121,7 @@ var init_setup_wizard = __esm({
|
|
|
41908
42121
|
init_deployment_pointer();
|
|
41909
42122
|
init_read_model();
|
|
41910
42123
|
init_index();
|
|
41911
|
-
RUNTIME_VERSION = "1.
|
|
42124
|
+
RUNTIME_VERSION = "1.7.0";
|
|
41912
42125
|
RECEIVER_VERSION = "1.4.0";
|
|
41913
42126
|
RUNTIME_PKG = "@edgehero/pi-dispatch";
|
|
41914
42127
|
RECEIVER_PKG = "@edgehero/pi-dispatch-receiver";
|
|
@@ -42089,7 +42302,7 @@ function registerTools(pi) {
|
|
|
42089
42302
|
pi.registerTool({
|
|
42090
42303
|
name: "dispatch_runs",
|
|
42091
42304
|
label: "pi-dispatch runs",
|
|
42092
|
-
description: "Read-only. Returns structured
|
|
42305
|
+
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
42306
|
parameters: typebox_exports.Object({
|
|
42094
42307
|
limit: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1, maximum: 50 })),
|
|
42095
42308
|
jobId: typebox_exports.Optional(typebox_exports.String())
|
|
@@ -42128,7 +42341,10 @@ function registerTools(pi) {
|
|
|
42128
42341
|
if (res.unreachable) {
|
|
42129
42342
|
throw new Error(`could not reach the queue at ${paths.valkeyUrl}: ${res.unreachable}`);
|
|
42130
42343
|
}
|
|
42131
|
-
|
|
42344
|
+
if (res.partial) {
|
|
42345
|
+
throw new Error(`paused ${res.partial.done.join(", ")} but FAILED at ${res.partial.failed} \u2014 the deployment is half paused: ${res.partial.error}`);
|
|
42346
|
+
}
|
|
42347
|
+
return toolText(`paused${res.queues.length > 1 ? ` (${res.queues.length} queues)` : ""}`);
|
|
42132
42348
|
}
|
|
42133
42349
|
});
|
|
42134
42350
|
pi.registerTool({
|
|
@@ -42143,7 +42359,10 @@ function registerTools(pi) {
|
|
|
42143
42359
|
if (res.unreachable) {
|
|
42144
42360
|
throw new Error(`could not reach the queue at ${paths.valkeyUrl}: ${res.unreachable}`);
|
|
42145
42361
|
}
|
|
42146
|
-
|
|
42362
|
+
if (res.partial) {
|
|
42363
|
+
throw new Error(`resumed ${res.partial.done.join(", ")} but FAILED at ${res.partial.failed} \u2014 the deployment is half resumed: ${res.partial.error}`);
|
|
42364
|
+
}
|
|
42365
|
+
return toolText(`resumed${res.queues.length > 1 ? ` (${res.queues.length} queues)` : ""}`);
|
|
42147
42366
|
}
|
|
42148
42367
|
});
|
|
42149
42368
|
pi.registerTool({
|
|
@@ -42758,11 +42977,15 @@ ${scopedText}` : ""}`);
|
|
|
42758
42977
|
const paused = sub === "pause";
|
|
42759
42978
|
const res = await setQueuePaused({ url: paths.valkeyUrl, paused });
|
|
42760
42979
|
if (res.unreachable) {
|
|
42761
|
-
notify?.(
|
|
42980
|
+
notify?.(
|
|
42981
|
+
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)`,
|
|
42982
|
+
"error"
|
|
42983
|
+
);
|
|
42762
42984
|
return;
|
|
42763
42985
|
}
|
|
42986
|
+
const span = res.blind ? ` (fleet unknown: ${res.blind}, only ${res.queues[0]})` : res.queues.length > 1 ? ` (${res.queues.length} queues)` : "";
|
|
42764
42987
|
notify?.(
|
|
42765
|
-
paused ?
|
|
42988
|
+
paused ? `paused \u2014 worker will stop taking new jobs (jobs still enqueue; durable, survives restart)${span}` : `resumed${span}`,
|
|
42766
42989
|
"info"
|
|
42767
42990
|
);
|
|
42768
42991
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edgehero/pi-dispatch-admin",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.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",
|