@edgehero/pi-dispatch-admin 1.4.0 → 1.5.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 +4 -3
- package/dist/index.mjs +559 -52
- package/package.json +1 -1
- package/skills/operate-pi-dispatch/SKILL.md +31 -0
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
# pi-dispatch: run the pi coding agent as a self-hosted service
|
|
6
6
|
|
|
7
|
-
**Let a coding agent work on your repositories while you are not watching, without surprise bills and without giving it the keys to your machine.** [pi](https://github.com/earendil-works/pi) is a superb coding agent, but it has no job queue, no spend limit, and, by its own README, no permission system. pi-dispatch adds exactly those, so you can run pi unattended and safely: on a cron schedule, on your repo's issues and PRs, or straight from the CLI. Every job runs in an isolated container, spend is bounded before a single token is spent, and everything the agent did (and what it cost) is recorded, graphed and priced for you. On a forge you stay in the loop at both ends: a job starts only when someone with write access to the repo asks for one, by a label, an `@pi` comment or a review, and what comes back is a pull request you review and land, because pi-dispatch never merges anything. This is not another agent to weigh against the one you already use; it is the queue, the budget and the box for the pi you already run.
|
|
7
|
+
**Let a coding agent work on your repositories while you are not watching, without surprise bills and without giving it the keys to your machine.** [pi](https://github.com/earendil-works/pi) is a superb coding agent, but it has no job queue, no spend limit, and, by its own README, no permission system. pi-dispatch adds exactly those, so you can run pi unattended and safely: on a cron schedule, on your repo's issues and PRs, or straight from the CLI. Every job runs in an isolated container, spend is bounded before a single token is spent (per deployment and per-repo or per-folder), and everything the agent did (and what it cost) is recorded, graphed and priced for you. On a forge you stay in the loop at both ends: a job starts only when someone with write access to the repo asks for one, by a label, an `@pi` comment or a review, and what comes back is a pull request you review and land, because pi-dispatch never merges anything. This is not another agent to weigh against the one you already use; it is the queue, the budget and the box for the pi you already run.
|
|
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
|
|
|
@@ -56,7 +56,7 @@ Four forges: GitHub, GitLab, Forgejo (and Gitea), Azure DevOps. **Who may fire a
|
|
|
56
56
|
One command puts a live TUI over the whole deployment:
|
|
57
57
|
|
|
58
58
|
<p align="center">
|
|
59
|
-
<img src="https://raw.githubusercontent.com/edgehero/pi-dispatch/main/docs/images/dispatch-dashboard.png?v=1.0.0" alt="The /dispatch panel: status, spend meters, triggers (including a command trigger shown as its /name), pause windows, runs, and settings" width="820">
|
|
59
|
+
<img src="https://raw.githubusercontent.com/edgehero/pi-dispatch/main/docs/images/dispatch-dashboard.png?v=1.0.0" alt="The /dispatch panel: status, spend meters, triggers (including a command trigger shown as its /name), pause windows, scoped limits, runs, and settings" width="820">
|
|
60
60
|
</p>
|
|
61
61
|
|
|
62
62
|
- **Status and spend.** Queue and worker state, day/week/month spend meters, a daily token counter, and a run-history table with per-job tokens and cost.
|
|
@@ -68,7 +68,8 @@ One command puts a live TUI over the whole deployment:
|
|
|
68
68
|
|
|
69
69
|
- **Triggers, editable live.** cron, label, comment, pull_request and issue triggers with colored drill-ins showing what fires each one, what it runs, and its trust model. Added, edited and deleted without a restart. A one-shot (`once: true`) shows armed or spent in its drill-in: a spent entry stays in the list, matches nothing, and re-arms when the operator deletes its `on.disarmed` mark from the file. Triggers that run third-party code or a custom image are badged; opting in or out of either stays an edit to the reviewed `triggers.json`, which neither the console nor a model-callable tool will make for you.
|
|
70
70
|
- **Quiet hours.** Scheduled pause windows per folder or repo: defer runs between certain times, timezone-aware, and resume automatically. Deferred, never dropped, at zero budget cost.
|
|
71
|
-
- **
|
|
71
|
+
- **Scoped limits.** Per-repo or per-folder job caps (day, week, month) refused before any spend, plus a per-scope concurrency ceiling enforced by deferral. Local jobs also hold a one-job-per-folder guard that is always on and has no switch (it lives in the worker process, and one worker per docker daemon is the supported shape), because two agents editing one working tree race each other with no gate and no undo.
|
|
72
|
+
- **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.
|
|
72
73
|
- **Logs stay put.** Raw container output renders only in the overlay viewer, never into model context.
|
|
73
74
|
|
|
74
75
|
## Install
|
package/dist/index.mjs
CHANGED
|
@@ -8024,10 +8024,100 @@ var init_pause_windows = __esm({
|
|
|
8024
8024
|
}
|
|
8025
8025
|
});
|
|
8026
8026
|
|
|
8027
|
-
// ../worker/src/
|
|
8027
|
+
// ../worker/src/scoped-limits.mjs
|
|
8028
|
+
import { createHash } from "node:crypto";
|
|
8029
|
+
import { isAbsolute, resolve } from "node:path";
|
|
8028
8030
|
function isNonEmptyString3(value) {
|
|
8029
8031
|
return typeof value === "string" && value.trim() !== "";
|
|
8030
8032
|
}
|
|
8033
|
+
function parseScopedLimits(text, path) {
|
|
8034
|
+
let parsed;
|
|
8035
|
+
try {
|
|
8036
|
+
parsed = JSON.parse(text);
|
|
8037
|
+
} catch (error) {
|
|
8038
|
+
throw configError(`scoped-limits file is not valid JSON: ${path} (${error.message})`);
|
|
8039
|
+
}
|
|
8040
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
8041
|
+
throw configError(`scoped-limits file must be an object with "version" and "limits": ${path}`);
|
|
8042
|
+
}
|
|
8043
|
+
const version = parsed.version;
|
|
8044
|
+
if (!Number.isInteger(version) || version < 1) {
|
|
8045
|
+
throw configError(`scoped-limits file must have "version": 1 (an integer >= 1): ${path}`);
|
|
8046
|
+
}
|
|
8047
|
+
if (version > SCOPED_LIMITS_VERSION) {
|
|
8048
|
+
throw configError(`scoped-limits file written by a newer pi-dispatch (version ${version}; this build understands ${SCOPED_LIMITS_VERSION}): ${path}`);
|
|
8049
|
+
}
|
|
8050
|
+
if (!Array.isArray(parsed.limits)) {
|
|
8051
|
+
throw configError(`scoped-limits file must have a "limits" array: ${path}`);
|
|
8052
|
+
}
|
|
8053
|
+
const rows = parsed.limits.map((row, index) => normalizeLimit(row, index, path));
|
|
8054
|
+
const seen = /* @__PURE__ */ new Map();
|
|
8055
|
+
rows.forEach((row, index) => {
|
|
8056
|
+
if (seen.has(row.scope)) {
|
|
8057
|
+
throw configError(`scoped limit at index ${index}: duplicate scope ${JSON.stringify(row.scope)} (first at index ${seen.get(row.scope)}): ${path}`);
|
|
8058
|
+
}
|
|
8059
|
+
seen.set(row.scope, index);
|
|
8060
|
+
});
|
|
8061
|
+
return rows;
|
|
8062
|
+
}
|
|
8063
|
+
function normalizeLimit(row, index, path) {
|
|
8064
|
+
const at = `scoped limit at index ${index}`;
|
|
8065
|
+
if (row === null || typeof row !== "object" || Array.isArray(row)) {
|
|
8066
|
+
throw configError(`${at}: must be an object: ${path}`);
|
|
8067
|
+
}
|
|
8068
|
+
if (!isNonEmptyString3(row.scope)) throw configError(`${at}: scope must be a non-empty string: ${path}`);
|
|
8069
|
+
const trimmed = row.scope.trim().normalize("NFC");
|
|
8070
|
+
if (trimmed === "*") {
|
|
8071
|
+
throw configError(`${at}: "*" is not supported -- add one row per scope (a per-scope default may adopt "*" later): ${path}`);
|
|
8072
|
+
}
|
|
8073
|
+
if (trimmed.includes("*")) {
|
|
8074
|
+
throw configError(`${at}: scopes match exactly; a scope containing "*" is refused (no globs): ${path}`);
|
|
8075
|
+
}
|
|
8076
|
+
const norm = {
|
|
8077
|
+
// An absolute path is stored resolved so a `/srv/site/` row governs `/srv/site` jobs -- the same
|
|
8078
|
+
// collapse canonicalScope applies on the job side. isAbsolute is PLATFORM-NATIVE on purpose, so a
|
|
8079
|
+
// foreign-platform row (a windows drive path on a POSIX worker) stays verbatim and is inert here;
|
|
8080
|
+
// the doctor's unreferenced-scope advisory names it. Resolving it instead would "work" only by
|
|
8081
|
+
// both sides mangling into the same cwd-prefixed string -- a match by accident, not by contract.
|
|
8082
|
+
scope: isAbsolute(trimmed) ? resolve(trimmed) : trimmed,
|
|
8083
|
+
day: null,
|
|
8084
|
+
week: null,
|
|
8085
|
+
month: null,
|
|
8086
|
+
concurrent: null
|
|
8087
|
+
};
|
|
8088
|
+
let any = false;
|
|
8089
|
+
for (const field of LIMIT_FIELDS) {
|
|
8090
|
+
const value = row[field];
|
|
8091
|
+
if (value === void 0 || value === null) continue;
|
|
8092
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
8093
|
+
throw configError(`${at}: ${field} must be an integer >= 1: ${path}`);
|
|
8094
|
+
}
|
|
8095
|
+
norm[field] = value;
|
|
8096
|
+
any = true;
|
|
8097
|
+
}
|
|
8098
|
+
if (!any) {
|
|
8099
|
+
throw configError(`${at}: at least one of day, week, month, concurrent is required (a row that limits nothing is a row an operator sets and then trusts): ${path}`);
|
|
8100
|
+
}
|
|
8101
|
+
return norm;
|
|
8102
|
+
}
|
|
8103
|
+
function scopeKeyPrefix(scope) {
|
|
8104
|
+
const h = createHash("sha256").update(String(scope)).digest("hex").slice(0, 16);
|
|
8105
|
+
return `budget:s:${h}`;
|
|
8106
|
+
}
|
|
8107
|
+
var SCOPED_LIMITS_VERSION, LIMIT_FIELDS;
|
|
8108
|
+
var init_scoped_limits = __esm({
|
|
8109
|
+
"../worker/src/scoped-limits.mjs"() {
|
|
8110
|
+
init_config2();
|
|
8111
|
+
init_pause_windows();
|
|
8112
|
+
SCOPED_LIMITS_VERSION = 1;
|
|
8113
|
+
LIMIT_FIELDS = ["day", "week", "month", "concurrent"];
|
|
8114
|
+
}
|
|
8115
|
+
});
|
|
8116
|
+
|
|
8117
|
+
// ../worker/src/subscriptions.mjs
|
|
8118
|
+
function isNonEmptyString4(value) {
|
|
8119
|
+
return typeof value === "string" && value.trim() !== "";
|
|
8120
|
+
}
|
|
8031
8121
|
function normalizeGlobs(value, label, at, path) {
|
|
8032
8122
|
if (!Array.isArray(value) || value.length === 0) {
|
|
8033
8123
|
throw configError(`${at}: ${label} must be a non-empty array of model globs: ${path}`);
|
|
@@ -8073,14 +8163,14 @@ function normalizeSubscription(entry, index, path, seenIds) {
|
|
|
8073
8163
|
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
|
|
8074
8164
|
throw configError(`${at}: must be an object: ${path}`);
|
|
8075
8165
|
}
|
|
8076
|
-
if (!
|
|
8166
|
+
if (!isNonEmptyString4(entry.id)) throw configError(`${at}: id must be a non-empty string: ${path}`);
|
|
8077
8167
|
const id = entry.id.trim();
|
|
8078
8168
|
if (seenIds.has(id)) {
|
|
8079
8169
|
throw configError(`${at}: id ${JSON.stringify(id)} is already used by the subscription at index ${seenIds.get(id)}: ${path}`);
|
|
8080
8170
|
}
|
|
8081
8171
|
seenIds.set(id, index);
|
|
8082
|
-
if (!
|
|
8083
|
-
if (!
|
|
8172
|
+
if (!isNonEmptyString4(entry.vendor)) throw configError(`${at}: vendor must be a non-empty string: ${path}`);
|
|
8173
|
+
if (!isNonEmptyString4(entry.provider)) throw configError(`${at}: provider must be a non-empty string (the pi-ai provider id this plan covers): ${path}`);
|
|
8084
8174
|
const models = entry.models === void 0 ? ["*"] : normalizeGlobs(entry.models, "models", at, path);
|
|
8085
8175
|
const price = entry.price;
|
|
8086
8176
|
if (price === null || typeof price !== "object" || Array.isArray(price)) {
|
|
@@ -8100,7 +8190,7 @@ function normalizeSubscription(entry, index, path, seenIds) {
|
|
|
8100
8190
|
let counterfactualModel = null;
|
|
8101
8191
|
if (entry.counterfactualModel !== void 0 && entry.counterfactualModel !== null) {
|
|
8102
8192
|
const cm = entry.counterfactualModel;
|
|
8103
|
-
if (typeof cm !== "object" || Array.isArray(cm) || !
|
|
8193
|
+
if (typeof cm !== "object" || Array.isArray(cm) || !isNonEmptyString4(cm.provider) || !isNonEmptyString4(cm.id)) {
|
|
8104
8194
|
throw configError(`${at}: counterfactualModel must be { provider, id } with non-empty strings (a PRICED pi-ai model to re-price covered runs at API rates): ${path}`);
|
|
8105
8195
|
}
|
|
8106
8196
|
counterfactualModel = { provider: cm.provider.trim(), id: cm.id.trim() };
|
|
@@ -8197,9 +8287,9 @@ var init_connection = __esm({
|
|
|
8197
8287
|
});
|
|
8198
8288
|
|
|
8199
8289
|
// ../worker/src/job-id.mjs
|
|
8200
|
-
import { createHash } from "node:crypto";
|
|
8290
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
8201
8291
|
function localJobId({ folder, flow, task, minute }) {
|
|
8202
|
-
const digest =
|
|
8292
|
+
const digest = createHash2("sha256").update([folder, flow ?? "", task ?? "", minute].join("\0")).digest("hex");
|
|
8203
8293
|
return `local-${digest.slice(0, 16)}`;
|
|
8204
8294
|
}
|
|
8205
8295
|
var init_job_id = __esm({
|
|
@@ -8276,7 +8366,7 @@ var init_reserved_env = __esm({
|
|
|
8276
8366
|
});
|
|
8277
8367
|
|
|
8278
8368
|
// ../worker/src/triggers.mjs
|
|
8279
|
-
function
|
|
8369
|
+
function isNonEmptyString5(value) {
|
|
8280
8370
|
return typeof value === "string" && value.trim() !== "";
|
|
8281
8371
|
}
|
|
8282
8372
|
function parseTriggers(text, path) {
|
|
@@ -8331,7 +8421,7 @@ function normalizeTrigger(entry, index, path, state) {
|
|
|
8331
8421
|
function normalizeCron(on, run, index, path, state) {
|
|
8332
8422
|
const at = `trigger at index ${index}`;
|
|
8333
8423
|
const id = on.id;
|
|
8334
|
-
if (!
|
|
8424
|
+
if (!isNonEmptyString5(id)) {
|
|
8335
8425
|
throw configError(`${at}: cron on.id must be a non-empty string: ${path}`);
|
|
8336
8426
|
}
|
|
8337
8427
|
if (id.includes(":")) {
|
|
@@ -8345,7 +8435,7 @@ function normalizeCron(on, run, index, path, state) {
|
|
|
8345
8435
|
}
|
|
8346
8436
|
state.seenCronIds.add(id);
|
|
8347
8437
|
const pattern = on.pattern;
|
|
8348
|
-
if (!
|
|
8438
|
+
if (!isNonEmptyString5(pattern)) {
|
|
8349
8439
|
throw configError(`cron trigger "${id}": on.pattern must be a non-empty string: ${path}`);
|
|
8350
8440
|
}
|
|
8351
8441
|
const fieldCount = pattern.trim().split(/\s+/).length;
|
|
@@ -8353,13 +8443,13 @@ function normalizeCron(on, run, index, path, state) {
|
|
|
8353
8443
|
throw configError(`cron trigger "${id}": on.pattern must have 5 or 6 space-separated fields, got ${fieldCount}: ${path}`);
|
|
8354
8444
|
}
|
|
8355
8445
|
const command = validateCommand(run, `cron trigger "${id}"`, path, { onType: "cron" });
|
|
8356
|
-
if (!
|
|
8446
|
+
if (!isNonEmptyString5(run.folder)) {
|
|
8357
8447
|
throw configError(`cron trigger "${id}": run.folder must be a non-empty string: ${path}`);
|
|
8358
8448
|
}
|
|
8359
|
-
if (command === void 0 && !
|
|
8449
|
+
if (command === void 0 && !isNonEmptyString5(run.flow)) {
|
|
8360
8450
|
throw configError(`cron trigger "${id}": run.flow must be a non-empty string (or use run.command): ${path}`);
|
|
8361
8451
|
}
|
|
8362
|
-
if (command === void 0 && !
|
|
8452
|
+
if (command === void 0 && !isNonEmptyString5(run.task)) {
|
|
8363
8453
|
throw configError(`cron trigger "${id}": run.task must be a non-empty string: ${path}`);
|
|
8364
8454
|
}
|
|
8365
8455
|
if (run.github !== void 0 && typeof run.github !== "boolean") {
|
|
@@ -8443,7 +8533,7 @@ function validateCommand(run, at, path, { onType }) {
|
|
|
8443
8533
|
throw configError(`${at}: exactly one of run.flow or run.command must be set -- a trigger dispatches either a flow or a registered command, and with both present the file does not say which one runs: ${path}`);
|
|
8444
8534
|
}
|
|
8445
8535
|
if (raw === void 0) return void 0;
|
|
8446
|
-
if (!
|
|
8536
|
+
if (!isNonEmptyString5(raw)) {
|
|
8447
8537
|
throw configError(`${at}: run.command must be a non-empty string -- the registered command name, optionally followed by its arguments: ${path}`);
|
|
8448
8538
|
}
|
|
8449
8539
|
if (raw !== raw.trim()) {
|
|
@@ -8529,10 +8619,10 @@ function validateDisarmed(on, at, path, { capable = false, onType } = {}) {
|
|
|
8529
8619
|
throw configError(`${at}: on.disarmed has an unsupported key ${JSON.stringify(key)} (expected at|jobId): ${path}`);
|
|
8530
8620
|
}
|
|
8531
8621
|
}
|
|
8532
|
-
if (!
|
|
8622
|
+
if (!isNonEmptyString5(disarmed.at)) {
|
|
8533
8623
|
throw configError(`${at}: on.disarmed.at must be a non-empty string (the time the one-shot fired): ${path}`);
|
|
8534
8624
|
}
|
|
8535
|
-
if (disarmed.jobId !== void 0 && !
|
|
8625
|
+
if (disarmed.jobId !== void 0 && !isNonEmptyString5(disarmed.jobId)) {
|
|
8536
8626
|
throw configError(`${at}: on.disarmed.jobId must be a non-empty string when present (the run record it joins to): ${path}`);
|
|
8537
8627
|
}
|
|
8538
8628
|
if (on.once !== true) {
|
|
@@ -8556,7 +8646,7 @@ function validateRepository(run, onType, at, path) {
|
|
|
8556
8646
|
if (!needed) {
|
|
8557
8647
|
throw configError(`${at}: run.repository is only meaningful on an azure label or comment trigger (a pull request names its own repository): ${path}`);
|
|
8558
8648
|
}
|
|
8559
|
-
if (!
|
|
8649
|
+
if (!isNonEmptyString5(raw) || raw !== raw.trim() || raw.includes("/")) {
|
|
8560
8650
|
throw configError(`${at}: run.repository must be a non-empty repository NAME within the project, with no slashes and no surrounding whitespace (got ${JSON.stringify(raw)}): ${path}`);
|
|
8561
8651
|
}
|
|
8562
8652
|
return raw;
|
|
@@ -8596,7 +8686,7 @@ function validateSecrets(run, at, path) {
|
|
|
8596
8686
|
throw configError(`${at}: run.secrets key ${JSON.stringify(name)} is a variable the worker sets itself -- the job container would receive the worker's value, not this trigger's, and the trigger would look like it worked: ${path}`);
|
|
8597
8687
|
}
|
|
8598
8688
|
const reference = secrets[name];
|
|
8599
|
-
if (!
|
|
8689
|
+
if (!isNonEmptyString5(reference)) {
|
|
8600
8690
|
throw configError(`${at}: run.secrets.${name} must be a non-empty string reference for your resolver to read (got ${JSON.stringify(reference)}): ${path}`);
|
|
8601
8691
|
}
|
|
8602
8692
|
if (reference !== reference.trim()) {
|
|
@@ -8614,7 +8704,7 @@ function validateSecrets(run, at, path) {
|
|
|
8614
8704
|
function validateSecretsProfile(run, at, path) {
|
|
8615
8705
|
const profile = run.secretsProfile;
|
|
8616
8706
|
if (profile === void 0) return void 0;
|
|
8617
|
-
if (!
|
|
8707
|
+
if (!isNonEmptyString5(profile)) {
|
|
8618
8708
|
throw configError(`${at}: run.secretsProfile must be a non-empty string naming one of the resolver profiles this deployment declares (got ${JSON.stringify(profile)}): ${path}`);
|
|
8619
8709
|
}
|
|
8620
8710
|
if (!ID_CHARSET.test(profile)) {
|
|
@@ -8632,7 +8722,7 @@ function normalizeLabel(on, run, index, path) {
|
|
|
8632
8722
|
validateOnce(on, run, at, path, { onType: "label" });
|
|
8633
8723
|
validateDisarmed(on, at, path, { onType: "label" });
|
|
8634
8724
|
const command = validateCommand(run, at, path, { onType: "label" });
|
|
8635
|
-
if (command === void 0 && !
|
|
8725
|
+
if (command === void 0 && !isNonEmptyString5(run.flow)) {
|
|
8636
8726
|
throw configError(`${at}: label trigger run.flow must be a non-empty string (or use run.command): ${path}`);
|
|
8637
8727
|
}
|
|
8638
8728
|
validateFlowName(run, at, path);
|
|
@@ -8652,14 +8742,14 @@ function normalizeLabel(on, run, index, path) {
|
|
|
8652
8742
|
}
|
|
8653
8743
|
function normalizeComment(on, run, index, path, state) {
|
|
8654
8744
|
const at = `trigger at index ${index}`;
|
|
8655
|
-
if (!
|
|
8745
|
+
if (!isNonEmptyString5(on.phrase)) {
|
|
8656
8746
|
throw configError(`${at}: comment trigger on.phrase must be a non-empty string: ${path}`);
|
|
8657
8747
|
}
|
|
8658
8748
|
validateNumber(on, at, path, { onType: "comment" });
|
|
8659
8749
|
validateOnce(on, run, at, path, { onType: "comment" });
|
|
8660
8750
|
validateDisarmed(on, at, path, { onType: "comment" });
|
|
8661
8751
|
const command = validateCommand(run, at, path, { onType: "comment" });
|
|
8662
|
-
if (command === void 0 && !
|
|
8752
|
+
if (command === void 0 && !isNonEmptyString5(run.flow)) {
|
|
8663
8753
|
throw configError(`${at}: comment trigger run.flow (the default flow) must be a non-empty string (or use run.command): ${path}`);
|
|
8664
8754
|
}
|
|
8665
8755
|
validateFlowName(run, at, path);
|
|
@@ -8704,7 +8794,7 @@ function normalizeIssue(on, run, index, path) {
|
|
|
8704
8794
|
const once = validateOnce(on, run, at, path, { capable: true, onType: "issue" });
|
|
8705
8795
|
validateDisarmed(on, at, path, { capable: true, onType: "issue" });
|
|
8706
8796
|
const command = validateCommand(run, at, path, { onType: "issue" });
|
|
8707
|
-
if (command === void 0 && !
|
|
8797
|
+
if (command === void 0 && !isNonEmptyString5(run.flow)) {
|
|
8708
8798
|
throw configError(`${at}: issue trigger run.flow must be a non-empty string (or use run.command): ${path}`);
|
|
8709
8799
|
}
|
|
8710
8800
|
validateFlowName(run, at, path);
|
|
@@ -8761,7 +8851,7 @@ function normalizePullRequest(on, run, index, path) {
|
|
|
8761
8851
|
const once = validateOnce(on, run, at, path, { capable: isClose, onType: "pull_request" });
|
|
8762
8852
|
validateDisarmed(on, at, path, { capable: isClose, onType: "pull_request" });
|
|
8763
8853
|
const command = validateCommand(run, at, path, { onType: "pull_request" });
|
|
8764
|
-
if (command === void 0 && !
|
|
8854
|
+
if (command === void 0 && !isNonEmptyString5(run.flow)) {
|
|
8765
8855
|
throw configError(`${at}: pull_request trigger run.flow must be a non-empty string (or use run.command): ${path}`);
|
|
8766
8856
|
}
|
|
8767
8857
|
validateFlowName(run, at, path);
|
|
@@ -9031,8 +9121,8 @@ var init_docker_run = __esm({
|
|
|
9031
9121
|
|
|
9032
9122
|
// ../worker/src/packages.mjs
|
|
9033
9123
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
9034
|
-
import { basename, dirname as dirname2, join, resolve } from "node:path";
|
|
9035
|
-
function
|
|
9124
|
+
import { basename, dirname as dirname2, join, resolve as resolve2 } from "node:path";
|
|
9125
|
+
function isNonEmptyString6(value) {
|
|
9036
9126
|
return typeof value === "string" && value.trim() !== "";
|
|
9037
9127
|
}
|
|
9038
9128
|
function readStageManifest({ globalPiDir, readFile = readFileSync, fileExists = existsSync } = {}) {
|
|
@@ -9046,7 +9136,7 @@ function readStageManifest({ globalPiDir, readFile = readFileSync, fileExists =
|
|
|
9046
9136
|
for (const entry of parsed.packages) {
|
|
9047
9137
|
if (entry === null || typeof entry !== "object") return null;
|
|
9048
9138
|
const { name, version, dir } = entry;
|
|
9049
|
-
if (!
|
|
9139
|
+
if (!isNonEmptyString6(name) || !isNonEmptyString6(version) || !isNonEmptyString6(dir)) return null;
|
|
9050
9140
|
if (!ENTRY_NAME_RE.test(dir) || dir.length > MAX_DIR_LENGTH) return null;
|
|
9051
9141
|
packages.push({ name, version, dir, from: entry.from === FROM_HOST ? FROM_HOST : FROM_DECLARED });
|
|
9052
9142
|
}
|
|
@@ -9071,7 +9161,7 @@ function readStagedSkills({ globalPiDir, readFile = readFileSync, fileExists = e
|
|
|
9071
9161
|
out.unenumerable.push(pkg.name);
|
|
9072
9162
|
continue;
|
|
9073
9163
|
}
|
|
9074
|
-
sources = entries.filter((e) => !e.split(/[\\/]/).includes("..")).map((e) =>
|
|
9164
|
+
sources = entries.filter((e) => !e.split(/[\\/]/).includes("..")).map((e) => resolve2(root, e));
|
|
9075
9165
|
} else {
|
|
9076
9166
|
sources = [join(root, "skills")];
|
|
9077
9167
|
}
|
|
@@ -10368,6 +10458,7 @@ function resolvePaths(env = process.env) {
|
|
|
10368
10458
|
// root, and silently wrong (demo triggers) everywhere else.
|
|
10369
10459
|
triggersPath: env.PI_TRIGGERS_FILE ?? "./triggers.json",
|
|
10370
10460
|
pauseWindowsPath: env.PI_PAUSE_WINDOWS_FILE ?? "./pause-windows.json",
|
|
10461
|
+
scopedLimitsPath: env.PI_SCOPED_LIMITS_FILE ?? "./scoped-limits.json",
|
|
10371
10462
|
subscriptionsPath: env.PI_SUBSCRIPTIONS_FILE ?? "./subscriptions.json",
|
|
10372
10463
|
// The operator's global pi overlay dir (REQ-GLOBAL-PI-OVERLAY), where the staged third-party pi
|
|
10373
10464
|
// packages live under `packages/`. `|| null` so unset AND empty both read as "no overlay" -- the
|
|
@@ -10586,6 +10677,80 @@ function writePauseWindows({ pauseWindowsPath, mutate, fs = nodeFs3 }) {
|
|
|
10586
10677
|
fs.renameSync(tmp, pauseWindowsPath);
|
|
10587
10678
|
return { ok: true };
|
|
10588
10679
|
}
|
|
10680
|
+
function readScopedLimits({ scopedLimitsPath, fs = nodeFs3 }) {
|
|
10681
|
+
let text;
|
|
10682
|
+
try {
|
|
10683
|
+
text = fs.readFileSync(scopedLimitsPath, "utf8");
|
|
10684
|
+
} catch {
|
|
10685
|
+
return { missing: true };
|
|
10686
|
+
}
|
|
10687
|
+
try {
|
|
10688
|
+
return { limits: parseScopedLimits(text, scopedLimitsPath) };
|
|
10689
|
+
} catch (e) {
|
|
10690
|
+
return { invalid: e?.message ?? String(e) };
|
|
10691
|
+
}
|
|
10692
|
+
}
|
|
10693
|
+
function writeScopedLimits({ scopedLimitsPath, mutate, fs = nodeFs3 }) {
|
|
10694
|
+
let current = [];
|
|
10695
|
+
let existing = null;
|
|
10696
|
+
try {
|
|
10697
|
+
existing = fs.readFileSync(scopedLimitsPath, "utf8");
|
|
10698
|
+
} catch {
|
|
10699
|
+
}
|
|
10700
|
+
if (existing !== null) {
|
|
10701
|
+
try {
|
|
10702
|
+
current = parseScopedLimits(existing, scopedLimitsPath);
|
|
10703
|
+
} catch (e) {
|
|
10704
|
+
return { invalid: e?.message ?? String(e) };
|
|
10705
|
+
}
|
|
10706
|
+
}
|
|
10707
|
+
const next = mutate(current.map((l) => ({ ...l })));
|
|
10708
|
+
const rows = next.map((l) => Object.fromEntries(Object.entries(l).filter(([, v]) => v !== null && v !== void 0)));
|
|
10709
|
+
const text = `${JSON.stringify({ version: SCOPED_LIMITS_VERSION, limits: rows }, null, 2)}
|
|
10710
|
+
`;
|
|
10711
|
+
try {
|
|
10712
|
+
parseScopedLimits(text, scopedLimitsPath);
|
|
10713
|
+
} catch (e) {
|
|
10714
|
+
return { invalid: e?.message ?? String(e) };
|
|
10715
|
+
}
|
|
10716
|
+
const tmp = `${scopedLimitsPath}.tmp`;
|
|
10717
|
+
fs.writeFileSync(tmp, text, { mode: 420 });
|
|
10718
|
+
fs.renameSync(tmp, scopedLimitsPath);
|
|
10719
|
+
return { ok: true };
|
|
10720
|
+
}
|
|
10721
|
+
async function readScopedBudget({ url, limits, redisFn = makeRedisClient, timeoutMs = 2500 } = {}) {
|
|
10722
|
+
if (!Array.isArray(limits) || limits.length === 0) return { rows: [] };
|
|
10723
|
+
let redis;
|
|
10724
|
+
try {
|
|
10725
|
+
parseConnection(url, { failFast: true });
|
|
10726
|
+
redis = redisFn(url);
|
|
10727
|
+
const now = /* @__PURE__ */ new Date();
|
|
10728
|
+
const settled = Promise.all(
|
|
10729
|
+
limits.map(async (l) => {
|
|
10730
|
+
const prefix = scopeKeyPrefix(l.scope);
|
|
10731
|
+
const cell2 = async (key) => Number(await redis.get(key) ?? 0);
|
|
10732
|
+
const row = {};
|
|
10733
|
+
if (l.day !== null && l.day !== void 0) row.day = await cell2(dayKey(now, prefix));
|
|
10734
|
+
if (l.week !== null && l.week !== void 0) row.week = await cell2(weekKey(now, prefix));
|
|
10735
|
+
if (l.month !== null && l.month !== void 0) row.month = await cell2(monthKey(now, prefix));
|
|
10736
|
+
return row;
|
|
10737
|
+
})
|
|
10738
|
+
).then(
|
|
10739
|
+
(rows) => ({ rows }),
|
|
10740
|
+
(err) => ({ unreachable: err?.message ?? String(err) })
|
|
10741
|
+
);
|
|
10742
|
+
return await withTimeout(settled, timeoutMs, { unreachable: "timed out reaching the queue" });
|
|
10743
|
+
} catch (err) {
|
|
10744
|
+
return { unreachable: err?.message ?? String(err) };
|
|
10745
|
+
} finally {
|
|
10746
|
+
if (redis) {
|
|
10747
|
+
try {
|
|
10748
|
+
redis.disconnect();
|
|
10749
|
+
} catch {
|
|
10750
|
+
}
|
|
10751
|
+
}
|
|
10752
|
+
}
|
|
10753
|
+
}
|
|
10589
10754
|
function readSubscriptions({ subscriptionsPath, fs = nodeFs3 }) {
|
|
10590
10755
|
if (subscriptionsPath === null || subscriptionsPath === void 0) return { missing: true };
|
|
10591
10756
|
let text;
|
|
@@ -10662,8 +10827,8 @@ async function readBudget({ url, redisFn = makeRedisClient, timeoutMs = 2500 } =
|
|
|
10662
10827
|
}
|
|
10663
10828
|
function withTimeout(promise, ms, fallback) {
|
|
10664
10829
|
let timer;
|
|
10665
|
-
const timeout = new Promise((
|
|
10666
|
-
timer = setTimeout(() =>
|
|
10830
|
+
const timeout = new Promise((resolve3) => {
|
|
10831
|
+
timer = setTimeout(() => resolve3(fallback), ms);
|
|
10667
10832
|
if (typeof timer?.unref === "function") timer.unref();
|
|
10668
10833
|
});
|
|
10669
10834
|
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
@@ -11225,6 +11390,7 @@ var init_read_model = __esm({
|
|
|
11225
11390
|
init_run_history();
|
|
11226
11391
|
init_budget();
|
|
11227
11392
|
init_pause_windows();
|
|
11393
|
+
init_scoped_limits();
|
|
11228
11394
|
init_subscriptions();
|
|
11229
11395
|
init_connection();
|
|
11230
11396
|
init_queue();
|
|
@@ -12751,7 +12917,28 @@ function normBudget(v) {
|
|
|
12751
12917
|
cap: Number.isInteger(t.cap) && t.cap > 0 ? t.cap : null,
|
|
12752
12918
|
state: BUDGET_STATES.includes(t.state) ? t.state : "ok",
|
|
12753
12919
|
maxTokens: Number.isInteger(t.maxTokens) && t.maxTokens > 0 ? t.maxTokens : null
|
|
12754
|
-
}
|
|
12920
|
+
},
|
|
12921
|
+
// The scoped rows (issue #242): a pre-1.5 payload simply lacks the key and renders nothing.
|
|
12922
|
+
scoped: Array.isArray(v.scoped) ? v.scoped.map(normScopedRow).filter((r) => r !== null) : [],
|
|
12923
|
+
scopedInvalid: typeof v.scopedInvalid === "string" && v.scopedInvalid !== "" ? clip3(v.scopedInvalid, 160) : null
|
|
12924
|
+
};
|
|
12925
|
+
}
|
|
12926
|
+
function normScopedRow(v) {
|
|
12927
|
+
if (v === null || typeof v !== "object" || typeof v.scope !== "string" || v.scope === "") return null;
|
|
12928
|
+
const win = (w) => {
|
|
12929
|
+
if (w === null || w === void 0 || typeof w !== "object") return null;
|
|
12930
|
+
return {
|
|
12931
|
+
used: Number.isInteger(w.used) && w.used >= 0 ? w.used : null,
|
|
12932
|
+
cap: Number.isInteger(w.cap) && w.cap > 0 ? w.cap : null,
|
|
12933
|
+
state: BUDGET_STATES.includes(w.state) ? w.state : "ok"
|
|
12934
|
+
};
|
|
12935
|
+
};
|
|
12936
|
+
return {
|
|
12937
|
+
scope: clip3(v.scope, 60),
|
|
12938
|
+
day: win(v.day),
|
|
12939
|
+
week: win(v.week),
|
|
12940
|
+
month: win(v.month),
|
|
12941
|
+
concurrent: Number.isInteger(v.concurrent) && v.concurrent > 0 ? v.concurrent : null
|
|
12755
12942
|
};
|
|
12756
12943
|
}
|
|
12757
12944
|
function normSpendMap(v) {
|
|
@@ -13123,6 +13310,24 @@ function budgetSectionHtml(nb) {
|
|
|
13123
13310
|
if (nb.softHoldPct !== null) {
|
|
13124
13311
|
rows.push(`<div class="dim small">soft-hold band: ${fmt(nb.softHoldPct)}% of each cap (the amber tick)</div>`);
|
|
13125
13312
|
}
|
|
13313
|
+
if (nb.scopedInvalid !== null) {
|
|
13314
|
+
rows.push(`<div class="dim small">scoped limits: file invalid (${escapeHtml(nb.scopedInvalid)})</div>`);
|
|
13315
|
+
} else if (nb.scoped.length > 0) {
|
|
13316
|
+
rows.push('<div class="dim small">scoped limits (scoped-limits.json)</div>');
|
|
13317
|
+
for (const s of nb.scoped) {
|
|
13318
|
+
const bits = [];
|
|
13319
|
+
let worst = "ok";
|
|
13320
|
+
for (const key of ["day", "week", "month"]) {
|
|
13321
|
+
const w = s[key];
|
|
13322
|
+
if (w === null || w.cap === null) continue;
|
|
13323
|
+
bits.push(`${key} used ${w.used !== null ? fmt(w.used) : "?"} / cap ${fmt(w.cap)}`);
|
|
13324
|
+
if (w.state !== "ok") worst = w.state;
|
|
13325
|
+
}
|
|
13326
|
+
if (s.concurrent !== null) bits.push(`concurrent \u2264${fmt(s.concurrent)} (config; in-flight not shown)`);
|
|
13327
|
+
rows.push(`<div class="row"><span class="wl">${escapeHtml(s.scope)}</span><span>${escapeHtml(bits.join(" \xB7 "))}</span>${worst !== "ok" ? stateWord(worst) : ""}</div>`);
|
|
13328
|
+
}
|
|
13329
|
+
rows.push('<div class="lever">scoped: dispatch_limit_add/edit/delete \xB7 or press m in the /dispatch panel</div>');
|
|
13330
|
+
}
|
|
13126
13331
|
rows.push('<div class="lever">adjust: /dispatch set dailyCap|weeklyCap|monthlyCap|dailyTokenCap|softHoldPct <n> \xB7 or press s in the /dispatch panel</div>');
|
|
13127
13332
|
return `<div id="budget">${rows.join("")}</div>`;
|
|
13128
13333
|
}
|
|
@@ -13528,7 +13733,7 @@ var init_open_browser = __esm({
|
|
|
13528
13733
|
// src/deployment-pointer.mjs
|
|
13529
13734
|
import * as nodeFs4 from "node:fs";
|
|
13530
13735
|
import { homedir } from "node:os";
|
|
13531
|
-
import { join as join3, isAbsolute } from "node:path";
|
|
13736
|
+
import { join as join3, isAbsolute as isAbsolute2 } from "node:path";
|
|
13532
13737
|
function pointerPath(env = process.env) {
|
|
13533
13738
|
return env.PI_DISPATCH_DEPLOYMENT_FILE || join3(env.PI_CODING_AGENT_DIR || join3(homedir(), ".pi", "agent"), POINTER_BASENAME);
|
|
13534
13739
|
}
|
|
@@ -13542,7 +13747,7 @@ function normalizePointer(raw) {
|
|
|
13542
13747
|
if (raw.version > POINTER_VERSION) {
|
|
13543
13748
|
return { ignored: `version ${raw.version} is newer than the supported version ${POINTER_VERSION}` };
|
|
13544
13749
|
}
|
|
13545
|
-
if (typeof raw.deploymentDir !== "string" || !
|
|
13750
|
+
if (typeof raw.deploymentDir !== "string" || !isAbsolute2(raw.deploymentDir)) {
|
|
13546
13751
|
return { ignored: "deploymentDir must be an absolute path" };
|
|
13547
13752
|
}
|
|
13548
13753
|
const rawEnv = raw.env === void 0 ? {} : raw.env;
|
|
@@ -13553,7 +13758,7 @@ function normalizePointer(raw) {
|
|
|
13553
13758
|
for (const key of POINTER_ENV_ALLOWLIST) {
|
|
13554
13759
|
const value = rawEnv[key];
|
|
13555
13760
|
if (typeof value !== "string" || value === "") continue;
|
|
13556
|
-
if (key !== "VALKEY_URL" && !
|
|
13761
|
+
if (key !== "VALKEY_URL" && !isAbsolute2(value)) continue;
|
|
13557
13762
|
env[key] = value;
|
|
13558
13763
|
}
|
|
13559
13764
|
return { pointer: { version: raw.version, deploymentDir: raw.deploymentDir, env } };
|
|
@@ -13624,6 +13829,7 @@ var init_deployment_pointer = __esm({
|
|
|
13624
13829
|
"PI_SETTINGS_FILE",
|
|
13625
13830
|
"PI_TRIGGERS_FILE",
|
|
13626
13831
|
"PI_PAUSE_WINDOWS_FILE",
|
|
13832
|
+
"PI_SCOPED_LIMITS_FILE",
|
|
13627
13833
|
"PI_SUBSCRIPTIONS_FILE"
|
|
13628
13834
|
]);
|
|
13629
13835
|
POINTER_BASENAME = "pi-dispatch-deployment.json";
|
|
@@ -39168,7 +39374,7 @@ var init_pricing = __esm({
|
|
|
39168
39374
|
|
|
39169
39375
|
// ../worker/src/sandbox-store.mjs
|
|
39170
39376
|
import { lstatSync, mkdirSync, readFileSync as readFileSync3, readdirSync as readdirSync2, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
39171
|
-
import { isAbsolute as
|
|
39377
|
+
import { isAbsolute as isAbsolute3, join as join5, relative } from "node:path";
|
|
39172
39378
|
function readManifest({ sandboxDir, jobId, fs = defaultFs }) {
|
|
39173
39379
|
if (!sandboxDir || jobId === void 0 || jobId === null) return null;
|
|
39174
39380
|
const dir = join5(sandboxDir, sanitizeJobId(jobId));
|
|
@@ -39241,10 +39447,10 @@ function resolveSandbox({ jobId, sandboxDir, retentionHours, publish = [], fs, f
|
|
|
39241
39447
|
return { manifest, name: sandboxContainerName(jobId), publish };
|
|
39242
39448
|
}
|
|
39243
39449
|
function launchSandbox({ args, spawnFn = spawn }) {
|
|
39244
|
-
return new Promise((
|
|
39450
|
+
return new Promise((resolve3) => {
|
|
39245
39451
|
const child = spawnFn("docker", args, { stdio: "inherit" });
|
|
39246
|
-
child.on("error", (err) =>
|
|
39247
|
-
child.on("close", (code) =>
|
|
39452
|
+
child.on("error", (err) => resolve3({ code: null, error: err }));
|
|
39453
|
+
child.on("close", (code) => resolve3({ code }));
|
|
39248
39454
|
});
|
|
39249
39455
|
}
|
|
39250
39456
|
var exec3, SANDBOX_NAME_PREFIX;
|
|
@@ -39299,6 +39505,24 @@ function renderBudget({ budget, settings: settings2 } = {}) {
|
|
|
39299
39505
|
if (pct !== null) lines.push(` soft-hold band: ${pct}%`);
|
|
39300
39506
|
return lines.join("\n");
|
|
39301
39507
|
}
|
|
39508
|
+
function renderScopedLimits({ limits, scopedBudget } = {}) {
|
|
39509
|
+
if (limits?.invalid) return `Scoped limits: file invalid (${limits.invalid})`;
|
|
39510
|
+
const list = Array.isArray(limits?.limits) ? limits.limits : [];
|
|
39511
|
+
if (list.length === 0) return null;
|
|
39512
|
+
const lines = ["Scoped limits:"];
|
|
39513
|
+
list.forEach((l, i) => {
|
|
39514
|
+
const used = scopedBudget?.rows?.[i] ?? null;
|
|
39515
|
+
const bits = [];
|
|
39516
|
+
for (const key of ["day", "week", "month"]) {
|
|
39517
|
+
if (!Number.isInteger(l[key])) continue;
|
|
39518
|
+
const u = used && Number.isFinite(used[key]) ? used[key] : "-";
|
|
39519
|
+
bits.push(`${key} ${u}/${l[key]}`);
|
|
39520
|
+
}
|
|
39521
|
+
if (Number.isInteger(l.concurrent)) bits.push(`<=${l.concurrent} at once`);
|
|
39522
|
+
lines.push(` ${l.scope}: ${bits.join(" \xB7 ")}`);
|
|
39523
|
+
});
|
|
39524
|
+
return lines.join("\n");
|
|
39525
|
+
}
|
|
39302
39526
|
function capLabel(cap, reserved, pct) {
|
|
39303
39527
|
if (!Number.isInteger(cap)) return "unknown (worker env/default)";
|
|
39304
39528
|
const state = windowState(reserved, cap, pct);
|
|
@@ -39666,7 +39890,25 @@ function createDashboardDeps(paths) {
|
|
|
39666
39890
|
redis.hgetall(STALL_KEY).catch(() => ({}))
|
|
39667
39891
|
]);
|
|
39668
39892
|
const workers = Array.isArray(workerList) && workerList.length > 0 ? workerList.length : "unknown";
|
|
39893
|
+
const scopedLimits = readScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath });
|
|
39894
|
+
const limitRows = Array.isArray(scopedLimits?.limits) ? scopedLimits.limits : [];
|
|
39895
|
+
const scopedNow = /* @__PURE__ */ new Date();
|
|
39896
|
+
const scopedBudget = {
|
|
39897
|
+
rows: await Promise.all(
|
|
39898
|
+
limitRows.map(async (l) => {
|
|
39899
|
+
const prefix = scopeKeyPrefix(l.scope);
|
|
39900
|
+
const cell2 = (key) => redis.get(key).then((v) => Number(v ?? 0), () => null);
|
|
39901
|
+
const row = {};
|
|
39902
|
+
if (Number.isInteger(l.day)) row.day = await cell2(dayKey(scopedNow, prefix));
|
|
39903
|
+
if (Number.isInteger(l.week)) row.week = await cell2(weekKey(scopedNow, prefix));
|
|
39904
|
+
if (Number.isInteger(l.month)) row.month = await cell2(monthKey(scopedNow, prefix));
|
|
39905
|
+
return row;
|
|
39906
|
+
})
|
|
39907
|
+
)
|
|
39908
|
+
};
|
|
39669
39909
|
return {
|
|
39910
|
+
scopedLimits,
|
|
39911
|
+
scopedBudget,
|
|
39670
39912
|
queue: { pausedState, counts, workers },
|
|
39671
39913
|
budget: { day: Number(dayRaw ?? 0), week: Number(weekRaw ?? 0), month: Number(monthRaw ?? 0), tokensToday: Number(tokenRaw ?? 0) },
|
|
39672
39914
|
schedulers: mapSchedulers(schedulerList, Date.now()),
|
|
@@ -40027,6 +40269,10 @@ function makeDashboard({
|
|
|
40027
40269
|
void dispose().finally(() => done({ action: "managePauses" }));
|
|
40028
40270
|
return;
|
|
40029
40271
|
}
|
|
40272
|
+
if (data === "m" || data === "M") {
|
|
40273
|
+
void dispose().finally(() => done({ action: "manageLimits" }));
|
|
40274
|
+
return;
|
|
40275
|
+
}
|
|
40030
40276
|
if (matchesKey(data, "up")) {
|
|
40031
40277
|
selected = Math.max(0, selected - 1);
|
|
40032
40278
|
tui?.requestRender?.();
|
|
@@ -40130,6 +40376,8 @@ function renderPanel(snapshot, width, state, styler) {
|
|
|
40130
40376
|
{ title: "RUNS", lines: renderRunList(buildRows(snapshot, runSort), selected, 24) },
|
|
40131
40377
|
{ title: "SETTINGS", lines: toLines(renderSettingsView(snapshot.settings)) }
|
|
40132
40378
|
];
|
|
40379
|
+
const scopedText = renderScopedLimits({ limits: snapshot.scopedLimits, scopedBudget: snapshot.scopedBudget });
|
|
40380
|
+
if (scopedText) sections.splice(2, 0, { title: "SCOPED LIMITS", lines: toLines(scopedText) });
|
|
40133
40381
|
const plain = [title];
|
|
40134
40382
|
for (const section of sections) plain.push(section.title, ...section.lines);
|
|
40135
40383
|
plain.push(KEY_HINTS);
|
|
@@ -40157,6 +40405,7 @@ function collapseKeys(sections, availableRows, focus, baseRows) {
|
|
|
40157
40405
|
function buildListLines(snapshot, selected, inner, styler, runSort = "time", availableRows = null) {
|
|
40158
40406
|
const trg = triggerLines(snapshot, selected, inner, styler);
|
|
40159
40407
|
const pw = pauseLines(snapshot.pauseWindows, inner, styler);
|
|
40408
|
+
const sl = limitLines(snapshot.scopedLimits, snapshot.scopedBudget, inner, styler);
|
|
40160
40409
|
const runRows = buildRows(snapshot, runSort).slice(trg.count);
|
|
40161
40410
|
const runCount = Array.isArray(snapshot.runs) ? snapshot.runs.length : 0;
|
|
40162
40411
|
const sections = [
|
|
@@ -40164,6 +40413,10 @@ function buildListLines(snapshot, selected, inner, styler, runSort = "time", ava
|
|
|
40164
40413
|
{ key: "spend", head: ["spend & limits", "jobs & tokens/day \xB7 s set"], body: spendLines(snapshot.budget, snapshot.settings, inner, styler), priority: 4, viewKey: "s" },
|
|
40165
40414
|
{ key: "triggers", head: ["triggers", `${trg.count} standing \xB7 a add \xB7 \u21B5 open`], body: trg.lines, priority: 3, viewKey: "tab" },
|
|
40166
40415
|
{ key: "pauses", head: ["pause windows", `${pw.count} \xB7 w manage`], body: pw.lines, priority: 1, viewKey: "w" },
|
|
40416
|
+
// Priority 0: the section an operator acts on least often from the panel folds FIRST under the
|
|
40417
|
+
// collapse budget. The key hint lives in this divider, not the footer -- the footer's width
|
|
40418
|
+
// arithmetic has no headroom for another hint (its own comment), the s/o/Tab precedent.
|
|
40419
|
+
{ key: "limits", head: ["scoped limits", `${sl.count} \xB7 m manage`], body: sl.lines, priority: 0, viewKey: "m" },
|
|
40167
40420
|
{ key: "runs", head: ["runs", `last ${runCount} \xB7 o ${runSort}`], body: runLines(runRows, selected - trg.count, inner, styler) },
|
|
40168
40421
|
{ key: "settings", head: ["settings", "s edit"], body: settingsLines(snapshot.settings, inner, styler), priority: 2, viewKey: "s" }
|
|
40169
40422
|
];
|
|
@@ -40198,7 +40451,8 @@ function statusHeader(queue, inner, styler) {
|
|
|
40198
40451
|
const dot = styler.fg(stateColor, "\u25CF");
|
|
40199
40452
|
const word = styler.bold(styler.fg(stateColor, running ? "RUNNING" : "PAUSED"));
|
|
40200
40453
|
const sep2 = styler.fg("dim", " \xB7 ");
|
|
40201
|
-
const
|
|
40454
|
+
const delayed = Number(c.delayed ?? 0);
|
|
40455
|
+
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`;
|
|
40202
40456
|
const clock = (/* @__PURE__ */ new Date()).toISOString().slice(11, 19);
|
|
40203
40457
|
const left = `${dot} ${word} ${vitals}`;
|
|
40204
40458
|
const gap = inner - styler.visibleLen(left) - clock.length;
|
|
@@ -40353,6 +40607,39 @@ function pauseRow(w, now, inner, styler) {
|
|
|
40353
40607
|
if (until) bits.push(styler.fg("warning", `resumes in ${humanizeMs(until - now) || "<1m"}`));
|
|
40354
40608
|
return fitLine(bits.join(styler.fg("dim", " ")), inner, styler);
|
|
40355
40609
|
}
|
|
40610
|
+
function limitLines(scopedLimits, scopedBudget, inner, styler) {
|
|
40611
|
+
const lines = [];
|
|
40612
|
+
if (scopedLimits && scopedLimits.missing) {
|
|
40613
|
+
lines.push(styler.cell("(no scoped limits \xB7 m to manage)", inner, { color: "dim" }));
|
|
40614
|
+
return { count: 0, lines };
|
|
40615
|
+
}
|
|
40616
|
+
if (scopedLimits && scopedLimits.invalid) {
|
|
40617
|
+
lines.push(styler.cell(`(scoped-limits file invalid: ${scopedLimits.invalid})`, inner, { color: "error" }));
|
|
40618
|
+
return { count: 0, lines };
|
|
40619
|
+
}
|
|
40620
|
+
const list = (scopedLimits && scopedLimits.limits) ?? [];
|
|
40621
|
+
if (list.length === 0) {
|
|
40622
|
+
lines.push(styler.cell("(no scoped limits \xB7 m to manage)", inner, { color: "dim" }));
|
|
40623
|
+
return { count: 0, lines };
|
|
40624
|
+
}
|
|
40625
|
+
list.forEach((l, i) => lines.push(limitRow(l, scopedBudget?.rows?.[i] ?? null, inner, styler)));
|
|
40626
|
+
return { count: list.length, lines };
|
|
40627
|
+
}
|
|
40628
|
+
function limitRow(l, used, inner, styler) {
|
|
40629
|
+
let atCap = false;
|
|
40630
|
+
const windows = [];
|
|
40631
|
+
for (const key of ["day", "week", "month"]) {
|
|
40632
|
+
if (!Number.isInteger(l[key])) continue;
|
|
40633
|
+
const u = used && Number.isFinite(used[key]) ? used[key] : null;
|
|
40634
|
+
const over = u !== null && u >= l[key];
|
|
40635
|
+
if (over) atCap = true;
|
|
40636
|
+
windows.push(styler.fg(over ? "warning" : "text", `${key} ${u ?? "-"}/${l[key]}`));
|
|
40637
|
+
}
|
|
40638
|
+
const dot = atCap ? styler.fg("warning", "\u25CF") : styler.fg("dim", "\u25CB");
|
|
40639
|
+
const bits = [`${dot} ${styler.fg("accent", l.scope ?? "-")}`, ...windows];
|
|
40640
|
+
if (Number.isInteger(l.concurrent)) bits.push(styler.fg("muted", `\u2264${l.concurrent} at once`));
|
|
40641
|
+
return fitLine(bits.join(styler.fg("dim", " ")), inner, styler);
|
|
40642
|
+
}
|
|
40356
40643
|
function runLines(rows, selected, inner, styler) {
|
|
40357
40644
|
if (!Array.isArray(rows) || rows.length === 0) return [styler.cell("(no runs)", inner, { color: "dim" })];
|
|
40358
40645
|
const { top, count } = runsWindow(rows.length, selected);
|
|
@@ -40770,6 +41057,7 @@ var init_dashboard = __esm({
|
|
|
40770
41057
|
init_scheduler_stall_guard();
|
|
40771
41058
|
init_pause_windows();
|
|
40772
41059
|
init_read_model();
|
|
41060
|
+
init_scoped_limits();
|
|
40773
41061
|
init_render();
|
|
40774
41062
|
init_keys2();
|
|
40775
41063
|
init_panel();
|
|
@@ -40858,16 +41146,16 @@ async function runAttached(ctx, { title, argv0, args, cwd, env, shell, spawnFn =
|
|
|
40858
41146
|
${title}
|
|
40859
41147
|
|
|
40860
41148
|
`);
|
|
40861
|
-
outcome = await new Promise((
|
|
41149
|
+
outcome = await new Promise((resolve3) => {
|
|
40862
41150
|
let child;
|
|
40863
41151
|
try {
|
|
40864
41152
|
child = spawnFn(argv0, args, { stdio: "inherit", cwd, env, ...shell ? { shell: true } : {} });
|
|
40865
41153
|
} catch (err) {
|
|
40866
|
-
|
|
41154
|
+
resolve3({ code: null, error: err });
|
|
40867
41155
|
return;
|
|
40868
41156
|
}
|
|
40869
|
-
child.on("error", (err) =>
|
|
40870
|
-
child.on("close", (code) =>
|
|
41157
|
+
child.on("error", (err) => resolve3({ code: null, error: err }));
|
|
41158
|
+
child.on("close", (code) => resolve3({ code }));
|
|
40871
41159
|
});
|
|
40872
41160
|
} catch (err) {
|
|
40873
41161
|
outcome = { code: null, error: err };
|
|
@@ -41086,6 +41374,7 @@ up shows each docker action and asks y/N before it \u2014 nothing is auto-accept
|
|
|
41086
41374
|
env: {
|
|
41087
41375
|
PI_TRIGGERS_FILE: join6(dir, "triggers.json"),
|
|
41088
41376
|
PI_PAUSE_WINDOWS_FILE: join6(dir, "pause-windows.json"),
|
|
41377
|
+
PI_SCOPED_LIMITS_FILE: join6(dir, "scoped-limits.json"),
|
|
41089
41378
|
PI_SUBSCRIPTIONS_FILE: join6(dir, "subscriptions.json")
|
|
41090
41379
|
}
|
|
41091
41380
|
};
|
|
@@ -41318,7 +41607,7 @@ var init_setup_wizard = __esm({
|
|
|
41318
41607
|
init_deployment_pointer();
|
|
41319
41608
|
init_read_model();
|
|
41320
41609
|
init_index();
|
|
41321
|
-
RUNTIME_VERSION = "1.
|
|
41610
|
+
RUNTIME_VERSION = "1.5.0";
|
|
41322
41611
|
RECEIVER_VERSION = "1.3.0";
|
|
41323
41612
|
RUNTIME_PKG = "@edgehero/pi-dispatch";
|
|
41324
41613
|
RECEIVER_PKG = "@edgehero/pi-dispatch-receiver";
|
|
@@ -41513,15 +41802,15 @@ function registerTools(pi) {
|
|
|
41513
41802
|
pi.registerTool({
|
|
41514
41803
|
name: "dispatch_costs",
|
|
41515
41804
|
label: "pi-dispatch costs",
|
|
41516
|
-
description:
|
|
41517
|
-
parameters: typebox_exports.Object({ window: typebox_exports.Optional(typebox_exports.String()), flow: typebox_exports.Optional(typebox_exports.String()) }),
|
|
41805
|
+
description: `Read-only. Folds the PII-free run history against the operator's declared subscriptions and pi-ai's rate tables into the costs read-model: window totals, daily buckets, per-flow and per-model rollups, per-plan verdicts, and provenance. window = 7d | 30d | mtd (default mtd); flow filters to one flow's runs; repo filters to one repo's runs by the by-repo rollup's key (e.g. "acme/web" or "local:site").`,
|
|
41806
|
+
parameters: typebox_exports.Object({ window: typebox_exports.Optional(typebox_exports.String()), flow: typebox_exports.Optional(typebox_exports.String()), repo: typebox_exports.Optional(typebox_exports.String()) }),
|
|
41518
41807
|
async execute(_toolCallId, params) {
|
|
41519
41808
|
const window = params.window ?? "mtd";
|
|
41520
41809
|
if (!COSTS_WINDOWS.includes(window)) {
|
|
41521
41810
|
throw new Error(`unknown window '${window}' (7d|30d|mtd)`);
|
|
41522
41811
|
}
|
|
41523
41812
|
const paths = resolvePaths(process.env);
|
|
41524
|
-
const res = assembleCosts(paths, window, params.flow);
|
|
41813
|
+
const res = assembleCosts(paths, window, params.flow, params.repo);
|
|
41525
41814
|
if (res.unreachable) throw new Error(`could not read the run history: ${res.unreachable}`);
|
|
41526
41815
|
return toolText(JSON.stringify({ window, fold: res.fold }));
|
|
41527
41816
|
}
|
|
@@ -41803,6 +42092,113 @@ ${JSON.stringify(before)}
|
|
|
41803
42092
|
return toolText(JSON.stringify(result));
|
|
41804
42093
|
}
|
|
41805
42094
|
});
|
|
42095
|
+
pi.registerTool({
|
|
42096
|
+
name: "dispatch_limits",
|
|
42097
|
+
label: "pi-dispatch scoped limits",
|
|
42098
|
+
description: "Read-only. Lists the scoped limits (per repo/folder budget caps and concurrency, scoped-limits.json) with their array index and, when the queue is reachable, each scope's used day/week/month run counts. Use the index for dispatch_limit_edit / dispatch_limit_delete. The always-on one-job-per-folder mutex for local jobs is separate: it has no entry here and no switch anywhere.",
|
|
42099
|
+
parameters: typebox_exports.Object({}),
|
|
42100
|
+
async execute() {
|
|
42101
|
+
const paths = resolvePaths(process.env);
|
|
42102
|
+
const p = readScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath });
|
|
42103
|
+
if (!Array.isArray(p?.limits)) return toolText(JSON.stringify(p));
|
|
42104
|
+
const counters = await readScopedBudget({ url: paths.valkeyUrl, limits: p.limits });
|
|
42105
|
+
const rows = p.limits.map((l, index) => ({ index, ...l, used: counters.rows?.[index] ?? null }));
|
|
42106
|
+
return toolText(JSON.stringify(rows));
|
|
42107
|
+
}
|
|
42108
|
+
});
|
|
42109
|
+
pi.registerTool({
|
|
42110
|
+
name: "dispatch_limit_add",
|
|
42111
|
+
label: "pi-dispatch add scoped limit",
|
|
42112
|
+
description: 'Adds a scoped limit and applies it live: jobs whose scope (a repo "owner/name" or a local folder path, ABSOLUTE, matched exactly like pause windows \u2014 no globs) exceed the `day`/`week`/`month` job-count caps are REFUSED before any spend (reason scope-cap, never retried); jobs over `concurrent` are DEFERRED, never dropped, and run when a slot frees. At least one of day/week/month/concurrent is required, each an integer >= 1. The operator MUST approve a confirm dialog showing the entry; refused with no interactive operator.',
|
|
42113
|
+
executionMode: "sequential",
|
|
42114
|
+
parameters: typebox_exports.Object({
|
|
42115
|
+
scope: typebox_exports.String(),
|
|
42116
|
+
day: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1 })),
|
|
42117
|
+
week: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1 })),
|
|
42118
|
+
month: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1 })),
|
|
42119
|
+
concurrent: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1 }))
|
|
42120
|
+
}),
|
|
42121
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
42122
|
+
const paths = resolvePaths(process.env);
|
|
42123
|
+
const l = buildScopedLimit(params);
|
|
42124
|
+
const result = await confirmedWrite(
|
|
42125
|
+
ctx,
|
|
42126
|
+
{ title: "Add scoped limit", message: `Add to scoped-limits.json:
|
|
42127
|
+
${JSON.stringify(l)}` },
|
|
42128
|
+
() => {
|
|
42129
|
+
const res = writeScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath, mutate: (list) => [...list, l] });
|
|
42130
|
+
if (res.invalid) throw new Error(`rejected: ${res.invalid}`);
|
|
42131
|
+
return { applied: true, added: l };
|
|
42132
|
+
}
|
|
42133
|
+
);
|
|
42134
|
+
return toolText(JSON.stringify(result));
|
|
42135
|
+
}
|
|
42136
|
+
});
|
|
42137
|
+
pi.registerTool({
|
|
42138
|
+
name: "dispatch_limit_delete",
|
|
42139
|
+
label: "pi-dispatch delete scoped limit",
|
|
42140
|
+
description: "Removes a scoped limit (by array index from dispatch_limits) and applies it live. The operator MUST approve a confirm dialog showing the limit; refused with no interactive operator.",
|
|
42141
|
+
executionMode: "sequential",
|
|
42142
|
+
parameters: typebox_exports.Object({ index: typebox_exports.Integer({ minimum: 0 }) }),
|
|
42143
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
42144
|
+
const paths = resolvePaths(process.env);
|
|
42145
|
+
const p = readScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath });
|
|
42146
|
+
const list = Array.isArray(p?.limits) ? p.limits : [];
|
|
42147
|
+
const cur = list[params.index];
|
|
42148
|
+
if (!cur) throw new Error(`no scoped limit at index ${params.index} (have ${list.length})`);
|
|
42149
|
+
const result = await confirmedWrite(
|
|
42150
|
+
ctx,
|
|
42151
|
+
{ title: `Delete scoped limit #${params.index + 1}`, message: `Remove scoped limit #${params.index + 1}: ${cur.scope} ${limitSummary(cur)}` },
|
|
42152
|
+
() => {
|
|
42153
|
+
const res = writeScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath, mutate: (l) => l.filter((_, i) => i !== params.index) });
|
|
42154
|
+
if (res.invalid) throw new Error(`rejected: ${res.invalid}`);
|
|
42155
|
+
return { applied: true, deletedIndex: params.index };
|
|
42156
|
+
}
|
|
42157
|
+
);
|
|
42158
|
+
return toolText(JSON.stringify(result));
|
|
42159
|
+
}
|
|
42160
|
+
});
|
|
42161
|
+
pi.registerTool({
|
|
42162
|
+
name: "dispatch_limit_edit",
|
|
42163
|
+
label: "pi-dispatch edit scoped limit",
|
|
42164
|
+
description: "Changes fields of an existing scoped limit (by array index from dispatch_limits) and applies it live. Provide only the fields to change (scope/day/week/month/concurrent); the rest keep their current value (to drop one cap from an entry, delete it and re-add without that field). The operator MUST approve a confirm dialog showing the before->after; refused with no interactive operator.",
|
|
42165
|
+
executionMode: "sequential",
|
|
42166
|
+
parameters: typebox_exports.Object({
|
|
42167
|
+
index: typebox_exports.Integer({ minimum: 0 }),
|
|
42168
|
+
scope: typebox_exports.Optional(typebox_exports.String()),
|
|
42169
|
+
day: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1 })),
|
|
42170
|
+
week: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1 })),
|
|
42171
|
+
month: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1 })),
|
|
42172
|
+
concurrent: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1 }))
|
|
42173
|
+
}),
|
|
42174
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
42175
|
+
const paths = resolvePaths(process.env);
|
|
42176
|
+
const p = readScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath });
|
|
42177
|
+
const list = Array.isArray(p?.limits) ? p.limits : [];
|
|
42178
|
+
const cur = list[params.index];
|
|
42179
|
+
if (!cur) throw new Error(`no scoped limit at index ${params.index} (have ${list.length})`);
|
|
42180
|
+
const before = buildScopedLimit(cur);
|
|
42181
|
+
const merged = buildScopedLimit({
|
|
42182
|
+
scope: params.scope ?? cur.scope,
|
|
42183
|
+
day: params.day ?? cur.day,
|
|
42184
|
+
week: params.week ?? cur.week,
|
|
42185
|
+
month: params.month ?? cur.month,
|
|
42186
|
+
concurrent: params.concurrent ?? cur.concurrent
|
|
42187
|
+
});
|
|
42188
|
+
const result = await confirmedWrite(
|
|
42189
|
+
ctx,
|
|
42190
|
+
{ title: `Edit scoped limit #${params.index + 1}`, message: `scoped limit #${params.index + 1}:
|
|
42191
|
+
${JSON.stringify(before)}
|
|
42192
|
+
\u2192 ${JSON.stringify(merged)}` },
|
|
42193
|
+
() => {
|
|
42194
|
+
const res = writeScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath, mutate: (l) => l.map((w, i) => i === params.index ? merged : w) });
|
|
42195
|
+
if (res.invalid) throw new Error(`rejected: ${res.invalid}`);
|
|
42196
|
+
return { applied: true, index: params.index, limit: merged };
|
|
42197
|
+
}
|
|
42198
|
+
);
|
|
42199
|
+
return toolText(JSON.stringify(result));
|
|
42200
|
+
}
|
|
42201
|
+
});
|
|
41806
42202
|
pi.registerTool({
|
|
41807
42203
|
name: "dispatch_trigger_delete",
|
|
41808
42204
|
label: "pi-dispatch delete trigger",
|
|
@@ -41888,6 +42284,22 @@ function buildPauseWindow(f) {
|
|
|
41888
42284
|
if (dateTo) w.dateTo = dateTo;
|
|
41889
42285
|
return w;
|
|
41890
42286
|
}
|
|
42287
|
+
function buildScopedLimit(f) {
|
|
42288
|
+
const l = { scope: String(f.scope ?? "").trim() };
|
|
42289
|
+
for (const k of ["day", "week", "month", "concurrent"]) {
|
|
42290
|
+
const v = optInt(f[k]);
|
|
42291
|
+
if (v !== void 0) l[k] = v;
|
|
42292
|
+
}
|
|
42293
|
+
return l;
|
|
42294
|
+
}
|
|
42295
|
+
function limitSummary(l) {
|
|
42296
|
+
const bits = [];
|
|
42297
|
+
if (Number.isInteger(l?.day)) bits.push(`day ${l.day}`);
|
|
42298
|
+
if (Number.isInteger(l?.week)) bits.push(`week ${l.week}`);
|
|
42299
|
+
if (Number.isInteger(l?.month)) bits.push(`month ${l.month}`);
|
|
42300
|
+
if (Number.isInteger(l?.concurrent)) bits.push(`\u2264${l.concurrent} at once`);
|
|
42301
|
+
return bits.join(" \xB7 ");
|
|
42302
|
+
}
|
|
41891
42303
|
function asWords(x) {
|
|
41892
42304
|
if (Array.isArray(x)) return x.map((s) => String(s).trim()).filter(Boolean);
|
|
41893
42305
|
return splitWords(x);
|
|
@@ -41957,7 +42369,11 @@ ${renderBudget({ budget, settings: settings2 })}`);
|
|
|
41957
42369
|
case "budget": {
|
|
41958
42370
|
const budget = await readBudget({ url: paths.valkeyUrl });
|
|
41959
42371
|
const settings2 = readSettingsView({ settingsFile: paths.settingsFile });
|
|
41960
|
-
|
|
42372
|
+
const limits = readScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath });
|
|
42373
|
+
const scopedBudget = Array.isArray(limits?.limits) && limits.limits.length > 0 ? await readScopedBudget({ url: paths.valkeyUrl, limits: limits.limits }) : { rows: [] };
|
|
42374
|
+
const scopedText = renderScopedLimits({ limits, scopedBudget });
|
|
42375
|
+
send(pi, `${renderBudget({ budget, settings: settings2 })}${scopedText ? `
|
|
42376
|
+
${scopedText}` : ""}`);
|
|
41961
42377
|
return;
|
|
41962
42378
|
}
|
|
41963
42379
|
case "triggers": {
|
|
@@ -42038,13 +42454,14 @@ ${renderBudget({ budget, settings: settings2 })}`);
|
|
|
42038
42454
|
return;
|
|
42039
42455
|
}
|
|
42040
42456
|
}
|
|
42041
|
-
function assembleCosts(paths, window, flow) {
|
|
42457
|
+
function assembleCosts(paths, window, flow, repo) {
|
|
42042
42458
|
const nowMs = Date.now();
|
|
42043
42459
|
const sinceMs = costsSinceMs(window, nowMs);
|
|
42044
42460
|
const records = scanRunRecords({ logsDir: paths.logsDir, sinceMs, nowMs });
|
|
42045
42461
|
if (!Array.isArray(records)) return records;
|
|
42046
42462
|
const subs = readSubscriptions({ subscriptionsPath: paths.subscriptionsPath });
|
|
42047
|
-
const
|
|
42463
|
+
const flowScoped = typeof flow === "string" && flow !== "" ? records.filter((r) => (r?.flow ?? null) === flow) : records;
|
|
42464
|
+
const scoped = typeof repo === "string" && repo !== "" ? flowScoped.filter((r) => repoOfTarget(r?.target) === repo) : flowScoped;
|
|
42048
42465
|
const triggersView = readTriggers({ triggersPath: paths.triggersPath });
|
|
42049
42466
|
const triggerJoin = attributeRunsToTriggers({ records: scoped, triggers: Array.isArray(triggersView?.triggers) ? triggersView.triggers : [] });
|
|
42050
42467
|
const fold = foldCosts({
|
|
@@ -42107,13 +42524,27 @@ async function assembleBudgetView(paths) {
|
|
|
42107
42524
|
const tokensSpent = unreachable === null ? Number(raw.tokensToday ?? 0) : null;
|
|
42108
42525
|
const tokenCap = posIntOrNull(overlay.dailyTokenCap);
|
|
42109
42526
|
const tokenState = tokenCap !== null && tokensSpent !== null ? tokensSpent >= tokenCap ? "over" : pct !== null && tokensSpent > Math.floor(tokenCap * pct / 100) ? "soft-hold" : "ok" : "ok";
|
|
42527
|
+
const limitsView = readScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath });
|
|
42528
|
+
const limits = Array.isArray(limitsView?.limits) ? limitsView.limits : [];
|
|
42529
|
+
const scopedCounters = limits.length > 0 ? await readScopedBudget({ url: paths.valkeyUrl, limits }) : { rows: [] };
|
|
42530
|
+
const scoped = limits.map((l, i) => {
|
|
42531
|
+
const used = scopedCounters?.rows?.[i] ?? null;
|
|
42532
|
+
const win = (cap, key) => {
|
|
42533
|
+
if (cap === null) return null;
|
|
42534
|
+
const u = used && Number.isFinite(used[key]) ? used[key] : null;
|
|
42535
|
+
return { used: u, cap, state: u !== null ? windowState(u, cap, null) : "ok" };
|
|
42536
|
+
};
|
|
42537
|
+
return { scope: l.scope, day: win(l.day, "day"), week: win(l.week, "week"), month: win(l.month, "month"), concurrent: l.concurrent };
|
|
42538
|
+
});
|
|
42110
42539
|
return {
|
|
42111
42540
|
unreachable,
|
|
42112
42541
|
softHoldPct: pct,
|
|
42113
42542
|
day: slotWindow("day", "dailyCap"),
|
|
42114
42543
|
week: slotWindow("week", "weeklyCap"),
|
|
42115
42544
|
month: slotWindow("month", "monthlyCap"),
|
|
42116
|
-
tokens: { spent: tokensSpent, cap: tokenCap, state: tokenState, maxTokens: posIntOrNull(overlay.maxTokens) }
|
|
42545
|
+
tokens: { spent: tokensSpent, cap: tokenCap, state: tokenState, maxTokens: posIntOrNull(overlay.maxTokens) },
|
|
42546
|
+
scoped,
|
|
42547
|
+
scopedInvalid: limitsView?.invalid ? String(limitsView.invalid) : null
|
|
42117
42548
|
};
|
|
42118
42549
|
}
|
|
42119
42550
|
async function assembleInsights(paths, window) {
|
|
@@ -42280,7 +42711,7 @@ could not start docker: ${error.message}
|
|
|
42280
42711
|
}
|
|
42281
42712
|
}
|
|
42282
42713
|
function pauseForMessage() {
|
|
42283
|
-
return new Promise((
|
|
42714
|
+
return new Promise((resolve3) => setTimeout(resolve3, 2500));
|
|
42284
42715
|
}
|
|
42285
42716
|
function splitWords(s) {
|
|
42286
42717
|
return String(s ?? "").split(/\s+/).map((w) => w.trim()).filter(Boolean);
|
|
@@ -42306,8 +42737,84 @@ async function handleDashboardAction(result, paths, ctx) {
|
|
|
42306
42737
|
return editSettingsViaDialogs(paths, ui, notify);
|
|
42307
42738
|
case "managePauses":
|
|
42308
42739
|
return managePausesViaDialogs(paths, ui, notify);
|
|
42740
|
+
case "manageLimits":
|
|
42741
|
+
return manageLimitsViaDialogs(paths, ui, notify);
|
|
42309
42742
|
}
|
|
42310
42743
|
}
|
|
42744
|
+
async function manageLimitsViaDialogs(paths, ui, notify) {
|
|
42745
|
+
const action = await ui.select("Scoped limits", ["Add a scoped limit", "Edit a scoped limit", "Delete a scoped limit"]);
|
|
42746
|
+
if (!action) return;
|
|
42747
|
+
if (action.startsWith("Add")) return addScopedLimitViaDialogs(paths, ui, notify);
|
|
42748
|
+
if (action.startsWith("Edit")) return editScopedLimitViaDialogs(paths, ui, notify);
|
|
42749
|
+
return deleteScopedLimitViaDialogs(paths, ui, notify);
|
|
42750
|
+
}
|
|
42751
|
+
async function addScopedLimitViaDialogs(paths, ui, notify) {
|
|
42752
|
+
const scope = await ui.input('scope \u2014 a repo "owner/name" or an ABSOLUTE local folder path (exact match, no globs)', "");
|
|
42753
|
+
if (scope === void 0 || scope.trim() === "") return;
|
|
42754
|
+
const day = await ui.input("day \u2014 max jobs per UTC day for this scope (blank = no day cap)", "");
|
|
42755
|
+
if (day === void 0) return;
|
|
42756
|
+
const week = await ui.input("week \u2014 max jobs per Mon-Sun UTC week (blank = no week cap)", "");
|
|
42757
|
+
if (week === void 0) return;
|
|
42758
|
+
const month = await ui.input("month \u2014 max jobs per calendar month (blank = no month cap)", "");
|
|
42759
|
+
if (month === void 0) return;
|
|
42760
|
+
const concurrent = await ui.input("concurrent \u2014 max jobs in flight for this scope, extras deferred (blank = no limit)", "");
|
|
42761
|
+
if (concurrent === void 0) return;
|
|
42762
|
+
const l = buildScopedLimit({ scope, day, week, month, concurrent });
|
|
42763
|
+
const res = writeScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath, mutate: (list) => [...list, l] });
|
|
42764
|
+
notify?.(res.ok ? `scoped limit added (live) \u2014 ${l.scope} ${limitSummary(l)}` : `add rejected: ${res.invalid}`, res.ok ? "info" : "error");
|
|
42765
|
+
}
|
|
42766
|
+
async function editScopedLimitViaDialogs(paths, ui, notify) {
|
|
42767
|
+
const p = readScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath });
|
|
42768
|
+
const list = Array.isArray(p?.limits) ? p.limits : [];
|
|
42769
|
+
if (list.length === 0) {
|
|
42770
|
+
notify?.("no scoped limits to edit", "info");
|
|
42771
|
+
return;
|
|
42772
|
+
}
|
|
42773
|
+
const labels = list.map((l, i) => `#${i + 1} ${l.scope} ${limitSummary(l)}`);
|
|
42774
|
+
const picked = await ui.select("Edit which scoped limit", labels);
|
|
42775
|
+
if (!picked) return;
|
|
42776
|
+
const index = labels.indexOf(picked);
|
|
42777
|
+
if (index < 0) return;
|
|
42778
|
+
const cur = list[index];
|
|
42779
|
+
const ask = async (label, current) => ui.input(`${label} \u2014 blank keeps "${current}"`, current);
|
|
42780
|
+
const keep = (v, current) => v === void 0 || v.trim() === "" ? current : v;
|
|
42781
|
+
const scope = await ask("scope", cur.scope ?? "");
|
|
42782
|
+
if (scope === void 0) return;
|
|
42783
|
+
const day = await ask("day cap", String(cur.day ?? ""));
|
|
42784
|
+
if (day === void 0) return;
|
|
42785
|
+
const week = await ask("week cap", String(cur.week ?? ""));
|
|
42786
|
+
if (week === void 0) return;
|
|
42787
|
+
const month = await ask("month cap", String(cur.month ?? ""));
|
|
42788
|
+
if (month === void 0) return;
|
|
42789
|
+
const concurrent = await ask("concurrent (max in flight)", String(cur.concurrent ?? ""));
|
|
42790
|
+
if (concurrent === void 0) return;
|
|
42791
|
+
const merged = buildScopedLimit({
|
|
42792
|
+
scope: keep(scope, cur.scope),
|
|
42793
|
+
day: keep(day, cur.day),
|
|
42794
|
+
week: keep(week, cur.week),
|
|
42795
|
+
month: keep(month, cur.month),
|
|
42796
|
+
concurrent: keep(concurrent, cur.concurrent)
|
|
42797
|
+
});
|
|
42798
|
+
const res = writeScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath, mutate: (l) => l.map((w, i) => i === index ? merged : w) });
|
|
42799
|
+
notify?.(res.ok ? `scoped limit #${index + 1} updated (live) \u2014 ${merged.scope} ${limitSummary(merged)}` : `edit rejected: ${res.invalid}`, res.ok ? "info" : "error");
|
|
42800
|
+
}
|
|
42801
|
+
async function deleteScopedLimitViaDialogs(paths, ui, notify) {
|
|
42802
|
+
const p = readScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath });
|
|
42803
|
+
const list = Array.isArray(p?.limits) ? p.limits : [];
|
|
42804
|
+
if (list.length === 0) {
|
|
42805
|
+
notify?.("no scoped limits to delete", "info");
|
|
42806
|
+
return;
|
|
42807
|
+
}
|
|
42808
|
+
const labels = list.map((l, i) => `#${i + 1} ${l.scope} ${limitSummary(l)}`);
|
|
42809
|
+
const picked = await ui.select("Delete which scoped limit", labels);
|
|
42810
|
+
if (!picked) return;
|
|
42811
|
+
const index = labels.indexOf(picked);
|
|
42812
|
+
if (index < 0) return;
|
|
42813
|
+
const ok = await ui.confirm("Delete scoped limit", `Remove ${labels[index]}?`);
|
|
42814
|
+
if (!ok) return;
|
|
42815
|
+
const res = writeScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath, mutate: (l) => l.filter((_, i) => i !== index) });
|
|
42816
|
+
notify?.(res.ok ? `scoped limit #${index + 1} deleted (live)` : `delete rejected: ${res.invalid}`, res.ok ? "info" : "error");
|
|
42817
|
+
}
|
|
42311
42818
|
async function managePausesViaDialogs(paths, ui, notify) {
|
|
42312
42819
|
const action = await ui.select("Pause windows", ["Add a pause window", "Edit a pause window", "Delete a pause window"]);
|
|
42313
42820
|
if (!action) return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edgehero/pi-dispatch-admin",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.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",
|
|
@@ -41,6 +41,12 @@ dialog before it takes effect**:
|
|
|
41
41
|
(per folder/repo "quiet hours": runs for a scope are deferred between certain times and auto-resume after;
|
|
42
42
|
`dispatch_pauses` lists them with their index). `dispatch_pause_edit` is a partial change — pass the index
|
|
43
43
|
plus only the fields to alter. Deferring never drops a job and costs no budget.
|
|
44
|
+
- `dispatch_limit_add` / `dispatch_limit_edit` / `dispatch_limit_delete` — manage scoped limits (per
|
|
45
|
+
repo/folder budget caps and concurrency: `day`/`week`/`month` job-count caps refuse a job pre-spend with
|
|
46
|
+
reason `scope-cap`, never retried; `concurrent` defers the excess, never drops it; `dispatch_limits`
|
|
47
|
+
lists them with their index and used counts). `dispatch_limit_edit` is a partial change — pass the index
|
|
48
|
+
plus only the fields to alter. Scopes match exactly (a repo `owner/name` or an ABSOLUTE folder path, no
|
|
49
|
+
globs).
|
|
44
50
|
|
|
45
51
|
Use them like this:
|
|
46
52
|
|
|
@@ -235,3 +241,28 @@ If you are asked why a GitLab trigger did not fire, the usual answers in order:
|
|
|
235
241
|
2. **No label was added by that event.** GitLab has no `labeled` action; the trigger fires on the labels
|
|
236
242
|
an event *added*, so editing an already-labelled issue does nothing. This is deliberate.
|
|
237
243
|
3. **The action word belongs to the other forge.** See above.
|
|
244
|
+
|
|
245
|
+
## Scoped limits — and the folder mutex you cannot turn off
|
|
246
|
+
|
|
247
|
+
`scoped-limits.json` holds per-scope bounds beside the deployment-global ones: `day`/`week`/`month` cap
|
|
248
|
+
how many jobs a repo or folder may run per window (refused pre-spend, reason `scope-cap`, never retried,
|
|
249
|
+
the scope's own counter still counts the refusal), and `concurrent` caps how many run at once (the excess
|
|
250
|
+
is deferred to the delayed set and runs when a slot frees — never dropped, no budget spent while waiting).
|
|
251
|
+
Edits apply live: the worker hot-reloads the file and keeps the last good version on a bad edit.
|
|
252
|
+
|
|
253
|
+
Separate from all of that, **local jobs carry a built-in one-job-per-folder mutex: at most one job per
|
|
254
|
+
folder at a time, always on, with NO configuration, NO tool, and NO panel key.** If an operator asks to
|
|
255
|
+
disable it, say plainly that there is no switch, deliberately: two agents editing one working tree race
|
|
256
|
+
each other with no gate and no undo, and an off switch's only use is re-opening that race. A `concurrent`
|
|
257
|
+
value on a folder scope can never raise the mutex's one-at-a-time (the lower bound always wins).
|
|
258
|
+
|
|
259
|
+
Three more things to say when asked:
|
|
260
|
+
|
|
261
|
+
- **A `scope-cap` refusal is final for that window.** The counter is not resettable from any tool; the
|
|
262
|
+
window rolls over on its own (day/week/month, UTC). Raising the cap via `dispatch_limit_edit` takes
|
|
263
|
+
effect at the next job.
|
|
264
|
+
- **Deferrals are visible only as the queue's delayed count** (the panel's status line shows it when it
|
|
265
|
+
is nonzero). That count also includes cron next-occurrences and retry backoff — a nonzero number is
|
|
266
|
+
normal on any deployment with schedules.
|
|
267
|
+
- **Per-scope in-flight is not displayed anywhere.** The panel and `dispatch_limits` show `concurrent` as
|
|
268
|
+
configuration only; the live count lives inside the worker process and no reader can see it.
|