@edgehero/pi-dispatch-admin 1.4.0 → 1.6.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 +5 -3
- package/dist/index.mjs +903 -60
- package/package.json +1 -1
- package/skills/operate-pi-dispatch/SKILL.md +59 -0
package/dist/index.mjs
CHANGED
|
@@ -7687,6 +7687,33 @@ var init_secret_profiles = __esm({
|
|
|
7687
7687
|
}
|
|
7688
7688
|
});
|
|
7689
7689
|
|
|
7690
|
+
// ../worker/src/wait-for.mjs
|
|
7691
|
+
function parseAfterInstant(text) {
|
|
7692
|
+
if (typeof text !== "string" || !AFTER_INSTANT.test(text)) return { error: "shape" };
|
|
7693
|
+
const ms = Date.parse(text);
|
|
7694
|
+
if (!Number.isFinite(ms)) return { error: "calendar" };
|
|
7695
|
+
const [year, month, day] = text.slice(0, 10).split("-").map(Number);
|
|
7696
|
+
const probe = new Date(Date.UTC(year, month - 1, day));
|
|
7697
|
+
if (year < 100) probe.setUTCFullYear(year);
|
|
7698
|
+
if (probe.getUTCFullYear() !== year || probe.getUTCMonth() !== month - 1 || probe.getUTCDate() !== day) {
|
|
7699
|
+
return { error: "calendar" };
|
|
7700
|
+
}
|
|
7701
|
+
return { ms };
|
|
7702
|
+
}
|
|
7703
|
+
function afterInstantMs(text) {
|
|
7704
|
+
const parsed = parseAfterInstant(text);
|
|
7705
|
+
return parsed.error ? null : parsed.ms;
|
|
7706
|
+
}
|
|
7707
|
+
var WAIT_CONDITION_KEYS, WAIT_CONDITION_MAX, WAIT_AFTER_MAX_DEFAULT_MS, AFTER_INSTANT;
|
|
7708
|
+
var init_wait_for = __esm({
|
|
7709
|
+
"../worker/src/wait-for.mjs"() {
|
|
7710
|
+
WAIT_CONDITION_KEYS = ["after", "profile"];
|
|
7711
|
+
WAIT_CONDITION_MAX = 4;
|
|
7712
|
+
WAIT_AFTER_MAX_DEFAULT_MS = 30 * 24 * 3600 * 1e3;
|
|
7713
|
+
AFTER_INSTANT = /^\d{4}-\d{2}-\d{2}[Tt]([01]\d|2[0-3]):[0-5]\d(:[0-5]\d(\.\d+)?)?([Zz]|[+-]\d{2}:\d{2})$/;
|
|
7714
|
+
}
|
|
7715
|
+
});
|
|
7716
|
+
|
|
7690
7717
|
// ../worker/src/config.mjs
|
|
7691
7718
|
function configError(message) {
|
|
7692
7719
|
const error = new Error(message);
|
|
@@ -7714,6 +7741,7 @@ var init_config2 = __esm({
|
|
|
7714
7741
|
init_egress();
|
|
7715
7742
|
init_forges();
|
|
7716
7743
|
init_secret_profiles();
|
|
7744
|
+
init_wait_for();
|
|
7717
7745
|
CHAIN_DEPTH_MAX_DEFAULT = 1;
|
|
7718
7746
|
CHAIN_MAX_PER_JOB_DEFAULT = 2;
|
|
7719
7747
|
WORKER_ONLY_SECRET_VARS = /* @__PURE__ */ new Set(["GITHUB_APP_PRIVATE_KEY"]);
|
|
@@ -8024,10 +8052,112 @@ var init_pause_windows = __esm({
|
|
|
8024
8052
|
}
|
|
8025
8053
|
});
|
|
8026
8054
|
|
|
8027
|
-
// ../worker/src/
|
|
8055
|
+
// ../worker/src/scoped-limits.mjs
|
|
8056
|
+
import { createHash } from "node:crypto";
|
|
8057
|
+
import { isAbsolute, resolve } from "node:path";
|
|
8028
8058
|
function isNonEmptyString3(value) {
|
|
8029
8059
|
return typeof value === "string" && value.trim() !== "";
|
|
8030
8060
|
}
|
|
8061
|
+
function parseScopedLimits(text, path) {
|
|
8062
|
+
let parsed;
|
|
8063
|
+
try {
|
|
8064
|
+
parsed = JSON.parse(text);
|
|
8065
|
+
} catch (error) {
|
|
8066
|
+
throw configError(`scoped-limits file is not valid JSON: ${path} (${error.message})`);
|
|
8067
|
+
}
|
|
8068
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
8069
|
+
throw configError(`scoped-limits file must be an object with "version" and "limits": ${path}`);
|
|
8070
|
+
}
|
|
8071
|
+
const version = parsed.version;
|
|
8072
|
+
if (!Number.isInteger(version) || version < 1) {
|
|
8073
|
+
throw configError(`scoped-limits file must have "version": 1 (an integer >= 1): ${path}`);
|
|
8074
|
+
}
|
|
8075
|
+
if (version > SCOPED_LIMITS_VERSION) {
|
|
8076
|
+
throw configError(`scoped-limits file written by a newer pi-dispatch (version ${version}; this build understands ${SCOPED_LIMITS_VERSION}): ${path}`);
|
|
8077
|
+
}
|
|
8078
|
+
if (!Array.isArray(parsed.limits)) {
|
|
8079
|
+
throw configError(`scoped-limits file must have a "limits" array: ${path}`);
|
|
8080
|
+
}
|
|
8081
|
+
const rows = parsed.limits.map((row, index) => normalizeLimit(row, index, path));
|
|
8082
|
+
const seen = /* @__PURE__ */ new Map();
|
|
8083
|
+
rows.forEach((row, index) => {
|
|
8084
|
+
if (seen.has(row.scope)) {
|
|
8085
|
+
throw configError(`scoped limit at index ${index}: duplicate scope ${JSON.stringify(row.scope)} (first at index ${seen.get(row.scope)}): ${path}`);
|
|
8086
|
+
}
|
|
8087
|
+
seen.set(row.scope, index);
|
|
8088
|
+
});
|
|
8089
|
+
return rows;
|
|
8090
|
+
}
|
|
8091
|
+
function normalizeLimit(row, index, path) {
|
|
8092
|
+
const at = `scoped limit at index ${index}`;
|
|
8093
|
+
if (row === null || typeof row !== "object" || Array.isArray(row)) {
|
|
8094
|
+
throw configError(`${at}: must be an object: ${path}`);
|
|
8095
|
+
}
|
|
8096
|
+
if (!isNonEmptyString3(row.scope)) throw configError(`${at}: scope must be a non-empty string: ${path}`);
|
|
8097
|
+
const trimmed = row.scope.trim().normalize("NFC");
|
|
8098
|
+
if (trimmed === "*") {
|
|
8099
|
+
throw configError(`${at}: "*" is not supported -- add one row per scope (a per-scope default may adopt "*" later): ${path}`);
|
|
8100
|
+
}
|
|
8101
|
+
if (trimmed.includes("*")) {
|
|
8102
|
+
throw configError(`${at}: scopes match exactly; a scope containing "*" is refused (no globs): ${path}`);
|
|
8103
|
+
}
|
|
8104
|
+
const norm = {
|
|
8105
|
+
// An absolute path is stored resolved so a `/srv/site/` row governs `/srv/site` jobs -- the same
|
|
8106
|
+
// collapse canonicalScope applies on the job side. isAbsolute is PLATFORM-NATIVE on purpose, so a
|
|
8107
|
+
// foreign-platform row (a windows drive path on a POSIX worker) stays verbatim and is inert here;
|
|
8108
|
+
// the doctor's unreferenced-scope advisory names it. Resolving it instead would "work" only by
|
|
8109
|
+
// both sides mangling into the same cwd-prefixed string -- a match by accident, not by contract.
|
|
8110
|
+
scope: isAbsolute(trimmed) ? resolve(trimmed) : trimmed,
|
|
8111
|
+
day: null,
|
|
8112
|
+
week: null,
|
|
8113
|
+
month: null,
|
|
8114
|
+
concurrent: null
|
|
8115
|
+
};
|
|
8116
|
+
let any = false;
|
|
8117
|
+
for (const field of LIMIT_FIELDS) {
|
|
8118
|
+
const value = row[field];
|
|
8119
|
+
if (value === void 0 || value === null) continue;
|
|
8120
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
8121
|
+
throw configError(`${at}: ${field} must be an integer >= 1: ${path}`);
|
|
8122
|
+
}
|
|
8123
|
+
norm[field] = value;
|
|
8124
|
+
any = true;
|
|
8125
|
+
}
|
|
8126
|
+
if (!any) {
|
|
8127
|
+
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}`);
|
|
8128
|
+
}
|
|
8129
|
+
return norm;
|
|
8130
|
+
}
|
|
8131
|
+
function scopeKeyPrefix(scope) {
|
|
8132
|
+
const h = createHash("sha256").update(String(scope)).digest("hex").slice(0, 16);
|
|
8133
|
+
return `budget:s:${h}`;
|
|
8134
|
+
}
|
|
8135
|
+
var SCOPED_LIMITS_VERSION, LIMIT_FIELDS;
|
|
8136
|
+
var init_scoped_limits = __esm({
|
|
8137
|
+
"../worker/src/scoped-limits.mjs"() {
|
|
8138
|
+
init_config2();
|
|
8139
|
+
init_pause_windows();
|
|
8140
|
+
SCOPED_LIMITS_VERSION = 1;
|
|
8141
|
+
LIMIT_FIELDS = ["day", "week", "month", "concurrent"];
|
|
8142
|
+
}
|
|
8143
|
+
});
|
|
8144
|
+
|
|
8145
|
+
// ../worker/src/wait-state.mjs
|
|
8146
|
+
var LEASE_SLACK_MS, LEASE_MIN_MS, HELD_SET, jobKey, SATISFIED_MS;
|
|
8147
|
+
var init_wait_state = __esm({
|
|
8148
|
+
"../worker/src/wait-state.mjs"() {
|
|
8149
|
+
LEASE_SLACK_MS = 60 * 60 * 1e3;
|
|
8150
|
+
LEASE_MIN_MS = 5 * 60 * 1e3;
|
|
8151
|
+
HELD_SET = "wait:held";
|
|
8152
|
+
jobKey = (jobId) => `wait:job:${jobId}`;
|
|
8153
|
+
SATISFIED_MS = 15 * 60 * 1e3;
|
|
8154
|
+
}
|
|
8155
|
+
});
|
|
8156
|
+
|
|
8157
|
+
// ../worker/src/subscriptions.mjs
|
|
8158
|
+
function isNonEmptyString4(value) {
|
|
8159
|
+
return typeof value === "string" && value.trim() !== "";
|
|
8160
|
+
}
|
|
8031
8161
|
function normalizeGlobs(value, label, at, path) {
|
|
8032
8162
|
if (!Array.isArray(value) || value.length === 0) {
|
|
8033
8163
|
throw configError(`${at}: ${label} must be a non-empty array of model globs: ${path}`);
|
|
@@ -8073,14 +8203,14 @@ function normalizeSubscription(entry, index, path, seenIds) {
|
|
|
8073
8203
|
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
|
|
8074
8204
|
throw configError(`${at}: must be an object: ${path}`);
|
|
8075
8205
|
}
|
|
8076
|
-
if (!
|
|
8206
|
+
if (!isNonEmptyString4(entry.id)) throw configError(`${at}: id must be a non-empty string: ${path}`);
|
|
8077
8207
|
const id = entry.id.trim();
|
|
8078
8208
|
if (seenIds.has(id)) {
|
|
8079
8209
|
throw configError(`${at}: id ${JSON.stringify(id)} is already used by the subscription at index ${seenIds.get(id)}: ${path}`);
|
|
8080
8210
|
}
|
|
8081
8211
|
seenIds.set(id, index);
|
|
8082
|
-
if (!
|
|
8083
|
-
if (!
|
|
8212
|
+
if (!isNonEmptyString4(entry.vendor)) throw configError(`${at}: vendor must be a non-empty string: ${path}`);
|
|
8213
|
+
if (!isNonEmptyString4(entry.provider)) throw configError(`${at}: provider must be a non-empty string (the pi-ai provider id this plan covers): ${path}`);
|
|
8084
8214
|
const models = entry.models === void 0 ? ["*"] : normalizeGlobs(entry.models, "models", at, path);
|
|
8085
8215
|
const price = entry.price;
|
|
8086
8216
|
if (price === null || typeof price !== "object" || Array.isArray(price)) {
|
|
@@ -8100,7 +8230,7 @@ function normalizeSubscription(entry, index, path, seenIds) {
|
|
|
8100
8230
|
let counterfactualModel = null;
|
|
8101
8231
|
if (entry.counterfactualModel !== void 0 && entry.counterfactualModel !== null) {
|
|
8102
8232
|
const cm = entry.counterfactualModel;
|
|
8103
|
-
if (typeof cm !== "object" || Array.isArray(cm) || !
|
|
8233
|
+
if (typeof cm !== "object" || Array.isArray(cm) || !isNonEmptyString4(cm.provider) || !isNonEmptyString4(cm.id)) {
|
|
8104
8234
|
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
8235
|
}
|
|
8106
8236
|
counterfactualModel = { provider: cm.provider.trim(), id: cm.id.trim() };
|
|
@@ -8197,9 +8327,9 @@ var init_connection = __esm({
|
|
|
8197
8327
|
});
|
|
8198
8328
|
|
|
8199
8329
|
// ../worker/src/job-id.mjs
|
|
8200
|
-
import { createHash } from "node:crypto";
|
|
8330
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
8201
8331
|
function localJobId({ folder, flow, task, minute }) {
|
|
8202
|
-
const digest =
|
|
8332
|
+
const digest = createHash2("sha256").update([folder, flow ?? "", task ?? "", minute].join("\0")).digest("hex");
|
|
8203
8333
|
return `local-${digest.slice(0, 16)}`;
|
|
8204
8334
|
}
|
|
8205
8335
|
var init_job_id = __esm({
|
|
@@ -8276,7 +8406,7 @@ var init_reserved_env = __esm({
|
|
|
8276
8406
|
});
|
|
8277
8407
|
|
|
8278
8408
|
// ../worker/src/triggers.mjs
|
|
8279
|
-
function
|
|
8409
|
+
function isNonEmptyString5(value) {
|
|
8280
8410
|
return typeof value === "string" && value.trim() !== "";
|
|
8281
8411
|
}
|
|
8282
8412
|
function parseTriggers(text, path) {
|
|
@@ -8331,7 +8461,7 @@ function normalizeTrigger(entry, index, path, state) {
|
|
|
8331
8461
|
function normalizeCron(on, run, index, path, state) {
|
|
8332
8462
|
const at = `trigger at index ${index}`;
|
|
8333
8463
|
const id = on.id;
|
|
8334
|
-
if (!
|
|
8464
|
+
if (!isNonEmptyString5(id)) {
|
|
8335
8465
|
throw configError(`${at}: cron on.id must be a non-empty string: ${path}`);
|
|
8336
8466
|
}
|
|
8337
8467
|
if (id.includes(":")) {
|
|
@@ -8345,7 +8475,7 @@ function normalizeCron(on, run, index, path, state) {
|
|
|
8345
8475
|
}
|
|
8346
8476
|
state.seenCronIds.add(id);
|
|
8347
8477
|
const pattern = on.pattern;
|
|
8348
|
-
if (!
|
|
8478
|
+
if (!isNonEmptyString5(pattern)) {
|
|
8349
8479
|
throw configError(`cron trigger "${id}": on.pattern must be a non-empty string: ${path}`);
|
|
8350
8480
|
}
|
|
8351
8481
|
const fieldCount = pattern.trim().split(/\s+/).length;
|
|
@@ -8353,13 +8483,13 @@ function normalizeCron(on, run, index, path, state) {
|
|
|
8353
8483
|
throw configError(`cron trigger "${id}": on.pattern must have 5 or 6 space-separated fields, got ${fieldCount}: ${path}`);
|
|
8354
8484
|
}
|
|
8355
8485
|
const command = validateCommand(run, `cron trigger "${id}"`, path, { onType: "cron" });
|
|
8356
|
-
if (!
|
|
8486
|
+
if (!isNonEmptyString5(run.folder)) {
|
|
8357
8487
|
throw configError(`cron trigger "${id}": run.folder must be a non-empty string: ${path}`);
|
|
8358
8488
|
}
|
|
8359
|
-
if (command === void 0 && !
|
|
8489
|
+
if (command === void 0 && !isNonEmptyString5(run.flow)) {
|
|
8360
8490
|
throw configError(`cron trigger "${id}": run.flow must be a non-empty string (or use run.command): ${path}`);
|
|
8361
8491
|
}
|
|
8362
|
-
if (command === void 0 && !
|
|
8492
|
+
if (command === void 0 && !isNonEmptyString5(run.task)) {
|
|
8363
8493
|
throw configError(`cron trigger "${id}": run.task must be a non-empty string: ${path}`);
|
|
8364
8494
|
}
|
|
8365
8495
|
if (run.github !== void 0 && typeof run.github !== "boolean") {
|
|
@@ -8376,6 +8506,7 @@ function normalizeCron(on, run, index, path, state) {
|
|
|
8376
8506
|
validateDisarmed(on, `cron trigger "${id}"`, path, { onType: "cron" });
|
|
8377
8507
|
const secrets = validateSecrets(run, `cron trigger "${id}"`, path);
|
|
8378
8508
|
const secretsProfile = validateSecretsProfile(run, `cron trigger "${id}"`, path);
|
|
8509
|
+
validateWaitFor(on, run, `cron trigger "${id}"`, path, { onType: "cron" });
|
|
8379
8510
|
return {
|
|
8380
8511
|
on: { type: "cron", id, pattern },
|
|
8381
8512
|
run: { kind: "local", folder: run.folder, flow: run.flow, task: run.task, provider: run.provider, model: run.model, maxTurns: run.maxTurns, github: run.github, packages, image, resume, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...secrets !== void 0 && { secrets }, ...secretsProfile !== void 0 && { secretsProfile } }
|
|
@@ -8443,7 +8574,7 @@ function validateCommand(run, at, path, { onType }) {
|
|
|
8443
8574
|
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
8575
|
}
|
|
8445
8576
|
if (raw === void 0) return void 0;
|
|
8446
|
-
if (!
|
|
8577
|
+
if (!isNonEmptyString5(raw)) {
|
|
8447
8578
|
throw configError(`${at}: run.command must be a non-empty string -- the registered command name, optionally followed by its arguments: ${path}`);
|
|
8448
8579
|
}
|
|
8449
8580
|
if (raw !== raw.trim()) {
|
|
@@ -8529,10 +8660,10 @@ function validateDisarmed(on, at, path, { capable = false, onType } = {}) {
|
|
|
8529
8660
|
throw configError(`${at}: on.disarmed has an unsupported key ${JSON.stringify(key)} (expected at|jobId): ${path}`);
|
|
8530
8661
|
}
|
|
8531
8662
|
}
|
|
8532
|
-
if (!
|
|
8663
|
+
if (!isNonEmptyString5(disarmed.at)) {
|
|
8533
8664
|
throw configError(`${at}: on.disarmed.at must be a non-empty string (the time the one-shot fired): ${path}`);
|
|
8534
8665
|
}
|
|
8535
|
-
if (disarmed.jobId !== void 0 && !
|
|
8666
|
+
if (disarmed.jobId !== void 0 && !isNonEmptyString5(disarmed.jobId)) {
|
|
8536
8667
|
throw configError(`${at}: on.disarmed.jobId must be a non-empty string when present (the run record it joins to): ${path}`);
|
|
8537
8668
|
}
|
|
8538
8669
|
if (on.once !== true) {
|
|
@@ -8556,7 +8687,7 @@ function validateRepository(run, onType, at, path) {
|
|
|
8556
8687
|
if (!needed) {
|
|
8557
8688
|
throw configError(`${at}: run.repository is only meaningful on an azure label or comment trigger (a pull request names its own repository): ${path}`);
|
|
8558
8689
|
}
|
|
8559
|
-
if (!
|
|
8690
|
+
if (!isNonEmptyString5(raw) || raw !== raw.trim() || raw.includes("/")) {
|
|
8560
8691
|
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
8692
|
}
|
|
8562
8693
|
return raw;
|
|
@@ -8596,7 +8727,7 @@ function validateSecrets(run, at, path) {
|
|
|
8596
8727
|
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
8728
|
}
|
|
8598
8729
|
const reference = secrets[name];
|
|
8599
|
-
if (!
|
|
8730
|
+
if (!isNonEmptyString5(reference)) {
|
|
8600
8731
|
throw configError(`${at}: run.secrets.${name} must be a non-empty string reference for your resolver to read (got ${JSON.stringify(reference)}): ${path}`);
|
|
8601
8732
|
}
|
|
8602
8733
|
if (reference !== reference.trim()) {
|
|
@@ -8614,7 +8745,7 @@ function validateSecrets(run, at, path) {
|
|
|
8614
8745
|
function validateSecretsProfile(run, at, path) {
|
|
8615
8746
|
const profile = run.secretsProfile;
|
|
8616
8747
|
if (profile === void 0) return void 0;
|
|
8617
|
-
if (!
|
|
8748
|
+
if (!isNonEmptyString5(profile)) {
|
|
8618
8749
|
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
8750
|
}
|
|
8620
8751
|
if (!ID_CHARSET.test(profile)) {
|
|
@@ -8625,6 +8756,81 @@ function validateSecretsProfile(run, at, path) {
|
|
|
8625
8756
|
}
|
|
8626
8757
|
return profile;
|
|
8627
8758
|
}
|
|
8759
|
+
function validateWaitFor(on, run, at, path, { onType }) {
|
|
8760
|
+
for (const [label, source, exactIsLegal] of [
|
|
8761
|
+
["run", run, true],
|
|
8762
|
+
["on", on, false]
|
|
8763
|
+
]) {
|
|
8764
|
+
for (const key of Object.keys(source ?? {})) {
|
|
8765
|
+
if (exactIsLegal && key === "waitFor") continue;
|
|
8766
|
+
if (key.replace(/[^a-z0-9]/gi, "").toLowerCase() !== "waitfor") continue;
|
|
8767
|
+
throw configError(`${at}: ${label}.${key} is not a field -- did you mean run.waitFor? A wait condition the loader drops holds nothing while the file reads as though it does, so a near miss is refused rather than dropped: ${path}`);
|
|
8768
|
+
}
|
|
8769
|
+
}
|
|
8770
|
+
const waitFor = run.waitFor;
|
|
8771
|
+
if (waitFor === void 0) return void 0;
|
|
8772
|
+
if (onType === "cron") {
|
|
8773
|
+
throw configError(`${at}: run.waitFor is not yet covered on a cron trigger -- the scheduler advances at pickup, so a held occurrence outlives the teardown that would delete it and still pays, and its surviving job hash can fail worker boot with a scheduler id collision; bounding that needs delayed-set-aware teardown, which is a gap to close, not a limit: ${path}`);
|
|
8774
|
+
}
|
|
8775
|
+
if (on?.once === true) {
|
|
8776
|
+
throw configError(`${at}: run.waitFor and on.once cannot be combined -- the one-shot disarms on every run record, so a wait that never cleared would spend the one-shot with no container ever started and no way to re-arm it but editing this file: ${path}`);
|
|
8777
|
+
}
|
|
8778
|
+
if (run.replicas !== void 0) {
|
|
8779
|
+
throw configError(`${at}: run.waitFor and run.replicas cannot be combined -- replicas fan out at enqueue, so each one would hold and poll the same external condition independently, multiplying the checks by ${run.replicas} for one answer: ${path}`);
|
|
8780
|
+
}
|
|
8781
|
+
if (!Array.isArray(waitFor) || waitFor.length === 0) {
|
|
8782
|
+
throw configError(`${at}: run.waitFor must be a non-empty array of conditions when present (got ${JSON.stringify(waitFor)}): ${path}`);
|
|
8783
|
+
}
|
|
8784
|
+
if (waitFor.length > WAIT_CONDITION_MAX) {
|
|
8785
|
+
throw configError(`${at}: run.waitFor names ${waitFor.length} conditions, over the ${WAIT_CONDITION_MAX} cap -- each profile condition is a subprocess run before the container, holding a concurrency slot while it runs: ${path}`);
|
|
8786
|
+
}
|
|
8787
|
+
const expected = WAIT_CONDITION_KEYS.join("|");
|
|
8788
|
+
const normalized = [];
|
|
8789
|
+
const profiles = /* @__PURE__ */ new Set();
|
|
8790
|
+
let seenAfter = false;
|
|
8791
|
+
for (let i = 0; i < waitFor.length; i += 1) {
|
|
8792
|
+
const condition = waitFor[i];
|
|
8793
|
+
const where = `${at}: run.waitFor[${i}]`;
|
|
8794
|
+
if (condition === null || typeof condition !== "object" || Array.isArray(condition)) {
|
|
8795
|
+
throw configError(`${where} must be an object naming exactly one condition (got ${JSON.stringify(condition)}): ${path}`);
|
|
8796
|
+
}
|
|
8797
|
+
const keys = Object.keys(condition);
|
|
8798
|
+
if (keys.length !== 1) {
|
|
8799
|
+
throw configError(`${where} must name exactly one condition (expected ${expected}), got ${keys.length === 0 ? "an empty object" : JSON.stringify(keys)}: ${path}`);
|
|
8800
|
+
}
|
|
8801
|
+
const key = keys[0];
|
|
8802
|
+
if (key === "exclusive") {
|
|
8803
|
+
throw configError(`${where}: exclusive is no longer a condition you write -- the worker already holds at most one local job per folder, always on, with no configuration and no off switch, and scoped-limits.json bounds any scope further: ${path}`);
|
|
8804
|
+
}
|
|
8805
|
+
if (!WAIT_CONDITION_KEYS.includes(key)) {
|
|
8806
|
+
throw configError(`${where} has an unsupported condition ${JSON.stringify(key)} (expected ${expected}) -- an unknown condition here is a term of the gate that would silently do nothing, so it is refused rather than dropped: ${path}`);
|
|
8807
|
+
}
|
|
8808
|
+
if (key === "after") {
|
|
8809
|
+
if (seenAfter) {
|
|
8810
|
+
throw configError(`${where}: run.waitFor names a second "after" -- the conditions are a conjunction, so two instants mean the later one and the file would read as though both mattered: ${path}`);
|
|
8811
|
+
}
|
|
8812
|
+
if (afterInstantMs(condition.after) === null) {
|
|
8813
|
+
throw configError(`${where}: after must be an ISO-8601 instant carrying its own zone, like "2026-09-01T09:00:00Z" or "2026-09-01T11:00:00+02:00" (got ${JSON.stringify(condition.after)}) -- without a zone the same file would hold for a different instant on a worker in a different timezone: ${path}`);
|
|
8814
|
+
}
|
|
8815
|
+
seenAfter = true;
|
|
8816
|
+
normalized.push({ after: condition.after });
|
|
8817
|
+
continue;
|
|
8818
|
+
}
|
|
8819
|
+
const profile = condition.profile;
|
|
8820
|
+
if (!isNonEmptyString5(profile)) {
|
|
8821
|
+
throw configError(`${where}: profile must be a non-empty string naming one of the wait profiles this deployment declares (got ${JSON.stringify(profile)}): ${path}`);
|
|
8822
|
+
}
|
|
8823
|
+
if (!ID_CHARSET.test(profile)) {
|
|
8824
|
+
throw configError(`${where}: profile ${JSON.stringify(profile)} may use letters, digits, dot, dash and underscore only -- PI_WAIT_PROFILES is a comma-separated list of name:path pairs, so a name carrying either separator cannot be declared: ${path}`);
|
|
8825
|
+
}
|
|
8826
|
+
if (profiles.has(profile)) {
|
|
8827
|
+
throw configError(`${where}: run.waitFor names the profile ${JSON.stringify(profile)} twice -- the conditions are a conjunction, so the second one can never change the answer while it doubles the subprocesses: ${path}`);
|
|
8828
|
+
}
|
|
8829
|
+
profiles.add(profile);
|
|
8830
|
+
normalized.push({ profile });
|
|
8831
|
+
}
|
|
8832
|
+
return normalized;
|
|
8833
|
+
}
|
|
8628
8834
|
function normalizeLabel(on, run, index, path) {
|
|
8629
8835
|
const at = `trigger at index ${index}`;
|
|
8630
8836
|
const predicate = validatePredicate(on, index, path, true);
|
|
@@ -8632,7 +8838,7 @@ function normalizeLabel(on, run, index, path) {
|
|
|
8632
8838
|
validateOnce(on, run, at, path, { onType: "label" });
|
|
8633
8839
|
validateDisarmed(on, at, path, { onType: "label" });
|
|
8634
8840
|
const command = validateCommand(run, at, path, { onType: "label" });
|
|
8635
|
-
if (command === void 0 && !
|
|
8841
|
+
if (command === void 0 && !isNonEmptyString5(run.flow)) {
|
|
8636
8842
|
throw configError(`${at}: label trigger run.flow must be a non-empty string (or use run.command): ${path}`);
|
|
8637
8843
|
}
|
|
8638
8844
|
validateFlowName(run, at, path);
|
|
@@ -8645,21 +8851,22 @@ function normalizeLabel(on, run, index, path) {
|
|
|
8645
8851
|
const replicas = validateReplicas(run, at, path);
|
|
8646
8852
|
const secrets = validateSecrets(run, at, path);
|
|
8647
8853
|
const secretsProfile = validateSecretsProfile(run, at, path);
|
|
8854
|
+
const waitFor = validateWaitFor(on, run, at, path, { onType: on.type });
|
|
8648
8855
|
return {
|
|
8649
8856
|
on: { type: "label", any: predicate.any, all: predicate.all, none: predicate.none },
|
|
8650
|
-
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...repository !== void 0 && { repository }, ...secrets !== void 0 && { secrets }, ...secretsProfile !== void 0 && { secretsProfile } }
|
|
8857
|
+
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...repository !== void 0 && { repository }, ...secrets !== void 0 && { secrets }, ...secretsProfile !== void 0 && { secretsProfile }, ...waitFor !== void 0 && { waitFor } }
|
|
8651
8858
|
};
|
|
8652
8859
|
}
|
|
8653
8860
|
function normalizeComment(on, run, index, path, state) {
|
|
8654
8861
|
const at = `trigger at index ${index}`;
|
|
8655
|
-
if (!
|
|
8862
|
+
if (!isNonEmptyString5(on.phrase)) {
|
|
8656
8863
|
throw configError(`${at}: comment trigger on.phrase must be a non-empty string: ${path}`);
|
|
8657
8864
|
}
|
|
8658
8865
|
validateNumber(on, at, path, { onType: "comment" });
|
|
8659
8866
|
validateOnce(on, run, at, path, { onType: "comment" });
|
|
8660
8867
|
validateDisarmed(on, at, path, { onType: "comment" });
|
|
8661
8868
|
const command = validateCommand(run, at, path, { onType: "comment" });
|
|
8662
|
-
if (command === void 0 && !
|
|
8869
|
+
if (command === void 0 && !isNonEmptyString5(run.flow)) {
|
|
8663
8870
|
throw configError(`${at}: comment trigger run.flow (the default flow) must be a non-empty string (or use run.command): ${path}`);
|
|
8664
8871
|
}
|
|
8665
8872
|
validateFlowName(run, at, path);
|
|
@@ -8676,9 +8883,10 @@ function normalizeComment(on, run, index, path, state) {
|
|
|
8676
8883
|
const replicas = validateReplicas(run, at, path);
|
|
8677
8884
|
const secrets = validateSecrets(run, at, path);
|
|
8678
8885
|
const secretsProfile = validateSecretsProfile(run, at, path);
|
|
8886
|
+
const waitFor = validateWaitFor(on, run, at, path, { onType: on.type });
|
|
8679
8887
|
return {
|
|
8680
8888
|
on: { type: "comment", phrase: on.phrase },
|
|
8681
|
-
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...repository !== void 0 && { repository }, ...secrets !== void 0 && { secrets }, ...secretsProfile !== void 0 && { secretsProfile } }
|
|
8889
|
+
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...repository !== void 0 && { repository }, ...secrets !== void 0 && { secrets }, ...secretsProfile !== void 0 && { secretsProfile }, ...waitFor !== void 0 && { waitFor } }
|
|
8682
8890
|
};
|
|
8683
8891
|
}
|
|
8684
8892
|
function normalizeIssue(on, run, index, path) {
|
|
@@ -8704,7 +8912,7 @@ function normalizeIssue(on, run, index, path) {
|
|
|
8704
8912
|
const once = validateOnce(on, run, at, path, { capable: true, onType: "issue" });
|
|
8705
8913
|
validateDisarmed(on, at, path, { capable: true, onType: "issue" });
|
|
8706
8914
|
const command = validateCommand(run, at, path, { onType: "issue" });
|
|
8707
|
-
if (command === void 0 && !
|
|
8915
|
+
if (command === void 0 && !isNonEmptyString5(run.flow)) {
|
|
8708
8916
|
throw configError(`${at}: issue trigger run.flow must be a non-empty string (or use run.command): ${path}`);
|
|
8709
8917
|
}
|
|
8710
8918
|
validateFlowName(run, at, path);
|
|
@@ -8717,6 +8925,7 @@ function normalizeIssue(on, run, index, path) {
|
|
|
8717
8925
|
const replicas = validateReplicas(run, at, path);
|
|
8718
8926
|
const secrets = validateSecrets(run, at, path);
|
|
8719
8927
|
const secretsProfile = validateSecretsProfile(run, at, path);
|
|
8928
|
+
const waitFor = validateWaitFor(on, run, at, path, { onType: on.type });
|
|
8720
8929
|
return {
|
|
8721
8930
|
on: {
|
|
8722
8931
|
type: "issue",
|
|
@@ -8726,7 +8935,7 @@ function normalizeIssue(on, run, index, path) {
|
|
|
8726
8935
|
...number !== void 0 && { number },
|
|
8727
8936
|
...once !== void 0 && { once }
|
|
8728
8937
|
},
|
|
8729
|
-
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...secrets !== void 0 && { secrets }, ...secretsProfile !== void 0 && { secretsProfile } }
|
|
8938
|
+
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...secrets !== void 0 && { secrets }, ...secretsProfile !== void 0 && { secretsProfile }, ...waitFor !== void 0 && { waitFor } }
|
|
8730
8939
|
};
|
|
8731
8940
|
}
|
|
8732
8941
|
function normalizePullRequest(on, run, index, path) {
|
|
@@ -8761,7 +8970,7 @@ function normalizePullRequest(on, run, index, path) {
|
|
|
8761
8970
|
const once = validateOnce(on, run, at, path, { capable: isClose, onType: "pull_request" });
|
|
8762
8971
|
validateDisarmed(on, at, path, { capable: isClose, onType: "pull_request" });
|
|
8763
8972
|
const command = validateCommand(run, at, path, { onType: "pull_request" });
|
|
8764
|
-
if (command === void 0 && !
|
|
8973
|
+
if (command === void 0 && !isNonEmptyString5(run.flow)) {
|
|
8765
8974
|
throw configError(`${at}: pull_request trigger run.flow must be a non-empty string (or use run.command): ${path}`);
|
|
8766
8975
|
}
|
|
8767
8976
|
validateFlowName(run, at, path);
|
|
@@ -8774,6 +8983,7 @@ function normalizePullRequest(on, run, index, path) {
|
|
|
8774
8983
|
const replicas = validateReplicas(run, at, path);
|
|
8775
8984
|
const secrets = validateSecrets(run, at, path);
|
|
8776
8985
|
const secretsProfile = validateSecretsProfile(run, at, path);
|
|
8986
|
+
const waitFor = validateWaitFor(on, run, at, path, { onType: on.type });
|
|
8777
8987
|
return {
|
|
8778
8988
|
on: {
|
|
8779
8989
|
type: "pull_request",
|
|
@@ -8788,7 +8998,7 @@ function normalizePullRequest(on, run, index, path) {
|
|
|
8788
8998
|
...number !== void 0 && { number },
|
|
8789
8999
|
...once !== void 0 && { once }
|
|
8790
9000
|
},
|
|
8791
|
-
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...secrets !== void 0 && { secrets }, ...secretsProfile !== void 0 && { secretsProfile } }
|
|
9001
|
+
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...secrets !== void 0 && { secrets }, ...secretsProfile !== void 0 && { secretsProfile }, ...waitFor !== void 0 && { waitFor } }
|
|
8792
9002
|
};
|
|
8793
9003
|
}
|
|
8794
9004
|
function validateReviewState(on, actions, run, at, path) {
|
|
@@ -8817,6 +9027,7 @@ var init_triggers = __esm({
|
|
|
8817
9027
|
init_flow_gate();
|
|
8818
9028
|
init_forges();
|
|
8819
9029
|
init_reserved_env();
|
|
9030
|
+
init_wait_for();
|
|
8820
9031
|
ON_TYPES = /* @__PURE__ */ new Set(["cron", "label", "comment", "pull_request", "issue"]);
|
|
8821
9032
|
DISARMED_TYPE = "disarmed";
|
|
8822
9033
|
PR_ACTIONS = {
|
|
@@ -9031,8 +9242,8 @@ var init_docker_run = __esm({
|
|
|
9031
9242
|
|
|
9032
9243
|
// ../worker/src/packages.mjs
|
|
9033
9244
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
9034
|
-
import { basename, dirname as dirname2, join, resolve } from "node:path";
|
|
9035
|
-
function
|
|
9245
|
+
import { basename, dirname as dirname2, join, resolve as resolve2 } from "node:path";
|
|
9246
|
+
function isNonEmptyString6(value) {
|
|
9036
9247
|
return typeof value === "string" && value.trim() !== "";
|
|
9037
9248
|
}
|
|
9038
9249
|
function readStageManifest({ globalPiDir, readFile = readFileSync, fileExists = existsSync } = {}) {
|
|
@@ -9046,7 +9257,7 @@ function readStageManifest({ globalPiDir, readFile = readFileSync, fileExists =
|
|
|
9046
9257
|
for (const entry of parsed.packages) {
|
|
9047
9258
|
if (entry === null || typeof entry !== "object") return null;
|
|
9048
9259
|
const { name, version, dir } = entry;
|
|
9049
|
-
if (!
|
|
9260
|
+
if (!isNonEmptyString6(name) || !isNonEmptyString6(version) || !isNonEmptyString6(dir)) return null;
|
|
9050
9261
|
if (!ENTRY_NAME_RE.test(dir) || dir.length > MAX_DIR_LENGTH) return null;
|
|
9051
9262
|
packages.push({ name, version, dir, from: entry.from === FROM_HOST ? FROM_HOST : FROM_DECLARED });
|
|
9052
9263
|
}
|
|
@@ -9071,7 +9282,7 @@ function readStagedSkills({ globalPiDir, readFile = readFileSync, fileExists = e
|
|
|
9071
9282
|
out.unenumerable.push(pkg.name);
|
|
9072
9283
|
continue;
|
|
9073
9284
|
}
|
|
9074
|
-
sources = entries.filter((e) => !e.split(/[\\/]/).includes("..")).map((e) =>
|
|
9285
|
+
sources = entries.filter((e) => !e.split(/[\\/]/).includes("..")).map((e) => resolve2(root, e));
|
|
9075
9286
|
} else {
|
|
9076
9287
|
sources = [join(root, "skills")];
|
|
9077
9288
|
}
|
|
@@ -10368,6 +10579,7 @@ function resolvePaths(env = process.env) {
|
|
|
10368
10579
|
// root, and silently wrong (demo triggers) everywhere else.
|
|
10369
10580
|
triggersPath: env.PI_TRIGGERS_FILE ?? "./triggers.json",
|
|
10370
10581
|
pauseWindowsPath: env.PI_PAUSE_WINDOWS_FILE ?? "./pause-windows.json",
|
|
10582
|
+
scopedLimitsPath: env.PI_SCOPED_LIMITS_FILE ?? "./scoped-limits.json",
|
|
10371
10583
|
subscriptionsPath: env.PI_SUBSCRIPTIONS_FILE ?? "./subscriptions.json",
|
|
10372
10584
|
// The operator's global pi overlay dir (REQ-GLOBAL-PI-OVERLAY), where the staged third-party pi
|
|
10373
10585
|
// packages live under `packages/`. `|| null` so unset AND empty both read as "no overlay" -- the
|
|
@@ -10586,6 +10798,172 @@ function writePauseWindows({ pauseWindowsPath, mutate, fs = nodeFs3 }) {
|
|
|
10586
10798
|
fs.renameSync(tmp, pauseWindowsPath);
|
|
10587
10799
|
return { ok: true };
|
|
10588
10800
|
}
|
|
10801
|
+
function readScopedLimits({ scopedLimitsPath, fs = nodeFs3 }) {
|
|
10802
|
+
let text;
|
|
10803
|
+
try {
|
|
10804
|
+
text = fs.readFileSync(scopedLimitsPath, "utf8");
|
|
10805
|
+
} catch {
|
|
10806
|
+
return { missing: true };
|
|
10807
|
+
}
|
|
10808
|
+
try {
|
|
10809
|
+
return { limits: parseScopedLimits(text, scopedLimitsPath) };
|
|
10810
|
+
} catch (e) {
|
|
10811
|
+
return { invalid: e?.message ?? String(e) };
|
|
10812
|
+
}
|
|
10813
|
+
}
|
|
10814
|
+
function writeScopedLimits({ scopedLimitsPath, mutate, fs = nodeFs3 }) {
|
|
10815
|
+
let current = [];
|
|
10816
|
+
let existing = null;
|
|
10817
|
+
try {
|
|
10818
|
+
existing = fs.readFileSync(scopedLimitsPath, "utf8");
|
|
10819
|
+
} catch {
|
|
10820
|
+
}
|
|
10821
|
+
if (existing !== null) {
|
|
10822
|
+
try {
|
|
10823
|
+
current = parseScopedLimits(existing, scopedLimitsPath);
|
|
10824
|
+
} catch (e) {
|
|
10825
|
+
return { invalid: e?.message ?? String(e) };
|
|
10826
|
+
}
|
|
10827
|
+
}
|
|
10828
|
+
const next = mutate(current.map((l) => ({ ...l })));
|
|
10829
|
+
const rows = next.map((l) => Object.fromEntries(Object.entries(l).filter(([, v]) => v !== null && v !== void 0)));
|
|
10830
|
+
const text = `${JSON.stringify({ version: SCOPED_LIMITS_VERSION, limits: rows }, null, 2)}
|
|
10831
|
+
`;
|
|
10832
|
+
try {
|
|
10833
|
+
parseScopedLimits(text, scopedLimitsPath);
|
|
10834
|
+
} catch (e) {
|
|
10835
|
+
return { invalid: e?.message ?? String(e) };
|
|
10836
|
+
}
|
|
10837
|
+
const tmp = `${scopedLimitsPath}.tmp`;
|
|
10838
|
+
fs.writeFileSync(tmp, text, { mode: 420 });
|
|
10839
|
+
fs.renameSync(tmp, scopedLimitsPath);
|
|
10840
|
+
return { ok: true };
|
|
10841
|
+
}
|
|
10842
|
+
async function readScopedBudget({ url, limits, redisFn = makeRedisClient, timeoutMs = 2500 } = {}) {
|
|
10843
|
+
if (!Array.isArray(limits) || limits.length === 0) return { rows: [] };
|
|
10844
|
+
let redis;
|
|
10845
|
+
try {
|
|
10846
|
+
parseConnection(url, { failFast: true });
|
|
10847
|
+
redis = redisFn(url);
|
|
10848
|
+
const now = /* @__PURE__ */ new Date();
|
|
10849
|
+
const settled = Promise.all(
|
|
10850
|
+
limits.map(async (l) => {
|
|
10851
|
+
const prefix = scopeKeyPrefix(l.scope);
|
|
10852
|
+
const cell2 = async (key) => Number(await redis.get(key) ?? 0);
|
|
10853
|
+
const row = {};
|
|
10854
|
+
if (l.day !== null && l.day !== void 0) row.day = await cell2(dayKey(now, prefix));
|
|
10855
|
+
if (l.week !== null && l.week !== void 0) row.week = await cell2(weekKey(now, prefix));
|
|
10856
|
+
if (l.month !== null && l.month !== void 0) row.month = await cell2(monthKey(now, prefix));
|
|
10857
|
+
return row;
|
|
10858
|
+
})
|
|
10859
|
+
).then(
|
|
10860
|
+
(rows) => ({ rows }),
|
|
10861
|
+
(err) => ({ unreachable: err?.message ?? String(err) })
|
|
10862
|
+
);
|
|
10863
|
+
return await withTimeout(settled, timeoutMs, { unreachable: "timed out reaching the queue" });
|
|
10864
|
+
} catch (err) {
|
|
10865
|
+
return { unreachable: err?.message ?? String(err) };
|
|
10866
|
+
} finally {
|
|
10867
|
+
if (redis) {
|
|
10868
|
+
try {
|
|
10869
|
+
redis.disconnect();
|
|
10870
|
+
} catch {
|
|
10871
|
+
}
|
|
10872
|
+
}
|
|
10873
|
+
}
|
|
10874
|
+
}
|
|
10875
|
+
async function readHeldJobs({ url, limit = 20, redisFn = makeRedisClient, timeoutMs = 2500, now = () => Date.now() } = {}) {
|
|
10876
|
+
let redis;
|
|
10877
|
+
try {
|
|
10878
|
+
parseConnection(url, { failFast: true });
|
|
10879
|
+
redis = redisFn(url);
|
|
10880
|
+
const settled = (async () => {
|
|
10881
|
+
const ids = await redis.smembers(HELD_SET);
|
|
10882
|
+
const hydrated = await Promise.all(
|
|
10883
|
+
ids.slice(0, HELD_HYDRATE_MAX).map(async (jobId) => {
|
|
10884
|
+
try {
|
|
10885
|
+
const h = await redis.hgetall(jobKey(jobId));
|
|
10886
|
+
if (!h || !h.since) {
|
|
10887
|
+
await redis.srem(HELD_SET, jobId).catch(() => {
|
|
10888
|
+
});
|
|
10889
|
+
return null;
|
|
10890
|
+
}
|
|
10891
|
+
const since = Number(h.since);
|
|
10892
|
+
return {
|
|
10893
|
+
jobId,
|
|
10894
|
+
target: h.target || null,
|
|
10895
|
+
label: h.label || null,
|
|
10896
|
+
waitedMs: Number.isFinite(since) && since > 0 ? Math.max(0, now() - since) : null,
|
|
10897
|
+
checks: Number(h.checks) || 0
|
|
10898
|
+
};
|
|
10899
|
+
} catch {
|
|
10900
|
+
return null;
|
|
10901
|
+
}
|
|
10902
|
+
})
|
|
10903
|
+
);
|
|
10904
|
+
const rows = hydrated.filter(Boolean);
|
|
10905
|
+
rows.sort((a, b) => (b.waitedMs ?? -1) - (a.waitedMs ?? -1));
|
|
10906
|
+
return {
|
|
10907
|
+
rows: rows.slice(0, limit),
|
|
10908
|
+
more: Math.max(0, rows.length - limit),
|
|
10909
|
+
// True when the index itself was longer than this reader will hydrate: `more` is then a floor, and
|
|
10910
|
+
// a caller that renders a total must say so rather than state a number it cannot know.
|
|
10911
|
+
truncated: ids.length > HELD_HYDRATE_MAX
|
|
10912
|
+
};
|
|
10913
|
+
})();
|
|
10914
|
+
return await withTimeout(settled, timeoutMs, { unreachable: "timed out reaching the queue" });
|
|
10915
|
+
} catch (err) {
|
|
10916
|
+
return { unreachable: err?.message ?? String(err) };
|
|
10917
|
+
} finally {
|
|
10918
|
+
if (redis) {
|
|
10919
|
+
try {
|
|
10920
|
+
redis.disconnect();
|
|
10921
|
+
} catch {
|
|
10922
|
+
}
|
|
10923
|
+
}
|
|
10924
|
+
}
|
|
10925
|
+
}
|
|
10926
|
+
async function cancelHeldJob({ url, jobId, redisFn = makeRedisClient, queueFn = makeQueue, timeoutMs = 2500 } = {}) {
|
|
10927
|
+
if (typeof jobId !== "string" || jobId === "") return { invalid: "a job id is required" };
|
|
10928
|
+
let redis;
|
|
10929
|
+
let queue;
|
|
10930
|
+
try {
|
|
10931
|
+
parseConnection(url, { failFast: true });
|
|
10932
|
+
redis = redisFn(url);
|
|
10933
|
+
queue = queueFn(parseConnection(url, { failFast: true }));
|
|
10934
|
+
const settled = (async () => {
|
|
10935
|
+
const hash = await redis.hgetall(jobKey(jobId));
|
|
10936
|
+
if (!hash || !hash.since) return { invalid: `job ${jobId} is not waiting on a condition` };
|
|
10937
|
+
const job = await queue.getJob(jobId);
|
|
10938
|
+
if (!job) return { invalid: `job ${jobId} is no longer in the queue` };
|
|
10939
|
+
const state = await job.getState().catch(() => null);
|
|
10940
|
+
if (state !== "delayed" && state !== "waiting" && state !== "prioritized") {
|
|
10941
|
+
return { invalid: `job ${jobId} is ${state ?? "in an unknown state"}, not waiting -- it has already left the hold` };
|
|
10942
|
+
}
|
|
10943
|
+
await redis.del(jobKey(jobId));
|
|
10944
|
+
await redis.srem(HELD_SET, jobId).catch(() => {
|
|
10945
|
+
});
|
|
10946
|
+
if (hash.dedupId) {
|
|
10947
|
+
const holder = await redis.get(`wait:key:${hash.dedupId}`);
|
|
10948
|
+
if (holder === jobId) await redis.del(`wait:key:${hash.dedupId}`);
|
|
10949
|
+
}
|
|
10950
|
+
await job.remove();
|
|
10951
|
+
return { ok: true, jobId };
|
|
10952
|
+
})();
|
|
10953
|
+
return await withTimeout(settled, timeoutMs, { invalid: "timed out reaching the queue" });
|
|
10954
|
+
} catch (err) {
|
|
10955
|
+
return { invalid: err?.message ?? String(err) };
|
|
10956
|
+
} finally {
|
|
10957
|
+
try {
|
|
10958
|
+
redis?.disconnect?.();
|
|
10959
|
+
} catch {
|
|
10960
|
+
}
|
|
10961
|
+
try {
|
|
10962
|
+
await queue?.close?.();
|
|
10963
|
+
} catch {
|
|
10964
|
+
}
|
|
10965
|
+
}
|
|
10966
|
+
}
|
|
10589
10967
|
function readSubscriptions({ subscriptionsPath, fs = nodeFs3 }) {
|
|
10590
10968
|
if (subscriptionsPath === null || subscriptionsPath === void 0) return { missing: true };
|
|
10591
10969
|
let text;
|
|
@@ -10662,8 +11040,8 @@ async function readBudget({ url, redisFn = makeRedisClient, timeoutMs = 2500 } =
|
|
|
10662
11040
|
}
|
|
10663
11041
|
function withTimeout(promise, ms, fallback) {
|
|
10664
11042
|
let timer;
|
|
10665
|
-
const timeout = new Promise((
|
|
10666
|
-
timer = setTimeout(() =>
|
|
11043
|
+
const timeout = new Promise((resolve3) => {
|
|
11044
|
+
timer = setTimeout(() => resolve3(fallback), ms);
|
|
10667
11045
|
if (typeof timer?.unref === "function") timer.unref();
|
|
10668
11046
|
});
|
|
10669
11047
|
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
@@ -11217,7 +11595,7 @@ function clampLines(lines) {
|
|
|
11217
11595
|
const n = Number.isFinite(lines) ? Math.floor(lines) : 200;
|
|
11218
11596
|
return Math.min(2e3, Math.max(1, n));
|
|
11219
11597
|
}
|
|
11220
|
-
var DISPATCH_RUN_TTL_SECONDS, SCAN_WINDOW_MAX_DAYS, GRAPH_LIMITS, GRAPH_LS_TREE_MAX_BYTES;
|
|
11598
|
+
var DISPATCH_RUN_TTL_SECONDS, HELD_HYDRATE_MAX, SCAN_WINDOW_MAX_DAYS, GRAPH_LIMITS, GRAPH_LS_TREE_MAX_BYTES;
|
|
11221
11599
|
var init_read_model = __esm({
|
|
11222
11600
|
"src/read-model.mjs"() {
|
|
11223
11601
|
init_config2();
|
|
@@ -11225,6 +11603,8 @@ var init_read_model = __esm({
|
|
|
11225
11603
|
init_run_history();
|
|
11226
11604
|
init_budget();
|
|
11227
11605
|
init_pause_windows();
|
|
11606
|
+
init_scoped_limits();
|
|
11607
|
+
init_wait_state();
|
|
11228
11608
|
init_subscriptions();
|
|
11229
11609
|
init_connection();
|
|
11230
11610
|
init_queue();
|
|
@@ -11237,6 +11617,7 @@ var init_read_model = __esm({
|
|
|
11237
11617
|
init_costs();
|
|
11238
11618
|
init_triggers_file();
|
|
11239
11619
|
DISPATCH_RUN_TTL_SECONDS = 2 * 60 * 60;
|
|
11620
|
+
HELD_HYDRATE_MAX = 200;
|
|
11240
11621
|
SCAN_WINDOW_MAX_DAYS = 92;
|
|
11241
11622
|
GRAPH_LIMITS = Object.freeze({
|
|
11242
11623
|
maxFoldersScanned: 16,
|
|
@@ -12751,7 +13132,28 @@ function normBudget(v) {
|
|
|
12751
13132
|
cap: Number.isInteger(t.cap) && t.cap > 0 ? t.cap : null,
|
|
12752
13133
|
state: BUDGET_STATES.includes(t.state) ? t.state : "ok",
|
|
12753
13134
|
maxTokens: Number.isInteger(t.maxTokens) && t.maxTokens > 0 ? t.maxTokens : null
|
|
12754
|
-
}
|
|
13135
|
+
},
|
|
13136
|
+
// The scoped rows (issue #242): a pre-1.5 payload simply lacks the key and renders nothing.
|
|
13137
|
+
scoped: Array.isArray(v.scoped) ? v.scoped.map(normScopedRow).filter((r) => r !== null) : [],
|
|
13138
|
+
scopedInvalid: typeof v.scopedInvalid === "string" && v.scopedInvalid !== "" ? clip3(v.scopedInvalid, 160) : null
|
|
13139
|
+
};
|
|
13140
|
+
}
|
|
13141
|
+
function normScopedRow(v) {
|
|
13142
|
+
if (v === null || typeof v !== "object" || typeof v.scope !== "string" || v.scope === "") return null;
|
|
13143
|
+
const win = (w) => {
|
|
13144
|
+
if (w === null || w === void 0 || typeof w !== "object") return null;
|
|
13145
|
+
return {
|
|
13146
|
+
used: Number.isInteger(w.used) && w.used >= 0 ? w.used : null,
|
|
13147
|
+
cap: Number.isInteger(w.cap) && w.cap > 0 ? w.cap : null,
|
|
13148
|
+
state: BUDGET_STATES.includes(w.state) ? w.state : "ok"
|
|
13149
|
+
};
|
|
13150
|
+
};
|
|
13151
|
+
return {
|
|
13152
|
+
scope: clip3(v.scope, 60),
|
|
13153
|
+
day: win(v.day),
|
|
13154
|
+
week: win(v.week),
|
|
13155
|
+
month: win(v.month),
|
|
13156
|
+
concurrent: Number.isInteger(v.concurrent) && v.concurrent > 0 ? v.concurrent : null
|
|
12755
13157
|
};
|
|
12756
13158
|
}
|
|
12757
13159
|
function normSpendMap(v) {
|
|
@@ -13123,6 +13525,24 @@ function budgetSectionHtml(nb) {
|
|
|
13123
13525
|
if (nb.softHoldPct !== null) {
|
|
13124
13526
|
rows.push(`<div class="dim small">soft-hold band: ${fmt(nb.softHoldPct)}% of each cap (the amber tick)</div>`);
|
|
13125
13527
|
}
|
|
13528
|
+
if (nb.scopedInvalid !== null) {
|
|
13529
|
+
rows.push(`<div class="dim small">scoped limits: file invalid (${escapeHtml(nb.scopedInvalid)})</div>`);
|
|
13530
|
+
} else if (nb.scoped.length > 0) {
|
|
13531
|
+
rows.push('<div class="dim small">scoped limits (scoped-limits.json)</div>');
|
|
13532
|
+
for (const s of nb.scoped) {
|
|
13533
|
+
const bits = [];
|
|
13534
|
+
let worst = "ok";
|
|
13535
|
+
for (const key of ["day", "week", "month"]) {
|
|
13536
|
+
const w = s[key];
|
|
13537
|
+
if (w === null || w.cap === null) continue;
|
|
13538
|
+
bits.push(`${key} used ${w.used !== null ? fmt(w.used) : "?"} / cap ${fmt(w.cap)}`);
|
|
13539
|
+
if (w.state !== "ok") worst = w.state;
|
|
13540
|
+
}
|
|
13541
|
+
if (s.concurrent !== null) bits.push(`concurrent \u2264${fmt(s.concurrent)} (config; in-flight not shown)`);
|
|
13542
|
+
rows.push(`<div class="row"><span class="wl">${escapeHtml(s.scope)}</span><span>${escapeHtml(bits.join(" \xB7 "))}</span>${worst !== "ok" ? stateWord(worst) : ""}</div>`);
|
|
13543
|
+
}
|
|
13544
|
+
rows.push('<div class="lever">scoped: dispatch_limit_add/edit/delete \xB7 or press m in the /dispatch panel</div>');
|
|
13545
|
+
}
|
|
13126
13546
|
rows.push('<div class="lever">adjust: /dispatch set dailyCap|weeklyCap|monthlyCap|dailyTokenCap|softHoldPct <n> \xB7 or press s in the /dispatch panel</div>');
|
|
13127
13547
|
return `<div id="budget">${rows.join("")}</div>`;
|
|
13128
13548
|
}
|
|
@@ -13528,7 +13948,7 @@ var init_open_browser = __esm({
|
|
|
13528
13948
|
// src/deployment-pointer.mjs
|
|
13529
13949
|
import * as nodeFs4 from "node:fs";
|
|
13530
13950
|
import { homedir } from "node:os";
|
|
13531
|
-
import { join as join3, isAbsolute } from "node:path";
|
|
13951
|
+
import { join as join3, isAbsolute as isAbsolute2 } from "node:path";
|
|
13532
13952
|
function pointerPath(env = process.env) {
|
|
13533
13953
|
return env.PI_DISPATCH_DEPLOYMENT_FILE || join3(env.PI_CODING_AGENT_DIR || join3(homedir(), ".pi", "agent"), POINTER_BASENAME);
|
|
13534
13954
|
}
|
|
@@ -13542,7 +13962,7 @@ function normalizePointer(raw) {
|
|
|
13542
13962
|
if (raw.version > POINTER_VERSION) {
|
|
13543
13963
|
return { ignored: `version ${raw.version} is newer than the supported version ${POINTER_VERSION}` };
|
|
13544
13964
|
}
|
|
13545
|
-
if (typeof raw.deploymentDir !== "string" || !
|
|
13965
|
+
if (typeof raw.deploymentDir !== "string" || !isAbsolute2(raw.deploymentDir)) {
|
|
13546
13966
|
return { ignored: "deploymentDir must be an absolute path" };
|
|
13547
13967
|
}
|
|
13548
13968
|
const rawEnv = raw.env === void 0 ? {} : raw.env;
|
|
@@ -13553,7 +13973,7 @@ function normalizePointer(raw) {
|
|
|
13553
13973
|
for (const key of POINTER_ENV_ALLOWLIST) {
|
|
13554
13974
|
const value = rawEnv[key];
|
|
13555
13975
|
if (typeof value !== "string" || value === "") continue;
|
|
13556
|
-
if (key !== "VALKEY_URL" && !
|
|
13976
|
+
if (key !== "VALKEY_URL" && !isAbsolute2(value)) continue;
|
|
13557
13977
|
env[key] = value;
|
|
13558
13978
|
}
|
|
13559
13979
|
return { pointer: { version: raw.version, deploymentDir: raw.deploymentDir, env } };
|
|
@@ -13624,6 +14044,7 @@ var init_deployment_pointer = __esm({
|
|
|
13624
14044
|
"PI_SETTINGS_FILE",
|
|
13625
14045
|
"PI_TRIGGERS_FILE",
|
|
13626
14046
|
"PI_PAUSE_WINDOWS_FILE",
|
|
14047
|
+
"PI_SCOPED_LIMITS_FILE",
|
|
13627
14048
|
"PI_SUBSCRIPTIONS_FILE"
|
|
13628
14049
|
]);
|
|
13629
14050
|
POINTER_BASENAME = "pi-dispatch-deployment.json";
|
|
@@ -39168,7 +39589,7 @@ var init_pricing = __esm({
|
|
|
39168
39589
|
|
|
39169
39590
|
// ../worker/src/sandbox-store.mjs
|
|
39170
39591
|
import { lstatSync, mkdirSync, readFileSync as readFileSync3, readdirSync as readdirSync2, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
39171
|
-
import { isAbsolute as
|
|
39592
|
+
import { isAbsolute as isAbsolute3, join as join5, relative } from "node:path";
|
|
39172
39593
|
function readManifest({ sandboxDir, jobId, fs = defaultFs }) {
|
|
39173
39594
|
if (!sandboxDir || jobId === void 0 || jobId === null) return null;
|
|
39174
39595
|
const dir = join5(sandboxDir, sanitizeJobId(jobId));
|
|
@@ -39241,10 +39662,10 @@ function resolveSandbox({ jobId, sandboxDir, retentionHours, publish = [], fs, f
|
|
|
39241
39662
|
return { manifest, name: sandboxContainerName(jobId), publish };
|
|
39242
39663
|
}
|
|
39243
39664
|
function launchSandbox({ args, spawnFn = spawn }) {
|
|
39244
|
-
return new Promise((
|
|
39665
|
+
return new Promise((resolve3) => {
|
|
39245
39666
|
const child = spawnFn("docker", args, { stdio: "inherit" });
|
|
39246
|
-
child.on("error", (err) =>
|
|
39247
|
-
child.on("close", (code) =>
|
|
39667
|
+
child.on("error", (err) => resolve3({ code: null, error: err }));
|
|
39668
|
+
child.on("close", (code) => resolve3({ code }));
|
|
39248
39669
|
});
|
|
39249
39670
|
}
|
|
39250
39671
|
var exec3, SANDBOX_NAME_PREFIX;
|
|
@@ -39299,6 +39720,43 @@ function renderBudget({ budget, settings: settings2 } = {}) {
|
|
|
39299
39720
|
if (pct !== null) lines.push(` soft-hold band: ${pct}%`);
|
|
39300
39721
|
return lines.join("\n");
|
|
39301
39722
|
}
|
|
39723
|
+
function renderHeldJobs({ held } = {}) {
|
|
39724
|
+
if (!held) return null;
|
|
39725
|
+
if (held.unreachable) return `unreadable (${held.unreachable})`;
|
|
39726
|
+
const rows = Array.isArray(held.rows) ? held.rows : [];
|
|
39727
|
+
if (rows.length === 0) return null;
|
|
39728
|
+
const more = Number(held.more) || 0;
|
|
39729
|
+
const lines = rows.map((r) => `${r.target ?? r.jobId ?? "-"} ${r.label ?? "-"} waited ${plainDuration(r.waitedMs)}`);
|
|
39730
|
+
if (more > 0) lines.push(`... and ${more} more`);
|
|
39731
|
+
return lines.join("\n");
|
|
39732
|
+
}
|
|
39733
|
+
function plainDuration(ms) {
|
|
39734
|
+
if (!Number.isFinite(ms) || ms < 0) return "?";
|
|
39735
|
+
const s = Math.floor(ms / 1e3);
|
|
39736
|
+
if (s < 60) return `${s}s`;
|
|
39737
|
+
const m = Math.floor(s / 60);
|
|
39738
|
+
if (m < 60) return `${m}m`;
|
|
39739
|
+
const h = Math.floor(m / 60);
|
|
39740
|
+
return h < 24 ? `${h}h${m % 60}m` : `${Math.floor(h / 24)}d${h % 24}h`;
|
|
39741
|
+
}
|
|
39742
|
+
function renderScopedLimits({ limits, scopedBudget } = {}) {
|
|
39743
|
+
if (limits?.invalid) return `Scoped limits: file invalid (${limits.invalid})`;
|
|
39744
|
+
const list = Array.isArray(limits?.limits) ? limits.limits : [];
|
|
39745
|
+
if (list.length === 0) return null;
|
|
39746
|
+
const lines = ["Scoped limits:"];
|
|
39747
|
+
list.forEach((l, i) => {
|
|
39748
|
+
const used = scopedBudget?.rows?.[i] ?? null;
|
|
39749
|
+
const bits = [];
|
|
39750
|
+
for (const key of ["day", "week", "month"]) {
|
|
39751
|
+
if (!Number.isInteger(l[key])) continue;
|
|
39752
|
+
const u = used && Number.isFinite(used[key]) ? used[key] : "-";
|
|
39753
|
+
bits.push(`${key} ${u}/${l[key]}`);
|
|
39754
|
+
}
|
|
39755
|
+
if (Number.isInteger(l.concurrent)) bits.push(`<=${l.concurrent} at once`);
|
|
39756
|
+
lines.push(` ${l.scope}: ${bits.join(" \xB7 ")}`);
|
|
39757
|
+
});
|
|
39758
|
+
return lines.join("\n");
|
|
39759
|
+
}
|
|
39302
39760
|
function capLabel(cap, reserved, pct) {
|
|
39303
39761
|
if (!Number.isInteger(cap)) return "unknown (worker env/default)";
|
|
39304
39762
|
const state = windowState(reserved, cap, pct);
|
|
@@ -39645,6 +40103,34 @@ function centerBlock(lines, overlayWidth, blockWidth) {
|
|
|
39645
40103
|
const prefix = " ".repeat(pad2);
|
|
39646
40104
|
return lines.map((l) => prefix + l);
|
|
39647
40105
|
}
|
|
40106
|
+
async function heldJobs(redis) {
|
|
40107
|
+
const ids = await redis.smembers("wait:held");
|
|
40108
|
+
const at = Date.now();
|
|
40109
|
+
const hydrated = await Promise.all(
|
|
40110
|
+
ids.slice(0, HELD_HYDRATE_MAX2).map(async (jobId) => {
|
|
40111
|
+
try {
|
|
40112
|
+
const h = await redis.hgetall(`wait:job:${jobId}`);
|
|
40113
|
+
if (!h || !h.since) {
|
|
40114
|
+
await redis.srem("wait:held", jobId).catch(() => {
|
|
40115
|
+
});
|
|
40116
|
+
return null;
|
|
40117
|
+
}
|
|
40118
|
+
const since = Number(h.since);
|
|
40119
|
+
return {
|
|
40120
|
+
jobId,
|
|
40121
|
+
target: h.target || null,
|
|
40122
|
+
label: h.label || null,
|
|
40123
|
+
waitedMs: Number.isFinite(since) && since > 0 ? Math.max(0, at - since) : null
|
|
40124
|
+
};
|
|
40125
|
+
} catch {
|
|
40126
|
+
return null;
|
|
40127
|
+
}
|
|
40128
|
+
})
|
|
40129
|
+
);
|
|
40130
|
+
const rows = hydrated.filter(Boolean);
|
|
40131
|
+
rows.sort((a, b) => (b.waitedMs ?? -1) - (a.waitedMs ?? -1));
|
|
40132
|
+
return { rows: rows.slice(0, HELD_LIMIT), more: Math.max(0, rows.length - HELD_LIMIT), truncated: ids.length > HELD_HYDRATE_MAX2 };
|
|
40133
|
+
}
|
|
39648
40134
|
function createDashboardDeps(paths) {
|
|
39649
40135
|
const queue = makeQueue(parseConnection(paths.valkeyUrl, { failFast: true }));
|
|
39650
40136
|
const redis = makeRedisClient(paths.valkeyUrl);
|
|
@@ -39666,7 +40152,27 @@ function createDashboardDeps(paths) {
|
|
|
39666
40152
|
redis.hgetall(STALL_KEY).catch(() => ({}))
|
|
39667
40153
|
]);
|
|
39668
40154
|
const workers = Array.isArray(workerList) && workerList.length > 0 ? workerList.length : "unknown";
|
|
40155
|
+
const scopedLimits = readScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath });
|
|
40156
|
+
const limitRows = Array.isArray(scopedLimits?.limits) ? scopedLimits.limits : [];
|
|
40157
|
+
const scopedNow = /* @__PURE__ */ new Date();
|
|
40158
|
+
const scopedBudget = {
|
|
40159
|
+
rows: await Promise.all(
|
|
40160
|
+
limitRows.map(async (l) => {
|
|
40161
|
+
const prefix = scopeKeyPrefix(l.scope);
|
|
40162
|
+
const cell2 = (key) => redis.get(key).then((v) => Number(v ?? 0), () => null);
|
|
40163
|
+
const row = {};
|
|
40164
|
+
if (Number.isInteger(l.day)) row.day = await cell2(dayKey(scopedNow, prefix));
|
|
40165
|
+
if (Number.isInteger(l.week)) row.week = await cell2(weekKey(scopedNow, prefix));
|
|
40166
|
+
if (Number.isInteger(l.month)) row.month = await cell2(monthKey(scopedNow, prefix));
|
|
40167
|
+
return row;
|
|
40168
|
+
})
|
|
40169
|
+
)
|
|
40170
|
+
};
|
|
40171
|
+
const held = await heldJobs(redis).catch((err) => ({ unreachable: err?.message ?? String(err) }));
|
|
39669
40172
|
return {
|
|
40173
|
+
held,
|
|
40174
|
+
scopedLimits,
|
|
40175
|
+
scopedBudget,
|
|
39670
40176
|
queue: { pausedState, counts, workers },
|
|
39671
40177
|
budget: { day: Number(dayRaw ?? 0), week: Number(weekRaw ?? 0), month: Number(monthRaw ?? 0), tokensToday: Number(tokenRaw ?? 0) },
|
|
39672
40178
|
schedulers: mapSchedulers(schedulerList, Date.now()),
|
|
@@ -40027,6 +40533,10 @@ function makeDashboard({
|
|
|
40027
40533
|
void dispose().finally(() => done({ action: "managePauses" }));
|
|
40028
40534
|
return;
|
|
40029
40535
|
}
|
|
40536
|
+
if (data === "m" || data === "M") {
|
|
40537
|
+
void dispose().finally(() => done({ action: "manageLimits" }));
|
|
40538
|
+
return;
|
|
40539
|
+
}
|
|
40030
40540
|
if (matchesKey(data, "up")) {
|
|
40031
40541
|
selected = Math.max(0, selected - 1);
|
|
40032
40542
|
tui?.requestRender?.();
|
|
@@ -40130,6 +40640,10 @@ function renderPanel(snapshot, width, state, styler) {
|
|
|
40130
40640
|
{ title: "RUNS", lines: renderRunList(buildRows(snapshot, runSort), selected, 24) },
|
|
40131
40641
|
{ title: "SETTINGS", lines: toLines(renderSettingsView(snapshot.settings)) }
|
|
40132
40642
|
];
|
|
40643
|
+
const scopedText = renderScopedLimits({ limits: snapshot.scopedLimits, scopedBudget: snapshot.scopedBudget });
|
|
40644
|
+
if (scopedText) sections.splice(2, 0, { title: "SCOPED LIMITS", lines: toLines(scopedText) });
|
|
40645
|
+
const heldText = renderHeldJobs({ held: snapshot.held });
|
|
40646
|
+
if (heldText) sections.push({ title: "HELD", lines: toLines(heldText) });
|
|
40133
40647
|
const plain = [title];
|
|
40134
40648
|
for (const section of sections) plain.push(section.title, ...section.lines);
|
|
40135
40649
|
plain.push(KEY_HINTS);
|
|
@@ -40157,6 +40671,7 @@ function collapseKeys(sections, availableRows, focus, baseRows) {
|
|
|
40157
40671
|
function buildListLines(snapshot, selected, inner, styler, runSort = "time", availableRows = null) {
|
|
40158
40672
|
const trg = triggerLines(snapshot, selected, inner, styler);
|
|
40159
40673
|
const pw = pauseLines(snapshot.pauseWindows, inner, styler);
|
|
40674
|
+
const sl = limitLines(snapshot.scopedLimits, snapshot.scopedBudget, inner, styler);
|
|
40160
40675
|
const runRows = buildRows(snapshot, runSort).slice(trg.count);
|
|
40161
40676
|
const runCount = Array.isArray(snapshot.runs) ? snapshot.runs.length : 0;
|
|
40162
40677
|
const sections = [
|
|
@@ -40164,6 +40679,11 @@ function buildListLines(snapshot, selected, inner, styler, runSort = "time", ava
|
|
|
40164
40679
|
{ key: "spend", head: ["spend & limits", "jobs & tokens/day \xB7 s set"], body: spendLines(snapshot.budget, snapshot.settings, inner, styler), priority: 4, viewKey: "s" },
|
|
40165
40680
|
{ key: "triggers", head: ["triggers", `${trg.count} standing \xB7 a add \xB7 \u21B5 open`], body: trg.lines, priority: 3, viewKey: "tab" },
|
|
40166
40681
|
{ key: "pauses", head: ["pause windows", `${pw.count} \xB7 w manage`], body: pw.lines, priority: 1, viewKey: "w" },
|
|
40682
|
+
// Priority 0: the section an operator acts on least often from the panel folds FIRST under the
|
|
40683
|
+
// collapse budget. The key hint lives in this divider, not the footer -- the footer's width
|
|
40684
|
+
// arithmetic has no headroom for another hint (its own comment), the s/o/Tab precedent.
|
|
40685
|
+
{ key: "limits", head: ["scoped limits", `${sl.count} \xB7 m manage`], body: sl.lines, priority: 0, viewKey: "m" },
|
|
40686
|
+
...heldSection(snapshot.held, inner, styler),
|
|
40167
40687
|
{ key: "runs", head: ["runs", `last ${runCount} \xB7 o ${runSort}`], body: runLines(runRows, selected - trg.count, inner, styler) },
|
|
40168
40688
|
{ key: "settings", head: ["settings", "s edit"], body: settingsLines(snapshot.settings, inner, styler), priority: 2, viewKey: "s" }
|
|
40169
40689
|
];
|
|
@@ -40179,7 +40699,7 @@ function buildListLines(snapshot, selected, inner, styler, runSort = "time", ava
|
|
|
40179
40699
|
sections.forEach((s, i) => {
|
|
40180
40700
|
if (i > 0) lines.push(RULE);
|
|
40181
40701
|
if (collapsed.has(s.key)) {
|
|
40182
|
-
lines.push(styler.divider(s.head[0], `(${s.body.length} hidden \u2014 ${s.viewKey} to view)`, inner));
|
|
40702
|
+
lines.push(styler.divider(s.head[0], s.viewKey ? `(${s.body.length} hidden \u2014 ${s.viewKey} to view)` : `(${s.body.length} hidden)`, inner));
|
|
40183
40703
|
return;
|
|
40184
40704
|
}
|
|
40185
40705
|
if (s.head) lines.push(styler.divider(s.head[0], s.head[1], inner));
|
|
@@ -40187,6 +40707,37 @@ function buildListLines(snapshot, selected, inner, styler, runSort = "time", ava
|
|
|
40187
40707
|
});
|
|
40188
40708
|
return lines;
|
|
40189
40709
|
}
|
|
40710
|
+
function heldSection(held, inner, styler) {
|
|
40711
|
+
if (!held) return [];
|
|
40712
|
+
if (held.unreachable) {
|
|
40713
|
+
return [{ key: "held", priority: 5, head: ["held", "waiting on conditions"], body: [styler.cell(`unreadable (${held.unreachable})`, inner, { color: "error" })] }];
|
|
40714
|
+
}
|
|
40715
|
+
const rows = Array.isArray(held.rows) ? held.rows : [];
|
|
40716
|
+
if (rows.length === 0) return [];
|
|
40717
|
+
const shown = rows.slice(0, HELD_ON_DASHBOARD);
|
|
40718
|
+
const hidden = rows.length - shown.length + (Number(held.more) || 0);
|
|
40719
|
+
const body = shown.map((r) => heldRow(r, inner, styler));
|
|
40720
|
+
if (hidden > 0) body.push(styler.cell(styler.fg("dim", `\u2193 ${hidden} more`), inner));
|
|
40721
|
+
const total = rows.length + (Number(held.more) || 0);
|
|
40722
|
+
return [{ key: "held", priority: 5, head: ["held", `${total}${held.truncated ? "+" : ""} waiting on conditions`], body }];
|
|
40723
|
+
}
|
|
40724
|
+
function heldRow(r, inner, styler) {
|
|
40725
|
+
const bits = [
|
|
40726
|
+
`${styler.fg("dim", "\u25CB")} ${styler.fg("accent", r.target ?? r.jobId ?? "-")}`,
|
|
40727
|
+
styler.fg("text", r.label ?? "-"),
|
|
40728
|
+
styler.fg("dim", `waited ${fmtDuration(r.waitedMs)}`)
|
|
40729
|
+
];
|
|
40730
|
+
return fitLine(bits.join(styler.fg("dim", " \xB7 ")), inner, styler);
|
|
40731
|
+
}
|
|
40732
|
+
function fmtDuration(ms) {
|
|
40733
|
+
if (!Number.isFinite(ms) || ms < 0) return "?";
|
|
40734
|
+
const s = Math.floor(ms / 1e3);
|
|
40735
|
+
if (s < 60) return `${s}s`;
|
|
40736
|
+
const m = Math.floor(s / 60);
|
|
40737
|
+
if (m < 60) return `${m}m`;
|
|
40738
|
+
const h = Math.floor(m / 60);
|
|
40739
|
+
return h < 24 ? `${h}h${m % 60}m` : `${Math.floor(h / 24)}d${h % 24}h`;
|
|
40740
|
+
}
|
|
40190
40741
|
function statusHeader(queue, inner, styler) {
|
|
40191
40742
|
if (!queue || queue.unreachable) {
|
|
40192
40743
|
return styler.cell(`queue unreachable (${queue?.unreachable ?? "?"})`, inner, { color: "error" });
|
|
@@ -40198,7 +40749,8 @@ function statusHeader(queue, inner, styler) {
|
|
|
40198
40749
|
const dot = styler.fg(stateColor, "\u25CF");
|
|
40199
40750
|
const word = styler.bold(styler.fg(stateColor, running ? "RUNNING" : "PAUSED"));
|
|
40200
40751
|
const sep2 = styler.fg("dim", " \xB7 ");
|
|
40201
|
-
const
|
|
40752
|
+
const delayed = Number(c.delayed ?? 0);
|
|
40753
|
+
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
40754
|
const clock = (/* @__PURE__ */ new Date()).toISOString().slice(11, 19);
|
|
40203
40755
|
const left = `${dot} ${word} ${vitals}`;
|
|
40204
40756
|
const gap = inner - styler.visibleLen(left) - clock.length;
|
|
@@ -40353,6 +40905,39 @@ function pauseRow(w, now, inner, styler) {
|
|
|
40353
40905
|
if (until) bits.push(styler.fg("warning", `resumes in ${humanizeMs(until - now) || "<1m"}`));
|
|
40354
40906
|
return fitLine(bits.join(styler.fg("dim", " ")), inner, styler);
|
|
40355
40907
|
}
|
|
40908
|
+
function limitLines(scopedLimits, scopedBudget, inner, styler) {
|
|
40909
|
+
const lines = [];
|
|
40910
|
+
if (scopedLimits && scopedLimits.missing) {
|
|
40911
|
+
lines.push(styler.cell("(no scoped limits \xB7 m to manage)", inner, { color: "dim" }));
|
|
40912
|
+
return { count: 0, lines };
|
|
40913
|
+
}
|
|
40914
|
+
if (scopedLimits && scopedLimits.invalid) {
|
|
40915
|
+
lines.push(styler.cell(`(scoped-limits file invalid: ${scopedLimits.invalid})`, inner, { color: "error" }));
|
|
40916
|
+
return { count: 0, lines };
|
|
40917
|
+
}
|
|
40918
|
+
const list = (scopedLimits && scopedLimits.limits) ?? [];
|
|
40919
|
+
if (list.length === 0) {
|
|
40920
|
+
lines.push(styler.cell("(no scoped limits \xB7 m to manage)", inner, { color: "dim" }));
|
|
40921
|
+
return { count: 0, lines };
|
|
40922
|
+
}
|
|
40923
|
+
list.forEach((l, i) => lines.push(limitRow(l, scopedBudget?.rows?.[i] ?? null, inner, styler)));
|
|
40924
|
+
return { count: list.length, lines };
|
|
40925
|
+
}
|
|
40926
|
+
function limitRow(l, used, inner, styler) {
|
|
40927
|
+
let atCap = false;
|
|
40928
|
+
const windows = [];
|
|
40929
|
+
for (const key of ["day", "week", "month"]) {
|
|
40930
|
+
if (!Number.isInteger(l[key])) continue;
|
|
40931
|
+
const u = used && Number.isFinite(used[key]) ? used[key] : null;
|
|
40932
|
+
const over = u !== null && u >= l[key];
|
|
40933
|
+
if (over) atCap = true;
|
|
40934
|
+
windows.push(styler.fg(over ? "warning" : "text", `${key} ${u ?? "-"}/${l[key]}`));
|
|
40935
|
+
}
|
|
40936
|
+
const dot = atCap ? styler.fg("warning", "\u25CF") : styler.fg("dim", "\u25CB");
|
|
40937
|
+
const bits = [`${dot} ${styler.fg("accent", l.scope ?? "-")}`, ...windows];
|
|
40938
|
+
if (Number.isInteger(l.concurrent)) bits.push(styler.fg("muted", `\u2264${l.concurrent} at once`));
|
|
40939
|
+
return fitLine(bits.join(styler.fg("dim", " ")), inner, styler);
|
|
40940
|
+
}
|
|
40356
40941
|
function runLines(rows, selected, inner, styler) {
|
|
40357
40942
|
if (!Array.isArray(rows) || rows.length === 0) return [styler.cell("(no runs)", inner, { color: "dim" })];
|
|
40358
40943
|
const { top, count } = runsWindow(rows.length, selected);
|
|
@@ -40761,7 +41346,7 @@ function runDetailHints(inner, styler, canOpen = false, canCopy = false, copiedN
|
|
|
40761
41346
|
if (copiedNote) return fitLine(styler.fg("success", copiedNote) + styler.fg("dim", " \xB7 ") + line, inner, styler);
|
|
40762
41347
|
return fitLine(line, inner, styler);
|
|
40763
41348
|
}
|
|
40764
|
-
var KEY_HINTS, RUNS_ON_DASHBOARD, RUNS_VIEWPORT, RUN_SORTS, REFRESH_MS, TAIL_LINES, TAIL_VIEWPORT, MIN_WIDTH2, DRILL_WIDTH, KIND_COLOR, KIND_WIDTH, FRAME_CHROME_ROWS;
|
|
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;
|
|
40765
41350
|
var init_dashboard = __esm({
|
|
40766
41351
|
"src/dashboard.ts"() {
|
|
40767
41352
|
init_budget();
|
|
@@ -40770,6 +41355,7 @@ var init_dashboard = __esm({
|
|
|
40770
41355
|
init_scheduler_stall_guard();
|
|
40771
41356
|
init_pause_windows();
|
|
40772
41357
|
init_read_model();
|
|
41358
|
+
init_scoped_limits();
|
|
40773
41359
|
init_render();
|
|
40774
41360
|
init_keys2();
|
|
40775
41361
|
init_panel();
|
|
@@ -40783,9 +41369,12 @@ var init_dashboard = __esm({
|
|
|
40783
41369
|
TAIL_VIEWPORT = 20;
|
|
40784
41370
|
MIN_WIDTH2 = 8;
|
|
40785
41371
|
DRILL_WIDTH = 70;
|
|
41372
|
+
HELD_LIMIT = 20;
|
|
41373
|
+
HELD_HYDRATE_MAX2 = 200;
|
|
40786
41374
|
KIND_COLOR = { cron: "accent", label: "syntaxType", comment: "syntaxKeyword", pull_request: "syntaxFunction", issue: "syntaxString" };
|
|
40787
41375
|
KIND_WIDTH = 13;
|
|
40788
41376
|
FRAME_CHROME_ROWS = 4;
|
|
41377
|
+
HELD_ON_DASHBOARD = 3;
|
|
40789
41378
|
}
|
|
40790
41379
|
});
|
|
40791
41380
|
|
|
@@ -40858,16 +41447,16 @@ async function runAttached(ctx, { title, argv0, args, cwd, env, shell, spawnFn =
|
|
|
40858
41447
|
${title}
|
|
40859
41448
|
|
|
40860
41449
|
`);
|
|
40861
|
-
outcome = await new Promise((
|
|
41450
|
+
outcome = await new Promise((resolve3) => {
|
|
40862
41451
|
let child;
|
|
40863
41452
|
try {
|
|
40864
41453
|
child = spawnFn(argv0, args, { stdio: "inherit", cwd, env, ...shell ? { shell: true } : {} });
|
|
40865
41454
|
} catch (err) {
|
|
40866
|
-
|
|
41455
|
+
resolve3({ code: null, error: err });
|
|
40867
41456
|
return;
|
|
40868
41457
|
}
|
|
40869
|
-
child.on("error", (err) =>
|
|
40870
|
-
child.on("close", (code) =>
|
|
41458
|
+
child.on("error", (err) => resolve3({ code: null, error: err }));
|
|
41459
|
+
child.on("close", (code) => resolve3({ code }));
|
|
40871
41460
|
});
|
|
40872
41461
|
} catch (err) {
|
|
40873
41462
|
outcome = { code: null, error: err };
|
|
@@ -41086,6 +41675,7 @@ up shows each docker action and asks y/N before it \u2014 nothing is auto-accept
|
|
|
41086
41675
|
env: {
|
|
41087
41676
|
PI_TRIGGERS_FILE: join6(dir, "triggers.json"),
|
|
41088
41677
|
PI_PAUSE_WINDOWS_FILE: join6(dir, "pause-windows.json"),
|
|
41678
|
+
PI_SCOPED_LIMITS_FILE: join6(dir, "scoped-limits.json"),
|
|
41089
41679
|
PI_SUBSCRIPTIONS_FILE: join6(dir, "subscriptions.json")
|
|
41090
41680
|
}
|
|
41091
41681
|
};
|
|
@@ -41318,8 +41908,8 @@ var init_setup_wizard = __esm({
|
|
|
41318
41908
|
init_deployment_pointer();
|
|
41319
41909
|
init_read_model();
|
|
41320
41910
|
init_index();
|
|
41321
|
-
RUNTIME_VERSION = "1.
|
|
41322
|
-
RECEIVER_VERSION = "1.
|
|
41911
|
+
RUNTIME_VERSION = "1.6.0";
|
|
41912
|
+
RECEIVER_VERSION = "1.4.0";
|
|
41323
41913
|
RUNTIME_PKG = "@edgehero/pi-dispatch";
|
|
41324
41914
|
RECEIVER_PKG = "@edgehero/pi-dispatch-receiver";
|
|
41325
41915
|
NUDGE_MARKER_BASENAME = "pi-dispatch-setup.nudged";
|
|
@@ -41513,15 +42103,15 @@ function registerTools(pi) {
|
|
|
41513
42103
|
pi.registerTool({
|
|
41514
42104
|
name: "dispatch_costs",
|
|
41515
42105
|
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()) }),
|
|
42106
|
+
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").`,
|
|
42107
|
+
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
42108
|
async execute(_toolCallId, params) {
|
|
41519
42109
|
const window = params.window ?? "mtd";
|
|
41520
42110
|
if (!COSTS_WINDOWS.includes(window)) {
|
|
41521
42111
|
throw new Error(`unknown window '${window}' (7d|30d|mtd)`);
|
|
41522
42112
|
}
|
|
41523
42113
|
const paths = resolvePaths(process.env);
|
|
41524
|
-
const res = assembleCosts(paths, window, params.flow);
|
|
42114
|
+
const res = assembleCosts(paths, window, params.flow, params.repo);
|
|
41525
42115
|
if (res.unreachable) throw new Error(`could not read the run history: ${res.unreachable}`);
|
|
41526
42116
|
return toolText(JSON.stringify({ window, fold: res.fold }));
|
|
41527
42117
|
}
|
|
@@ -41803,6 +42393,148 @@ ${JSON.stringify(before)}
|
|
|
41803
42393
|
return toolText(JSON.stringify(result));
|
|
41804
42394
|
}
|
|
41805
42395
|
});
|
|
42396
|
+
pi.registerTool({
|
|
42397
|
+
name: "dispatch_limits",
|
|
42398
|
+
label: "pi-dispatch scoped limits",
|
|
42399
|
+
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.",
|
|
42400
|
+
parameters: typebox_exports.Object({}),
|
|
42401
|
+
async execute() {
|
|
42402
|
+
const paths = resolvePaths(process.env);
|
|
42403
|
+
const p = readScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath });
|
|
42404
|
+
if (!Array.isArray(p?.limits)) return toolText(JSON.stringify(p));
|
|
42405
|
+
const counters = await readScopedBudget({ url: paths.valkeyUrl, limits: p.limits });
|
|
42406
|
+
const rows = p.limits.map((l, index) => ({ index, ...l, used: counters.rows?.[index] ?? null }));
|
|
42407
|
+
return toolText(JSON.stringify(rows));
|
|
42408
|
+
}
|
|
42409
|
+
});
|
|
42410
|
+
pi.registerTool({
|
|
42411
|
+
name: "dispatch_waits",
|
|
42412
|
+
label: "pi-dispatch held jobs",
|
|
42413
|
+
description: "Read-only. Lists the jobs the worker is currently HOLDING on a trigger's run.waitFor condition: the job id, an id-only target, the operator's own words for what it is waiting on, and how long it has waited. A held job has reserved no budget slot and started no container. This is a different set from the queue's `delayed` count, which also mixes cron next-occurrences, retry backoff and quiet hours; only waits appear here. Use the job id for dispatch_wait_cancel.",
|
|
42414
|
+
parameters: typebox_exports.Object({}),
|
|
42415
|
+
async execute() {
|
|
42416
|
+
const paths = resolvePaths(process.env);
|
|
42417
|
+
const held = await readHeldJobs({ url: paths.valkeyUrl });
|
|
42418
|
+
return toolText(JSON.stringify(held));
|
|
42419
|
+
}
|
|
42420
|
+
});
|
|
42421
|
+
pi.registerTool({
|
|
42422
|
+
name: "dispatch_wait_cancel",
|
|
42423
|
+
label: "pi-dispatch cancel a held job",
|
|
42424
|
+
description: "Removes a job that is waiting on a run.waitFor condition, so it never runs. The operator MUST approve a confirm dialog naming the job and what it was waiting on; refused with no interactive operator. Use dispatch_waits for the job id. This is the ONLY way to stop a held job short of editing redis: a held job has spent nothing, so no budget cap will ever stop it, and deleting the trigger does not reach a job already enqueued. Cancelling writes no run record, because the job never ran.",
|
|
42425
|
+
executionMode: "sequential",
|
|
42426
|
+
parameters: typebox_exports.Object({ jobId: typebox_exports.String({ minLength: 1 }) }),
|
|
42427
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
42428
|
+
const paths = resolvePaths(process.env);
|
|
42429
|
+
const held = await readHeldJobs({ url: paths.valkeyUrl });
|
|
42430
|
+
if (held.unreachable) throw new Error(`queue unreachable: ${held.unreachable}`);
|
|
42431
|
+
const row = (held.rows ?? []).find((r) => r.jobId === params.jobId);
|
|
42432
|
+
if (!row) throw new Error(`no held job ${params.jobId} (dispatch_waits lists the ids that are waiting)`);
|
|
42433
|
+
const result = await confirmedWrite(
|
|
42434
|
+
ctx,
|
|
42435
|
+
{ title: `Cancel held job ${row.jobId}`, message: `Cancel ${row.target ?? row.jobId}, waiting on ${row.label ?? "a condition"}. It will never run.` },
|
|
42436
|
+
async () => {
|
|
42437
|
+
const res = await cancelHeldJob({ url: paths.valkeyUrl, jobId: params.jobId });
|
|
42438
|
+
if (res.invalid) throw new Error(`rejected: ${res.invalid}`);
|
|
42439
|
+
return { applied: true, jobId: params.jobId };
|
|
42440
|
+
}
|
|
42441
|
+
);
|
|
42442
|
+
return toolText(JSON.stringify(result));
|
|
42443
|
+
}
|
|
42444
|
+
});
|
|
42445
|
+
pi.registerTool({
|
|
42446
|
+
name: "dispatch_limit_add",
|
|
42447
|
+
label: "pi-dispatch add scoped limit",
|
|
42448
|
+
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.',
|
|
42449
|
+
executionMode: "sequential",
|
|
42450
|
+
parameters: typebox_exports.Object({
|
|
42451
|
+
scope: typebox_exports.String(),
|
|
42452
|
+
day: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1 })),
|
|
42453
|
+
week: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1 })),
|
|
42454
|
+
month: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1 })),
|
|
42455
|
+
concurrent: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1 }))
|
|
42456
|
+
}),
|
|
42457
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
42458
|
+
const paths = resolvePaths(process.env);
|
|
42459
|
+
const l = buildScopedLimit(params);
|
|
42460
|
+
const result = await confirmedWrite(
|
|
42461
|
+
ctx,
|
|
42462
|
+
{ title: "Add scoped limit", message: `Add to scoped-limits.json:
|
|
42463
|
+
${JSON.stringify(l)}` },
|
|
42464
|
+
() => {
|
|
42465
|
+
const res = writeScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath, mutate: (list) => [...list, l] });
|
|
42466
|
+
if (res.invalid) throw new Error(`rejected: ${res.invalid}`);
|
|
42467
|
+
return { applied: true, added: l };
|
|
42468
|
+
}
|
|
42469
|
+
);
|
|
42470
|
+
return toolText(JSON.stringify(result));
|
|
42471
|
+
}
|
|
42472
|
+
});
|
|
42473
|
+
pi.registerTool({
|
|
42474
|
+
name: "dispatch_limit_delete",
|
|
42475
|
+
label: "pi-dispatch delete scoped limit",
|
|
42476
|
+
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.",
|
|
42477
|
+
executionMode: "sequential",
|
|
42478
|
+
parameters: typebox_exports.Object({ index: typebox_exports.Integer({ minimum: 0 }) }),
|
|
42479
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
42480
|
+
const paths = resolvePaths(process.env);
|
|
42481
|
+
const p = readScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath });
|
|
42482
|
+
const list = Array.isArray(p?.limits) ? p.limits : [];
|
|
42483
|
+
const cur = list[params.index];
|
|
42484
|
+
if (!cur) throw new Error(`no scoped limit at index ${params.index} (have ${list.length})`);
|
|
42485
|
+
const result = await confirmedWrite(
|
|
42486
|
+
ctx,
|
|
42487
|
+
{ title: `Delete scoped limit #${params.index + 1}`, message: `Remove scoped limit #${params.index + 1}: ${cur.scope} ${limitSummary(cur)}` },
|
|
42488
|
+
() => {
|
|
42489
|
+
const res = writeScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath, mutate: (l) => l.filter((_, i) => i !== params.index) });
|
|
42490
|
+
if (res.invalid) throw new Error(`rejected: ${res.invalid}`);
|
|
42491
|
+
return { applied: true, deletedIndex: params.index };
|
|
42492
|
+
}
|
|
42493
|
+
);
|
|
42494
|
+
return toolText(JSON.stringify(result));
|
|
42495
|
+
}
|
|
42496
|
+
});
|
|
42497
|
+
pi.registerTool({
|
|
42498
|
+
name: "dispatch_limit_edit",
|
|
42499
|
+
label: "pi-dispatch edit scoped limit",
|
|
42500
|
+
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.",
|
|
42501
|
+
executionMode: "sequential",
|
|
42502
|
+
parameters: typebox_exports.Object({
|
|
42503
|
+
index: typebox_exports.Integer({ minimum: 0 }),
|
|
42504
|
+
scope: typebox_exports.Optional(typebox_exports.String()),
|
|
42505
|
+
day: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1 })),
|
|
42506
|
+
week: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1 })),
|
|
42507
|
+
month: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1 })),
|
|
42508
|
+
concurrent: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1 }))
|
|
42509
|
+
}),
|
|
42510
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
42511
|
+
const paths = resolvePaths(process.env);
|
|
42512
|
+
const p = readScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath });
|
|
42513
|
+
const list = Array.isArray(p?.limits) ? p.limits : [];
|
|
42514
|
+
const cur = list[params.index];
|
|
42515
|
+
if (!cur) throw new Error(`no scoped limit at index ${params.index} (have ${list.length})`);
|
|
42516
|
+
const before = buildScopedLimit(cur);
|
|
42517
|
+
const merged = buildScopedLimit({
|
|
42518
|
+
scope: params.scope ?? cur.scope,
|
|
42519
|
+
day: params.day ?? cur.day,
|
|
42520
|
+
week: params.week ?? cur.week,
|
|
42521
|
+
month: params.month ?? cur.month,
|
|
42522
|
+
concurrent: params.concurrent ?? cur.concurrent
|
|
42523
|
+
});
|
|
42524
|
+
const result = await confirmedWrite(
|
|
42525
|
+
ctx,
|
|
42526
|
+
{ title: `Edit scoped limit #${params.index + 1}`, message: `scoped limit #${params.index + 1}:
|
|
42527
|
+
${JSON.stringify(before)}
|
|
42528
|
+
\u2192 ${JSON.stringify(merged)}` },
|
|
42529
|
+
() => {
|
|
42530
|
+
const res = writeScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath, mutate: (l) => l.map((w, i) => i === params.index ? merged : w) });
|
|
42531
|
+
if (res.invalid) throw new Error(`rejected: ${res.invalid}`);
|
|
42532
|
+
return { applied: true, index: params.index, limit: merged };
|
|
42533
|
+
}
|
|
42534
|
+
);
|
|
42535
|
+
return toolText(JSON.stringify(result));
|
|
42536
|
+
}
|
|
42537
|
+
});
|
|
41806
42538
|
pi.registerTool({
|
|
41807
42539
|
name: "dispatch_trigger_delete",
|
|
41808
42540
|
label: "pi-dispatch delete trigger",
|
|
@@ -41888,6 +42620,22 @@ function buildPauseWindow(f) {
|
|
|
41888
42620
|
if (dateTo) w.dateTo = dateTo;
|
|
41889
42621
|
return w;
|
|
41890
42622
|
}
|
|
42623
|
+
function buildScopedLimit(f) {
|
|
42624
|
+
const l = { scope: String(f.scope ?? "").trim() };
|
|
42625
|
+
for (const k of ["day", "week", "month", "concurrent"]) {
|
|
42626
|
+
const v = optInt(f[k]);
|
|
42627
|
+
if (v !== void 0) l[k] = v;
|
|
42628
|
+
}
|
|
42629
|
+
return l;
|
|
42630
|
+
}
|
|
42631
|
+
function limitSummary(l) {
|
|
42632
|
+
const bits = [];
|
|
42633
|
+
if (Number.isInteger(l?.day)) bits.push(`day ${l.day}`);
|
|
42634
|
+
if (Number.isInteger(l?.week)) bits.push(`week ${l.week}`);
|
|
42635
|
+
if (Number.isInteger(l?.month)) bits.push(`month ${l.month}`);
|
|
42636
|
+
if (Number.isInteger(l?.concurrent)) bits.push(`\u2264${l.concurrent} at once`);
|
|
42637
|
+
return bits.join(" \xB7 ");
|
|
42638
|
+
}
|
|
41891
42639
|
function asWords(x) {
|
|
41892
42640
|
if (Array.isArray(x)) return x.map((s) => String(s).trim()).filter(Boolean);
|
|
41893
42641
|
return splitWords(x);
|
|
@@ -41957,7 +42705,11 @@ ${renderBudget({ budget, settings: settings2 })}`);
|
|
|
41957
42705
|
case "budget": {
|
|
41958
42706
|
const budget = await readBudget({ url: paths.valkeyUrl });
|
|
41959
42707
|
const settings2 = readSettingsView({ settingsFile: paths.settingsFile });
|
|
41960
|
-
|
|
42708
|
+
const limits = readScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath });
|
|
42709
|
+
const scopedBudget = Array.isArray(limits?.limits) && limits.limits.length > 0 ? await readScopedBudget({ url: paths.valkeyUrl, limits: limits.limits }) : { rows: [] };
|
|
42710
|
+
const scopedText = renderScopedLimits({ limits, scopedBudget });
|
|
42711
|
+
send(pi, `${renderBudget({ budget, settings: settings2 })}${scopedText ? `
|
|
42712
|
+
${scopedText}` : ""}`);
|
|
41961
42713
|
return;
|
|
41962
42714
|
}
|
|
41963
42715
|
case "triggers": {
|
|
@@ -42038,13 +42790,14 @@ ${renderBudget({ budget, settings: settings2 })}`);
|
|
|
42038
42790
|
return;
|
|
42039
42791
|
}
|
|
42040
42792
|
}
|
|
42041
|
-
function assembleCosts(paths, window, flow) {
|
|
42793
|
+
function assembleCosts(paths, window, flow, repo) {
|
|
42042
42794
|
const nowMs = Date.now();
|
|
42043
42795
|
const sinceMs = costsSinceMs(window, nowMs);
|
|
42044
42796
|
const records = scanRunRecords({ logsDir: paths.logsDir, sinceMs, nowMs });
|
|
42045
42797
|
if (!Array.isArray(records)) return records;
|
|
42046
42798
|
const subs = readSubscriptions({ subscriptionsPath: paths.subscriptionsPath });
|
|
42047
|
-
const
|
|
42799
|
+
const flowScoped = typeof flow === "string" && flow !== "" ? records.filter((r) => (r?.flow ?? null) === flow) : records;
|
|
42800
|
+
const scoped = typeof repo === "string" && repo !== "" ? flowScoped.filter((r) => repoOfTarget(r?.target) === repo) : flowScoped;
|
|
42048
42801
|
const triggersView = readTriggers({ triggersPath: paths.triggersPath });
|
|
42049
42802
|
const triggerJoin = attributeRunsToTriggers({ records: scoped, triggers: Array.isArray(triggersView?.triggers) ? triggersView.triggers : [] });
|
|
42050
42803
|
const fold = foldCosts({
|
|
@@ -42107,13 +42860,27 @@ async function assembleBudgetView(paths) {
|
|
|
42107
42860
|
const tokensSpent = unreachable === null ? Number(raw.tokensToday ?? 0) : null;
|
|
42108
42861
|
const tokenCap = posIntOrNull(overlay.dailyTokenCap);
|
|
42109
42862
|
const tokenState = tokenCap !== null && tokensSpent !== null ? tokensSpent >= tokenCap ? "over" : pct !== null && tokensSpent > Math.floor(tokenCap * pct / 100) ? "soft-hold" : "ok" : "ok";
|
|
42863
|
+
const limitsView = readScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath });
|
|
42864
|
+
const limits = Array.isArray(limitsView?.limits) ? limitsView.limits : [];
|
|
42865
|
+
const scopedCounters = limits.length > 0 ? await readScopedBudget({ url: paths.valkeyUrl, limits }) : { rows: [] };
|
|
42866
|
+
const scoped = limits.map((l, i) => {
|
|
42867
|
+
const used = scopedCounters?.rows?.[i] ?? null;
|
|
42868
|
+
const win = (cap, key) => {
|
|
42869
|
+
if (cap === null) return null;
|
|
42870
|
+
const u = used && Number.isFinite(used[key]) ? used[key] : null;
|
|
42871
|
+
return { used: u, cap, state: u !== null ? windowState(u, cap, null) : "ok" };
|
|
42872
|
+
};
|
|
42873
|
+
return { scope: l.scope, day: win(l.day, "day"), week: win(l.week, "week"), month: win(l.month, "month"), concurrent: l.concurrent };
|
|
42874
|
+
});
|
|
42110
42875
|
return {
|
|
42111
42876
|
unreachable,
|
|
42112
42877
|
softHoldPct: pct,
|
|
42113
42878
|
day: slotWindow("day", "dailyCap"),
|
|
42114
42879
|
week: slotWindow("week", "weeklyCap"),
|
|
42115
42880
|
month: slotWindow("month", "monthlyCap"),
|
|
42116
|
-
tokens: { spent: tokensSpent, cap: tokenCap, state: tokenState, maxTokens: posIntOrNull(overlay.maxTokens) }
|
|
42881
|
+
tokens: { spent: tokensSpent, cap: tokenCap, state: tokenState, maxTokens: posIntOrNull(overlay.maxTokens) },
|
|
42882
|
+
scoped,
|
|
42883
|
+
scopedInvalid: limitsView?.invalid ? String(limitsView.invalid) : null
|
|
42117
42884
|
};
|
|
42118
42885
|
}
|
|
42119
42886
|
async function assembleInsights(paths, window) {
|
|
@@ -42280,7 +43047,7 @@ could not start docker: ${error.message}
|
|
|
42280
43047
|
}
|
|
42281
43048
|
}
|
|
42282
43049
|
function pauseForMessage() {
|
|
42283
|
-
return new Promise((
|
|
43050
|
+
return new Promise((resolve3) => setTimeout(resolve3, 2500));
|
|
42284
43051
|
}
|
|
42285
43052
|
function splitWords(s) {
|
|
42286
43053
|
return String(s ?? "").split(/\s+/).map((w) => w.trim()).filter(Boolean);
|
|
@@ -42306,8 +43073,84 @@ async function handleDashboardAction(result, paths, ctx) {
|
|
|
42306
43073
|
return editSettingsViaDialogs(paths, ui, notify);
|
|
42307
43074
|
case "managePauses":
|
|
42308
43075
|
return managePausesViaDialogs(paths, ui, notify);
|
|
43076
|
+
case "manageLimits":
|
|
43077
|
+
return manageLimitsViaDialogs(paths, ui, notify);
|
|
42309
43078
|
}
|
|
42310
43079
|
}
|
|
43080
|
+
async function manageLimitsViaDialogs(paths, ui, notify) {
|
|
43081
|
+
const action = await ui.select("Scoped limits", ["Add a scoped limit", "Edit a scoped limit", "Delete a scoped limit"]);
|
|
43082
|
+
if (!action) return;
|
|
43083
|
+
if (action.startsWith("Add")) return addScopedLimitViaDialogs(paths, ui, notify);
|
|
43084
|
+
if (action.startsWith("Edit")) return editScopedLimitViaDialogs(paths, ui, notify);
|
|
43085
|
+
return deleteScopedLimitViaDialogs(paths, ui, notify);
|
|
43086
|
+
}
|
|
43087
|
+
async function addScopedLimitViaDialogs(paths, ui, notify) {
|
|
43088
|
+
const scope = await ui.input('scope \u2014 a repo "owner/name" or an ABSOLUTE local folder path (exact match, no globs)', "");
|
|
43089
|
+
if (scope === void 0 || scope.trim() === "") return;
|
|
43090
|
+
const day = await ui.input("day \u2014 max jobs per UTC day for this scope (blank = no day cap)", "");
|
|
43091
|
+
if (day === void 0) return;
|
|
43092
|
+
const week = await ui.input("week \u2014 max jobs per Mon-Sun UTC week (blank = no week cap)", "");
|
|
43093
|
+
if (week === void 0) return;
|
|
43094
|
+
const month = await ui.input("month \u2014 max jobs per calendar month (blank = no month cap)", "");
|
|
43095
|
+
if (month === void 0) return;
|
|
43096
|
+
const concurrent = await ui.input("concurrent \u2014 max jobs in flight for this scope, extras deferred (blank = no limit)", "");
|
|
43097
|
+
if (concurrent === void 0) return;
|
|
43098
|
+
const l = buildScopedLimit({ scope, day, week, month, concurrent });
|
|
43099
|
+
const res = writeScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath, mutate: (list) => [...list, l] });
|
|
43100
|
+
notify?.(res.ok ? `scoped limit added (live) \u2014 ${l.scope} ${limitSummary(l)}` : `add rejected: ${res.invalid}`, res.ok ? "info" : "error");
|
|
43101
|
+
}
|
|
43102
|
+
async function editScopedLimitViaDialogs(paths, ui, notify) {
|
|
43103
|
+
const p = readScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath });
|
|
43104
|
+
const list = Array.isArray(p?.limits) ? p.limits : [];
|
|
43105
|
+
if (list.length === 0) {
|
|
43106
|
+
notify?.("no scoped limits to edit", "info");
|
|
43107
|
+
return;
|
|
43108
|
+
}
|
|
43109
|
+
const labels = list.map((l, i) => `#${i + 1} ${l.scope} ${limitSummary(l)}`);
|
|
43110
|
+
const picked = await ui.select("Edit which scoped limit", labels);
|
|
43111
|
+
if (!picked) return;
|
|
43112
|
+
const index = labels.indexOf(picked);
|
|
43113
|
+
if (index < 0) return;
|
|
43114
|
+
const cur = list[index];
|
|
43115
|
+
const ask = async (label, current) => ui.input(`${label} \u2014 blank keeps "${current}"`, current);
|
|
43116
|
+
const keep = (v, current) => v === void 0 || v.trim() === "" ? current : v;
|
|
43117
|
+
const scope = await ask("scope", cur.scope ?? "");
|
|
43118
|
+
if (scope === void 0) return;
|
|
43119
|
+
const day = await ask("day cap", String(cur.day ?? ""));
|
|
43120
|
+
if (day === void 0) return;
|
|
43121
|
+
const week = await ask("week cap", String(cur.week ?? ""));
|
|
43122
|
+
if (week === void 0) return;
|
|
43123
|
+
const month = await ask("month cap", String(cur.month ?? ""));
|
|
43124
|
+
if (month === void 0) return;
|
|
43125
|
+
const concurrent = await ask("concurrent (max in flight)", String(cur.concurrent ?? ""));
|
|
43126
|
+
if (concurrent === void 0) return;
|
|
43127
|
+
const merged = buildScopedLimit({
|
|
43128
|
+
scope: keep(scope, cur.scope),
|
|
43129
|
+
day: keep(day, cur.day),
|
|
43130
|
+
week: keep(week, cur.week),
|
|
43131
|
+
month: keep(month, cur.month),
|
|
43132
|
+
concurrent: keep(concurrent, cur.concurrent)
|
|
43133
|
+
});
|
|
43134
|
+
const res = writeScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath, mutate: (l) => l.map((w, i) => i === index ? merged : w) });
|
|
43135
|
+
notify?.(res.ok ? `scoped limit #${index + 1} updated (live) \u2014 ${merged.scope} ${limitSummary(merged)}` : `edit rejected: ${res.invalid}`, res.ok ? "info" : "error");
|
|
43136
|
+
}
|
|
43137
|
+
async function deleteScopedLimitViaDialogs(paths, ui, notify) {
|
|
43138
|
+
const p = readScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath });
|
|
43139
|
+
const list = Array.isArray(p?.limits) ? p.limits : [];
|
|
43140
|
+
if (list.length === 0) {
|
|
43141
|
+
notify?.("no scoped limits to delete", "info");
|
|
43142
|
+
return;
|
|
43143
|
+
}
|
|
43144
|
+
const labels = list.map((l, i) => `#${i + 1} ${l.scope} ${limitSummary(l)}`);
|
|
43145
|
+
const picked = await ui.select("Delete which scoped limit", labels);
|
|
43146
|
+
if (!picked) return;
|
|
43147
|
+
const index = labels.indexOf(picked);
|
|
43148
|
+
if (index < 0) return;
|
|
43149
|
+
const ok = await ui.confirm("Delete scoped limit", `Remove ${labels[index]}?`);
|
|
43150
|
+
if (!ok) return;
|
|
43151
|
+
const res = writeScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath, mutate: (l) => l.filter((_, i) => i !== index) });
|
|
43152
|
+
notify?.(res.ok ? `scoped limit #${index + 1} deleted (live)` : `delete rejected: ${res.invalid}`, res.ok ? "info" : "error");
|
|
43153
|
+
}
|
|
42311
43154
|
async function managePausesViaDialogs(paths, ui, notify) {
|
|
42312
43155
|
const action = await ui.select("Pause windows", ["Add a pause window", "Edit a pause window", "Delete a pause window"]);
|
|
42313
43156
|
if (!action) return;
|