@edgehero/pi-dispatch-admin 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -6
- package/dist/index.mjs +1347 -497
- package/package.json +1 -1
- package/skills/operate-pi-dispatch/SKILL.md +67 -6
package/dist/index.mjs
CHANGED
|
@@ -7886,6 +7886,462 @@ var init_budget = __esm({
|
|
|
7886
7886
|
}
|
|
7887
7887
|
});
|
|
7888
7888
|
|
|
7889
|
+
// ../worker/src/pause-windows.mjs
|
|
7890
|
+
function isNonEmptyString2(value) {
|
|
7891
|
+
return typeof value === "string" && value.trim() !== "";
|
|
7892
|
+
}
|
|
7893
|
+
function parseHHMM(value, label, at, path) {
|
|
7894
|
+
const m = isNonEmptyString2(value) ? /^([0-9]{1,2}):([0-9]{2})$/.exec(value.trim()) : null;
|
|
7895
|
+
if (!m) throw configError(`${at}: ${label} must be "HH:MM": ${JSON.stringify(value)} (${path})`);
|
|
7896
|
+
const h = Number(m[1]);
|
|
7897
|
+
const min = Number(m[2]);
|
|
7898
|
+
if (h > 23 || min > 59) throw configError(`${at}: ${label} out of range 00:00-23:59: ${JSON.stringify(value)} (${path})`);
|
|
7899
|
+
return h * 60 + min;
|
|
7900
|
+
}
|
|
7901
|
+
function assertDate(value, label, at, path) {
|
|
7902
|
+
const m = /^([0-9]{4})-([0-9]{2})-([0-9]{2})$/.exec(String(value).trim());
|
|
7903
|
+
const mo = m && Number(m[2]);
|
|
7904
|
+
const d = m && Number(m[3]);
|
|
7905
|
+
if (!m || mo < 1 || mo > 12 || d < 1 || d > 31) {
|
|
7906
|
+
throw configError(`${at}: ${label} must be "YYYY-MM-DD": ${JSON.stringify(value)} (${path})`);
|
|
7907
|
+
}
|
|
7908
|
+
}
|
|
7909
|
+
function validateTz(tz, at, path) {
|
|
7910
|
+
try {
|
|
7911
|
+
new Intl.DateTimeFormat("en-US", { timeZone: tz });
|
|
7912
|
+
return tz;
|
|
7913
|
+
} catch {
|
|
7914
|
+
throw configError(`${at}: tz is not a valid IANA timezone: ${JSON.stringify(tz)} (${path})`);
|
|
7915
|
+
}
|
|
7916
|
+
}
|
|
7917
|
+
function parsePauseWindows(text, path) {
|
|
7918
|
+
let parsed;
|
|
7919
|
+
try {
|
|
7920
|
+
parsed = JSON.parse(text);
|
|
7921
|
+
} catch (error) {
|
|
7922
|
+
throw configError(`pause-windows file is not valid JSON: ${path} (${error.message})`);
|
|
7923
|
+
}
|
|
7924
|
+
const windows = parsed?.windows;
|
|
7925
|
+
if (!Array.isArray(windows)) {
|
|
7926
|
+
throw configError(`pause-windows file must have a "windows" array: ${path}`);
|
|
7927
|
+
}
|
|
7928
|
+
return windows.map((w, index) => normalizeWindow(w, index, path));
|
|
7929
|
+
}
|
|
7930
|
+
function normalizeWindow(w, index, path) {
|
|
7931
|
+
const at = `pause window at index ${index}`;
|
|
7932
|
+
if (w === null || typeof w !== "object") throw configError(`${at}: must be an object: ${path}`);
|
|
7933
|
+
if (!isNonEmptyString2(w.scope)) throw configError(`${at}: scope must be a non-empty string: ${path}`);
|
|
7934
|
+
const fromMin = parseHHMM(w.from, "from", at, path);
|
|
7935
|
+
const toMin = parseHHMM(w.to, "to", at, path);
|
|
7936
|
+
if (fromMin === toMin) {
|
|
7937
|
+
throw configError(`${at}: from and to must differ (a 24h pause is not expressible; remove the trigger): ${path}`);
|
|
7938
|
+
}
|
|
7939
|
+
const tz = w.tz === void 0 ? "UTC" : validateTz(w.tz, at, path);
|
|
7940
|
+
let days = null;
|
|
7941
|
+
if (w.days !== void 0) {
|
|
7942
|
+
if (!Array.isArray(w.days) || w.days.length === 0) {
|
|
7943
|
+
throw configError(`${at}: days must be a non-empty array of weekday names (mon..sun): ${path}`);
|
|
7944
|
+
}
|
|
7945
|
+
days = w.days.map((d) => {
|
|
7946
|
+
const key = String(d).trim().toLowerCase().slice(0, 3);
|
|
7947
|
+
if (!DAY_SET.has(key)) throw configError(`${at}: unknown weekday ${JSON.stringify(d)} (use mon..sun): ${path}`);
|
|
7948
|
+
return key;
|
|
7949
|
+
});
|
|
7950
|
+
}
|
|
7951
|
+
let dateFrom = null;
|
|
7952
|
+
let dateTo = null;
|
|
7953
|
+
if (w.dateFrom !== void 0) {
|
|
7954
|
+
assertDate(w.dateFrom, "dateFrom", at, path);
|
|
7955
|
+
dateFrom = String(w.dateFrom).trim();
|
|
7956
|
+
}
|
|
7957
|
+
if (w.dateTo !== void 0) {
|
|
7958
|
+
assertDate(w.dateTo, "dateTo", at, path);
|
|
7959
|
+
dateTo = String(w.dateTo).trim();
|
|
7960
|
+
}
|
|
7961
|
+
if (dateFrom && dateTo && dateFrom > dateTo) throw configError(`${at}: dateFrom must be <= dateTo: ${path}`);
|
|
7962
|
+
const norm = { scope: w.scope.trim(), from: w.from.trim(), to: w.to.trim(), fromMin, toMin, tz };
|
|
7963
|
+
if (days) norm.days = days;
|
|
7964
|
+
if (dateFrom) norm.dateFrom = dateFrom;
|
|
7965
|
+
if (dateTo) norm.dateTo = dateTo;
|
|
7966
|
+
return norm;
|
|
7967
|
+
}
|
|
7968
|
+
function zonedParts(tz, ms) {
|
|
7969
|
+
const fmt2 = new Intl.DateTimeFormat("en-US", {
|
|
7970
|
+
timeZone: tz,
|
|
7971
|
+
hour12: false,
|
|
7972
|
+
year: "numeric",
|
|
7973
|
+
month: "2-digit",
|
|
7974
|
+
day: "2-digit",
|
|
7975
|
+
hour: "2-digit",
|
|
7976
|
+
minute: "2-digit",
|
|
7977
|
+
weekday: "short"
|
|
7978
|
+
});
|
|
7979
|
+
const p = {};
|
|
7980
|
+
for (const part of fmt2.formatToParts(new Date(ms))) p[part.type] = part.value;
|
|
7981
|
+
let hh = Number(p.hour);
|
|
7982
|
+
if (hh === 24) hh = 0;
|
|
7983
|
+
return { y: Number(p.year), mo: Number(p.month), d: Number(p.day), hh, mi: Number(p.minute), dow: WEEKDAY_INDEX[p.weekday] };
|
|
7984
|
+
}
|
|
7985
|
+
function wallToMs(tz, y, mo, d, hh, mi) {
|
|
7986
|
+
const guess = Date.UTC(y, mo - 1, d, hh, mi);
|
|
7987
|
+
const shown = zonedParts(tz, guess);
|
|
7988
|
+
const shownAsUtc = Date.UTC(shown.y, shown.mo - 1, shown.d, shown.hh, shown.mi);
|
|
7989
|
+
return guess - (shownAsUtc - guess);
|
|
7990
|
+
}
|
|
7991
|
+
function shiftDay(tz, parts, delta) {
|
|
7992
|
+
const noon = wallToMs(tz, parts.y, parts.mo, parts.d, 12, 0);
|
|
7993
|
+
return zonedParts(tz, noon + delta * 864e5);
|
|
7994
|
+
}
|
|
7995
|
+
function ymd(parts) {
|
|
7996
|
+
return `${String(parts.y).padStart(4, "0")}-${String(parts.mo).padStart(2, "0")}-${String(parts.d).padStart(2, "0")}`;
|
|
7997
|
+
}
|
|
7998
|
+
function qualifies(w, parts) {
|
|
7999
|
+
if (w.days && !w.days.includes(DAYS[parts.dow])) return false;
|
|
8000
|
+
const date = ymd(parts);
|
|
8001
|
+
if (w.dateFrom && date < w.dateFrom) return false;
|
|
8002
|
+
if (w.dateTo && date > w.dateTo) return false;
|
|
8003
|
+
return true;
|
|
8004
|
+
}
|
|
8005
|
+
function windowEndAt(w, nowMs) {
|
|
8006
|
+
const p = zonedParts(w.tz, nowMs);
|
|
8007
|
+
const nowMin = p.hh * 60 + p.mi;
|
|
8008
|
+
const endAt = (dateParts) => wallToMs(w.tz, dateParts.y, dateParts.mo, dateParts.d, Math.floor(w.toMin / 60), w.toMin % 60);
|
|
8009
|
+
if (w.fromMin < w.toMin) {
|
|
8010
|
+
if (nowMin >= w.fromMin && nowMin < w.toMin && qualifies(w, p)) return endAt(p);
|
|
8011
|
+
return null;
|
|
8012
|
+
}
|
|
8013
|
+
if (nowMin >= w.fromMin && qualifies(w, p)) return endAt(shiftDay(w.tz, p, 1));
|
|
8014
|
+
if (nowMin < w.toMin && qualifies(w, shiftDay(w.tz, p, -1))) return endAt(p);
|
|
8015
|
+
return null;
|
|
8016
|
+
}
|
|
8017
|
+
var DAYS, DAY_SET, WEEKDAY_INDEX;
|
|
8018
|
+
var init_pause_windows = __esm({
|
|
8019
|
+
"../worker/src/pause-windows.mjs"() {
|
|
8020
|
+
init_config2();
|
|
8021
|
+
DAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
|
|
8022
|
+
DAY_SET = new Set(DAYS);
|
|
8023
|
+
WEEKDAY_INDEX = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
|
|
8024
|
+
}
|
|
8025
|
+
});
|
|
8026
|
+
|
|
8027
|
+
// ../worker/src/scoped-limits.mjs
|
|
8028
|
+
import { createHash } from "node:crypto";
|
|
8029
|
+
import { isAbsolute, resolve } from "node:path";
|
|
8030
|
+
function isNonEmptyString3(value) {
|
|
8031
|
+
return typeof value === "string" && value.trim() !== "";
|
|
8032
|
+
}
|
|
8033
|
+
function parseScopedLimits(text, path) {
|
|
8034
|
+
let parsed;
|
|
8035
|
+
try {
|
|
8036
|
+
parsed = JSON.parse(text);
|
|
8037
|
+
} catch (error) {
|
|
8038
|
+
throw configError(`scoped-limits file is not valid JSON: ${path} (${error.message})`);
|
|
8039
|
+
}
|
|
8040
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
8041
|
+
throw configError(`scoped-limits file must be an object with "version" and "limits": ${path}`);
|
|
8042
|
+
}
|
|
8043
|
+
const version = parsed.version;
|
|
8044
|
+
if (!Number.isInteger(version) || version < 1) {
|
|
8045
|
+
throw configError(`scoped-limits file must have "version": 1 (an integer >= 1): ${path}`);
|
|
8046
|
+
}
|
|
8047
|
+
if (version > SCOPED_LIMITS_VERSION) {
|
|
8048
|
+
throw configError(`scoped-limits file written by a newer pi-dispatch (version ${version}; this build understands ${SCOPED_LIMITS_VERSION}): ${path}`);
|
|
8049
|
+
}
|
|
8050
|
+
if (!Array.isArray(parsed.limits)) {
|
|
8051
|
+
throw configError(`scoped-limits file must have a "limits" array: ${path}`);
|
|
8052
|
+
}
|
|
8053
|
+
const rows = parsed.limits.map((row, index) => normalizeLimit(row, index, path));
|
|
8054
|
+
const seen = /* @__PURE__ */ new Map();
|
|
8055
|
+
rows.forEach((row, index) => {
|
|
8056
|
+
if (seen.has(row.scope)) {
|
|
8057
|
+
throw configError(`scoped limit at index ${index}: duplicate scope ${JSON.stringify(row.scope)} (first at index ${seen.get(row.scope)}): ${path}`);
|
|
8058
|
+
}
|
|
8059
|
+
seen.set(row.scope, index);
|
|
8060
|
+
});
|
|
8061
|
+
return rows;
|
|
8062
|
+
}
|
|
8063
|
+
function normalizeLimit(row, index, path) {
|
|
8064
|
+
const at = `scoped limit at index ${index}`;
|
|
8065
|
+
if (row === null || typeof row !== "object" || Array.isArray(row)) {
|
|
8066
|
+
throw configError(`${at}: must be an object: ${path}`);
|
|
8067
|
+
}
|
|
8068
|
+
if (!isNonEmptyString3(row.scope)) throw configError(`${at}: scope must be a non-empty string: ${path}`);
|
|
8069
|
+
const trimmed = row.scope.trim().normalize("NFC");
|
|
8070
|
+
if (trimmed === "*") {
|
|
8071
|
+
throw configError(`${at}: "*" is not supported -- add one row per scope (a per-scope default may adopt "*" later): ${path}`);
|
|
8072
|
+
}
|
|
8073
|
+
if (trimmed.includes("*")) {
|
|
8074
|
+
throw configError(`${at}: scopes match exactly; a scope containing "*" is refused (no globs): ${path}`);
|
|
8075
|
+
}
|
|
8076
|
+
const norm = {
|
|
8077
|
+
// An absolute path is stored resolved so a `/srv/site/` row governs `/srv/site` jobs -- the same
|
|
8078
|
+
// collapse canonicalScope applies on the job side. isAbsolute is PLATFORM-NATIVE on purpose, so a
|
|
8079
|
+
// foreign-platform row (a windows drive path on a POSIX worker) stays verbatim and is inert here;
|
|
8080
|
+
// the doctor's unreferenced-scope advisory names it. Resolving it instead would "work" only by
|
|
8081
|
+
// both sides mangling into the same cwd-prefixed string -- a match by accident, not by contract.
|
|
8082
|
+
scope: isAbsolute(trimmed) ? resolve(trimmed) : trimmed,
|
|
8083
|
+
day: null,
|
|
8084
|
+
week: null,
|
|
8085
|
+
month: null,
|
|
8086
|
+
concurrent: null
|
|
8087
|
+
};
|
|
8088
|
+
let any = false;
|
|
8089
|
+
for (const field of LIMIT_FIELDS) {
|
|
8090
|
+
const value = row[field];
|
|
8091
|
+
if (value === void 0 || value === null) continue;
|
|
8092
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
8093
|
+
throw configError(`${at}: ${field} must be an integer >= 1: ${path}`);
|
|
8094
|
+
}
|
|
8095
|
+
norm[field] = value;
|
|
8096
|
+
any = true;
|
|
8097
|
+
}
|
|
8098
|
+
if (!any) {
|
|
8099
|
+
throw configError(`${at}: at least one of day, week, month, concurrent is required (a row that limits nothing is a row an operator sets and then trusts): ${path}`);
|
|
8100
|
+
}
|
|
8101
|
+
return norm;
|
|
8102
|
+
}
|
|
8103
|
+
function scopeKeyPrefix(scope) {
|
|
8104
|
+
const h = createHash("sha256").update(String(scope)).digest("hex").slice(0, 16);
|
|
8105
|
+
return `budget:s:${h}`;
|
|
8106
|
+
}
|
|
8107
|
+
var SCOPED_LIMITS_VERSION, LIMIT_FIELDS;
|
|
8108
|
+
var init_scoped_limits = __esm({
|
|
8109
|
+
"../worker/src/scoped-limits.mjs"() {
|
|
8110
|
+
init_config2();
|
|
8111
|
+
init_pause_windows();
|
|
8112
|
+
SCOPED_LIMITS_VERSION = 1;
|
|
8113
|
+
LIMIT_FIELDS = ["day", "week", "month", "concurrent"];
|
|
8114
|
+
}
|
|
8115
|
+
});
|
|
8116
|
+
|
|
8117
|
+
// ../worker/src/subscriptions.mjs
|
|
8118
|
+
function isNonEmptyString4(value) {
|
|
8119
|
+
return typeof value === "string" && value.trim() !== "";
|
|
8120
|
+
}
|
|
8121
|
+
function normalizeGlobs(value, label, at, path) {
|
|
8122
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
8123
|
+
throw configError(`${at}: ${label} must be a non-empty array of model globs: ${path}`);
|
|
8124
|
+
}
|
|
8125
|
+
return value.map((glob) => {
|
|
8126
|
+
if (typeof glob !== "string" || !MODEL_GLOB_RE.test(glob.trim())) {
|
|
8127
|
+
throw configError(`${at}: ${label} glob ${JSON.stringify(glob)} must match ${MODEL_GLOB_RE} (model-id charset plus "*", max 64 chars): ${path}`);
|
|
8128
|
+
}
|
|
8129
|
+
return glob.trim();
|
|
8130
|
+
});
|
|
8131
|
+
}
|
|
8132
|
+
function normalizeBool(value, label, at, path, fallback) {
|
|
8133
|
+
if (value === void 0) return fallback;
|
|
8134
|
+
if (typeof value !== "boolean") throw configError(`${at}: ${label} must be a boolean: ${path}`);
|
|
8135
|
+
return value;
|
|
8136
|
+
}
|
|
8137
|
+
function parseSubscriptions(text, path) {
|
|
8138
|
+
let parsed;
|
|
8139
|
+
try {
|
|
8140
|
+
parsed = JSON.parse(text);
|
|
8141
|
+
} catch (error) {
|
|
8142
|
+
throw configError(`subscriptions file is not valid JSON: ${path} (${error.message})`);
|
|
8143
|
+
}
|
|
8144
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
8145
|
+
throw configError(`subscriptions file must be a JSON object: ${path}`);
|
|
8146
|
+
}
|
|
8147
|
+
const version = parsed.version;
|
|
8148
|
+
if (!Number.isInteger(version) || version < 1) {
|
|
8149
|
+
throw configError(`subscriptions file must declare an integer "version" >= 1 (got ${JSON.stringify(version)}): ${path}`);
|
|
8150
|
+
}
|
|
8151
|
+
if (version > SUBSCRIPTIONS_VERSION) {
|
|
8152
|
+
throw configError(`subscriptions file written by a newer pi-dispatch (version ${version}; this build understands ${SUBSCRIPTIONS_VERSION}): ${path}`);
|
|
8153
|
+
}
|
|
8154
|
+
const entries = parsed.subscriptions;
|
|
8155
|
+
if (!Array.isArray(entries)) {
|
|
8156
|
+
throw configError(`subscriptions file must have a "subscriptions" array: ${path}`);
|
|
8157
|
+
}
|
|
8158
|
+
const seenIds = /* @__PURE__ */ new Map();
|
|
8159
|
+
return { version, subscriptions: entries.map((entry, index) => normalizeSubscription(entry, index, path, seenIds)) };
|
|
8160
|
+
}
|
|
8161
|
+
function normalizeSubscription(entry, index, path, seenIds) {
|
|
8162
|
+
const at = `subscription at index ${index}`;
|
|
8163
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
|
|
8164
|
+
throw configError(`${at}: must be an object: ${path}`);
|
|
8165
|
+
}
|
|
8166
|
+
if (!isNonEmptyString4(entry.id)) throw configError(`${at}: id must be a non-empty string: ${path}`);
|
|
8167
|
+
const id = entry.id.trim();
|
|
8168
|
+
if (seenIds.has(id)) {
|
|
8169
|
+
throw configError(`${at}: id ${JSON.stringify(id)} is already used by the subscription at index ${seenIds.get(id)}: ${path}`);
|
|
8170
|
+
}
|
|
8171
|
+
seenIds.set(id, index);
|
|
8172
|
+
if (!isNonEmptyString4(entry.vendor)) throw configError(`${at}: vendor must be a non-empty string: ${path}`);
|
|
8173
|
+
if (!isNonEmptyString4(entry.provider)) throw configError(`${at}: provider must be a non-empty string (the pi-ai provider id this plan covers): ${path}`);
|
|
8174
|
+
const models = entry.models === void 0 ? ["*"] : normalizeGlobs(entry.models, "models", at, path);
|
|
8175
|
+
const price = entry.price;
|
|
8176
|
+
if (price === null || typeof price !== "object" || Array.isArray(price)) {
|
|
8177
|
+
throw configError(`${at}: price must be an object { amount, currency, per }: ${path}`);
|
|
8178
|
+
}
|
|
8179
|
+
if (typeof price.amount !== "number" || !Number.isFinite(price.amount) || price.amount <= 0) {
|
|
8180
|
+
throw configError(`${at}: price.amount must be a number > 0: ${path}`);
|
|
8181
|
+
}
|
|
8182
|
+
if (typeof price.currency !== "string" || !/^[A-Z]{3}$/.test(price.currency)) {
|
|
8183
|
+
throw configError(`${at}: price.currency must be a 3-letter uppercase code (e.g. "USD"): ${path}`);
|
|
8184
|
+
}
|
|
8185
|
+
if (price.per !== "month") {
|
|
8186
|
+
throw configError(`${at}: price.per must be "month" (got ${JSON.stringify(price.per)}): ${path}`);
|
|
8187
|
+
}
|
|
8188
|
+
const sharedWithOtherProducts = normalizeBool(entry.sharedWithOtherProducts, "sharedWithOtherProducts", at, path, false);
|
|
8189
|
+
const hypothetical = normalizeBool(entry.hypothetical, "hypothetical", at, path, false);
|
|
8190
|
+
let counterfactualModel = null;
|
|
8191
|
+
if (entry.counterfactualModel !== void 0 && entry.counterfactualModel !== null) {
|
|
8192
|
+
const cm = entry.counterfactualModel;
|
|
8193
|
+
if (typeof cm !== "object" || Array.isArray(cm) || !isNonEmptyString4(cm.provider) || !isNonEmptyString4(cm.id)) {
|
|
8194
|
+
throw configError(`${at}: counterfactualModel must be { provider, id } with non-empty strings (a PRICED pi-ai model to re-price covered runs at API rates): ${path}`);
|
|
8195
|
+
}
|
|
8196
|
+
counterfactualModel = { provider: cm.provider.trim(), id: cm.id.trim() };
|
|
8197
|
+
}
|
|
8198
|
+
let windows = [];
|
|
8199
|
+
if (entry.windows !== void 0) {
|
|
8200
|
+
if (!Array.isArray(entry.windows)) throw configError(`${at}: windows must be an array: ${path}`);
|
|
8201
|
+
windows = entry.windows.map((w, windowIndex) => normalizeWindow2(w, windowIndex, index, path));
|
|
8202
|
+
}
|
|
8203
|
+
return {
|
|
8204
|
+
id,
|
|
8205
|
+
vendor: entry.vendor.trim(),
|
|
8206
|
+
provider: entry.provider.trim(),
|
|
8207
|
+
models,
|
|
8208
|
+
price: { amount: price.amount, currency: price.currency, per: "month" },
|
|
8209
|
+
sharedWithOtherProducts,
|
|
8210
|
+
hypothetical,
|
|
8211
|
+
counterfactualModel,
|
|
8212
|
+
windows
|
|
8213
|
+
};
|
|
8214
|
+
}
|
|
8215
|
+
function normalizeWindow2(w, windowIndex, subIndex, path) {
|
|
8216
|
+
const at = `window at index ${windowIndex} of subscription at index ${subIndex}`;
|
|
8217
|
+
if (w === null || typeof w !== "object" || Array.isArray(w)) {
|
|
8218
|
+
throw configError(`${at}: must be an object: ${path}`);
|
|
8219
|
+
}
|
|
8220
|
+
if (!WINDOW_PERS.has(w.per)) {
|
|
8221
|
+
throw configError(`${at}: per must be one of 5h|7d|30d|month (got ${JSON.stringify(w.per)}): ${path}`);
|
|
8222
|
+
}
|
|
8223
|
+
const rolling = normalizeBool(w.rolling, "rolling", at, path, false);
|
|
8224
|
+
let unit = null;
|
|
8225
|
+
if (w.unit !== void 0 && w.unit !== null) {
|
|
8226
|
+
if (!WINDOW_UNITS.has(w.unit)) {
|
|
8227
|
+
throw configError(`${at}: unit must be one of tokens|requests|prompts|credits, or null (vendor undisclosed): ${path}`);
|
|
8228
|
+
}
|
|
8229
|
+
unit = w.unit;
|
|
8230
|
+
}
|
|
8231
|
+
let limit = null;
|
|
8232
|
+
if (w.limit !== void 0 && w.limit !== null) {
|
|
8233
|
+
if (typeof w.limit === "number") {
|
|
8234
|
+
if (!Number.isFinite(w.limit) || w.limit <= 0) throw configError(`${at}: limit must be a number > 0, [min, max], or null (vendor undisclosed): ${path}`);
|
|
8235
|
+
limit = w.limit;
|
|
8236
|
+
} else if (Array.isArray(w.limit)) {
|
|
8237
|
+
const [min, max] = w.limit;
|
|
8238
|
+
if (w.limit.length !== 2 || typeof min !== "number" || typeof max !== "number" || !Number.isFinite(min) || !Number.isFinite(max) || min <= 0 || max <= 0 || min >= max) {
|
|
8239
|
+
throw configError(`${at}: limit range must be [min, max] with both > 0 and min < max: ${path}`);
|
|
8240
|
+
}
|
|
8241
|
+
limit = [min, max];
|
|
8242
|
+
} else {
|
|
8243
|
+
throw configError(`${at}: limit must be a number > 0, [min, max], or null (vendor undisclosed): ${path}`);
|
|
8244
|
+
}
|
|
8245
|
+
}
|
|
8246
|
+
const scope = w.scope === void 0 || w.scope === null ? null : normalizeGlobs(w.scope, "scope", at, path);
|
|
8247
|
+
return { per: w.per, rolling, unit, limit, scope };
|
|
8248
|
+
}
|
|
8249
|
+
var SUBSCRIPTIONS_VERSION, WINDOW_PERS, WINDOW_UNITS, MODEL_GLOB_RE;
|
|
8250
|
+
var init_subscriptions = __esm({
|
|
8251
|
+
"../worker/src/subscriptions.mjs"() {
|
|
8252
|
+
init_config2();
|
|
8253
|
+
SUBSCRIPTIONS_VERSION = 1;
|
|
8254
|
+
WINDOW_PERS = /* @__PURE__ */ new Set(["5h", "7d", "30d", "month"]);
|
|
8255
|
+
WINDOW_UNITS = /* @__PURE__ */ new Set(["tokens", "requests", "prompts", "credits"]);
|
|
8256
|
+
MODEL_GLOB_RE = /^[A-Za-z0-9*._:/-]{1,64}$/;
|
|
8257
|
+
}
|
|
8258
|
+
});
|
|
8259
|
+
|
|
8260
|
+
// ../worker/src/connection.mjs
|
|
8261
|
+
import { Redis } from "ioredis";
|
|
8262
|
+
function parseConnection(url, { failFast = false } = {}) {
|
|
8263
|
+
const u = new URL(url);
|
|
8264
|
+
return {
|
|
8265
|
+
host: u.hostname || "127.0.0.1",
|
|
8266
|
+
port: Number(u.port || 6379),
|
|
8267
|
+
...u.password ? { password: u.password } : {},
|
|
8268
|
+
...u.username ? { username: u.username } : {},
|
|
8269
|
+
...u.pathname && u.pathname !== "/" ? { db: Number(u.pathname.slice(1)) } : {},
|
|
8270
|
+
maxRetriesPerRequest: null,
|
|
8271
|
+
// required for BullMQ blocking connections
|
|
8272
|
+
...failFast ? {
|
|
8273
|
+
connectTimeout: 2e3,
|
|
8274
|
+
enableOfflineQueue: false,
|
|
8275
|
+
// don't buffer commands while disconnected -- error now
|
|
8276
|
+
retryStrategy: (attempts) => attempts > 2 ? null : 200
|
|
8277
|
+
// give up after ~2 tries
|
|
8278
|
+
} : {}
|
|
8279
|
+
};
|
|
8280
|
+
}
|
|
8281
|
+
function makeRedisClient(url) {
|
|
8282
|
+
return new Redis(url, { maxRetriesPerRequest: null });
|
|
8283
|
+
}
|
|
8284
|
+
var init_connection = __esm({
|
|
8285
|
+
"../worker/src/connection.mjs"() {
|
|
8286
|
+
}
|
|
8287
|
+
});
|
|
8288
|
+
|
|
8289
|
+
// ../worker/src/job-id.mjs
|
|
8290
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
8291
|
+
function localJobId({ folder, flow, task, minute }) {
|
|
8292
|
+
const digest = createHash2("sha256").update([folder, flow ?? "", task ?? "", minute].join("\0")).digest("hex");
|
|
8293
|
+
return `local-${digest.slice(0, 16)}`;
|
|
8294
|
+
}
|
|
8295
|
+
var init_job_id = __esm({
|
|
8296
|
+
"../worker/src/job-id.mjs"() {
|
|
8297
|
+
init_config2();
|
|
8298
|
+
init_forges();
|
|
8299
|
+
}
|
|
8300
|
+
});
|
|
8301
|
+
|
|
8302
|
+
// ../worker/src/flow-gate.mjs
|
|
8303
|
+
import { execFile } from "node:child_process";
|
|
8304
|
+
import { promisify } from "node:util";
|
|
8305
|
+
async function readFlowGate({ folder, flow, sha, git = defaultGit }) {
|
|
8306
|
+
if (typeof sha !== "string" || sha.trim() === "") return { gate: "deny" };
|
|
8307
|
+
if (typeof flow !== "string" || !SKILL_NAME_RE.test(flow)) return { gate: "deny" };
|
|
8308
|
+
const path = `.pi/skills/${flow}/SKILL.md`;
|
|
8309
|
+
try {
|
|
8310
|
+
const lsTreeZ = await git(folder, ["ls-tree", "-z", sha, path]);
|
|
8311
|
+
const record = lsTreeZ.split("\0").find((r) => r);
|
|
8312
|
+
if (!record) return { gate: "no-skill" };
|
|
8313
|
+
const tab = record.indexOf(" ");
|
|
8314
|
+
if (tab === -1) return { gate: "deny" };
|
|
8315
|
+
const [mode, type, oid] = record.slice(0, tab).split(/\s+/);
|
|
8316
|
+
if (mode !== "100644" || type !== "blob") return { gate: "deny" };
|
|
8317
|
+
const content = await git(folder, ["cat-file", "blob", oid], { raw: true });
|
|
8318
|
+
return { gate: aiTriggerAllows(content) ? "allow" : "deny" };
|
|
8319
|
+
} catch {
|
|
8320
|
+
return { gate: "deny" };
|
|
8321
|
+
}
|
|
8322
|
+
}
|
|
8323
|
+
function aiTriggerAllows(buf) {
|
|
8324
|
+
const text = buf.toString("utf8").replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
|
|
8325
|
+
const block = /^---\n([\s\S]*?)\n---(?:\n|$)/.exec(text);
|
|
8326
|
+
if (!block) return false;
|
|
8327
|
+
return /^ai-trigger:\s*("?)allow\1\s*$/m.test(block[1]);
|
|
8328
|
+
}
|
|
8329
|
+
async function defaultGit(gitDir, args, { raw = false } = {}) {
|
|
8330
|
+
const hardened = ["-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", "--no-pager", "-C", gitDir, ...args];
|
|
8331
|
+
const { stdout } = await exec("git", hardened, {
|
|
8332
|
+
encoding: raw ? "buffer" : "utf8",
|
|
8333
|
+
maxBuffer: 16 * 1024 * 1024
|
|
8334
|
+
});
|
|
8335
|
+
return stdout;
|
|
8336
|
+
}
|
|
8337
|
+
var exec, SKILL_NAME_RE;
|
|
8338
|
+
var init_flow_gate = __esm({
|
|
8339
|
+
"../worker/src/flow-gate.mjs"() {
|
|
8340
|
+
exec = promisify(execFile);
|
|
8341
|
+
SKILL_NAME_RE = /^[a-z0-9](?:[a-z0-9_-]{0,62}[a-z0-9])?$/;
|
|
8342
|
+
}
|
|
8343
|
+
});
|
|
8344
|
+
|
|
7889
8345
|
// ../worker/src/reserved-env.mjs
|
|
7890
8346
|
var CONTAINER_ENV_NAMES;
|
|
7891
8347
|
var init_reserved_env = __esm({
|
|
@@ -7910,7 +8366,7 @@ var init_reserved_env = __esm({
|
|
|
7910
8366
|
});
|
|
7911
8367
|
|
|
7912
8368
|
// ../worker/src/triggers.mjs
|
|
7913
|
-
function
|
|
8369
|
+
function isNonEmptyString5(value) {
|
|
7914
8370
|
return typeof value === "string" && value.trim() !== "";
|
|
7915
8371
|
}
|
|
7916
8372
|
function parseTriggers(text, path) {
|
|
@@ -7940,7 +8396,7 @@ function normalizeTrigger(entry, index, path, state) {
|
|
|
7940
8396
|
throw configError(`${at}: "run" must be an object: ${path}`);
|
|
7941
8397
|
}
|
|
7942
8398
|
if (!ON_TYPES.has(on.type)) {
|
|
7943
|
-
throw configError(`${at}: on.type must be one of
|
|
8399
|
+
throw configError(`${at}: on.type must be one of ${[...ON_TYPES].join("|")} (got ${JSON.stringify(on.type)}): ${path}`);
|
|
7944
8400
|
}
|
|
7945
8401
|
if (!RUN_KINDS.includes(run.kind)) {
|
|
7946
8402
|
throw configError(`${at}: run.kind must be one of ${RUN_KINDS.join("|")} (got ${JSON.stringify(run.kind)}): ${path}`);
|
|
@@ -7954,14 +8410,18 @@ function normalizeTrigger(entry, index, path, state) {
|
|
|
7954
8410
|
if (!isForgeKind(run.kind)) {
|
|
7955
8411
|
throw configError(`${at}: a ${on.type} trigger is webhook-driven and produces a forge job; run.kind must be one of ${FORGE_KINDS.join("|")} (got ${JSON.stringify(run.kind)}): ${path}`);
|
|
7956
8412
|
}
|
|
7957
|
-
|
|
7958
|
-
if (on.type === "
|
|
7959
|
-
|
|
8413
|
+
let normalized;
|
|
8414
|
+
if (on.type === "label") normalized = normalizeLabel(on, run, index, path);
|
|
8415
|
+
else if (on.type === "comment") normalized = normalizeComment(on, run, index, path, state);
|
|
8416
|
+
else if (on.type === "issue") normalized = normalizeIssue(on, run, index, path);
|
|
8417
|
+
else normalized = normalizePullRequest(on, run, index, path);
|
|
8418
|
+
if (on.disarmed !== void 0) return { on: { type: DISARMED_TYPE }, run: {} };
|
|
8419
|
+
return normalized;
|
|
7960
8420
|
}
|
|
7961
8421
|
function normalizeCron(on, run, index, path, state) {
|
|
7962
8422
|
const at = `trigger at index ${index}`;
|
|
7963
8423
|
const id = on.id;
|
|
7964
|
-
if (!
|
|
8424
|
+
if (!isNonEmptyString5(id)) {
|
|
7965
8425
|
throw configError(`${at}: cron on.id must be a non-empty string: ${path}`);
|
|
7966
8426
|
}
|
|
7967
8427
|
if (id.includes(":")) {
|
|
@@ -7975,7 +8435,7 @@ function normalizeCron(on, run, index, path, state) {
|
|
|
7975
8435
|
}
|
|
7976
8436
|
state.seenCronIds.add(id);
|
|
7977
8437
|
const pattern = on.pattern;
|
|
7978
|
-
if (!
|
|
8438
|
+
if (!isNonEmptyString5(pattern)) {
|
|
7979
8439
|
throw configError(`cron trigger "${id}": on.pattern must be a non-empty string: ${path}`);
|
|
7980
8440
|
}
|
|
7981
8441
|
const fieldCount = pattern.trim().split(/\s+/).length;
|
|
@@ -7983,13 +8443,13 @@ function normalizeCron(on, run, index, path, state) {
|
|
|
7983
8443
|
throw configError(`cron trigger "${id}": on.pattern must have 5 or 6 space-separated fields, got ${fieldCount}: ${path}`);
|
|
7984
8444
|
}
|
|
7985
8445
|
const command = validateCommand(run, `cron trigger "${id}"`, path, { onType: "cron" });
|
|
7986
|
-
if (!
|
|
8446
|
+
if (!isNonEmptyString5(run.folder)) {
|
|
7987
8447
|
throw configError(`cron trigger "${id}": run.folder must be a non-empty string: ${path}`);
|
|
7988
8448
|
}
|
|
7989
|
-
if (command === void 0 && !
|
|
8449
|
+
if (command === void 0 && !isNonEmptyString5(run.flow)) {
|
|
7990
8450
|
throw configError(`cron trigger "${id}": run.flow must be a non-empty string (or use run.command): ${path}`);
|
|
7991
8451
|
}
|
|
7992
|
-
if (command === void 0 && !
|
|
8452
|
+
if (command === void 0 && !isNonEmptyString5(run.task)) {
|
|
7993
8453
|
throw configError(`cron trigger "${id}": run.task must be a non-empty string: ${path}`);
|
|
7994
8454
|
}
|
|
7995
8455
|
if (run.github !== void 0 && typeof run.github !== "boolean") {
|
|
@@ -8001,6 +8461,9 @@ function normalizeCron(on, run, index, path, state) {
|
|
|
8001
8461
|
validateInstructions(run, `cron trigger "${id}"`, path, { cron: true });
|
|
8002
8462
|
const resume = validateResumeFlag(run, `cron trigger "${id}"`, path);
|
|
8003
8463
|
validateReplicas(run, `cron trigger "${id}"`, path);
|
|
8464
|
+
validateNumber(on, `cron trigger "${id}"`, path, { onType: "cron" });
|
|
8465
|
+
validateOnce(on, run, `cron trigger "${id}"`, path, { onType: "cron" });
|
|
8466
|
+
validateDisarmed(on, `cron trigger "${id}"`, path, { onType: "cron" });
|
|
8004
8467
|
const secrets = validateSecrets(run, `cron trigger "${id}"`, path);
|
|
8005
8468
|
const secretsProfile = validateSecretsProfile(run, `cron trigger "${id}"`, path);
|
|
8006
8469
|
return {
|
|
@@ -8070,7 +8533,7 @@ function validateCommand(run, at, path, { onType }) {
|
|
|
8070
8533
|
throw configError(`${at}: exactly one of run.flow or run.command must be set -- a trigger dispatches either a flow or a registered command, and with both present the file does not say which one runs: ${path}`);
|
|
8071
8534
|
}
|
|
8072
8535
|
if (raw === void 0) return void 0;
|
|
8073
|
-
if (!
|
|
8536
|
+
if (!isNonEmptyString5(raw)) {
|
|
8074
8537
|
throw configError(`${at}: run.command must be a non-empty string -- the registered command name, optionally followed by its arguments: ${path}`);
|
|
8075
8538
|
}
|
|
8076
8539
|
if (raw !== raw.trim()) {
|
|
@@ -8107,6 +8570,72 @@ function validatePredicate(on, index, path, requirePositive) {
|
|
|
8107
8570
|
}
|
|
8108
8571
|
return { any: on.any, all: on.all, none: on.none };
|
|
8109
8572
|
}
|
|
8573
|
+
function validateNumber(on, at, path, { capable = false, onType } = {}) {
|
|
8574
|
+
const number = on.number;
|
|
8575
|
+
if (number === void 0) return void 0;
|
|
8576
|
+
if (!capable) {
|
|
8577
|
+
if (onType === "cron") {
|
|
8578
|
+
throw configError(`${at}: on.number is not available on a cron trigger -- a schedule fires on time, not on an item, so there is no delivery for a number to narrow: ${path}`);
|
|
8579
|
+
}
|
|
8580
|
+
throw configError(`${at}: on.number is not yet covered for ${onType} triggers here -- only the close routes read it, so on a rule they never serve it would sit in the file looking configured; narrowing labels, comments or non-close pull_request rules is a gap to close, not a limit: ${path}`);
|
|
8581
|
+
}
|
|
8582
|
+
if (!Number.isInteger(number) || number < 1) {
|
|
8583
|
+
throw configError(`${at}: on.number must be an integer >= 1 when present -- the item number the forge itself assigns (on GitLab, the iid) (got ${JSON.stringify(number)}): ${path}`);
|
|
8584
|
+
}
|
|
8585
|
+
return number;
|
|
8586
|
+
}
|
|
8587
|
+
function validateOnce(on, run, at, path, { capable = false, onType } = {}) {
|
|
8588
|
+
const once = on.once;
|
|
8589
|
+
if (once === void 0) return void 0;
|
|
8590
|
+
if (!capable) {
|
|
8591
|
+
if (onType === "cron") {
|
|
8592
|
+
throw configError(`${at}: on.once is not available on a cron trigger -- a one-shot that can re-arm is a schedule, and a cron job carries no matched delivery for the disarm to name; delete the entry when the work is done: ${path}`);
|
|
8593
|
+
}
|
|
8594
|
+
const closeWords = Object.entries(PR_CLOSE_ACTIONS).map(([k, w]) => `${k} has ${JSON.stringify(w)}`).join(", ");
|
|
8595
|
+
throw configError(`${at}: on.once is not yet covered here -- the disarm writes back to the one entry a delivery matched, and only a close delivery names the single item that spends it; use on.type "issue", or a pull_request rule whose only action is the close word (${closeWords}; azure has no close trigger yet): ${path}`);
|
|
8596
|
+
}
|
|
8597
|
+
if (typeof once !== "boolean") {
|
|
8598
|
+
throw configError(`${at}: on.once must be true or false when present -- a truthy string arming a one-shot is exactly the drift this validator exists to refuse (got ${JSON.stringify(once)}): ${path}`);
|
|
8599
|
+
}
|
|
8600
|
+
if (once === true && on.number === void 0) {
|
|
8601
|
+
throw configError(`${at}: on.once requires on.number -- a one-shot without a named item is spent by whichever close arrives first, and two different items closing inside one dedup window would race for it; the number is also the identity the disarm re-checks before it writes: ${path}`);
|
|
8602
|
+
}
|
|
8603
|
+
if (once === true && run.replicas !== void 0) {
|
|
8604
|
+
throw configError(`${at}: on.once and run.replicas cannot be combined -- "exactly one run" and "N sandboxes race" contradict, and with N run records the disarm no longer says which one spent the trigger: ${path}`);
|
|
8605
|
+
}
|
|
8606
|
+
return once;
|
|
8607
|
+
}
|
|
8608
|
+
function validateDisarmed(on, at, path, { capable = false, onType } = {}) {
|
|
8609
|
+
const disarmed = on.disarmed;
|
|
8610
|
+
if (disarmed === void 0) return void 0;
|
|
8611
|
+
if (!capable) {
|
|
8612
|
+
throw configError(`${at}: on.disarmed marks a spent one-shot, and on.once is not ${onType === "cron" ? "available on a cron trigger" : "yet covered here"}, so there is nothing this entry could have spent: ${path}`);
|
|
8613
|
+
}
|
|
8614
|
+
if (disarmed === null || typeof disarmed !== "object" || Array.isArray(disarmed)) {
|
|
8615
|
+
throw configError(`${at}: on.disarmed must be an object with a non-empty string "at" (and optionally a non-empty string "jobId") -- the worker writes it when the one-shot fires, and a hand-written one disarms the entry deliberately (got ${JSON.stringify(disarmed)}): ${path}`);
|
|
8616
|
+
}
|
|
8617
|
+
for (const key of Object.keys(disarmed)) {
|
|
8618
|
+
if (key !== "at" && key !== "jobId") {
|
|
8619
|
+
throw configError(`${at}: on.disarmed has an unsupported key ${JSON.stringify(key)} (expected at|jobId): ${path}`);
|
|
8620
|
+
}
|
|
8621
|
+
}
|
|
8622
|
+
if (!isNonEmptyString5(disarmed.at)) {
|
|
8623
|
+
throw configError(`${at}: on.disarmed.at must be a non-empty string (the time the one-shot fired): ${path}`);
|
|
8624
|
+
}
|
|
8625
|
+
if (disarmed.jobId !== void 0 && !isNonEmptyString5(disarmed.jobId)) {
|
|
8626
|
+
throw configError(`${at}: on.disarmed.jobId must be a non-empty string when present (the run record it joins to): ${path}`);
|
|
8627
|
+
}
|
|
8628
|
+
if (on.once !== true) {
|
|
8629
|
+
throw configError(`${at}: on.disarmed is only meaningful beside on.once: true -- an entry that was never a one-shot has nothing to spend: ${path}`);
|
|
8630
|
+
}
|
|
8631
|
+
return disarmed;
|
|
8632
|
+
}
|
|
8633
|
+
function validateFlowName(run, at, path) {
|
|
8634
|
+
if (run.flow === void 0) return;
|
|
8635
|
+
if (!SKILL_NAME_RE.test(run.flow)) {
|
|
8636
|
+
throw configError(`${at}: run.flow ${JSON.stringify(run.flow)} fails the skill-name charset (lowercase letters, digits, dash and underscore, 1-64 chars, starting and ending alphanumeric) -- a name outside it can never materialise, so this trigger could only ever fail after the budget slot was reserved: ${path}`);
|
|
8637
|
+
}
|
|
8638
|
+
}
|
|
8110
8639
|
function validateRepository(run, onType, at, path) {
|
|
8111
8640
|
const needed = run.kind === "azure" && (onType === "label" || onType === "comment");
|
|
8112
8641
|
const raw = run.repository;
|
|
@@ -8117,7 +8646,7 @@ function validateRepository(run, onType, at, path) {
|
|
|
8117
8646
|
if (!needed) {
|
|
8118
8647
|
throw configError(`${at}: run.repository is only meaningful on an azure label or comment trigger (a pull request names its own repository): ${path}`);
|
|
8119
8648
|
}
|
|
8120
|
-
if (!
|
|
8649
|
+
if (!isNonEmptyString5(raw) || raw !== raw.trim() || raw.includes("/")) {
|
|
8121
8650
|
throw configError(`${at}: run.repository must be a non-empty repository NAME within the project, with no slashes and no surrounding whitespace (got ${JSON.stringify(raw)}): ${path}`);
|
|
8122
8651
|
}
|
|
8123
8652
|
return raw;
|
|
@@ -8157,7 +8686,7 @@ function validateSecrets(run, at, path) {
|
|
|
8157
8686
|
throw configError(`${at}: run.secrets key ${JSON.stringify(name)} is a variable the worker sets itself -- the job container would receive the worker's value, not this trigger's, and the trigger would look like it worked: ${path}`);
|
|
8158
8687
|
}
|
|
8159
8688
|
const reference = secrets[name];
|
|
8160
|
-
if (!
|
|
8689
|
+
if (!isNonEmptyString5(reference)) {
|
|
8161
8690
|
throw configError(`${at}: run.secrets.${name} must be a non-empty string reference for your resolver to read (got ${JSON.stringify(reference)}): ${path}`);
|
|
8162
8691
|
}
|
|
8163
8692
|
if (reference !== reference.trim()) {
|
|
@@ -8175,7 +8704,7 @@ function validateSecrets(run, at, path) {
|
|
|
8175
8704
|
function validateSecretsProfile(run, at, path) {
|
|
8176
8705
|
const profile = run.secretsProfile;
|
|
8177
8706
|
if (profile === void 0) return void 0;
|
|
8178
|
-
if (!
|
|
8707
|
+
if (!isNonEmptyString5(profile)) {
|
|
8179
8708
|
throw configError(`${at}: run.secretsProfile must be a non-empty string naming one of the resolver profiles this deployment declares (got ${JSON.stringify(profile)}): ${path}`);
|
|
8180
8709
|
}
|
|
8181
8710
|
if (!ID_CHARSET.test(profile)) {
|
|
@@ -8189,10 +8718,14 @@ function validateSecretsProfile(run, at, path) {
|
|
|
8189
8718
|
function normalizeLabel(on, run, index, path) {
|
|
8190
8719
|
const at = `trigger at index ${index}`;
|
|
8191
8720
|
const predicate = validatePredicate(on, index, path, true);
|
|
8721
|
+
validateNumber(on, at, path, { onType: "label" });
|
|
8722
|
+
validateOnce(on, run, at, path, { onType: "label" });
|
|
8723
|
+
validateDisarmed(on, at, path, { onType: "label" });
|
|
8192
8724
|
const command = validateCommand(run, at, path, { onType: "label" });
|
|
8193
|
-
if (command === void 0 && !
|
|
8725
|
+
if (command === void 0 && !isNonEmptyString5(run.flow)) {
|
|
8194
8726
|
throw configError(`${at}: label trigger run.flow must be a non-empty string (or use run.command): ${path}`);
|
|
8195
8727
|
}
|
|
8728
|
+
validateFlowName(run, at, path);
|
|
8196
8729
|
const packages = validatePackagesFlag(run, at, path);
|
|
8197
8730
|
const image = validateImageRef(run, at, path);
|
|
8198
8731
|
const skillsDir = validateSkillsDir(run, at, path);
|
|
@@ -8209,13 +8742,17 @@ function normalizeLabel(on, run, index, path) {
|
|
|
8209
8742
|
}
|
|
8210
8743
|
function normalizeComment(on, run, index, path, state) {
|
|
8211
8744
|
const at = `trigger at index ${index}`;
|
|
8212
|
-
if (!
|
|
8745
|
+
if (!isNonEmptyString5(on.phrase)) {
|
|
8213
8746
|
throw configError(`${at}: comment trigger on.phrase must be a non-empty string: ${path}`);
|
|
8214
8747
|
}
|
|
8748
|
+
validateNumber(on, at, path, { onType: "comment" });
|
|
8749
|
+
validateOnce(on, run, at, path, { onType: "comment" });
|
|
8750
|
+
validateDisarmed(on, at, path, { onType: "comment" });
|
|
8215
8751
|
const command = validateCommand(run, at, path, { onType: "comment" });
|
|
8216
|
-
if (command === void 0 && !
|
|
8752
|
+
if (command === void 0 && !isNonEmptyString5(run.flow)) {
|
|
8217
8753
|
throw configError(`${at}: comment trigger run.flow (the default flow) must be a non-empty string (or use run.command): ${path}`);
|
|
8218
8754
|
}
|
|
8755
|
+
validateFlowName(run, at, path);
|
|
8219
8756
|
state.commentCounts[run.kind] = (state.commentCounts[run.kind] ?? 0) + 1;
|
|
8220
8757
|
if (state.commentCounts[run.kind] > 1) {
|
|
8221
8758
|
throw configError(`${at}: at most one ${run.kind} comment trigger is allowed: ${path}`);
|
|
@@ -8234,6 +8771,54 @@ function normalizeComment(on, run, index, path, state) {
|
|
|
8234
8771
|
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 } }
|
|
8235
8772
|
};
|
|
8236
8773
|
}
|
|
8774
|
+
function normalizeIssue(on, run, index, path) {
|
|
8775
|
+
const at = `trigger at index ${index}`;
|
|
8776
|
+
if (run.kind === "azure") {
|
|
8777
|
+
throw configError(`${at}: an issue trigger is not yet covered for azure -- a work item's close is a System.State transition whose terminal names vary by process template (Agile "Closed", Scrum "Done"), and the projected subset carries only System.Tags, so nothing in the delivery says the item closed; widening the payload subset is the gap to close, not a limit: ${path}`);
|
|
8778
|
+
}
|
|
8779
|
+
const actions = on.action;
|
|
8780
|
+
if (!Array.isArray(actions) || actions.length === 0) {
|
|
8781
|
+
throw configError(`${at}: issue on.action must be a non-empty array: ${path}`);
|
|
8782
|
+
}
|
|
8783
|
+
const allowed = ISSUE_ACTIONS[run.kind];
|
|
8784
|
+
const expected = [...allowed].join("|");
|
|
8785
|
+
for (const a of actions) {
|
|
8786
|
+
if (!allowed.has(a)) {
|
|
8787
|
+
throw configError(`${at}: issue on.action has an unsupported ${run.kind} action ${JSON.stringify(a)} (expected ${expected}): ${path}`);
|
|
8788
|
+
}
|
|
8789
|
+
}
|
|
8790
|
+
if (on.any !== void 0 || on.all !== void 0 || on.none !== void 0) {
|
|
8791
|
+
throw configError(`${at}: an issue trigger cannot carry a label predicate -- it fires on a lifecycle action, not a label diff, so any/all/none could never match; a label-predicated rule is on.type "label": ${path}`);
|
|
8792
|
+
}
|
|
8793
|
+
const number = validateNumber(on, at, path, { capable: true, onType: "issue" });
|
|
8794
|
+
const once = validateOnce(on, run, at, path, { capable: true, onType: "issue" });
|
|
8795
|
+
validateDisarmed(on, at, path, { capable: true, onType: "issue" });
|
|
8796
|
+
const command = validateCommand(run, at, path, { onType: "issue" });
|
|
8797
|
+
if (command === void 0 && !isNonEmptyString5(run.flow)) {
|
|
8798
|
+
throw configError(`${at}: issue trigger run.flow must be a non-empty string (or use run.command): ${path}`);
|
|
8799
|
+
}
|
|
8800
|
+
validateFlowName(run, at, path);
|
|
8801
|
+
const packages = validatePackagesFlag(run, at, path);
|
|
8802
|
+
const image = validateImageRef(run, at, path);
|
|
8803
|
+
const skillsDir = validateSkillsDir(run, at, path);
|
|
8804
|
+
const instructions = validateInstructions(run, at, path);
|
|
8805
|
+
const resume = validateResumeFlag(run, at, path);
|
|
8806
|
+
validateRepository(run, "issue", at, path);
|
|
8807
|
+
const replicas = validateReplicas(run, at, path);
|
|
8808
|
+
const secrets = validateSecrets(run, at, path);
|
|
8809
|
+
const secretsProfile = validateSecretsProfile(run, at, path);
|
|
8810
|
+
return {
|
|
8811
|
+
on: {
|
|
8812
|
+
type: "issue",
|
|
8813
|
+
action: [...actions],
|
|
8814
|
+
// Absent rather than present-and-undefined, reviewState's rule below: an unnarrowed rule's
|
|
8815
|
+
// normalized shape must not grow keys.
|
|
8816
|
+
...number !== void 0 && { number },
|
|
8817
|
+
...once !== void 0 && { once }
|
|
8818
|
+
},
|
|
8819
|
+
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 } }
|
|
8820
|
+
};
|
|
8821
|
+
}
|
|
8237
8822
|
function normalizePullRequest(on, run, index, path) {
|
|
8238
8823
|
const at = `trigger at index ${index}`;
|
|
8239
8824
|
const actions = on.action;
|
|
@@ -8247,17 +8832,29 @@ function normalizePullRequest(on, run, index, path) {
|
|
|
8247
8832
|
throw configError(`${at}: pull_request on.action has an unsupported ${run.kind} action ${JSON.stringify(a)} (expected ${expected}): ${path}`);
|
|
8248
8833
|
}
|
|
8249
8834
|
}
|
|
8835
|
+
const closeWord = PR_CLOSE_ACTIONS[run.kind];
|
|
8836
|
+
const isClose = closeWord !== void 0 && actions.includes(closeWord);
|
|
8837
|
+
if (isClose && actions.some((a) => a !== closeWord)) {
|
|
8838
|
+
throw configError(`${at}: pull_request on.action cannot mix ${JSON.stringify(closeWord)} with other actions -- a close rule is gated on the closer's write access and every other action on the author's, and one rule cannot gate on two different actors; split it into two entries: ${path}`);
|
|
8839
|
+
}
|
|
8250
8840
|
const reviewState = validateReviewState(on, actions, run, at, path);
|
|
8251
8841
|
const labelAction = forgeSpec(run.kind)?.prLabelAction;
|
|
8252
8842
|
const requirePositive = typeof labelAction === "string" && actions.includes(labelAction);
|
|
8253
8843
|
if (run.kind === "azure" && (on.any !== void 0 || on.all !== void 0 || on.none !== void 0)) {
|
|
8254
8844
|
throw configError(`${at}: an azure pull_request trigger cannot carry a label predicate -- Azure DevOps attaches tags to work items, never to pull requests, so any/all/none could never match: ${path}`);
|
|
8255
8845
|
}
|
|
8846
|
+
if (isClose && (on.any !== void 0 || on.all !== void 0 || on.none !== void 0)) {
|
|
8847
|
+
throw configError(`${at}: a close pull_request rule cannot carry a label predicate -- the close route matches the action and on.number alone, so any/all/none would sit in the file looking configured and never match anything: ${path}`);
|
|
8848
|
+
}
|
|
8256
8849
|
const predicate = validatePredicate(on, index, path, requirePositive);
|
|
8850
|
+
const number = validateNumber(on, at, path, { capable: isClose, onType: "pull_request" });
|
|
8851
|
+
const once = validateOnce(on, run, at, path, { capable: isClose, onType: "pull_request" });
|
|
8852
|
+
validateDisarmed(on, at, path, { capable: isClose, onType: "pull_request" });
|
|
8257
8853
|
const command = validateCommand(run, at, path, { onType: "pull_request" });
|
|
8258
|
-
if (command === void 0 && !
|
|
8854
|
+
if (command === void 0 && !isNonEmptyString5(run.flow)) {
|
|
8259
8855
|
throw configError(`${at}: pull_request trigger run.flow must be a non-empty string (or use run.command): ${path}`);
|
|
8260
8856
|
}
|
|
8857
|
+
validateFlowName(run, at, path);
|
|
8261
8858
|
const packages = validatePackagesFlag(run, at, path);
|
|
8262
8859
|
const image = validateImageRef(run, at, path);
|
|
8263
8860
|
const skillsDir = validateSkillsDir(run, at, path);
|
|
@@ -8276,7 +8873,10 @@ function normalizePullRequest(on, run, index, path) {
|
|
|
8276
8873
|
...reviewState !== void 0 && { reviewState },
|
|
8277
8874
|
any: predicate.any,
|
|
8278
8875
|
all: predicate.all,
|
|
8279
|
-
none: predicate.none
|
|
8876
|
+
none: predicate.none,
|
|
8877
|
+
// Same rule for the close-narrowing pair (issue #231): only a close-only rule can carry them.
|
|
8878
|
+
...number !== void 0 && { number },
|
|
8879
|
+
...once !== void 0 && { once }
|
|
8280
8880
|
},
|
|
8281
8881
|
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 } }
|
|
8282
8882
|
};
|
|
@@ -8300,30 +8900,41 @@ function validateReviewState(on, actions, run, at, path) {
|
|
|
8300
8900
|
}
|
|
8301
8901
|
return [...on.reviewState];
|
|
8302
8902
|
}
|
|
8303
|
-
var ON_TYPES, PR_ACTIONS, REVIEW_STATES, REVIEW_ACTION, ID_CHARSET, REPLICAS_MAX, SECRETS_MAX, ENV_NAME, RESERVED_ENV_NAMES, INSTRUCTIONS_MAX;
|
|
8903
|
+
var ON_TYPES, DISARMED_TYPE, PR_ACTIONS, PR_CLOSE_ACTIONS, ISSUE_ACTIONS, REVIEW_STATES, REVIEW_ACTION, ID_CHARSET, REPLICAS_MAX, SECRETS_MAX, ENV_NAME, RESERVED_ENV_NAMES, INSTRUCTIONS_MAX;
|
|
8304
8904
|
var init_triggers = __esm({
|
|
8305
8905
|
"../worker/src/triggers.mjs"() {
|
|
8306
8906
|
init_config2();
|
|
8907
|
+
init_flow_gate();
|
|
8307
8908
|
init_forges();
|
|
8308
8909
|
init_reserved_env();
|
|
8309
|
-
ON_TYPES = /* @__PURE__ */ new Set(["cron", "label", "comment", "pull_request"]);
|
|
8910
|
+
ON_TYPES = /* @__PURE__ */ new Set(["cron", "label", "comment", "pull_request", "issue"]);
|
|
8911
|
+
DISARMED_TYPE = "disarmed";
|
|
8310
8912
|
PR_ACTIONS = {
|
|
8311
|
-
github: /* @__PURE__ */ new Set(["labeled", "opened", "synchronize", "reopened", "review_submitted"]),
|
|
8913
|
+
github: /* @__PURE__ */ new Set(["labeled", "opened", "synchronize", "reopened", "review_submitted", "closed"]),
|
|
8312
8914
|
// GitLab's `approved` is its review gate (a member approved the MR). It is NOT github's
|
|
8313
8915
|
// `review_submitted` renamed: `approved` is one verdict, `review_submitted` is every verdict, which is
|
|
8314
|
-
// what `on.reviewState` below exists to narrow.
|
|
8315
|
-
gitlab: /* @__PURE__ */ new Set(["open", "update", "reopen", "approved"]),
|
|
8916
|
+
// what `on.reviewState` below exists to narrow. `close` is GitLab's own spelling of the close action.
|
|
8917
|
+
gitlab: /* @__PURE__ */ new Set(["open", "update", "reopen", "approved", "close"]),
|
|
8316
8918
|
// Forgejo's own spellings. `label_updated` is its `labeled` and `synchronized` its `synchronize` -- a
|
|
8317
8919
|
// one-letter difference that an operator would otherwise discover as a trigger that loads clean and
|
|
8318
8920
|
// never fires. `label_cleared` is deliberately ABSENT and always will be: REMOVING a label must never
|
|
8319
8921
|
// start a paid run, and it has no GitHub counterpart to inherit that rule from.
|
|
8320
|
-
forgejo: /* @__PURE__ */ new Set(["label_updated", "opened", "synchronized", "reopened"]),
|
|
8922
|
+
forgejo: /* @__PURE__ */ new Set(["label_updated", "opened", "synchronized", "reopened", "closed"]),
|
|
8321
8923
|
// Azure's Service Hook events reduced to the two that leave something to act on. `git.pullrequest.merged`
|
|
8322
|
-
// is omitted for the
|
|
8323
|
-
//
|
|
8324
|
-
//
|
|
8924
|
+
// is omitted for the reason the close words' own comment above records, and there is no close word here at
|
|
8925
|
+
// all: an abandon arrives as `git.pullrequest.updated` with nothing in the projected subset to tell it from
|
|
8926
|
+
// any other update, so an azure close rule could only ever fire on the wrong events or never. Widening
|
|
8927
|
+
// INT-AZURE-PAYLOAD-SUBSET (a PR status field) is the gap to close before this set can grow. There is no
|
|
8928
|
+
// label action either -- Azure attaches tags to WORK ITEMS, never to pull requests -- which is why azure's
|
|
8929
|
+
// `prLabelAction` is null and a predicated PR rule is refused below.
|
|
8325
8930
|
azure: /* @__PURE__ */ new Set(["created", "updated"])
|
|
8326
8931
|
};
|
|
8932
|
+
PR_CLOSE_ACTIONS = { github: "closed", gitlab: "close", forgejo: "closed" };
|
|
8933
|
+
ISSUE_ACTIONS = {
|
|
8934
|
+
github: /* @__PURE__ */ new Set(["closed"]),
|
|
8935
|
+
gitlab: /* @__PURE__ */ new Set(["close"]),
|
|
8936
|
+
forgejo: /* @__PURE__ */ new Set(["closed"])
|
|
8937
|
+
};
|
|
8327
8938
|
REVIEW_STATES = /* @__PURE__ */ new Set(["approved", "changes_requested", "commented"]);
|
|
8328
8939
|
REVIEW_ACTION = "review_submitted";
|
|
8329
8940
|
ID_CHARSET = /^[A-Za-z0-9._-]+$/;
|
|
@@ -8335,329 +8946,6 @@ var init_triggers = __esm({
|
|
|
8335
8946
|
}
|
|
8336
8947
|
});
|
|
8337
8948
|
|
|
8338
|
-
// ../worker/src/pause-windows.mjs
|
|
8339
|
-
function isNonEmptyString3(value) {
|
|
8340
|
-
return typeof value === "string" && value.trim() !== "";
|
|
8341
|
-
}
|
|
8342
|
-
function parseHHMM(value, label, at, path) {
|
|
8343
|
-
const m = isNonEmptyString3(value) ? /^([0-9]{1,2}):([0-9]{2})$/.exec(value.trim()) : null;
|
|
8344
|
-
if (!m) throw configError(`${at}: ${label} must be "HH:MM": ${JSON.stringify(value)} (${path})`);
|
|
8345
|
-
const h = Number(m[1]);
|
|
8346
|
-
const min = Number(m[2]);
|
|
8347
|
-
if (h > 23 || min > 59) throw configError(`${at}: ${label} out of range 00:00-23:59: ${JSON.stringify(value)} (${path})`);
|
|
8348
|
-
return h * 60 + min;
|
|
8349
|
-
}
|
|
8350
|
-
function assertDate(value, label, at, path) {
|
|
8351
|
-
const m = /^([0-9]{4})-([0-9]{2})-([0-9]{2})$/.exec(String(value).trim());
|
|
8352
|
-
const mo = m && Number(m[2]);
|
|
8353
|
-
const d = m && Number(m[3]);
|
|
8354
|
-
if (!m || mo < 1 || mo > 12 || d < 1 || d > 31) {
|
|
8355
|
-
throw configError(`${at}: ${label} must be "YYYY-MM-DD": ${JSON.stringify(value)} (${path})`);
|
|
8356
|
-
}
|
|
8357
|
-
}
|
|
8358
|
-
function validateTz(tz, at, path) {
|
|
8359
|
-
try {
|
|
8360
|
-
new Intl.DateTimeFormat("en-US", { timeZone: tz });
|
|
8361
|
-
return tz;
|
|
8362
|
-
} catch {
|
|
8363
|
-
throw configError(`${at}: tz is not a valid IANA timezone: ${JSON.stringify(tz)} (${path})`);
|
|
8364
|
-
}
|
|
8365
|
-
}
|
|
8366
|
-
function parsePauseWindows(text, path) {
|
|
8367
|
-
let parsed;
|
|
8368
|
-
try {
|
|
8369
|
-
parsed = JSON.parse(text);
|
|
8370
|
-
} catch (error) {
|
|
8371
|
-
throw configError(`pause-windows file is not valid JSON: ${path} (${error.message})`);
|
|
8372
|
-
}
|
|
8373
|
-
const windows = parsed?.windows;
|
|
8374
|
-
if (!Array.isArray(windows)) {
|
|
8375
|
-
throw configError(`pause-windows file must have a "windows" array: ${path}`);
|
|
8376
|
-
}
|
|
8377
|
-
return windows.map((w, index) => normalizeWindow(w, index, path));
|
|
8378
|
-
}
|
|
8379
|
-
function normalizeWindow(w, index, path) {
|
|
8380
|
-
const at = `pause window at index ${index}`;
|
|
8381
|
-
if (w === null || typeof w !== "object") throw configError(`${at}: must be an object: ${path}`);
|
|
8382
|
-
if (!isNonEmptyString3(w.scope)) throw configError(`${at}: scope must be a non-empty string: ${path}`);
|
|
8383
|
-
const fromMin = parseHHMM(w.from, "from", at, path);
|
|
8384
|
-
const toMin = parseHHMM(w.to, "to", at, path);
|
|
8385
|
-
if (fromMin === toMin) {
|
|
8386
|
-
throw configError(`${at}: from and to must differ (a 24h pause is not expressible; remove the trigger): ${path}`);
|
|
8387
|
-
}
|
|
8388
|
-
const tz = w.tz === void 0 ? "UTC" : validateTz(w.tz, at, path);
|
|
8389
|
-
let days = null;
|
|
8390
|
-
if (w.days !== void 0) {
|
|
8391
|
-
if (!Array.isArray(w.days) || w.days.length === 0) {
|
|
8392
|
-
throw configError(`${at}: days must be a non-empty array of weekday names (mon..sun): ${path}`);
|
|
8393
|
-
}
|
|
8394
|
-
days = w.days.map((d) => {
|
|
8395
|
-
const key = String(d).trim().toLowerCase().slice(0, 3);
|
|
8396
|
-
if (!DAY_SET.has(key)) throw configError(`${at}: unknown weekday ${JSON.stringify(d)} (use mon..sun): ${path}`);
|
|
8397
|
-
return key;
|
|
8398
|
-
});
|
|
8399
|
-
}
|
|
8400
|
-
let dateFrom = null;
|
|
8401
|
-
let dateTo = null;
|
|
8402
|
-
if (w.dateFrom !== void 0) {
|
|
8403
|
-
assertDate(w.dateFrom, "dateFrom", at, path);
|
|
8404
|
-
dateFrom = String(w.dateFrom).trim();
|
|
8405
|
-
}
|
|
8406
|
-
if (w.dateTo !== void 0) {
|
|
8407
|
-
assertDate(w.dateTo, "dateTo", at, path);
|
|
8408
|
-
dateTo = String(w.dateTo).trim();
|
|
8409
|
-
}
|
|
8410
|
-
if (dateFrom && dateTo && dateFrom > dateTo) throw configError(`${at}: dateFrom must be <= dateTo: ${path}`);
|
|
8411
|
-
const norm = { scope: w.scope.trim(), from: w.from.trim(), to: w.to.trim(), fromMin, toMin, tz };
|
|
8412
|
-
if (days) norm.days = days;
|
|
8413
|
-
if (dateFrom) norm.dateFrom = dateFrom;
|
|
8414
|
-
if (dateTo) norm.dateTo = dateTo;
|
|
8415
|
-
return norm;
|
|
8416
|
-
}
|
|
8417
|
-
function zonedParts(tz, ms) {
|
|
8418
|
-
const fmt2 = new Intl.DateTimeFormat("en-US", {
|
|
8419
|
-
timeZone: tz,
|
|
8420
|
-
hour12: false,
|
|
8421
|
-
year: "numeric",
|
|
8422
|
-
month: "2-digit",
|
|
8423
|
-
day: "2-digit",
|
|
8424
|
-
hour: "2-digit",
|
|
8425
|
-
minute: "2-digit",
|
|
8426
|
-
weekday: "short"
|
|
8427
|
-
});
|
|
8428
|
-
const p = {};
|
|
8429
|
-
for (const part of fmt2.formatToParts(new Date(ms))) p[part.type] = part.value;
|
|
8430
|
-
let hh = Number(p.hour);
|
|
8431
|
-
if (hh === 24) hh = 0;
|
|
8432
|
-
return { y: Number(p.year), mo: Number(p.month), d: Number(p.day), hh, mi: Number(p.minute), dow: WEEKDAY_INDEX[p.weekday] };
|
|
8433
|
-
}
|
|
8434
|
-
function wallToMs(tz, y, mo, d, hh, mi) {
|
|
8435
|
-
const guess = Date.UTC(y, mo - 1, d, hh, mi);
|
|
8436
|
-
const shown = zonedParts(tz, guess);
|
|
8437
|
-
const shownAsUtc = Date.UTC(shown.y, shown.mo - 1, shown.d, shown.hh, shown.mi);
|
|
8438
|
-
return guess - (shownAsUtc - guess);
|
|
8439
|
-
}
|
|
8440
|
-
function shiftDay(tz, parts, delta) {
|
|
8441
|
-
const noon = wallToMs(tz, parts.y, parts.mo, parts.d, 12, 0);
|
|
8442
|
-
return zonedParts(tz, noon + delta * 864e5);
|
|
8443
|
-
}
|
|
8444
|
-
function ymd(parts) {
|
|
8445
|
-
return `${String(parts.y).padStart(4, "0")}-${String(parts.mo).padStart(2, "0")}-${String(parts.d).padStart(2, "0")}`;
|
|
8446
|
-
}
|
|
8447
|
-
function qualifies(w, parts) {
|
|
8448
|
-
if (w.days && !w.days.includes(DAYS[parts.dow])) return false;
|
|
8449
|
-
const date = ymd(parts);
|
|
8450
|
-
if (w.dateFrom && date < w.dateFrom) return false;
|
|
8451
|
-
if (w.dateTo && date > w.dateTo) return false;
|
|
8452
|
-
return true;
|
|
8453
|
-
}
|
|
8454
|
-
function windowEndAt(w, nowMs) {
|
|
8455
|
-
const p = zonedParts(w.tz, nowMs);
|
|
8456
|
-
const nowMin = p.hh * 60 + p.mi;
|
|
8457
|
-
const endAt = (dateParts) => wallToMs(w.tz, dateParts.y, dateParts.mo, dateParts.d, Math.floor(w.toMin / 60), w.toMin % 60);
|
|
8458
|
-
if (w.fromMin < w.toMin) {
|
|
8459
|
-
if (nowMin >= w.fromMin && nowMin < w.toMin && qualifies(w, p)) return endAt(p);
|
|
8460
|
-
return null;
|
|
8461
|
-
}
|
|
8462
|
-
if (nowMin >= w.fromMin && qualifies(w, p)) return endAt(shiftDay(w.tz, p, 1));
|
|
8463
|
-
if (nowMin < w.toMin && qualifies(w, shiftDay(w.tz, p, -1))) return endAt(p);
|
|
8464
|
-
return null;
|
|
8465
|
-
}
|
|
8466
|
-
var DAYS, DAY_SET, WEEKDAY_INDEX;
|
|
8467
|
-
var init_pause_windows = __esm({
|
|
8468
|
-
"../worker/src/pause-windows.mjs"() {
|
|
8469
|
-
init_config2();
|
|
8470
|
-
DAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
|
|
8471
|
-
DAY_SET = new Set(DAYS);
|
|
8472
|
-
WEEKDAY_INDEX = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
|
|
8473
|
-
}
|
|
8474
|
-
});
|
|
8475
|
-
|
|
8476
|
-
// ../worker/src/subscriptions.mjs
|
|
8477
|
-
function isNonEmptyString4(value) {
|
|
8478
|
-
return typeof value === "string" && value.trim() !== "";
|
|
8479
|
-
}
|
|
8480
|
-
function normalizeGlobs(value, label, at, path) {
|
|
8481
|
-
if (!Array.isArray(value) || value.length === 0) {
|
|
8482
|
-
throw configError(`${at}: ${label} must be a non-empty array of model globs: ${path}`);
|
|
8483
|
-
}
|
|
8484
|
-
return value.map((glob) => {
|
|
8485
|
-
if (typeof glob !== "string" || !MODEL_GLOB_RE.test(glob.trim())) {
|
|
8486
|
-
throw configError(`${at}: ${label} glob ${JSON.stringify(glob)} must match ${MODEL_GLOB_RE} (model-id charset plus "*", max 64 chars): ${path}`);
|
|
8487
|
-
}
|
|
8488
|
-
return glob.trim();
|
|
8489
|
-
});
|
|
8490
|
-
}
|
|
8491
|
-
function normalizeBool(value, label, at, path, fallback) {
|
|
8492
|
-
if (value === void 0) return fallback;
|
|
8493
|
-
if (typeof value !== "boolean") throw configError(`${at}: ${label} must be a boolean: ${path}`);
|
|
8494
|
-
return value;
|
|
8495
|
-
}
|
|
8496
|
-
function parseSubscriptions(text, path) {
|
|
8497
|
-
let parsed;
|
|
8498
|
-
try {
|
|
8499
|
-
parsed = JSON.parse(text);
|
|
8500
|
-
} catch (error) {
|
|
8501
|
-
throw configError(`subscriptions file is not valid JSON: ${path} (${error.message})`);
|
|
8502
|
-
}
|
|
8503
|
-
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
8504
|
-
throw configError(`subscriptions file must be a JSON object: ${path}`);
|
|
8505
|
-
}
|
|
8506
|
-
const version = parsed.version;
|
|
8507
|
-
if (!Number.isInteger(version) || version < 1) {
|
|
8508
|
-
throw configError(`subscriptions file must declare an integer "version" >= 1 (got ${JSON.stringify(version)}): ${path}`);
|
|
8509
|
-
}
|
|
8510
|
-
if (version > SUBSCRIPTIONS_VERSION) {
|
|
8511
|
-
throw configError(`subscriptions file written by a newer pi-dispatch (version ${version}; this build understands ${SUBSCRIPTIONS_VERSION}): ${path}`);
|
|
8512
|
-
}
|
|
8513
|
-
const entries = parsed.subscriptions;
|
|
8514
|
-
if (!Array.isArray(entries)) {
|
|
8515
|
-
throw configError(`subscriptions file must have a "subscriptions" array: ${path}`);
|
|
8516
|
-
}
|
|
8517
|
-
const seenIds = /* @__PURE__ */ new Map();
|
|
8518
|
-
return { version, subscriptions: entries.map((entry, index) => normalizeSubscription(entry, index, path, seenIds)) };
|
|
8519
|
-
}
|
|
8520
|
-
function normalizeSubscription(entry, index, path, seenIds) {
|
|
8521
|
-
const at = `subscription at index ${index}`;
|
|
8522
|
-
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
|
|
8523
|
-
throw configError(`${at}: must be an object: ${path}`);
|
|
8524
|
-
}
|
|
8525
|
-
if (!isNonEmptyString4(entry.id)) throw configError(`${at}: id must be a non-empty string: ${path}`);
|
|
8526
|
-
const id = entry.id.trim();
|
|
8527
|
-
if (seenIds.has(id)) {
|
|
8528
|
-
throw configError(`${at}: id ${JSON.stringify(id)} is already used by the subscription at index ${seenIds.get(id)}: ${path}`);
|
|
8529
|
-
}
|
|
8530
|
-
seenIds.set(id, index);
|
|
8531
|
-
if (!isNonEmptyString4(entry.vendor)) throw configError(`${at}: vendor must be a non-empty string: ${path}`);
|
|
8532
|
-
if (!isNonEmptyString4(entry.provider)) throw configError(`${at}: provider must be a non-empty string (the pi-ai provider id this plan covers): ${path}`);
|
|
8533
|
-
const models = entry.models === void 0 ? ["*"] : normalizeGlobs(entry.models, "models", at, path);
|
|
8534
|
-
const price = entry.price;
|
|
8535
|
-
if (price === null || typeof price !== "object" || Array.isArray(price)) {
|
|
8536
|
-
throw configError(`${at}: price must be an object { amount, currency, per }: ${path}`);
|
|
8537
|
-
}
|
|
8538
|
-
if (typeof price.amount !== "number" || !Number.isFinite(price.amount) || price.amount <= 0) {
|
|
8539
|
-
throw configError(`${at}: price.amount must be a number > 0: ${path}`);
|
|
8540
|
-
}
|
|
8541
|
-
if (typeof price.currency !== "string" || !/^[A-Z]{3}$/.test(price.currency)) {
|
|
8542
|
-
throw configError(`${at}: price.currency must be a 3-letter uppercase code (e.g. "USD"): ${path}`);
|
|
8543
|
-
}
|
|
8544
|
-
if (price.per !== "month") {
|
|
8545
|
-
throw configError(`${at}: price.per must be "month" (got ${JSON.stringify(price.per)}): ${path}`);
|
|
8546
|
-
}
|
|
8547
|
-
const sharedWithOtherProducts = normalizeBool(entry.sharedWithOtherProducts, "sharedWithOtherProducts", at, path, false);
|
|
8548
|
-
const hypothetical = normalizeBool(entry.hypothetical, "hypothetical", at, path, false);
|
|
8549
|
-
let counterfactualModel = null;
|
|
8550
|
-
if (entry.counterfactualModel !== void 0 && entry.counterfactualModel !== null) {
|
|
8551
|
-
const cm = entry.counterfactualModel;
|
|
8552
|
-
if (typeof cm !== "object" || Array.isArray(cm) || !isNonEmptyString4(cm.provider) || !isNonEmptyString4(cm.id)) {
|
|
8553
|
-
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}`);
|
|
8554
|
-
}
|
|
8555
|
-
counterfactualModel = { provider: cm.provider.trim(), id: cm.id.trim() };
|
|
8556
|
-
}
|
|
8557
|
-
let windows = [];
|
|
8558
|
-
if (entry.windows !== void 0) {
|
|
8559
|
-
if (!Array.isArray(entry.windows)) throw configError(`${at}: windows must be an array: ${path}`);
|
|
8560
|
-
windows = entry.windows.map((w, windowIndex) => normalizeWindow2(w, windowIndex, index, path));
|
|
8561
|
-
}
|
|
8562
|
-
return {
|
|
8563
|
-
id,
|
|
8564
|
-
vendor: entry.vendor.trim(),
|
|
8565
|
-
provider: entry.provider.trim(),
|
|
8566
|
-
models,
|
|
8567
|
-
price: { amount: price.amount, currency: price.currency, per: "month" },
|
|
8568
|
-
sharedWithOtherProducts,
|
|
8569
|
-
hypothetical,
|
|
8570
|
-
counterfactualModel,
|
|
8571
|
-
windows
|
|
8572
|
-
};
|
|
8573
|
-
}
|
|
8574
|
-
function normalizeWindow2(w, windowIndex, subIndex, path) {
|
|
8575
|
-
const at = `window at index ${windowIndex} of subscription at index ${subIndex}`;
|
|
8576
|
-
if (w === null || typeof w !== "object" || Array.isArray(w)) {
|
|
8577
|
-
throw configError(`${at}: must be an object: ${path}`);
|
|
8578
|
-
}
|
|
8579
|
-
if (!WINDOW_PERS.has(w.per)) {
|
|
8580
|
-
throw configError(`${at}: per must be one of 5h|7d|30d|month (got ${JSON.stringify(w.per)}): ${path}`);
|
|
8581
|
-
}
|
|
8582
|
-
const rolling = normalizeBool(w.rolling, "rolling", at, path, false);
|
|
8583
|
-
let unit = null;
|
|
8584
|
-
if (w.unit !== void 0 && w.unit !== null) {
|
|
8585
|
-
if (!WINDOW_UNITS.has(w.unit)) {
|
|
8586
|
-
throw configError(`${at}: unit must be one of tokens|requests|prompts|credits, or null (vendor undisclosed): ${path}`);
|
|
8587
|
-
}
|
|
8588
|
-
unit = w.unit;
|
|
8589
|
-
}
|
|
8590
|
-
let limit = null;
|
|
8591
|
-
if (w.limit !== void 0 && w.limit !== null) {
|
|
8592
|
-
if (typeof w.limit === "number") {
|
|
8593
|
-
if (!Number.isFinite(w.limit) || w.limit <= 0) throw configError(`${at}: limit must be a number > 0, [min, max], or null (vendor undisclosed): ${path}`);
|
|
8594
|
-
limit = w.limit;
|
|
8595
|
-
} else if (Array.isArray(w.limit)) {
|
|
8596
|
-
const [min, max] = w.limit;
|
|
8597
|
-
if (w.limit.length !== 2 || typeof min !== "number" || typeof max !== "number" || !Number.isFinite(min) || !Number.isFinite(max) || min <= 0 || max <= 0 || min >= max) {
|
|
8598
|
-
throw configError(`${at}: limit range must be [min, max] with both > 0 and min < max: ${path}`);
|
|
8599
|
-
}
|
|
8600
|
-
limit = [min, max];
|
|
8601
|
-
} else {
|
|
8602
|
-
throw configError(`${at}: limit must be a number > 0, [min, max], or null (vendor undisclosed): ${path}`);
|
|
8603
|
-
}
|
|
8604
|
-
}
|
|
8605
|
-
const scope = w.scope === void 0 || w.scope === null ? null : normalizeGlobs(w.scope, "scope", at, path);
|
|
8606
|
-
return { per: w.per, rolling, unit, limit, scope };
|
|
8607
|
-
}
|
|
8608
|
-
var SUBSCRIPTIONS_VERSION, WINDOW_PERS, WINDOW_UNITS, MODEL_GLOB_RE;
|
|
8609
|
-
var init_subscriptions = __esm({
|
|
8610
|
-
"../worker/src/subscriptions.mjs"() {
|
|
8611
|
-
init_config2();
|
|
8612
|
-
SUBSCRIPTIONS_VERSION = 1;
|
|
8613
|
-
WINDOW_PERS = /* @__PURE__ */ new Set(["5h", "7d", "30d", "month"]);
|
|
8614
|
-
WINDOW_UNITS = /* @__PURE__ */ new Set(["tokens", "requests", "prompts", "credits"]);
|
|
8615
|
-
MODEL_GLOB_RE = /^[A-Za-z0-9*._:/-]{1,64}$/;
|
|
8616
|
-
}
|
|
8617
|
-
});
|
|
8618
|
-
|
|
8619
|
-
// ../worker/src/connection.mjs
|
|
8620
|
-
import { Redis } from "ioredis";
|
|
8621
|
-
function parseConnection(url, { failFast = false } = {}) {
|
|
8622
|
-
const u = new URL(url);
|
|
8623
|
-
return {
|
|
8624
|
-
host: u.hostname || "127.0.0.1",
|
|
8625
|
-
port: Number(u.port || 6379),
|
|
8626
|
-
...u.password ? { password: u.password } : {},
|
|
8627
|
-
...u.username ? { username: u.username } : {},
|
|
8628
|
-
...u.pathname && u.pathname !== "/" ? { db: Number(u.pathname.slice(1)) } : {},
|
|
8629
|
-
maxRetriesPerRequest: null,
|
|
8630
|
-
// required for BullMQ blocking connections
|
|
8631
|
-
...failFast ? {
|
|
8632
|
-
connectTimeout: 2e3,
|
|
8633
|
-
enableOfflineQueue: false,
|
|
8634
|
-
// don't buffer commands while disconnected -- error now
|
|
8635
|
-
retryStrategy: (attempts) => attempts > 2 ? null : 200
|
|
8636
|
-
// give up after ~2 tries
|
|
8637
|
-
} : {}
|
|
8638
|
-
};
|
|
8639
|
-
}
|
|
8640
|
-
function makeRedisClient(url) {
|
|
8641
|
-
return new Redis(url, { maxRetriesPerRequest: null });
|
|
8642
|
-
}
|
|
8643
|
-
var init_connection = __esm({
|
|
8644
|
-
"../worker/src/connection.mjs"() {
|
|
8645
|
-
}
|
|
8646
|
-
});
|
|
8647
|
-
|
|
8648
|
-
// ../worker/src/job-id.mjs
|
|
8649
|
-
import { createHash } from "node:crypto";
|
|
8650
|
-
function localJobId({ folder, flow, task, minute }) {
|
|
8651
|
-
const digest = createHash("sha256").update([folder, flow ?? "", task ?? "", minute].join("\0")).digest("hex");
|
|
8652
|
-
return `local-${digest.slice(0, 16)}`;
|
|
8653
|
-
}
|
|
8654
|
-
var init_job_id = __esm({
|
|
8655
|
-
"../worker/src/job-id.mjs"() {
|
|
8656
|
-
init_config2();
|
|
8657
|
-
init_forges();
|
|
8658
|
-
}
|
|
8659
|
-
});
|
|
8660
|
-
|
|
8661
8949
|
// ../worker/src/queue.mjs
|
|
8662
8950
|
import { Queue } from "bullmq";
|
|
8663
8951
|
function makeQueue(connection) {
|
|
@@ -8701,59 +8989,18 @@ async function enqueueLocalJob(queue, { folder, flow, task, command, provider, m
|
|
|
8701
8989
|
});
|
|
8702
8990
|
return id;
|
|
8703
8991
|
}
|
|
8704
|
-
var QUEUE, SEMANTIC_WINDOW_MS;
|
|
8992
|
+
var PR_CLOSE_WORDS, QUEUE, SEMANTIC_WINDOW_MS;
|
|
8705
8993
|
var init_queue = __esm({
|
|
8706
8994
|
"../worker/src/queue.mjs"() {
|
|
8707
8995
|
init_job_id();
|
|
8708
8996
|
init_forges();
|
|
8997
|
+
init_triggers();
|
|
8998
|
+
PR_CLOSE_WORDS = new Set(Object.values(PR_CLOSE_ACTIONS));
|
|
8709
8999
|
QUEUE = "pi-jobs";
|
|
8710
9000
|
SEMANTIC_WINDOW_MS = 10 * 60 * 1e3;
|
|
8711
9001
|
}
|
|
8712
9002
|
});
|
|
8713
9003
|
|
|
8714
|
-
// ../worker/src/flow-gate.mjs
|
|
8715
|
-
import { execFile } from "node:child_process";
|
|
8716
|
-
import { promisify } from "node:util";
|
|
8717
|
-
async function readFlowGate({ folder, flow, sha, git = defaultGit }) {
|
|
8718
|
-
if (typeof sha !== "string" || sha.trim() === "") return { gate: "deny" };
|
|
8719
|
-
if (typeof flow !== "string" || !SKILL_NAME_RE.test(flow)) return { gate: "deny" };
|
|
8720
|
-
const path = `.pi/skills/${flow}/SKILL.md`;
|
|
8721
|
-
try {
|
|
8722
|
-
const lsTreeZ = await git(folder, ["ls-tree", "-z", sha, path]);
|
|
8723
|
-
const record = lsTreeZ.split("\0").find((r) => r);
|
|
8724
|
-
if (!record) return { gate: "no-skill" };
|
|
8725
|
-
const tab = record.indexOf(" ");
|
|
8726
|
-
if (tab === -1) return { gate: "deny" };
|
|
8727
|
-
const [mode, type, oid] = record.slice(0, tab).split(/\s+/);
|
|
8728
|
-
if (mode !== "100644" || type !== "blob") return { gate: "deny" };
|
|
8729
|
-
const content = await git(folder, ["cat-file", "blob", oid], { raw: true });
|
|
8730
|
-
return { gate: aiTriggerAllows(content) ? "allow" : "deny" };
|
|
8731
|
-
} catch {
|
|
8732
|
-
return { gate: "deny" };
|
|
8733
|
-
}
|
|
8734
|
-
}
|
|
8735
|
-
function aiTriggerAllows(buf) {
|
|
8736
|
-
const text = buf.toString("utf8").replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
|
|
8737
|
-
const block = /^---\n([\s\S]*?)\n---(?:\n|$)/.exec(text);
|
|
8738
|
-
if (!block) return false;
|
|
8739
|
-
return /^ai-trigger:\s*("?)allow\1\s*$/m.test(block[1]);
|
|
8740
|
-
}
|
|
8741
|
-
async function defaultGit(gitDir, args, { raw = false } = {}) {
|
|
8742
|
-
const hardened = ["-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", "--no-pager", "-C", gitDir, ...args];
|
|
8743
|
-
const { stdout } = await exec("git", hardened, {
|
|
8744
|
-
encoding: raw ? "buffer" : "utf8",
|
|
8745
|
-
maxBuffer: 16 * 1024 * 1024
|
|
8746
|
-
});
|
|
8747
|
-
return stdout;
|
|
8748
|
-
}
|
|
8749
|
-
var exec, SKILL_NAME_RE;
|
|
8750
|
-
var init_flow_gate = __esm({
|
|
8751
|
-
"../worker/src/flow-gate.mjs"() {
|
|
8752
|
-
exec = promisify(execFile);
|
|
8753
|
-
SKILL_NAME_RE = /^[a-z0-9](?:[a-z0-9_-]{0,62}[a-z0-9])?$/;
|
|
8754
|
-
}
|
|
8755
|
-
});
|
|
8756
|
-
|
|
8757
9004
|
// ../worker/src/git-dirty.mjs
|
|
8758
9005
|
import { execFileSync } from "node:child_process";
|
|
8759
9006
|
function gitDirty(folder, { exec: exec4 = execFileSync } = {}) {
|
|
@@ -8874,8 +9121,8 @@ var init_docker_run = __esm({
|
|
|
8874
9121
|
|
|
8875
9122
|
// ../worker/src/packages.mjs
|
|
8876
9123
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
8877
|
-
import { basename, dirname as dirname2, join, resolve } from "node:path";
|
|
8878
|
-
function
|
|
9124
|
+
import { basename, dirname as dirname2, join, resolve as resolve2 } from "node:path";
|
|
9125
|
+
function isNonEmptyString6(value) {
|
|
8879
9126
|
return typeof value === "string" && value.trim() !== "";
|
|
8880
9127
|
}
|
|
8881
9128
|
function readStageManifest({ globalPiDir, readFile = readFileSync, fileExists = existsSync } = {}) {
|
|
@@ -8889,7 +9136,7 @@ function readStageManifest({ globalPiDir, readFile = readFileSync, fileExists =
|
|
|
8889
9136
|
for (const entry of parsed.packages) {
|
|
8890
9137
|
if (entry === null || typeof entry !== "object") return null;
|
|
8891
9138
|
const { name, version, dir } = entry;
|
|
8892
|
-
if (!
|
|
9139
|
+
if (!isNonEmptyString6(name) || !isNonEmptyString6(version) || !isNonEmptyString6(dir)) return null;
|
|
8893
9140
|
if (!ENTRY_NAME_RE.test(dir) || dir.length > MAX_DIR_LENGTH) return null;
|
|
8894
9141
|
packages.push({ name, version, dir, from: entry.from === FROM_HOST ? FROM_HOST : FROM_DECLARED });
|
|
8895
9142
|
}
|
|
@@ -8914,7 +9161,7 @@ function readStagedSkills({ globalPiDir, readFile = readFileSync, fileExists = e
|
|
|
8914
9161
|
out.unenumerable.push(pkg.name);
|
|
8915
9162
|
continue;
|
|
8916
9163
|
}
|
|
8917
|
-
sources = entries.filter((e) => !e.split(/[\\/]/).includes("..")).map((e) =>
|
|
9164
|
+
sources = entries.filter((e) => !e.split(/[\\/]/).includes("..")).map((e) => resolve2(root, e));
|
|
8918
9165
|
} else {
|
|
8919
9166
|
sources = [join(root, "skills")];
|
|
8920
9167
|
}
|
|
@@ -9331,6 +9578,16 @@ function buildGraphModel({ triggers, schedulers, folderSkills, injectedSkills, o
|
|
|
9331
9578
|
// (guarded on `typeof t.flow === "string"`) correctly draws nothing for it.
|
|
9332
9579
|
command: t.command ?? null,
|
|
9333
9580
|
replicas: t.replicas ?? null,
|
|
9581
|
+
// The one-shot facts (issue #231), carried the way `cost` is: node FACTS, never new vocabulary
|
|
9582
|
+
// -- the #188 rule, and GRAPH_FLAGS/GRAPH_NODE_KINDS are byte-unchanged by this issue too. A
|
|
9583
|
+
// spent entry still arrives here because the display normalizer reads the RAW file (the spent
|
|
9584
|
+
// row must stay visible), so the two states a renderer has to keep apart ride as one boolean
|
|
9585
|
+
// and one mark: `once` is true only while ARMED -- a spent one-shot is not "a rule that will
|
|
9586
|
+
// fire once", and letting both read true would ask every consumer to re-derive the difference
|
|
9587
|
+
// -- and `disarmed` is the worker's mark verbatim ({ at, jobId? }), null like every absent
|
|
9588
|
+
// fact on this literal.
|
|
9589
|
+
once: t.once === true && !t.disarmed,
|
|
9590
|
+
disarmed: t.disarmed !== null && typeof t.disarmed === "object" ? t.disarmed : null,
|
|
9334
9591
|
folderKey: group?.key ?? null,
|
|
9335
9592
|
runs: Number.isInteger(stats?.runs) ? stats.runs : 0,
|
|
9336
9593
|
lastOutcome: stats?.lastOutcome ?? null,
|
|
@@ -9468,8 +9725,16 @@ function triggerMatchLabel(t) {
|
|
|
9468
9725
|
return selectorLabel(t);
|
|
9469
9726
|
case "comment":
|
|
9470
9727
|
return `"${t.phrase ?? "-"}"`;
|
|
9471
|
-
case "pull_request":
|
|
9472
|
-
|
|
9728
|
+
case "pull_request": {
|
|
9729
|
+
const action = `action[${(Array.isArray(t.action) ? t.action : []).join(",")}]`;
|
|
9730
|
+
const num = Number.isInteger(t.number) ? ` #${t.number}` : "";
|
|
9731
|
+
return t.disarmed ? `${action}${num} (spent)` : `${action}${num}`;
|
|
9732
|
+
}
|
|
9733
|
+
case "issue": {
|
|
9734
|
+
const action = `action[${(Array.isArray(t.action) ? t.action : []).join(",")}]`;
|
|
9735
|
+
const base = Number.isInteger(t.number) ? `${action} #${t.number}` : action;
|
|
9736
|
+
return t.disarmed ? `${base} (spent)` : base;
|
|
9737
|
+
}
|
|
9473
9738
|
default:
|
|
9474
9739
|
return "(unknown)";
|
|
9475
9740
|
}
|
|
@@ -10076,8 +10341,110 @@ var init_costs = __esm({
|
|
|
10076
10341
|
}
|
|
10077
10342
|
});
|
|
10078
10343
|
|
|
10344
|
+
// ../worker/src/triggers-file.mjs
|
|
10345
|
+
import nodeFs2 from "node:fs";
|
|
10346
|
+
function lockPathFor(triggersPath) {
|
|
10347
|
+
return `${triggersPath}.lock`;
|
|
10348
|
+
}
|
|
10349
|
+
function takeLock(triggersPath, fs, log) {
|
|
10350
|
+
const lock = lockPathFor(triggersPath);
|
|
10351
|
+
let sweptAgeMs = null;
|
|
10352
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
10353
|
+
try {
|
|
10354
|
+
const fd = fs.openSync(lock, "wx");
|
|
10355
|
+
if (sweptAgeMs !== null) log("triggers_lock_stale_taken", { ageMs: sweptAgeMs });
|
|
10356
|
+
return fd;
|
|
10357
|
+
} catch (err) {
|
|
10358
|
+
if (err?.code !== "EEXIST") throw err;
|
|
10359
|
+
let mtimeMs;
|
|
10360
|
+
try {
|
|
10361
|
+
mtimeMs = fs.statSync(lock).mtimeMs;
|
|
10362
|
+
} catch {
|
|
10363
|
+
continue;
|
|
10364
|
+
}
|
|
10365
|
+
if (Date.now() - mtimeMs <= LOCK_STALE_MS) return null;
|
|
10366
|
+
try {
|
|
10367
|
+
fs.unlinkSync(lock);
|
|
10368
|
+
} catch {
|
|
10369
|
+
}
|
|
10370
|
+
sweptAgeMs = Math.round(Date.now() - mtimeMs);
|
|
10371
|
+
}
|
|
10372
|
+
}
|
|
10373
|
+
return null;
|
|
10374
|
+
}
|
|
10375
|
+
function releaseLock(fd, triggersPath, fs, log) {
|
|
10376
|
+
fs.closeSync(fd);
|
|
10377
|
+
try {
|
|
10378
|
+
fs.unlinkSync(lockPathFor(triggersPath));
|
|
10379
|
+
} catch {
|
|
10380
|
+
log("triggers_lock_stuck", {});
|
|
10381
|
+
}
|
|
10382
|
+
}
|
|
10383
|
+
function serialize(triggersArray) {
|
|
10384
|
+
return `${JSON.stringify({ triggers: triggersArray }, null, 2)}
|
|
10385
|
+
`;
|
|
10386
|
+
}
|
|
10387
|
+
function renameIntoPlace(fs, tmp, dest) {
|
|
10388
|
+
try {
|
|
10389
|
+
fs.renameSync(tmp, dest);
|
|
10390
|
+
} catch (err) {
|
|
10391
|
+
if (err?.code !== "EPERM") throw err;
|
|
10392
|
+
fs.renameSync(tmp, dest);
|
|
10393
|
+
}
|
|
10394
|
+
}
|
|
10395
|
+
function tmpPathFor(triggersPath) {
|
|
10396
|
+
return `${triggersPath}.${process.pid}.${tmpSeq++}.tmp`;
|
|
10397
|
+
}
|
|
10398
|
+
function discardTmp(fs, tmp) {
|
|
10399
|
+
try {
|
|
10400
|
+
fs.unlinkSync(tmp);
|
|
10401
|
+
} catch {
|
|
10402
|
+
}
|
|
10403
|
+
}
|
|
10404
|
+
function writeTriggers({ triggersPath, mutate, fs = nodeFs2, log = () => {
|
|
10405
|
+
} }) {
|
|
10406
|
+
const fd = takeLock(triggersPath, fs, log);
|
|
10407
|
+
if (fd === null) {
|
|
10408
|
+
return { invalid: `triggers file locked (another write in progress): ${lockPathFor(triggersPath)}` };
|
|
10409
|
+
}
|
|
10410
|
+
try {
|
|
10411
|
+
let current = [];
|
|
10412
|
+
try {
|
|
10413
|
+
const raw = JSON.parse(fs.readFileSync(triggersPath, "utf8"));
|
|
10414
|
+
if (Array.isArray(raw?.triggers)) current = raw.triggers;
|
|
10415
|
+
} catch {
|
|
10416
|
+
}
|
|
10417
|
+
const next = mutate(current.map((t) => ({ ...t })));
|
|
10418
|
+
const text = serialize(next);
|
|
10419
|
+
try {
|
|
10420
|
+
parseTriggers(text, triggersPath);
|
|
10421
|
+
} catch (e) {
|
|
10422
|
+
return { invalid: e?.message ?? String(e) };
|
|
10423
|
+
}
|
|
10424
|
+
const tmp = tmpPathFor(triggersPath);
|
|
10425
|
+
try {
|
|
10426
|
+
fs.writeFileSync(tmp, text, { mode: 420 });
|
|
10427
|
+
renameIntoPlace(fs, tmp, triggersPath);
|
|
10428
|
+
} catch (err) {
|
|
10429
|
+
discardTmp(fs, tmp);
|
|
10430
|
+
throw err;
|
|
10431
|
+
}
|
|
10432
|
+
return { ok: true };
|
|
10433
|
+
} finally {
|
|
10434
|
+
releaseLock(fd, triggersPath, fs, log);
|
|
10435
|
+
}
|
|
10436
|
+
}
|
|
10437
|
+
var LOCK_STALE_MS, tmpSeq;
|
|
10438
|
+
var init_triggers_file = __esm({
|
|
10439
|
+
"../worker/src/triggers-file.mjs"() {
|
|
10440
|
+
init_triggers();
|
|
10441
|
+
LOCK_STALE_MS = 1e4;
|
|
10442
|
+
tmpSeq = 0;
|
|
10443
|
+
}
|
|
10444
|
+
});
|
|
10445
|
+
|
|
10079
10446
|
// src/read-model.mjs
|
|
10080
|
-
import * as
|
|
10447
|
+
import * as nodeFs3 from "node:fs";
|
|
10081
10448
|
import { join as join2, delimiter, sep } from "node:path";
|
|
10082
10449
|
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
10083
10450
|
function resolvePaths(env = process.env) {
|
|
@@ -10091,6 +10458,7 @@ function resolvePaths(env = process.env) {
|
|
|
10091
10458
|
// root, and silently wrong (demo triggers) everywhere else.
|
|
10092
10459
|
triggersPath: env.PI_TRIGGERS_FILE ?? "./triggers.json",
|
|
10093
10460
|
pauseWindowsPath: env.PI_PAUSE_WINDOWS_FILE ?? "./pause-windows.json",
|
|
10461
|
+
scopedLimitsPath: env.PI_SCOPED_LIMITS_FILE ?? "./scoped-limits.json",
|
|
10094
10462
|
subscriptionsPath: env.PI_SUBSCRIPTIONS_FILE ?? "./subscriptions.json",
|
|
10095
10463
|
// The operator's global pi overlay dir (REQ-GLOBAL-PI-OVERLAY), where the staged third-party pi
|
|
10096
10464
|
// packages live under `packages/`. `|| null` so unset AND empty both read as "no overlay" -- the
|
|
@@ -10250,14 +10618,14 @@ function folderUnderRoots(folder, roots) {
|
|
|
10250
10618
|
if (!Array.isArray(roots) || roots.length === 0) return false;
|
|
10251
10619
|
let realTarget;
|
|
10252
10620
|
try {
|
|
10253
|
-
realTarget =
|
|
10621
|
+
realTarget = nodeFs3.realpathSync(folder);
|
|
10254
10622
|
} catch {
|
|
10255
10623
|
return false;
|
|
10256
10624
|
}
|
|
10257
10625
|
for (const root of roots) {
|
|
10258
10626
|
let realRoot;
|
|
10259
10627
|
try {
|
|
10260
|
-
realRoot =
|
|
10628
|
+
realRoot = nodeFs3.realpathSync(root);
|
|
10261
10629
|
} catch {
|
|
10262
10630
|
continue;
|
|
10263
10631
|
}
|
|
@@ -10268,7 +10636,7 @@ function folderUnderRoots(folder, roots) {
|
|
|
10268
10636
|
function hourKey(nowMs) {
|
|
10269
10637
|
return `dispatch-run:${new Date(nowMs).toISOString().slice(0, 13).replace("T", "-")}`;
|
|
10270
10638
|
}
|
|
10271
|
-
function writeSettings({ settingsFile, mutate, fs =
|
|
10639
|
+
function writeSettings({ settingsFile, mutate, fs = nodeFs3 }) {
|
|
10272
10640
|
const res = readOverlay(settingsFile, { fs });
|
|
10273
10641
|
const base = res.overlay ?? {};
|
|
10274
10642
|
const next = mutate({ ...base });
|
|
@@ -10276,60 +10644,114 @@ function writeSettings({ settingsFile, mutate, fs = nodeFs2 }) {
|
|
|
10276
10644
|
if (w.invalid) return { invalid: w.invalid };
|
|
10277
10645
|
return res.invalid ? { ok: true, overlay: next, rebuiltFrom: res.invalid } : { ok: true, overlay: next };
|
|
10278
10646
|
}
|
|
10279
|
-
function
|
|
10647
|
+
function readPauseWindows({ pauseWindowsPath, fs = nodeFs3 }) {
|
|
10648
|
+
let text;
|
|
10649
|
+
try {
|
|
10650
|
+
text = fs.readFileSync(pauseWindowsPath, "utf8");
|
|
10651
|
+
} catch {
|
|
10652
|
+
return { missing: true };
|
|
10653
|
+
}
|
|
10654
|
+
try {
|
|
10655
|
+
return { windows: parsePauseWindows(text, pauseWindowsPath) };
|
|
10656
|
+
} catch (e) {
|
|
10657
|
+
return { invalid: e?.message ?? String(e) };
|
|
10658
|
+
}
|
|
10659
|
+
}
|
|
10660
|
+
function writePauseWindows({ pauseWindowsPath, mutate, fs = nodeFs3 }) {
|
|
10280
10661
|
let current = [];
|
|
10281
10662
|
try {
|
|
10282
|
-
const raw = JSON.parse(fs.readFileSync(
|
|
10283
|
-
if (Array.isArray(raw?.
|
|
10663
|
+
const raw = JSON.parse(fs.readFileSync(pauseWindowsPath, "utf8"));
|
|
10664
|
+
if (Array.isArray(raw?.windows)) current = raw.windows;
|
|
10284
10665
|
} catch {
|
|
10285
10666
|
}
|
|
10286
|
-
const next = mutate(current.map((
|
|
10287
|
-
const text = `${JSON.stringify({
|
|
10667
|
+
const next = mutate(current.map((w) => ({ ...w })));
|
|
10668
|
+
const text = `${JSON.stringify({ windows: next }, null, 2)}
|
|
10288
10669
|
`;
|
|
10289
10670
|
try {
|
|
10290
|
-
|
|
10671
|
+
parsePauseWindows(text, pauseWindowsPath);
|
|
10291
10672
|
} catch (e) {
|
|
10292
10673
|
return { invalid: e?.message ?? String(e) };
|
|
10293
10674
|
}
|
|
10294
|
-
const tmp = `${
|
|
10675
|
+
const tmp = `${pauseWindowsPath}.tmp`;
|
|
10295
10676
|
fs.writeFileSync(tmp, text, { mode: 420 });
|
|
10296
|
-
fs.renameSync(tmp,
|
|
10677
|
+
fs.renameSync(tmp, pauseWindowsPath);
|
|
10297
10678
|
return { ok: true };
|
|
10298
10679
|
}
|
|
10299
|
-
function
|
|
10680
|
+
function readScopedLimits({ scopedLimitsPath, fs = nodeFs3 }) {
|
|
10300
10681
|
let text;
|
|
10301
10682
|
try {
|
|
10302
|
-
text = fs.readFileSync(
|
|
10683
|
+
text = fs.readFileSync(scopedLimitsPath, "utf8");
|
|
10303
10684
|
} catch {
|
|
10304
10685
|
return { missing: true };
|
|
10305
10686
|
}
|
|
10306
10687
|
try {
|
|
10307
|
-
return {
|
|
10688
|
+
return { limits: parseScopedLimits(text, scopedLimitsPath) };
|
|
10308
10689
|
} catch (e) {
|
|
10309
10690
|
return { invalid: e?.message ?? String(e) };
|
|
10310
10691
|
}
|
|
10311
10692
|
}
|
|
10312
|
-
function
|
|
10693
|
+
function writeScopedLimits({ scopedLimitsPath, mutate, fs = nodeFs3 }) {
|
|
10313
10694
|
let current = [];
|
|
10695
|
+
let existing = null;
|
|
10314
10696
|
try {
|
|
10315
|
-
|
|
10316
|
-
if (Array.isArray(raw?.windows)) current = raw.windows;
|
|
10697
|
+
existing = fs.readFileSync(scopedLimitsPath, "utf8");
|
|
10317
10698
|
} catch {
|
|
10318
10699
|
}
|
|
10319
|
-
|
|
10320
|
-
|
|
10700
|
+
if (existing !== null) {
|
|
10701
|
+
try {
|
|
10702
|
+
current = parseScopedLimits(existing, scopedLimitsPath);
|
|
10703
|
+
} catch (e) {
|
|
10704
|
+
return { invalid: e?.message ?? String(e) };
|
|
10705
|
+
}
|
|
10706
|
+
}
|
|
10707
|
+
const next = mutate(current.map((l) => ({ ...l })));
|
|
10708
|
+
const rows = next.map((l) => Object.fromEntries(Object.entries(l).filter(([, v]) => v !== null && v !== void 0)));
|
|
10709
|
+
const text = `${JSON.stringify({ version: SCOPED_LIMITS_VERSION, limits: rows }, null, 2)}
|
|
10321
10710
|
`;
|
|
10322
10711
|
try {
|
|
10323
|
-
|
|
10712
|
+
parseScopedLimits(text, scopedLimitsPath);
|
|
10324
10713
|
} catch (e) {
|
|
10325
10714
|
return { invalid: e?.message ?? String(e) };
|
|
10326
10715
|
}
|
|
10327
|
-
const tmp = `${
|
|
10716
|
+
const tmp = `${scopedLimitsPath}.tmp`;
|
|
10328
10717
|
fs.writeFileSync(tmp, text, { mode: 420 });
|
|
10329
|
-
fs.renameSync(tmp,
|
|
10718
|
+
fs.renameSync(tmp, scopedLimitsPath);
|
|
10330
10719
|
return { ok: true };
|
|
10331
10720
|
}
|
|
10332
|
-
function
|
|
10721
|
+
async function readScopedBudget({ url, limits, redisFn = makeRedisClient, timeoutMs = 2500 } = {}) {
|
|
10722
|
+
if (!Array.isArray(limits) || limits.length === 0) return { rows: [] };
|
|
10723
|
+
let redis;
|
|
10724
|
+
try {
|
|
10725
|
+
parseConnection(url, { failFast: true });
|
|
10726
|
+
redis = redisFn(url);
|
|
10727
|
+
const now = /* @__PURE__ */ new Date();
|
|
10728
|
+
const settled = Promise.all(
|
|
10729
|
+
limits.map(async (l) => {
|
|
10730
|
+
const prefix = scopeKeyPrefix(l.scope);
|
|
10731
|
+
const cell2 = async (key) => Number(await redis.get(key) ?? 0);
|
|
10732
|
+
const row = {};
|
|
10733
|
+
if (l.day !== null && l.day !== void 0) row.day = await cell2(dayKey(now, prefix));
|
|
10734
|
+
if (l.week !== null && l.week !== void 0) row.week = await cell2(weekKey(now, prefix));
|
|
10735
|
+
if (l.month !== null && l.month !== void 0) row.month = await cell2(monthKey(now, prefix));
|
|
10736
|
+
return row;
|
|
10737
|
+
})
|
|
10738
|
+
).then(
|
|
10739
|
+
(rows) => ({ rows }),
|
|
10740
|
+
(err) => ({ unreachable: err?.message ?? String(err) })
|
|
10741
|
+
);
|
|
10742
|
+
return await withTimeout(settled, timeoutMs, { unreachable: "timed out reaching the queue" });
|
|
10743
|
+
} catch (err) {
|
|
10744
|
+
return { unreachable: err?.message ?? String(err) };
|
|
10745
|
+
} finally {
|
|
10746
|
+
if (redis) {
|
|
10747
|
+
try {
|
|
10748
|
+
redis.disconnect();
|
|
10749
|
+
} catch {
|
|
10750
|
+
}
|
|
10751
|
+
}
|
|
10752
|
+
}
|
|
10753
|
+
}
|
|
10754
|
+
function readSubscriptions({ subscriptionsPath, fs = nodeFs3 }) {
|
|
10333
10755
|
if (subscriptionsPath === null || subscriptionsPath === void 0) return { missing: true };
|
|
10334
10756
|
let text;
|
|
10335
10757
|
try {
|
|
@@ -10405,13 +10827,13 @@ async function readBudget({ url, redisFn = makeRedisClient, timeoutMs = 2500 } =
|
|
|
10405
10827
|
}
|
|
10406
10828
|
function withTimeout(promise, ms, fallback) {
|
|
10407
10829
|
let timer;
|
|
10408
|
-
const timeout = new Promise((
|
|
10409
|
-
timer = setTimeout(() =>
|
|
10830
|
+
const timeout = new Promise((resolve3) => {
|
|
10831
|
+
timer = setTimeout(() => resolve3(fallback), ms);
|
|
10410
10832
|
if (typeof timer?.unref === "function") timer.unref();
|
|
10411
10833
|
});
|
|
10412
10834
|
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
10413
10835
|
}
|
|
10414
|
-
function listRuns({ logsDir, limit = 10, fs =
|
|
10836
|
+
function listRuns({ logsDir, limit = 10, fs = nodeFs3 }) {
|
|
10415
10837
|
const cap = clampLimit(limit);
|
|
10416
10838
|
let names;
|
|
10417
10839
|
try {
|
|
@@ -10429,7 +10851,7 @@ function listRuns({ logsDir, limit = 10, fs = nodeFs2 }) {
|
|
|
10429
10851
|
records.sort(byEndedAtDesc);
|
|
10430
10852
|
return records.slice(0, cap);
|
|
10431
10853
|
}
|
|
10432
|
-
function scanRunRecords({ logsDir, sinceMs, nowMs = Date.now(), fs =
|
|
10854
|
+
function scanRunRecords({ logsDir, sinceMs, nowMs = Date.now(), fs = nodeFs3 }) {
|
|
10433
10855
|
const oldestMs = nowMs - SCAN_WINDOW_MAX_DAYS * 24 * 60 * 60 * 1e3;
|
|
10434
10856
|
const cutoffMs = Math.max(Number.isFinite(sinceMs) ? sinceMs : oldestMs, oldestMs);
|
|
10435
10857
|
let names;
|
|
@@ -10450,27 +10872,26 @@ function scanRunRecords({ logsDir, sinceMs, nowMs = Date.now(), fs = nodeFs2 })
|
|
|
10450
10872
|
}
|
|
10451
10873
|
return records;
|
|
10452
10874
|
}
|
|
10453
|
-
function readRun({ logsDir, jobId, fs =
|
|
10875
|
+
function readRun({ logsDir, jobId, fs = nodeFs3 }) {
|
|
10454
10876
|
return readJsonFile(join2(logsDir, `${sanitizeJobId(jobId)}.json`), fs);
|
|
10455
10877
|
}
|
|
10456
|
-
function readLogTail({ logsDir, jobId, lines = 200, fs =
|
|
10878
|
+
function readLogTail({ logsDir, jobId, lines = 200, fs = nodeFs3 }) {
|
|
10457
10879
|
let text;
|
|
10458
10880
|
try {
|
|
10459
10881
|
text = fs.readFileSync(join2(logsDir, `${sanitizeJobId(jobId)}.log`), "utf8");
|
|
10460
10882
|
} catch {
|
|
10461
10883
|
return { missing: true };
|
|
10462
10884
|
}
|
|
10463
|
-
const all = text.split("\n");
|
|
10464
|
-
if (all.length > 0 && all[all.length - 1] === "") all.pop();
|
|
10885
|
+
const all = text.split("\n").filter((line) => line !== "");
|
|
10465
10886
|
const cap = clampLines(lines);
|
|
10466
10887
|
return { lines: all.slice(Math.max(0, all.length - cap)) };
|
|
10467
10888
|
}
|
|
10468
|
-
function readSettingsView({ settingsFile, fs =
|
|
10889
|
+
function readSettingsView({ settingsFile, fs = nodeFs3 }) {
|
|
10469
10890
|
const result = readOverlay(settingsFile, { fs });
|
|
10470
10891
|
if (result.invalid) return { path: settingsFile, invalid: result.invalid };
|
|
10471
10892
|
return { path: settingsFile, overlay: result.overlay };
|
|
10472
10893
|
}
|
|
10473
|
-
function readTriggers({ triggersPath, fs =
|
|
10894
|
+
function readTriggers({ triggersPath, fs = nodeFs3 }) {
|
|
10474
10895
|
let text;
|
|
10475
10896
|
try {
|
|
10476
10897
|
text = fs.readFileSync(triggersPath, "utf8");
|
|
@@ -10534,13 +10955,17 @@ function normalizeTriggerForDisplay(entry) {
|
|
|
10534
10955
|
return { type: "label", any: normalizeSelector(on.any), all: normalizeSelector(on.all), none: normalizeSelector(on.none), flow, command, packages, image, skillsDir, instructions, resume, secrets, secretsProfile, replicas, forge };
|
|
10535
10956
|
case "comment":
|
|
10536
10957
|
return { type: "comment", phrase: typeof on.phrase === "string" ? on.phrase : null, flow, command, packages, image, skillsDir, instructions, resume, secrets, secretsProfile, replicas, forge };
|
|
10537
|
-
case "pull_request":
|
|
10958
|
+
case "pull_request": {
|
|
10959
|
+
const prDisarmed = on.disarmed !== null && typeof on.disarmed === "object" && !Array.isArray(on.disarmed) ? on.disarmed : null;
|
|
10538
10960
|
return {
|
|
10539
10961
|
type: "pull_request",
|
|
10540
10962
|
action: normalizeSelector(on.action),
|
|
10541
10963
|
any: normalizeSelector(on.any),
|
|
10542
10964
|
all: normalizeSelector(on.all),
|
|
10543
10965
|
none: normalizeSelector(on.none),
|
|
10966
|
+
...Number.isInteger(on.number) && on.number >= 1 && { number: on.number },
|
|
10967
|
+
...on.once === true && { once: true },
|
|
10968
|
+
...prDisarmed !== null && { disarmed: prDisarmed },
|
|
10544
10969
|
flow,
|
|
10545
10970
|
command,
|
|
10546
10971
|
packages,
|
|
@@ -10553,6 +10978,34 @@ function normalizeTriggerForDisplay(entry) {
|
|
|
10553
10978
|
replicas,
|
|
10554
10979
|
forge
|
|
10555
10980
|
};
|
|
10981
|
+
}
|
|
10982
|
+
case "issue": {
|
|
10983
|
+
const disarmed = on.disarmed !== null && typeof on.disarmed === "object" && !Array.isArray(on.disarmed) ? on.disarmed : null;
|
|
10984
|
+
return {
|
|
10985
|
+
type: "issue",
|
|
10986
|
+
action: normalizeSelector(on.action),
|
|
10987
|
+
// `null` is the every-item sentinel, this function's own flow/model/phrase convention; the loader
|
|
10988
|
+
// refused anything but an integer >= 1, so the display mirrors rather than re-validates.
|
|
10989
|
+
number: Number.isInteger(on.number) && on.number >= 1 ? on.number : null,
|
|
10990
|
+
// An opt-IN, `=== true` like `resume` above and NOT `!== false`: the rendering mistake that costs
|
|
10991
|
+
// is a one-shot with no marker, never a standing rule with a spurious one.
|
|
10992
|
+
once: on.once === true,
|
|
10993
|
+
// Absent, not null, when unspent: `disarmed` is a mark the worker adds, and a key that is usually
|
|
10994
|
+
// missing reads truer than a fifth null sentinel -- consumers test presence, not value.
|
|
10995
|
+
...disarmed !== null && { disarmed },
|
|
10996
|
+
flow,
|
|
10997
|
+
command,
|
|
10998
|
+
packages,
|
|
10999
|
+
image,
|
|
11000
|
+
skillsDir,
|
|
11001
|
+
instructions,
|
|
11002
|
+
resume,
|
|
11003
|
+
secrets,
|
|
11004
|
+
secretsProfile,
|
|
11005
|
+
replicas,
|
|
11006
|
+
forge
|
|
11007
|
+
};
|
|
11008
|
+
}
|
|
10556
11009
|
default:
|
|
10557
11010
|
return null;
|
|
10558
11011
|
}
|
|
@@ -10561,7 +11014,7 @@ function normalizeSelector(value) {
|
|
|
10561
11014
|
if (!Array.isArray(value)) return [];
|
|
10562
11015
|
return value.filter((member) => typeof member === "string");
|
|
10563
11016
|
}
|
|
10564
|
-
function readStagedPackages({ globalPiDir, fs =
|
|
11017
|
+
function readStagedPackages({ globalPiDir, fs = nodeFs3, readManifest: readManifest2 = readStageManifest } = {}) {
|
|
10565
11018
|
const empty = { stagedAt: null, packages: [] };
|
|
10566
11019
|
if (typeof globalPiDir !== "string" || globalPiDir === "") return empty;
|
|
10567
11020
|
let manifest;
|
|
@@ -10769,7 +11222,7 @@ function readFolderSkills({ folder, exec: exec4 = execFileSync2 } = {}) {
|
|
|
10769
11222
|
}
|
|
10770
11223
|
return { head, skills, truncated: truncated || anyUnread, unreachable: null };
|
|
10771
11224
|
}
|
|
10772
|
-
function readInjectedSkills({ skillsDir, fs =
|
|
11225
|
+
function readInjectedSkills({ skillsDir, fs = nodeFs3 } = {}) {
|
|
10773
11226
|
if (typeof skillsDir !== "string" || skillsDir === "") return { skills: [], truncated: false, unreachable: null };
|
|
10774
11227
|
let names;
|
|
10775
11228
|
try {
|
|
@@ -10792,7 +11245,7 @@ function readInjectedSkills({ skillsDir, fs = nodeFs2 } = {}) {
|
|
|
10792
11245
|
}
|
|
10793
11246
|
return { skills, truncated, unreachable: null };
|
|
10794
11247
|
}
|
|
10795
|
-
function readOverlaySkills({ globalPiDir, fs =
|
|
11248
|
+
function readOverlaySkills({ globalPiDir, fs = nodeFs3 } = {}) {
|
|
10796
11249
|
if (typeof globalPiDir !== "string" || globalPiDir === "") return { skills: [], truncated: false, unreachable: null };
|
|
10797
11250
|
const dir = join2(globalPiDir, "skills");
|
|
10798
11251
|
let names;
|
|
@@ -10816,7 +11269,7 @@ function readOverlaySkills({ globalPiDir, fs = nodeFs2 } = {}) {
|
|
|
10816
11269
|
}
|
|
10817
11270
|
return { skills, truncated, unreachable: null };
|
|
10818
11271
|
}
|
|
10819
|
-
function readStagedSkillsList({ globalPiDir, fs =
|
|
11272
|
+
function readStagedSkillsList({ globalPiDir, fs = nodeFs3, readStaged = readStagedSkills } = {}) {
|
|
10820
11273
|
const empty = { skills: [], unenumerable: [], truncated: false };
|
|
10821
11274
|
if (typeof globalPiDir !== "string" || globalPiDir === "") return empty;
|
|
10822
11275
|
let result;
|
|
@@ -10891,7 +11344,7 @@ function forgeRepoTargets({ records } = {}) {
|
|
|
10891
11344
|
function escapeRegExp2(s) {
|
|
10892
11345
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
10893
11346
|
}
|
|
10894
|
-
function listRunIds({ logsDir, fs =
|
|
11347
|
+
function listRunIds({ logsDir, fs = nodeFs3 }) {
|
|
10895
11348
|
let names;
|
|
10896
11349
|
try {
|
|
10897
11350
|
names = fs.readdirSync(logsDir);
|
|
@@ -10936,8 +11389,8 @@ var init_read_model = __esm({
|
|
|
10936
11389
|
init_runtime_settings();
|
|
10937
11390
|
init_run_history();
|
|
10938
11391
|
init_budget();
|
|
10939
|
-
init_triggers();
|
|
10940
11392
|
init_pause_windows();
|
|
11393
|
+
init_scoped_limits();
|
|
10941
11394
|
init_subscriptions();
|
|
10942
11395
|
init_connection();
|
|
10943
11396
|
init_queue();
|
|
@@ -10948,6 +11401,7 @@ var init_read_model = __esm({
|
|
|
10948
11401
|
init_forges();
|
|
10949
11402
|
init_graph_model();
|
|
10950
11403
|
init_costs();
|
|
11404
|
+
init_triggers_file();
|
|
10951
11405
|
DISPATCH_RUN_TTL_SECONDS = 2 * 60 * 60;
|
|
10952
11406
|
SCAN_WINDOW_MAX_DAYS = 92;
|
|
10953
11407
|
GRAPH_LIMITS = Object.freeze({
|
|
@@ -11066,6 +11520,12 @@ function normalizeModel(model) {
|
|
|
11066
11520
|
package: typeof n.package === "string" ? clip(n.package, 80) : null,
|
|
11067
11521
|
tiersUnknown: Array.isArray(n.tiersUnknown) ? [...new Set(n.tiersUnknown.filter((t) => typeof t === "string").map((t) => clip(t, 40)))].sort().slice(0, 4) : [],
|
|
11068
11522
|
replicas: intOr(n.replicas, null),
|
|
11523
|
+
// One-shot facts (#231): `once` is the armed state, and the disarm mark is narrowed to its
|
|
11524
|
+
// instant alone. The mark's jobId stays server-side on purpose: the page answers "spent, and
|
|
11525
|
+
// when", and provenance beyond that belongs to the operator's own console -- a shareable
|
|
11526
|
+
// file:// page should carry no more of a deployment's run history than the question needs.
|
|
11527
|
+
once: n.once === true,
|
|
11528
|
+
disarmed: n.disarmed !== null && n.disarmed !== void 0 && typeof n.disarmed === "object" && !Array.isArray(n.disarmed) ? { at: typeof n.disarmed.at === "string" ? clip(n.disarmed.at, 40) : Number.isFinite(n.disarmed.at) ? n.disarmed.at : null } : null,
|
|
11069
11529
|
folderKey: typeof n.folderKey === "string" ? n.folderKey : null,
|
|
11070
11530
|
runs: intOr(n.runs, 0),
|
|
11071
11531
|
lastOutcome: typeof n.lastOutcome === "string" ? n.lastOutcome : null,
|
|
@@ -11510,6 +11970,8 @@ function buildTip(n, flags, groupLabel, nowMs) {
|
|
|
11510
11970
|
const nextMs = typeof n.next === "number" ? n.next : typeof n.next === "string" ? Date.parse(n.next) : NaN;
|
|
11511
11971
|
if (Number.isFinite(nextMs) && Number.isFinite(nowMs) && nextMs > nowMs) lines.push(`next ${relSpan(nextMs - nowMs)}`);
|
|
11512
11972
|
}
|
|
11973
|
+
if (n.disarmed !== null) lines.push(`one-shot, spent${n.disarmed.at !== null ? ` ${n.disarmed.at}` : ""}`);
|
|
11974
|
+
else if (n.once) lines.push("one-shot (armed)");
|
|
11513
11975
|
}
|
|
11514
11976
|
if (n.aiTrigger) lines.push("chainable: ai-trigger allow");
|
|
11515
11977
|
if (n.kind === "skill" && n.loops.length > 0) {
|
|
@@ -11540,6 +12002,7 @@ function nodeState(n, flagNames) {
|
|
|
11540
12002
|
}
|
|
11541
12003
|
if (n.kind === "skill-unverified") return { stroke: PAGE_DIM, dash: "10,4", faded: false };
|
|
11542
12004
|
if (n.kind === "skill-not-at-head") return { stroke: PAGE_AMBER, dash: "10,4", faded: false };
|
|
12005
|
+
if (n.kind === "trigger" && n.disarmed !== null) return { stroke: CHIP_STROKE, dash: "8,3", faded: true };
|
|
11543
12006
|
if (flagNames.has("orphan")) return { stroke: CHIP_STROKE, dash: "8,3", faded: true };
|
|
11544
12007
|
return { stroke: CHIP_STROKE, dash: null, faded: false };
|
|
11545
12008
|
}
|
|
@@ -11784,6 +12247,7 @@ var init_graph_html = __esm({
|
|
|
11784
12247
|
label: "#d8bfd8",
|
|
11785
12248
|
comment: "#e7e7ae",
|
|
11786
12249
|
pull_request: "#c0deed",
|
|
12250
|
+
issue: "#c7e9c0",
|
|
11787
12251
|
skill: "#fdd0a2"
|
|
11788
12252
|
});
|
|
11789
12253
|
STATUS_COMPLETED = "#3fb950";
|
|
@@ -11813,6 +12277,7 @@ var init_graph_html = __esm({
|
|
|
11813
12277
|
label: "\u25C8",
|
|
11814
12278
|
comment: "\u275D",
|
|
11815
12279
|
pull_request: "\u21C4",
|
|
12280
|
+
issue: "\u25C9",
|
|
11816
12281
|
skill: "\u0192",
|
|
11817
12282
|
"skill-missing": "!",
|
|
11818
12283
|
"skill-unverified": "?",
|
|
@@ -12452,7 +12917,28 @@ function normBudget(v) {
|
|
|
12452
12917
|
cap: Number.isInteger(t.cap) && t.cap > 0 ? t.cap : null,
|
|
12453
12918
|
state: BUDGET_STATES.includes(t.state) ? t.state : "ok",
|
|
12454
12919
|
maxTokens: Number.isInteger(t.maxTokens) && t.maxTokens > 0 ? t.maxTokens : null
|
|
12455
|
-
}
|
|
12920
|
+
},
|
|
12921
|
+
// The scoped rows (issue #242): a pre-1.5 payload simply lacks the key and renders nothing.
|
|
12922
|
+
scoped: Array.isArray(v.scoped) ? v.scoped.map(normScopedRow).filter((r) => r !== null) : [],
|
|
12923
|
+
scopedInvalid: typeof v.scopedInvalid === "string" && v.scopedInvalid !== "" ? clip3(v.scopedInvalid, 160) : null
|
|
12924
|
+
};
|
|
12925
|
+
}
|
|
12926
|
+
function normScopedRow(v) {
|
|
12927
|
+
if (v === null || typeof v !== "object" || typeof v.scope !== "string" || v.scope === "") return null;
|
|
12928
|
+
const win = (w) => {
|
|
12929
|
+
if (w === null || w === void 0 || typeof w !== "object") return null;
|
|
12930
|
+
return {
|
|
12931
|
+
used: Number.isInteger(w.used) && w.used >= 0 ? w.used : null,
|
|
12932
|
+
cap: Number.isInteger(w.cap) && w.cap > 0 ? w.cap : null,
|
|
12933
|
+
state: BUDGET_STATES.includes(w.state) ? w.state : "ok"
|
|
12934
|
+
};
|
|
12935
|
+
};
|
|
12936
|
+
return {
|
|
12937
|
+
scope: clip3(v.scope, 60),
|
|
12938
|
+
day: win(v.day),
|
|
12939
|
+
week: win(v.week),
|
|
12940
|
+
month: win(v.month),
|
|
12941
|
+
concurrent: Number.isInteger(v.concurrent) && v.concurrent > 0 ? v.concurrent : null
|
|
12456
12942
|
};
|
|
12457
12943
|
}
|
|
12458
12944
|
function normSpendMap(v) {
|
|
@@ -12824,6 +13310,24 @@ function budgetSectionHtml(nb) {
|
|
|
12824
13310
|
if (nb.softHoldPct !== null) {
|
|
12825
13311
|
rows.push(`<div class="dim small">soft-hold band: ${fmt(nb.softHoldPct)}% of each cap (the amber tick)</div>`);
|
|
12826
13312
|
}
|
|
13313
|
+
if (nb.scopedInvalid !== null) {
|
|
13314
|
+
rows.push(`<div class="dim small">scoped limits: file invalid (${escapeHtml(nb.scopedInvalid)})</div>`);
|
|
13315
|
+
} else if (nb.scoped.length > 0) {
|
|
13316
|
+
rows.push('<div class="dim small">scoped limits (scoped-limits.json)</div>');
|
|
13317
|
+
for (const s of nb.scoped) {
|
|
13318
|
+
const bits = [];
|
|
13319
|
+
let worst = "ok";
|
|
13320
|
+
for (const key of ["day", "week", "month"]) {
|
|
13321
|
+
const w = s[key];
|
|
13322
|
+
if (w === null || w.cap === null) continue;
|
|
13323
|
+
bits.push(`${key} used ${w.used !== null ? fmt(w.used) : "?"} / cap ${fmt(w.cap)}`);
|
|
13324
|
+
if (w.state !== "ok") worst = w.state;
|
|
13325
|
+
}
|
|
13326
|
+
if (s.concurrent !== null) bits.push(`concurrent \u2264${fmt(s.concurrent)} (config; in-flight not shown)`);
|
|
13327
|
+
rows.push(`<div class="row"><span class="wl">${escapeHtml(s.scope)}</span><span>${escapeHtml(bits.join(" \xB7 "))}</span>${worst !== "ok" ? stateWord(worst) : ""}</div>`);
|
|
13328
|
+
}
|
|
13329
|
+
rows.push('<div class="lever">scoped: dispatch_limit_add/edit/delete \xB7 or press m in the /dispatch panel</div>');
|
|
13330
|
+
}
|
|
12827
13331
|
rows.push('<div class="lever">adjust: /dispatch set dailyCap|weeklyCap|monthlyCap|dailyTokenCap|softHoldPct <n> \xB7 or press s in the /dispatch panel</div>');
|
|
12828
13332
|
return `<div id="budget">${rows.join("")}</div>`;
|
|
12829
13333
|
}
|
|
@@ -13227,9 +13731,9 @@ var init_open_browser = __esm({
|
|
|
13227
13731
|
});
|
|
13228
13732
|
|
|
13229
13733
|
// src/deployment-pointer.mjs
|
|
13230
|
-
import * as
|
|
13734
|
+
import * as nodeFs4 from "node:fs";
|
|
13231
13735
|
import { homedir } from "node:os";
|
|
13232
|
-
import { join as join3, isAbsolute } from "node:path";
|
|
13736
|
+
import { join as join3, isAbsolute as isAbsolute2 } from "node:path";
|
|
13233
13737
|
function pointerPath(env = process.env) {
|
|
13234
13738
|
return env.PI_DISPATCH_DEPLOYMENT_FILE || join3(env.PI_CODING_AGENT_DIR || join3(homedir(), ".pi", "agent"), POINTER_BASENAME);
|
|
13235
13739
|
}
|
|
@@ -13243,7 +13747,7 @@ function normalizePointer(raw) {
|
|
|
13243
13747
|
if (raw.version > POINTER_VERSION) {
|
|
13244
13748
|
return { ignored: `version ${raw.version} is newer than the supported version ${POINTER_VERSION}` };
|
|
13245
13749
|
}
|
|
13246
|
-
if (typeof raw.deploymentDir !== "string" || !
|
|
13750
|
+
if (typeof raw.deploymentDir !== "string" || !isAbsolute2(raw.deploymentDir)) {
|
|
13247
13751
|
return { ignored: "deploymentDir must be an absolute path" };
|
|
13248
13752
|
}
|
|
13249
13753
|
const rawEnv = raw.env === void 0 ? {} : raw.env;
|
|
@@ -13254,12 +13758,12 @@ function normalizePointer(raw) {
|
|
|
13254
13758
|
for (const key of POINTER_ENV_ALLOWLIST) {
|
|
13255
13759
|
const value = rawEnv[key];
|
|
13256
13760
|
if (typeof value !== "string" || value === "") continue;
|
|
13257
|
-
if (key !== "VALKEY_URL" && !
|
|
13761
|
+
if (key !== "VALKEY_URL" && !isAbsolute2(value)) continue;
|
|
13258
13762
|
env[key] = value;
|
|
13259
13763
|
}
|
|
13260
13764
|
return { pointer: { version: raw.version, deploymentDir: raw.deploymentDir, env } };
|
|
13261
13765
|
}
|
|
13262
|
-
function readPointer({ path = pointerPath(), fs =
|
|
13766
|
+
function readPointer({ path = pointerPath(), fs = nodeFs4 } = {}) {
|
|
13263
13767
|
let text;
|
|
13264
13768
|
try {
|
|
13265
13769
|
text = fs.readFileSync(path, "utf8");
|
|
@@ -13292,12 +13796,12 @@ function layerPointer(env, fs) {
|
|
|
13292
13796
|
}
|
|
13293
13797
|
return { applied };
|
|
13294
13798
|
}
|
|
13295
|
-
function applyDeploymentPointer(env = process.env, { fs =
|
|
13799
|
+
function applyDeploymentPointer(env = process.env, { fs = nodeFs4 } = {}) {
|
|
13296
13800
|
if (appliedOnce) return { applied: [] };
|
|
13297
13801
|
appliedOnce = true;
|
|
13298
13802
|
return layerPointer(env, fs);
|
|
13299
13803
|
}
|
|
13300
|
-
function reapplyDeploymentPointer(env = process.env, { fs =
|
|
13804
|
+
function reapplyDeploymentPointer(env = process.env, { fs = nodeFs4 } = {}) {
|
|
13301
13805
|
appliedOnce = true;
|
|
13302
13806
|
return layerPointer(env, fs);
|
|
13303
13807
|
}
|
|
@@ -13306,7 +13810,7 @@ function takePointerNotice() {
|
|
|
13306
13810
|
notice = void 0;
|
|
13307
13811
|
return n;
|
|
13308
13812
|
}
|
|
13309
|
-
function writePointer({ path = pointerPath(), pointer, fs =
|
|
13813
|
+
function writePointer({ path = pointerPath(), pointer, fs = nodeFs4 } = {}) {
|
|
13310
13814
|
const res = normalizePointer(pointer);
|
|
13311
13815
|
if (res.ignored) return { invalid: res.ignored };
|
|
13312
13816
|
const tmp = `${path}.tmp`;
|
|
@@ -13325,6 +13829,7 @@ var init_deployment_pointer = __esm({
|
|
|
13325
13829
|
"PI_SETTINGS_FILE",
|
|
13326
13830
|
"PI_TRIGGERS_FILE",
|
|
13327
13831
|
"PI_PAUSE_WINDOWS_FILE",
|
|
13832
|
+
"PI_SCOPED_LIMITS_FILE",
|
|
13328
13833
|
"PI_SUBSCRIPTIONS_FILE"
|
|
13329
13834
|
]);
|
|
13330
13835
|
POINTER_BASENAME = "pi-dispatch-deployment.json";
|
|
@@ -38869,7 +39374,7 @@ var init_pricing = __esm({
|
|
|
38869
39374
|
|
|
38870
39375
|
// ../worker/src/sandbox-store.mjs
|
|
38871
39376
|
import { lstatSync, mkdirSync, readFileSync as readFileSync3, readdirSync as readdirSync2, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
38872
|
-
import { isAbsolute as
|
|
39377
|
+
import { isAbsolute as isAbsolute3, join as join5, relative } from "node:path";
|
|
38873
39378
|
function readManifest({ sandboxDir, jobId, fs = defaultFs }) {
|
|
38874
39379
|
if (!sandboxDir || jobId === void 0 || jobId === null) return null;
|
|
38875
39380
|
const dir = join5(sandboxDir, sanitizeJobId(jobId));
|
|
@@ -38942,10 +39447,10 @@ function resolveSandbox({ jobId, sandboxDir, retentionHours, publish = [], fs, f
|
|
|
38942
39447
|
return { manifest, name: sandboxContainerName(jobId), publish };
|
|
38943
39448
|
}
|
|
38944
39449
|
function launchSandbox({ args, spawnFn = spawn }) {
|
|
38945
|
-
return new Promise((
|
|
39450
|
+
return new Promise((resolve3) => {
|
|
38946
39451
|
const child = spawnFn("docker", args, { stdio: "inherit" });
|
|
38947
|
-
child.on("error", (err) =>
|
|
38948
|
-
child.on("close", (code) =>
|
|
39452
|
+
child.on("error", (err) => resolve3({ code: null, error: err }));
|
|
39453
|
+
child.on("close", (code) => resolve3({ code }));
|
|
38949
39454
|
});
|
|
38950
39455
|
}
|
|
38951
39456
|
var exec3, SANDBOX_NAME_PREFIX;
|
|
@@ -39000,6 +39505,24 @@ function renderBudget({ budget, settings: settings2 } = {}) {
|
|
|
39000
39505
|
if (pct !== null) lines.push(` soft-hold band: ${pct}%`);
|
|
39001
39506
|
return lines.join("\n");
|
|
39002
39507
|
}
|
|
39508
|
+
function renderScopedLimits({ limits, scopedBudget } = {}) {
|
|
39509
|
+
if (limits?.invalid) return `Scoped limits: file invalid (${limits.invalid})`;
|
|
39510
|
+
const list = Array.isArray(limits?.limits) ? limits.limits : [];
|
|
39511
|
+
if (list.length === 0) return null;
|
|
39512
|
+
const lines = ["Scoped limits:"];
|
|
39513
|
+
list.forEach((l, i) => {
|
|
39514
|
+
const used = scopedBudget?.rows?.[i] ?? null;
|
|
39515
|
+
const bits = [];
|
|
39516
|
+
for (const key of ["day", "week", "month"]) {
|
|
39517
|
+
if (!Number.isInteger(l[key])) continue;
|
|
39518
|
+
const u = used && Number.isFinite(used[key]) ? used[key] : "-";
|
|
39519
|
+
bits.push(`${key} ${u}/${l[key]}`);
|
|
39520
|
+
}
|
|
39521
|
+
if (Number.isInteger(l.concurrent)) bits.push(`<=${l.concurrent} at once`);
|
|
39522
|
+
lines.push(` ${l.scope}: ${bits.join(" \xB7 ")}`);
|
|
39523
|
+
});
|
|
39524
|
+
return lines.join("\n");
|
|
39525
|
+
}
|
|
39003
39526
|
function capLabel(cap, reserved, pct) {
|
|
39004
39527
|
if (!Number.isInteger(cap)) return "unknown (worker env/default)";
|
|
39005
39528
|
const state = windowState(reserved, cap, pct);
|
|
@@ -39039,6 +39562,7 @@ function triggerLine(t) {
|
|
|
39039
39562
|
const res = t?.resume === true ? " [resume]" : "";
|
|
39040
39563
|
const rep = t?.replicas > 1 ? ` [x${t.replicas}]` : "";
|
|
39041
39564
|
const sec = t?.secrets > 0 ? ` [secrets ${t.secrets}${t.secretsProfile ? ` via ${t.secretsProfile}` : ""}]` : "";
|
|
39565
|
+
const shot = t?.disarmed ? " [spent]" : t?.once === true ? " [once]" : "";
|
|
39042
39566
|
switch (t?.type) {
|
|
39043
39567
|
case "cron":
|
|
39044
39568
|
return `cron ${t.id ?? "-"} ${t.pattern ?? "-"} \u2192 ${t.folder ?? "-"}/${flow}${forge}${pkgs}${img}${skl}${ins}${res}${rep}${sec}`;
|
|
@@ -39049,7 +39573,13 @@ function triggerLine(t) {
|
|
|
39049
39573
|
case "pull_request": {
|
|
39050
39574
|
const clauses = ruleClauses(t);
|
|
39051
39575
|
const action = `action[${(t.action ?? []).join(",")}]`;
|
|
39052
|
-
|
|
39576
|
+
const num = Number.isInteger(t.number) ? ` #${t.number}` : "";
|
|
39577
|
+
return `pull_request ${action}${num}${clauses ? ` ${clauses}` : ""} \u2192 ${flow}${forge}${pkgs}${img}${skl}${ins}${res}${rep}${sec}${shot}`;
|
|
39578
|
+
}
|
|
39579
|
+
case "issue": {
|
|
39580
|
+
const action = `action[${(t.action ?? []).join(",")}]`;
|
|
39581
|
+
const num = Number.isInteger(t.number) ? ` #${t.number}` : "";
|
|
39582
|
+
return `issue ${action}${num} \u2192 ${flow}${forge}${pkgs}${img}${skl}${ins}${res}${rep}${sec}${shot}`;
|
|
39053
39583
|
}
|
|
39054
39584
|
default:
|
|
39055
39585
|
return "(unknown trigger)";
|
|
@@ -39360,7 +39890,25 @@ function createDashboardDeps(paths) {
|
|
|
39360
39890
|
redis.hgetall(STALL_KEY).catch(() => ({}))
|
|
39361
39891
|
]);
|
|
39362
39892
|
const workers = Array.isArray(workerList) && workerList.length > 0 ? workerList.length : "unknown";
|
|
39893
|
+
const scopedLimits = readScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath });
|
|
39894
|
+
const limitRows = Array.isArray(scopedLimits?.limits) ? scopedLimits.limits : [];
|
|
39895
|
+
const scopedNow = /* @__PURE__ */ new Date();
|
|
39896
|
+
const scopedBudget = {
|
|
39897
|
+
rows: await Promise.all(
|
|
39898
|
+
limitRows.map(async (l) => {
|
|
39899
|
+
const prefix = scopeKeyPrefix(l.scope);
|
|
39900
|
+
const cell2 = (key) => redis.get(key).then((v) => Number(v ?? 0), () => null);
|
|
39901
|
+
const row = {};
|
|
39902
|
+
if (Number.isInteger(l.day)) row.day = await cell2(dayKey(scopedNow, prefix));
|
|
39903
|
+
if (Number.isInteger(l.week)) row.week = await cell2(weekKey(scopedNow, prefix));
|
|
39904
|
+
if (Number.isInteger(l.month)) row.month = await cell2(monthKey(scopedNow, prefix));
|
|
39905
|
+
return row;
|
|
39906
|
+
})
|
|
39907
|
+
)
|
|
39908
|
+
};
|
|
39363
39909
|
return {
|
|
39910
|
+
scopedLimits,
|
|
39911
|
+
scopedBudget,
|
|
39364
39912
|
queue: { pausedState, counts, workers },
|
|
39365
39913
|
budget: { day: Number(dayRaw ?? 0), week: Number(weekRaw ?? 0), month: Number(monthRaw ?? 0), tokensToday: Number(tokenRaw ?? 0) },
|
|
39366
39914
|
schedulers: mapSchedulers(schedulerList, Date.now()),
|
|
@@ -39721,6 +40269,10 @@ function makeDashboard({
|
|
|
39721
40269
|
void dispose().finally(() => done({ action: "managePauses" }));
|
|
39722
40270
|
return;
|
|
39723
40271
|
}
|
|
40272
|
+
if (data === "m" || data === "M") {
|
|
40273
|
+
void dispose().finally(() => done({ action: "manageLimits" }));
|
|
40274
|
+
return;
|
|
40275
|
+
}
|
|
39724
40276
|
if (matchesKey(data, "up")) {
|
|
39725
40277
|
selected = Math.max(0, selected - 1);
|
|
39726
40278
|
tui?.requestRender?.();
|
|
@@ -39824,6 +40376,8 @@ function renderPanel(snapshot, width, state, styler) {
|
|
|
39824
40376
|
{ title: "RUNS", lines: renderRunList(buildRows(snapshot, runSort), selected, 24) },
|
|
39825
40377
|
{ title: "SETTINGS", lines: toLines(renderSettingsView(snapshot.settings)) }
|
|
39826
40378
|
];
|
|
40379
|
+
const scopedText = renderScopedLimits({ limits: snapshot.scopedLimits, scopedBudget: snapshot.scopedBudget });
|
|
40380
|
+
if (scopedText) sections.splice(2, 0, { title: "SCOPED LIMITS", lines: toLines(scopedText) });
|
|
39827
40381
|
const plain = [title];
|
|
39828
40382
|
for (const section of sections) plain.push(section.title, ...section.lines);
|
|
39829
40383
|
plain.push(KEY_HINTS);
|
|
@@ -39851,6 +40405,7 @@ function collapseKeys(sections, availableRows, focus, baseRows) {
|
|
|
39851
40405
|
function buildListLines(snapshot, selected, inner, styler, runSort = "time", availableRows = null) {
|
|
39852
40406
|
const trg = triggerLines(snapshot, selected, inner, styler);
|
|
39853
40407
|
const pw = pauseLines(snapshot.pauseWindows, inner, styler);
|
|
40408
|
+
const sl = limitLines(snapshot.scopedLimits, snapshot.scopedBudget, inner, styler);
|
|
39854
40409
|
const runRows = buildRows(snapshot, runSort).slice(trg.count);
|
|
39855
40410
|
const runCount = Array.isArray(snapshot.runs) ? snapshot.runs.length : 0;
|
|
39856
40411
|
const sections = [
|
|
@@ -39858,6 +40413,10 @@ function buildListLines(snapshot, selected, inner, styler, runSort = "time", ava
|
|
|
39858
40413
|
{ key: "spend", head: ["spend & limits", "jobs & tokens/day \xB7 s set"], body: spendLines(snapshot.budget, snapshot.settings, inner, styler), priority: 4, viewKey: "s" },
|
|
39859
40414
|
{ key: "triggers", head: ["triggers", `${trg.count} standing \xB7 a add \xB7 \u21B5 open`], body: trg.lines, priority: 3, viewKey: "tab" },
|
|
39860
40415
|
{ key: "pauses", head: ["pause windows", `${pw.count} \xB7 w manage`], body: pw.lines, priority: 1, viewKey: "w" },
|
|
40416
|
+
// Priority 0: the section an operator acts on least often from the panel folds FIRST under the
|
|
40417
|
+
// collapse budget. The key hint lives in this divider, not the footer -- the footer's width
|
|
40418
|
+
// arithmetic has no headroom for another hint (its own comment), the s/o/Tab precedent.
|
|
40419
|
+
{ key: "limits", head: ["scoped limits", `${sl.count} \xB7 m manage`], body: sl.lines, priority: 0, viewKey: "m" },
|
|
39861
40420
|
{ key: "runs", head: ["runs", `last ${runCount} \xB7 o ${runSort}`], body: runLines(runRows, selected - trg.count, inner, styler) },
|
|
39862
40421
|
{ key: "settings", head: ["settings", "s edit"], body: settingsLines(snapshot.settings, inner, styler), priority: 2, viewKey: "s" }
|
|
39863
40422
|
];
|
|
@@ -39892,7 +40451,8 @@ function statusHeader(queue, inner, styler) {
|
|
|
39892
40451
|
const dot = styler.fg(stateColor, "\u25CF");
|
|
39893
40452
|
const word = styler.bold(styler.fg(stateColor, running ? "RUNNING" : "PAUSED"));
|
|
39894
40453
|
const sep2 = styler.fg("dim", " \xB7 ");
|
|
39895
|
-
const
|
|
40454
|
+
const delayed = Number(c.delayed ?? 0);
|
|
40455
|
+
const vitals = `${c.waiting ?? 0} waiting` + sep2 + `${c.active ?? 0} active` + sep2 + (delayed > 0 ? `${delayed} delayed` + sep2 : "") + (failed > 0 ? styler.fg("error", `${failed} failed`) : `${failed} failed`) + sep2 + `${queue.workers ?? "?"} workers`;
|
|
39896
40456
|
const clock = (/* @__PURE__ */ new Date()).toISOString().slice(11, 19);
|
|
39897
40457
|
const left = `${dot} ${word} ${vitals}`;
|
|
39898
40458
|
const gap = inner - styler.visibleLen(left) - clock.length;
|
|
@@ -39978,8 +40538,9 @@ function triggerRow(t, sel, inner, styler, sched = null) {
|
|
|
39978
40538
|
const img = t?.image ? " " + styler.fg("accent", `[${t.image}]`) : "";
|
|
39979
40539
|
const res = t?.resume === true ? " " + styler.fg("warning", "[resume]") : "";
|
|
39980
40540
|
const rep = t?.replicas > 1 ? " " + styler.fg("warning", `[x${t.replicas}]`) : "";
|
|
40541
|
+
const shot = t?.disarmed ? " " + styler.fg("dim", "[spent]") : t?.once === true ? " " + styler.fg("accent", "[once]") : "";
|
|
39981
40542
|
const health = sched && (sched.overdueMs || sched.stallMax > 0 && sched.stalls >= sched.stallMax) ? " " + styler.fg("warning", sched.overdueMs ? "\u26A0 overdue" : "\u26A0 stalled") : "";
|
|
39982
|
-
return fitLine(`${cursor} ${badge} ${matchColored(t, styler)} ${targetColored(t, styler)}${pkgs}${img}${res}${rep}${health}`, inner, styler);
|
|
40543
|
+
return fitLine(`${cursor} ${badge} ${matchColored(t, styler)} ${targetColored(t, styler)}${pkgs}${img}${res}${rep}${shot}${health}`, inner, styler);
|
|
39983
40544
|
}
|
|
39984
40545
|
function matchColored(t, styler) {
|
|
39985
40546
|
switch (t?.type) {
|
|
@@ -39987,10 +40548,15 @@ function matchColored(t, styler) {
|
|
|
39987
40548
|
return styler.fg("text", `${t.id ?? "-"} ${t.pattern ?? "-"}`);
|
|
39988
40549
|
case "comment":
|
|
39989
40550
|
return styler.fg("text", `"${t.phrase ?? "-"}"`);
|
|
40551
|
+
case "issue": {
|
|
40552
|
+
const num = Number.isInteger(t.number) ? " " + styler.fg("success", `#${t.number}`) : "";
|
|
40553
|
+
return styler.fg("muted", `[${(t.action ?? []).join(",")}]`) + num;
|
|
40554
|
+
}
|
|
39990
40555
|
case "label":
|
|
39991
40556
|
case "pull_request": {
|
|
39992
40557
|
const parts = [];
|
|
39993
40558
|
if (t.type === "pull_request") parts.push(styler.fg("muted", `[${(t.action ?? []).join(",")}]`));
|
|
40559
|
+
if (t.type === "pull_request" && Number.isInteger(t.number)) parts.push(styler.fg("success", `#${t.number}`));
|
|
39994
40560
|
for (const x of t.any ?? []) parts.push(styler.fg("success", x));
|
|
39995
40561
|
for (const x of t.all ?? []) parts.push(styler.fg("success", `+${x}`));
|
|
39996
40562
|
for (const x of t.none ?? []) parts.push(styler.fg("error", `!${x}`));
|
|
@@ -40041,6 +40607,39 @@ function pauseRow(w, now, inner, styler) {
|
|
|
40041
40607
|
if (until) bits.push(styler.fg("warning", `resumes in ${humanizeMs(until - now) || "<1m"}`));
|
|
40042
40608
|
return fitLine(bits.join(styler.fg("dim", " ")), inner, styler);
|
|
40043
40609
|
}
|
|
40610
|
+
function limitLines(scopedLimits, scopedBudget, inner, styler) {
|
|
40611
|
+
const lines = [];
|
|
40612
|
+
if (scopedLimits && scopedLimits.missing) {
|
|
40613
|
+
lines.push(styler.cell("(no scoped limits \xB7 m to manage)", inner, { color: "dim" }));
|
|
40614
|
+
return { count: 0, lines };
|
|
40615
|
+
}
|
|
40616
|
+
if (scopedLimits && scopedLimits.invalid) {
|
|
40617
|
+
lines.push(styler.cell(`(scoped-limits file invalid: ${scopedLimits.invalid})`, inner, { color: "error" }));
|
|
40618
|
+
return { count: 0, lines };
|
|
40619
|
+
}
|
|
40620
|
+
const list = (scopedLimits && scopedLimits.limits) ?? [];
|
|
40621
|
+
if (list.length === 0) {
|
|
40622
|
+
lines.push(styler.cell("(no scoped limits \xB7 m to manage)", inner, { color: "dim" }));
|
|
40623
|
+
return { count: 0, lines };
|
|
40624
|
+
}
|
|
40625
|
+
list.forEach((l, i) => lines.push(limitRow(l, scopedBudget?.rows?.[i] ?? null, inner, styler)));
|
|
40626
|
+
return { count: list.length, lines };
|
|
40627
|
+
}
|
|
40628
|
+
function limitRow(l, used, inner, styler) {
|
|
40629
|
+
let atCap = false;
|
|
40630
|
+
const windows = [];
|
|
40631
|
+
for (const key of ["day", "week", "month"]) {
|
|
40632
|
+
if (!Number.isInteger(l[key])) continue;
|
|
40633
|
+
const u = used && Number.isFinite(used[key]) ? used[key] : null;
|
|
40634
|
+
const over = u !== null && u >= l[key];
|
|
40635
|
+
if (over) atCap = true;
|
|
40636
|
+
windows.push(styler.fg(over ? "warning" : "text", `${key} ${u ?? "-"}/${l[key]}`));
|
|
40637
|
+
}
|
|
40638
|
+
const dot = atCap ? styler.fg("warning", "\u25CF") : styler.fg("dim", "\u25CB");
|
|
40639
|
+
const bits = [`${dot} ${styler.fg("accent", l.scope ?? "-")}`, ...windows];
|
|
40640
|
+
if (Number.isInteger(l.concurrent)) bits.push(styler.fg("muted", `\u2264${l.concurrent} at once`));
|
|
40641
|
+
return fitLine(bits.join(styler.fg("dim", " ")), inner, styler);
|
|
40642
|
+
}
|
|
40044
40643
|
function runLines(rows, selected, inner, styler) {
|
|
40045
40644
|
if (!Array.isArray(rows) || rows.length === 0) return [styler.cell("(no runs)", inner, { color: "dim" })];
|
|
40046
40645
|
const { top, count } = runsWindow(rows.length, selected);
|
|
@@ -40125,11 +40724,17 @@ function renderTriggerDetail(t, inner, styler, sched = null, staged = null) {
|
|
|
40125
40724
|
}
|
|
40126
40725
|
} else if (t.type === "label" || t.type === "pull_request") {
|
|
40127
40726
|
if (t.type === "pull_request") out.push(kv("PR actions", (t.action ?? []).join(", ") || "-"));
|
|
40727
|
+
if (t.type === "pull_request" && Number.isInteger(t.number)) out.push(kv("item", `#${t.number}`, "success"));
|
|
40728
|
+
if (t.once === true) out.push(kv("one-shot", t.disarmed ? `spent ${t.disarmed.at ?? "-"}${t.disarmed.jobId ? ` by ${t.disarmed.jobId}` : ""}` : "armed", t.disarmed ? "dim" : "accent"));
|
|
40128
40729
|
out.push(kv("any of", (t.any ?? []).join(" \xB7 ") || "-", "success"));
|
|
40129
40730
|
out.push(kv("all of", (t.all ?? []).join(" \xB7 ") || "-", "success"));
|
|
40130
40731
|
out.push(kv("none of", (t.none ?? []).join(" \xB7 ") || "-", "error"));
|
|
40131
40732
|
} else if (t.type === "comment") {
|
|
40132
40733
|
out.push(kv("phrase", `"${t.phrase ?? "-"}"`));
|
|
40734
|
+
} else if (t.type === "issue") {
|
|
40735
|
+
out.push(kv("issue actions", (t.action ?? []).join(", ") || "-"));
|
|
40736
|
+
if (Number.isInteger(t.number)) out.push(kv("item", `#${t.number}`, "success"));
|
|
40737
|
+
if (t.once === true) out.push(kv("one-shot", t.disarmed ? `spent ${t.disarmed.at ?? "-"}${t.disarmed.jobId ? ` by ${t.disarmed.jobId}` : ""}` : "armed", t.disarmed ? "dim" : "accent"));
|
|
40133
40738
|
}
|
|
40134
40739
|
blank();
|
|
40135
40740
|
section("runs");
|
|
@@ -40193,33 +40798,38 @@ function trustModel(t) {
|
|
|
40193
40798
|
}
|
|
40194
40799
|
const subject = t?.type === "comment" ? "adversarial comment text" : "adversarial issue/PR text";
|
|
40195
40800
|
const where = `lives in the receiver \xB7 task is ${subject}`;
|
|
40801
|
+
const closeCapable = t?.type === "issue" || t?.type === "pull_request" && (t?.action ?? []).some((a) => a === "closed" || a === "close");
|
|
40802
|
+
const oneShot = (lines) => closeCapable && (t?.once === true || t?.disarmed) ? [...lines, "one-shot: the WORKER writes on.disarmed into triggers.json, strictly after the run record \u2014 spent always has its run"] : lines;
|
|
40196
40803
|
switch (t?.forge) {
|
|
40197
40804
|
case "gitlab":
|
|
40198
|
-
return [
|
|
40805
|
+
return oneShot([
|
|
40199
40806
|
"authorized by the actor's resolved project access level (>= Developer) \u2014 a label is NOT approval here",
|
|
40200
40807
|
"HMAC over the body (19.0+) or a bare X-Gitlab-Token \xB7 dedup by webhook-id",
|
|
40201
40808
|
where
|
|
40202
|
-
];
|
|
40809
|
+
]);
|
|
40203
40810
|
case "forgejo":
|
|
40204
|
-
return [
|
|
40811
|
+
return oneShot([
|
|
40205
40812
|
"authorized by the actor's resolved repository permission (admin|write), on every trigger type",
|
|
40206
40813
|
"HMAC over the body (GitHub-compatible) \xB7 dedup by X-GitHub-Delivery GUID",
|
|
40207
40814
|
where
|
|
40208
|
-
];
|
|
40815
|
+
]);
|
|
40209
40816
|
case "azure":
|
|
40210
|
-
return [
|
|
40817
|
+
return oneShot([
|
|
40211
40818
|
"authorized by the actor's resolved project membership \u2014 work items name the actor only by email",
|
|
40212
40819
|
"NO HMAC: a shared secret that covers no bytes \xB7 dedup by the payload's own id (OQ-015)",
|
|
40213
40820
|
where
|
|
40214
|
-
];
|
|
40821
|
+
]);
|
|
40215
40822
|
default:
|
|
40216
|
-
return [
|
|
40823
|
+
return oneShot([
|
|
40217
40824
|
// A review trigger reads the REVIEWER's author_association, not the PR author's (issue #66), so it
|
|
40218
40825
|
// cannot share the generic line: an operator reading "author gate" would picture the wrong person.
|
|
40219
|
-
|
|
40826
|
+
// A close rule reads the CLOSER's resolved permission (issue #231), for the inversion's sharper
|
|
40827
|
+
// sibling: GitHub sends NO association for a close at all, so the collaborator-label line would
|
|
40828
|
+
// name a gate this rule never runs — and a wrong trust model is worse than an absent one.
|
|
40829
|
+
closeCapable ? "authorized by the CLOSER's resolved repository permission (admin|write) + HMAC \u2014 a close names no author_association" : t?.type === "comment" ? "authorized by a collaborator comment (author_association) + HMAC" : t?.type === "pull_request" && (t?.action ?? []).includes("review_submitted") ? "authorized by the REVIEWER's author_association (not the PR author's) + HMAC" : "authorized by a collaborator's label + HMAC webhook + author gate",
|
|
40220
40830
|
"dedup by X-GitHub-Delivery GUID (redelivery-safe)",
|
|
40221
40831
|
where
|
|
40222
|
-
];
|
|
40832
|
+
]);
|
|
40223
40833
|
}
|
|
40224
40834
|
}
|
|
40225
40835
|
function triggerDetailHints(inner, styler, pendingDelete = false) {
|
|
@@ -40447,6 +41057,7 @@ var init_dashboard = __esm({
|
|
|
40447
41057
|
init_scheduler_stall_guard();
|
|
40448
41058
|
init_pause_windows();
|
|
40449
41059
|
init_read_model();
|
|
41060
|
+
init_scoped_limits();
|
|
40450
41061
|
init_render();
|
|
40451
41062
|
init_keys2();
|
|
40452
41063
|
init_panel();
|
|
@@ -40460,7 +41071,7 @@ var init_dashboard = __esm({
|
|
|
40460
41071
|
TAIL_VIEWPORT = 20;
|
|
40461
41072
|
MIN_WIDTH2 = 8;
|
|
40462
41073
|
DRILL_WIDTH = 70;
|
|
40463
|
-
KIND_COLOR = { cron: "accent", label: "syntaxType", comment: "syntaxKeyword", pull_request: "syntaxFunction" };
|
|
41074
|
+
KIND_COLOR = { cron: "accent", label: "syntaxType", comment: "syntaxKeyword", pull_request: "syntaxFunction", issue: "syntaxString" };
|
|
40464
41075
|
KIND_WIDTH = 13;
|
|
40465
41076
|
FRAME_CHROME_ROWS = 4;
|
|
40466
41077
|
}
|
|
@@ -40485,7 +41096,7 @@ __export(setup_wizard_exports, {
|
|
|
40485
41096
|
runSetupWizard: () => runSetupWizard,
|
|
40486
41097
|
runtimeDirFor: () => runtimeDirFor
|
|
40487
41098
|
});
|
|
40488
|
-
import * as
|
|
41099
|
+
import * as nodeFs5 from "node:fs";
|
|
40489
41100
|
import { execFileSync as execFileSync3, spawn as nodeSpawn2 } from "node:child_process";
|
|
40490
41101
|
import { homedir as homedir2 } from "node:os";
|
|
40491
41102
|
import { join as join6 } from "node:path";
|
|
@@ -40501,7 +41112,7 @@ async function defaultProbeQueue(url) {
|
|
|
40501
41112
|
async function detectDeployment({
|
|
40502
41113
|
env = process.env,
|
|
40503
41114
|
cwd = process.cwd(),
|
|
40504
|
-
fs =
|
|
41115
|
+
fs = nodeFs5,
|
|
40505
41116
|
probeQueue = defaultProbeQueue
|
|
40506
41117
|
} = {}) {
|
|
40507
41118
|
const pointerRes = readPointer({ path: pointerPath(env), fs });
|
|
@@ -40535,16 +41146,16 @@ async function runAttached(ctx, { title, argv0, args, cwd, env, shell, spawnFn =
|
|
|
40535
41146
|
${title}
|
|
40536
41147
|
|
|
40537
41148
|
`);
|
|
40538
|
-
outcome = await new Promise((
|
|
41149
|
+
outcome = await new Promise((resolve3) => {
|
|
40539
41150
|
let child;
|
|
40540
41151
|
try {
|
|
40541
41152
|
child = spawnFn(argv0, args, { stdio: "inherit", cwd, env, ...shell ? { shell: true } : {} });
|
|
40542
41153
|
} catch (err) {
|
|
40543
|
-
|
|
41154
|
+
resolve3({ code: null, error: err });
|
|
40544
41155
|
return;
|
|
40545
41156
|
}
|
|
40546
|
-
child.on("error", (err) =>
|
|
40547
|
-
child.on("close", (code) =>
|
|
41157
|
+
child.on("error", (err) => resolve3({ code: null, error: err }));
|
|
41158
|
+
child.on("close", (code) => resolve3({ code }));
|
|
40548
41159
|
});
|
|
40549
41160
|
} catch (err) {
|
|
40550
41161
|
outcome = { code: null, error: err };
|
|
@@ -40628,7 +41239,7 @@ async function ensureDocker(ui, notify, { platform, probeDockerFn }) {
|
|
|
40628
41239
|
function npmSpawnOptions(platform, dir) {
|
|
40629
41240
|
return platform === "win32" ? { bin: "npm.cmd", options: { cwd: dir, shell: true } } : { bin: "npm", options: { cwd: dir } };
|
|
40630
41241
|
}
|
|
40631
|
-
function listRepoSkills(cwd, fs =
|
|
41242
|
+
function listRepoSkills(cwd, fs = nodeFs5) {
|
|
40632
41243
|
try {
|
|
40633
41244
|
const root = join6(cwd, ".pi", "skills");
|
|
40634
41245
|
return fs.readdirSync(root).filter((name) => SKILL_NAME_RE.test(name) && fs.existsSync(join6(root, name, "SKILL.md")));
|
|
@@ -40656,7 +41267,7 @@ function ensureDeploymentPackageJson(fs, dir) {
|
|
|
40656
41267
|
}
|
|
40657
41268
|
async function runSetupWizard(paths, ctx, notify, deps = {}) {
|
|
40658
41269
|
const {
|
|
40659
|
-
fs =
|
|
41270
|
+
fs = nodeFs5,
|
|
40660
41271
|
env = process.env,
|
|
40661
41272
|
platform = process.platform,
|
|
40662
41273
|
execPath = process.execPath,
|
|
@@ -40763,6 +41374,7 @@ up shows each docker action and asks y/N before it \u2014 nothing is auto-accept
|
|
|
40763
41374
|
env: {
|
|
40764
41375
|
PI_TRIGGERS_FILE: join6(dir, "triggers.json"),
|
|
40765
41376
|
PI_PAUSE_WINDOWS_FILE: join6(dir, "pause-windows.json"),
|
|
41377
|
+
PI_SCOPED_LIMITS_FILE: join6(dir, "scoped-limits.json"),
|
|
40766
41378
|
PI_SUBSCRIPTIONS_FILE: join6(dir, "subscriptions.json")
|
|
40767
41379
|
}
|
|
40768
41380
|
};
|
|
@@ -40970,7 +41582,7 @@ async function offerFirstTrigger(repoCwd, deployDir, ui, notify, { fs, listRepoS
|
|
|
40970
41582
|
);
|
|
40971
41583
|
}
|
|
40972
41584
|
function registerNudge(pi, deps = {}) {
|
|
40973
|
-
const { fs =
|
|
41585
|
+
const { fs = nodeFs5, env = process.env, homedirFn = homedir2 } = deps;
|
|
40974
41586
|
pi.on("session_start", (event, ctx) => {
|
|
40975
41587
|
try {
|
|
40976
41588
|
if (event?.reason !== "startup" || !ctx?.hasUI) return;
|
|
@@ -40995,8 +41607,8 @@ var init_setup_wizard = __esm({
|
|
|
40995
41607
|
init_deployment_pointer();
|
|
40996
41608
|
init_read_model();
|
|
40997
41609
|
init_index();
|
|
40998
|
-
RUNTIME_VERSION = "1.
|
|
40999
|
-
RECEIVER_VERSION = "1.
|
|
41610
|
+
RUNTIME_VERSION = "1.5.0";
|
|
41611
|
+
RECEIVER_VERSION = "1.3.0";
|
|
41000
41612
|
RUNTIME_PKG = "@edgehero/pi-dispatch";
|
|
41001
41613
|
RECEIVER_PKG = "@edgehero/pi-dispatch-receiver";
|
|
41002
41614
|
NUDGE_MARKER_BASENAME = "pi-dispatch-setup.nudged";
|
|
@@ -41112,7 +41724,7 @@ var init_secrets_command = __esm({
|
|
|
41112
41724
|
// src/index.ts
|
|
41113
41725
|
import { VERSION } from "@earendil-works/pi-coding-agent";
|
|
41114
41726
|
import { fileURLToPath as fileURLToPath2, pathToFileURL } from "node:url";
|
|
41115
|
-
import * as
|
|
41727
|
+
import * as nodeFs6 from "node:fs";
|
|
41116
41728
|
function computePiVersionAdvisory(version, supported) {
|
|
41117
41729
|
if (version === supported || version === "0.0.0") return void 0;
|
|
41118
41730
|
return `pi-dispatch admin: running on pi ${version}, tested with pi ${supported} \u2014 things should work; report anything odd, and check for a newer @edgehero/pi-dispatch-admin`;
|
|
@@ -41190,15 +41802,15 @@ function registerTools(pi) {
|
|
|
41190
41802
|
pi.registerTool({
|
|
41191
41803
|
name: "dispatch_costs",
|
|
41192
41804
|
label: "pi-dispatch costs",
|
|
41193
|
-
description:
|
|
41194
|
-
parameters: typebox_exports.Object({ window: typebox_exports.Optional(typebox_exports.String()), flow: typebox_exports.Optional(typebox_exports.String()) }),
|
|
41805
|
+
description: `Read-only. Folds the PII-free run history against the operator's declared subscriptions and pi-ai's rate tables into the costs read-model: window totals, daily buckets, per-flow and per-model rollups, per-plan verdicts, and provenance. window = 7d | 30d | mtd (default mtd); flow filters to one flow's runs; repo filters to one repo's runs by the by-repo rollup's key (e.g. "acme/web" or "local:site").`,
|
|
41806
|
+
parameters: typebox_exports.Object({ window: typebox_exports.Optional(typebox_exports.String()), flow: typebox_exports.Optional(typebox_exports.String()), repo: typebox_exports.Optional(typebox_exports.String()) }),
|
|
41195
41807
|
async execute(_toolCallId, params) {
|
|
41196
41808
|
const window = params.window ?? "mtd";
|
|
41197
41809
|
if (!COSTS_WINDOWS.includes(window)) {
|
|
41198
41810
|
throw new Error(`unknown window '${window}' (7d|30d|mtd)`);
|
|
41199
41811
|
}
|
|
41200
41812
|
const paths = resolvePaths(process.env);
|
|
41201
|
-
const res = assembleCosts(paths, window, params.flow);
|
|
41813
|
+
const res = assembleCosts(paths, window, params.flow, params.repo);
|
|
41202
41814
|
if (res.unreachable) throw new Error(`could not read the run history: ${res.unreachable}`);
|
|
41203
41815
|
return toolText(JSON.stringify({ window, fold: res.fold }));
|
|
41204
41816
|
}
|
|
@@ -41254,7 +41866,7 @@ function registerTools(pi) {
|
|
|
41254
41866
|
pi.registerTool({
|
|
41255
41867
|
name: "dispatch_triggers",
|
|
41256
41868
|
label: "pi-dispatch triggers",
|
|
41257
|
-
description: "Read-only. Lists the configured triggers as `{ index, ...trigger }` entries. Use the `index` to target a specific trigger with dispatch_trigger_edit or dispatch_trigger_delete.",
|
|
41869
|
+
description: "Read-only. Lists the configured triggers as `{ index, ...trigger }` entries. Use the `index` to target a specific trigger with dispatch_trigger_edit or dispatch_trigger_delete. A close-capable entry (kind issue, or a pull_request on the forge's close word) may carry `on.number` (narrowed to one item), `on.once` (a one-shot), and `on.disarmed` { at, jobId? } \u2014 the worker's mark that the one-shot fired; a disarmed entry still lists here but matches nothing until an operator deletes the key to re-arm it.",
|
|
41258
41870
|
parameters: typebox_exports.Object({}),
|
|
41259
41871
|
async execute() {
|
|
41260
41872
|
const paths = resolvePaths(process.env);
|
|
@@ -41299,7 +41911,7 @@ function registerTools(pi) {
|
|
|
41299
41911
|
pi.registerTool({
|
|
41300
41912
|
name: "dispatch_trigger_add",
|
|
41301
41913
|
label: "pi-dispatch add trigger",
|
|
41302
|
-
description: "Adds a trigger to triggers.json and applies it live. The operator MUST approve a confirm dialog showing the entry; with no interactive operator it is refused. `flow` is the .pi/skills/<name> skill the job runs (its SKILL.md is the agent's instructions). `kind` = cron|label|comment|pull_request. cron (local) needs id, pattern, `folder` (absolute host path the job runs in), `flow`, and `task` (the prompt text handed to the agent), and may set optional model/provider/maxTurns for that schedule (omit = deployment default). label needs labels[]+flow; comment needs phrase+flow; pull_request needs action[] (+ optional labels[]) + flow. Webhook triggers take an optional `forge` = github (default) | gitlab | forgejo | azure, which also decides which action words pull_request accepts: github is labeled|opened|synchronize|reopened|review_submitted, gitlab is open|update|reopen|approved, forgejo is label_updated|opened|synchronized|reopened, azure is created|updated. An azure label or comment trigger must also set `repository` (a work item belongs to a project, not a repository), and an azure pull_request trigger may not carry labels[] at all. A github review_submitted trigger may also set reviewState[] (approved|changes_requested|commented) to narrow which verdicts fire; omitted, all three do. For webhook triggers the repo and the task come from the triggering issue/PR event \u2014 set only the match + flow \u2014 and they run under the deployment default model.",
|
|
41914
|
+
description: "Adds a trigger to triggers.json and applies it live. The operator MUST approve a confirm dialog showing the entry; with no interactive operator it is refused. `flow` is the .pi/skills/<name> skill the job runs (its SKILL.md is the agent's instructions). `kind` = cron|label|comment|pull_request|issue. cron (local) needs id, pattern, `folder` (absolute host path the job runs in), `flow`, and `task` (the prompt text handed to the agent), and may set optional model/provider/maxTurns for that schedule (omit = deployment default). label needs labels[]+flow; comment needs phrase+flow; pull_request needs action[] (+ optional labels[]) + flow. issue fires when an ISSUE closes: it needs flow, its action[] defaults to the forge's close word (github/forgejo closed, gitlab close; azure has no close trigger), and it may set `number` (the forge's own item number, narrowing the rule to that one issue) and `once: true` (a one-shot: it fires once, the run is recorded, then the worker disarms the entry by writing on.disarmed; once requires number). A close-only pull_request rule accepts the same number/once narrowing. Webhook triggers take an optional `forge` = github (default) | gitlab | forgejo | azure, which also decides which action words pull_request accepts: github is labeled|opened|synchronize|reopened|review_submitted|closed, gitlab is open|update|reopen|approved|close, forgejo is label_updated|opened|synchronized|reopened|closed, azure is created|updated (no close word). The close word rides alone: it cannot be mixed with other actions in one entry. An azure label or comment trigger must also set `repository` (a work item belongs to a project, not a repository), and an azure pull_request trigger may not carry labels[] at all. A github review_submitted trigger may also set reviewState[] (approved|changes_requested|commented) to narrow which verdicts fire; omitted, all three do. For webhook triggers the repo and the task come from the triggering issue/PR event \u2014 set only the match + flow \u2014 and they run under the deployment default model.",
|
|
41303
41915
|
executionMode: "sequential",
|
|
41304
41916
|
parameters: typebox_exports.Object({
|
|
41305
41917
|
kind: typebox_exports.String(),
|
|
@@ -41316,13 +41928,18 @@ function registerTools(pi) {
|
|
|
41316
41928
|
phrase: typebox_exports.Optional(typebox_exports.String()),
|
|
41317
41929
|
labels: typebox_exports.Optional(typebox_exports.Array(typebox_exports.String())),
|
|
41318
41930
|
action: typebox_exports.Optional(typebox_exports.Array(typebox_exports.String())),
|
|
41931
|
+
// The close-trigger narrowings (issue #231), read only by the close-capable arms of
|
|
41932
|
+
// buildTriggerEntry and refused fail-loud by the shared validator everywhere else -- the
|
|
41933
|
+
// `repository` posture above, restated for the two fields whose whole point is narrowing spend.
|
|
41934
|
+
number: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1 })),
|
|
41935
|
+
once: typebox_exports.Optional(typebox_exports.Boolean()),
|
|
41319
41936
|
model: typebox_exports.Optional(typebox_exports.String()),
|
|
41320
41937
|
provider: typebox_exports.Optional(typebox_exports.String()),
|
|
41321
41938
|
maxTurns: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1 }))
|
|
41322
41939
|
}),
|
|
41323
41940
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
41324
41941
|
const entry = buildTriggerEntry(params.kind, params);
|
|
41325
|
-
if (!entry) throw new Error(`unknown trigger kind '${params.kind}' (cron|label|comment|pull_request)`);
|
|
41942
|
+
if (!entry) throw new Error(`unknown trigger kind '${params.kind}' (cron|label|comment|pull_request|issue)`);
|
|
41326
41943
|
const result = await confirmedWrite(
|
|
41327
41944
|
ctx,
|
|
41328
41945
|
{ title: `Add ${params.kind} trigger`, message: `Add to triggers.json:
|
|
@@ -41475,6 +42092,113 @@ ${JSON.stringify(before)}
|
|
|
41475
42092
|
return toolText(JSON.stringify(result));
|
|
41476
42093
|
}
|
|
41477
42094
|
});
|
|
42095
|
+
pi.registerTool({
|
|
42096
|
+
name: "dispatch_limits",
|
|
42097
|
+
label: "pi-dispatch scoped limits",
|
|
42098
|
+
description: "Read-only. Lists the scoped limits (per repo/folder budget caps and concurrency, scoped-limits.json) with their array index and, when the queue is reachable, each scope's used day/week/month run counts. Use the index for dispatch_limit_edit / dispatch_limit_delete. The always-on one-job-per-folder mutex for local jobs is separate: it has no entry here and no switch anywhere.",
|
|
42099
|
+
parameters: typebox_exports.Object({}),
|
|
42100
|
+
async execute() {
|
|
42101
|
+
const paths = resolvePaths(process.env);
|
|
42102
|
+
const p = readScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath });
|
|
42103
|
+
if (!Array.isArray(p?.limits)) return toolText(JSON.stringify(p));
|
|
42104
|
+
const counters = await readScopedBudget({ url: paths.valkeyUrl, limits: p.limits });
|
|
42105
|
+
const rows = p.limits.map((l, index) => ({ index, ...l, used: counters.rows?.[index] ?? null }));
|
|
42106
|
+
return toolText(JSON.stringify(rows));
|
|
42107
|
+
}
|
|
42108
|
+
});
|
|
42109
|
+
pi.registerTool({
|
|
42110
|
+
name: "dispatch_limit_add",
|
|
42111
|
+
label: "pi-dispatch add scoped limit",
|
|
42112
|
+
description: 'Adds a scoped limit and applies it live: jobs whose scope (a repo "owner/name" or a local folder path, ABSOLUTE, matched exactly like pause windows \u2014 no globs) exceed the `day`/`week`/`month` job-count caps are REFUSED before any spend (reason scope-cap, never retried); jobs over `concurrent` are DEFERRED, never dropped, and run when a slot frees. At least one of day/week/month/concurrent is required, each an integer >= 1. The operator MUST approve a confirm dialog showing the entry; refused with no interactive operator.',
|
|
42113
|
+
executionMode: "sequential",
|
|
42114
|
+
parameters: typebox_exports.Object({
|
|
42115
|
+
scope: typebox_exports.String(),
|
|
42116
|
+
day: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1 })),
|
|
42117
|
+
week: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1 })),
|
|
42118
|
+
month: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1 })),
|
|
42119
|
+
concurrent: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1 }))
|
|
42120
|
+
}),
|
|
42121
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
42122
|
+
const paths = resolvePaths(process.env);
|
|
42123
|
+
const l = buildScopedLimit(params);
|
|
42124
|
+
const result = await confirmedWrite(
|
|
42125
|
+
ctx,
|
|
42126
|
+
{ title: "Add scoped limit", message: `Add to scoped-limits.json:
|
|
42127
|
+
${JSON.stringify(l)}` },
|
|
42128
|
+
() => {
|
|
42129
|
+
const res = writeScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath, mutate: (list) => [...list, l] });
|
|
42130
|
+
if (res.invalid) throw new Error(`rejected: ${res.invalid}`);
|
|
42131
|
+
return { applied: true, added: l };
|
|
42132
|
+
}
|
|
42133
|
+
);
|
|
42134
|
+
return toolText(JSON.stringify(result));
|
|
42135
|
+
}
|
|
42136
|
+
});
|
|
42137
|
+
pi.registerTool({
|
|
42138
|
+
name: "dispatch_limit_delete",
|
|
42139
|
+
label: "pi-dispatch delete scoped limit",
|
|
42140
|
+
description: "Removes a scoped limit (by array index from dispatch_limits) and applies it live. The operator MUST approve a confirm dialog showing the limit; refused with no interactive operator.",
|
|
42141
|
+
executionMode: "sequential",
|
|
42142
|
+
parameters: typebox_exports.Object({ index: typebox_exports.Integer({ minimum: 0 }) }),
|
|
42143
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
42144
|
+
const paths = resolvePaths(process.env);
|
|
42145
|
+
const p = readScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath });
|
|
42146
|
+
const list = Array.isArray(p?.limits) ? p.limits : [];
|
|
42147
|
+
const cur = list[params.index];
|
|
42148
|
+
if (!cur) throw new Error(`no scoped limit at index ${params.index} (have ${list.length})`);
|
|
42149
|
+
const result = await confirmedWrite(
|
|
42150
|
+
ctx,
|
|
42151
|
+
{ title: `Delete scoped limit #${params.index + 1}`, message: `Remove scoped limit #${params.index + 1}: ${cur.scope} ${limitSummary(cur)}` },
|
|
42152
|
+
() => {
|
|
42153
|
+
const res = writeScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath, mutate: (l) => l.filter((_, i) => i !== params.index) });
|
|
42154
|
+
if (res.invalid) throw new Error(`rejected: ${res.invalid}`);
|
|
42155
|
+
return { applied: true, deletedIndex: params.index };
|
|
42156
|
+
}
|
|
42157
|
+
);
|
|
42158
|
+
return toolText(JSON.stringify(result));
|
|
42159
|
+
}
|
|
42160
|
+
});
|
|
42161
|
+
pi.registerTool({
|
|
42162
|
+
name: "dispatch_limit_edit",
|
|
42163
|
+
label: "pi-dispatch edit scoped limit",
|
|
42164
|
+
description: "Changes fields of an existing scoped limit (by array index from dispatch_limits) and applies it live. Provide only the fields to change (scope/day/week/month/concurrent); the rest keep their current value (to drop one cap from an entry, delete it and re-add without that field). The operator MUST approve a confirm dialog showing the before->after; refused with no interactive operator.",
|
|
42165
|
+
executionMode: "sequential",
|
|
42166
|
+
parameters: typebox_exports.Object({
|
|
42167
|
+
index: typebox_exports.Integer({ minimum: 0 }),
|
|
42168
|
+
scope: typebox_exports.Optional(typebox_exports.String()),
|
|
42169
|
+
day: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1 })),
|
|
42170
|
+
week: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1 })),
|
|
42171
|
+
month: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1 })),
|
|
42172
|
+
concurrent: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1 }))
|
|
42173
|
+
}),
|
|
42174
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
42175
|
+
const paths = resolvePaths(process.env);
|
|
42176
|
+
const p = readScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath });
|
|
42177
|
+
const list = Array.isArray(p?.limits) ? p.limits : [];
|
|
42178
|
+
const cur = list[params.index];
|
|
42179
|
+
if (!cur) throw new Error(`no scoped limit at index ${params.index} (have ${list.length})`);
|
|
42180
|
+
const before = buildScopedLimit(cur);
|
|
42181
|
+
const merged = buildScopedLimit({
|
|
42182
|
+
scope: params.scope ?? cur.scope,
|
|
42183
|
+
day: params.day ?? cur.day,
|
|
42184
|
+
week: params.week ?? cur.week,
|
|
42185
|
+
month: params.month ?? cur.month,
|
|
42186
|
+
concurrent: params.concurrent ?? cur.concurrent
|
|
42187
|
+
});
|
|
42188
|
+
const result = await confirmedWrite(
|
|
42189
|
+
ctx,
|
|
42190
|
+
{ title: `Edit scoped limit #${params.index + 1}`, message: `scoped limit #${params.index + 1}:
|
|
42191
|
+
${JSON.stringify(before)}
|
|
42192
|
+
\u2192 ${JSON.stringify(merged)}` },
|
|
42193
|
+
() => {
|
|
42194
|
+
const res = writeScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath, mutate: (l) => l.map((w, i) => i === params.index ? merged : w) });
|
|
42195
|
+
if (res.invalid) throw new Error(`rejected: ${res.invalid}`);
|
|
42196
|
+
return { applied: true, index: params.index, limit: merged };
|
|
42197
|
+
}
|
|
42198
|
+
);
|
|
42199
|
+
return toolText(JSON.stringify(result));
|
|
42200
|
+
}
|
|
42201
|
+
});
|
|
41478
42202
|
pi.registerTool({
|
|
41479
42203
|
name: "dispatch_trigger_delete",
|
|
41480
42204
|
label: "pi-dispatch delete trigger",
|
|
@@ -41533,6 +42257,17 @@ function buildTriggerEntry(kind, f) {
|
|
|
41533
42257
|
const on = { type: "pull_request", action: asWords(f.action) };
|
|
41534
42258
|
const any = asWords(f.labels ?? f.any);
|
|
41535
42259
|
if (any.length > 0) on.any = any;
|
|
42260
|
+
const prNumber = optInt(f.number);
|
|
42261
|
+
if (prNumber !== void 0) on.number = prNumber;
|
|
42262
|
+
if (typeof f.once === "boolean") on.once = f.once;
|
|
42263
|
+
return { on, run: { kind: forge, flow: f.flow } };
|
|
42264
|
+
}
|
|
42265
|
+
if (kind === "issue") {
|
|
42266
|
+
const action = asWords(f.action);
|
|
42267
|
+
const on = { type: "issue", action: action.length > 0 ? action : [ISSUE_CLOSE_WORD[forge] ?? "closed"] };
|
|
42268
|
+
const number = optInt(f.number);
|
|
42269
|
+
if (number !== void 0) on.number = number;
|
|
42270
|
+
if (typeof f.once === "boolean") on.once = f.once;
|
|
41536
42271
|
return { on, run: { kind: forge, flow: f.flow } };
|
|
41537
42272
|
}
|
|
41538
42273
|
return null;
|
|
@@ -41549,6 +42284,22 @@ function buildPauseWindow(f) {
|
|
|
41549
42284
|
if (dateTo) w.dateTo = dateTo;
|
|
41550
42285
|
return w;
|
|
41551
42286
|
}
|
|
42287
|
+
function buildScopedLimit(f) {
|
|
42288
|
+
const l = { scope: String(f.scope ?? "").trim() };
|
|
42289
|
+
for (const k of ["day", "week", "month", "concurrent"]) {
|
|
42290
|
+
const v = optInt(f[k]);
|
|
42291
|
+
if (v !== void 0) l[k] = v;
|
|
42292
|
+
}
|
|
42293
|
+
return l;
|
|
42294
|
+
}
|
|
42295
|
+
function limitSummary(l) {
|
|
42296
|
+
const bits = [];
|
|
42297
|
+
if (Number.isInteger(l?.day)) bits.push(`day ${l.day}`);
|
|
42298
|
+
if (Number.isInteger(l?.week)) bits.push(`week ${l.week}`);
|
|
42299
|
+
if (Number.isInteger(l?.month)) bits.push(`month ${l.month}`);
|
|
42300
|
+
if (Number.isInteger(l?.concurrent)) bits.push(`\u2264${l.concurrent} at once`);
|
|
42301
|
+
return bits.join(" \xB7 ");
|
|
42302
|
+
}
|
|
41552
42303
|
function asWords(x) {
|
|
41553
42304
|
if (Array.isArray(x)) return x.map((s) => String(s).trim()).filter(Boolean);
|
|
41554
42305
|
return splitWords(x);
|
|
@@ -41584,7 +42335,7 @@ async function dispatch(pi, args, ctx) {
|
|
|
41584
42335
|
if (det.state === "pointer" && !runtimeSkewNoticed) {
|
|
41585
42336
|
const ptrRes = readPointer({ path: pointerPath(process.env) });
|
|
41586
42337
|
const deploymentDir = ptrRes.pointer?.deploymentDir;
|
|
41587
|
-
const installed = deploymentDir ? readInstalledVersion2(
|
|
42338
|
+
const installed = deploymentDir ? readInstalledVersion2(nodeFs6, runtimeDirFor2(deploymentDir)) : void 0;
|
|
41588
42339
|
if (typeof installed === "string" && installed !== RUNTIME_VERSION2) {
|
|
41589
42340
|
runtimeSkewNoticed = true;
|
|
41590
42341
|
notify?.(
|
|
@@ -41618,7 +42369,11 @@ ${renderBudget({ budget, settings: settings2 })}`);
|
|
|
41618
42369
|
case "budget": {
|
|
41619
42370
|
const budget = await readBudget({ url: paths.valkeyUrl });
|
|
41620
42371
|
const settings2 = readSettingsView({ settingsFile: paths.settingsFile });
|
|
41621
|
-
|
|
42372
|
+
const limits = readScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath });
|
|
42373
|
+
const scopedBudget = Array.isArray(limits?.limits) && limits.limits.length > 0 ? await readScopedBudget({ url: paths.valkeyUrl, limits: limits.limits }) : { rows: [] };
|
|
42374
|
+
const scopedText = renderScopedLimits({ limits, scopedBudget });
|
|
42375
|
+
send(pi, `${renderBudget({ budget, settings: settings2 })}${scopedText ? `
|
|
42376
|
+
${scopedText}` : ""}`);
|
|
41622
42377
|
return;
|
|
41623
42378
|
}
|
|
41624
42379
|
case "triggers": {
|
|
@@ -41699,13 +42454,14 @@ ${renderBudget({ budget, settings: settings2 })}`);
|
|
|
41699
42454
|
return;
|
|
41700
42455
|
}
|
|
41701
42456
|
}
|
|
41702
|
-
function assembleCosts(paths, window, flow) {
|
|
42457
|
+
function assembleCosts(paths, window, flow, repo) {
|
|
41703
42458
|
const nowMs = Date.now();
|
|
41704
42459
|
const sinceMs = costsSinceMs(window, nowMs);
|
|
41705
42460
|
const records = scanRunRecords({ logsDir: paths.logsDir, sinceMs, nowMs });
|
|
41706
42461
|
if (!Array.isArray(records)) return records;
|
|
41707
42462
|
const subs = readSubscriptions({ subscriptionsPath: paths.subscriptionsPath });
|
|
41708
|
-
const
|
|
42463
|
+
const flowScoped = typeof flow === "string" && flow !== "" ? records.filter((r) => (r?.flow ?? null) === flow) : records;
|
|
42464
|
+
const scoped = typeof repo === "string" && repo !== "" ? flowScoped.filter((r) => repoOfTarget(r?.target) === repo) : flowScoped;
|
|
41709
42465
|
const triggersView = readTriggers({ triggersPath: paths.triggersPath });
|
|
41710
42466
|
const triggerJoin = attributeRunsToTriggers({ records: scoped, triggers: Array.isArray(triggersView?.triggers) ? triggersView.triggers : [] });
|
|
41711
42467
|
const fold = foldCosts({
|
|
@@ -41743,7 +42499,7 @@ async function assembleGraph(paths) {
|
|
|
41743
42499
|
});
|
|
41744
42500
|
}
|
|
41745
42501
|
function realArtifactDeps() {
|
|
41746
|
-
return { fs:
|
|
42502
|
+
return { fs: nodeFs6, openBrowser, env: process.env, now: () => Date.now(), platform: process.platform };
|
|
41747
42503
|
}
|
|
41748
42504
|
function isHeadlessEnv(env, platform) {
|
|
41749
42505
|
if (env?.SSH_CONNECTION || env?.SSH_TTY) return "SSH session";
|
|
@@ -41768,13 +42524,27 @@ async function assembleBudgetView(paths) {
|
|
|
41768
42524
|
const tokensSpent = unreachable === null ? Number(raw.tokensToday ?? 0) : null;
|
|
41769
42525
|
const tokenCap = posIntOrNull(overlay.dailyTokenCap);
|
|
41770
42526
|
const tokenState = tokenCap !== null && tokensSpent !== null ? tokensSpent >= tokenCap ? "over" : pct !== null && tokensSpent > Math.floor(tokenCap * pct / 100) ? "soft-hold" : "ok" : "ok";
|
|
42527
|
+
const limitsView = readScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath });
|
|
42528
|
+
const limits = Array.isArray(limitsView?.limits) ? limitsView.limits : [];
|
|
42529
|
+
const scopedCounters = limits.length > 0 ? await readScopedBudget({ url: paths.valkeyUrl, limits }) : { rows: [] };
|
|
42530
|
+
const scoped = limits.map((l, i) => {
|
|
42531
|
+
const used = scopedCounters?.rows?.[i] ?? null;
|
|
42532
|
+
const win = (cap, key) => {
|
|
42533
|
+
if (cap === null) return null;
|
|
42534
|
+
const u = used && Number.isFinite(used[key]) ? used[key] : null;
|
|
42535
|
+
return { used: u, cap, state: u !== null ? windowState(u, cap, null) : "ok" };
|
|
42536
|
+
};
|
|
42537
|
+
return { scope: l.scope, day: win(l.day, "day"), week: win(l.week, "week"), month: win(l.month, "month"), concurrent: l.concurrent };
|
|
42538
|
+
});
|
|
41771
42539
|
return {
|
|
41772
42540
|
unreachable,
|
|
41773
42541
|
softHoldPct: pct,
|
|
41774
42542
|
day: slotWindow("day", "dailyCap"),
|
|
41775
42543
|
week: slotWindow("week", "weeklyCap"),
|
|
41776
42544
|
month: slotWindow("month", "monthlyCap"),
|
|
41777
|
-
tokens: { spent: tokensSpent, cap: tokenCap, state: tokenState, maxTokens: posIntOrNull(overlay.maxTokens) }
|
|
42545
|
+
tokens: { spent: tokensSpent, cap: tokenCap, state: tokenState, maxTokens: posIntOrNull(overlay.maxTokens) },
|
|
42546
|
+
scoped,
|
|
42547
|
+
scopedInvalid: limitsView?.invalid ? String(limitsView.invalid) : null
|
|
41778
42548
|
};
|
|
41779
42549
|
}
|
|
41780
42550
|
async function assembleInsights(paths, window) {
|
|
@@ -41941,7 +42711,7 @@ could not start docker: ${error.message}
|
|
|
41941
42711
|
}
|
|
41942
42712
|
}
|
|
41943
42713
|
function pauseForMessage() {
|
|
41944
|
-
return new Promise((
|
|
42714
|
+
return new Promise((resolve3) => setTimeout(resolve3, 2500));
|
|
41945
42715
|
}
|
|
41946
42716
|
function splitWords(s) {
|
|
41947
42717
|
return String(s ?? "").split(/\s+/).map((w) => w.trim()).filter(Boolean);
|
|
@@ -41967,7 +42737,83 @@ async function handleDashboardAction(result, paths, ctx) {
|
|
|
41967
42737
|
return editSettingsViaDialogs(paths, ui, notify);
|
|
41968
42738
|
case "managePauses":
|
|
41969
42739
|
return managePausesViaDialogs(paths, ui, notify);
|
|
42740
|
+
case "manageLimits":
|
|
42741
|
+
return manageLimitsViaDialogs(paths, ui, notify);
|
|
42742
|
+
}
|
|
42743
|
+
}
|
|
42744
|
+
async function manageLimitsViaDialogs(paths, ui, notify) {
|
|
42745
|
+
const action = await ui.select("Scoped limits", ["Add a scoped limit", "Edit a scoped limit", "Delete a scoped limit"]);
|
|
42746
|
+
if (!action) return;
|
|
42747
|
+
if (action.startsWith("Add")) return addScopedLimitViaDialogs(paths, ui, notify);
|
|
42748
|
+
if (action.startsWith("Edit")) return editScopedLimitViaDialogs(paths, ui, notify);
|
|
42749
|
+
return deleteScopedLimitViaDialogs(paths, ui, notify);
|
|
42750
|
+
}
|
|
42751
|
+
async function addScopedLimitViaDialogs(paths, ui, notify) {
|
|
42752
|
+
const scope = await ui.input('scope \u2014 a repo "owner/name" or an ABSOLUTE local folder path (exact match, no globs)', "");
|
|
42753
|
+
if (scope === void 0 || scope.trim() === "") return;
|
|
42754
|
+
const day = await ui.input("day \u2014 max jobs per UTC day for this scope (blank = no day cap)", "");
|
|
42755
|
+
if (day === void 0) return;
|
|
42756
|
+
const week = await ui.input("week \u2014 max jobs per Mon-Sun UTC week (blank = no week cap)", "");
|
|
42757
|
+
if (week === void 0) return;
|
|
42758
|
+
const month = await ui.input("month \u2014 max jobs per calendar month (blank = no month cap)", "");
|
|
42759
|
+
if (month === void 0) return;
|
|
42760
|
+
const concurrent = await ui.input("concurrent \u2014 max jobs in flight for this scope, extras deferred (blank = no limit)", "");
|
|
42761
|
+
if (concurrent === void 0) return;
|
|
42762
|
+
const l = buildScopedLimit({ scope, day, week, month, concurrent });
|
|
42763
|
+
const res = writeScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath, mutate: (list) => [...list, l] });
|
|
42764
|
+
notify?.(res.ok ? `scoped limit added (live) \u2014 ${l.scope} ${limitSummary(l)}` : `add rejected: ${res.invalid}`, res.ok ? "info" : "error");
|
|
42765
|
+
}
|
|
42766
|
+
async function editScopedLimitViaDialogs(paths, ui, notify) {
|
|
42767
|
+
const p = readScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath });
|
|
42768
|
+
const list = Array.isArray(p?.limits) ? p.limits : [];
|
|
42769
|
+
if (list.length === 0) {
|
|
42770
|
+
notify?.("no scoped limits to edit", "info");
|
|
42771
|
+
return;
|
|
41970
42772
|
}
|
|
42773
|
+
const labels = list.map((l, i) => `#${i + 1} ${l.scope} ${limitSummary(l)}`);
|
|
42774
|
+
const picked = await ui.select("Edit which scoped limit", labels);
|
|
42775
|
+
if (!picked) return;
|
|
42776
|
+
const index = labels.indexOf(picked);
|
|
42777
|
+
if (index < 0) return;
|
|
42778
|
+
const cur = list[index];
|
|
42779
|
+
const ask = async (label, current) => ui.input(`${label} \u2014 blank keeps "${current}"`, current);
|
|
42780
|
+
const keep = (v, current) => v === void 0 || v.trim() === "" ? current : v;
|
|
42781
|
+
const scope = await ask("scope", cur.scope ?? "");
|
|
42782
|
+
if (scope === void 0) return;
|
|
42783
|
+
const day = await ask("day cap", String(cur.day ?? ""));
|
|
42784
|
+
if (day === void 0) return;
|
|
42785
|
+
const week = await ask("week cap", String(cur.week ?? ""));
|
|
42786
|
+
if (week === void 0) return;
|
|
42787
|
+
const month = await ask("month cap", String(cur.month ?? ""));
|
|
42788
|
+
if (month === void 0) return;
|
|
42789
|
+
const concurrent = await ask("concurrent (max in flight)", String(cur.concurrent ?? ""));
|
|
42790
|
+
if (concurrent === void 0) return;
|
|
42791
|
+
const merged = buildScopedLimit({
|
|
42792
|
+
scope: keep(scope, cur.scope),
|
|
42793
|
+
day: keep(day, cur.day),
|
|
42794
|
+
week: keep(week, cur.week),
|
|
42795
|
+
month: keep(month, cur.month),
|
|
42796
|
+
concurrent: keep(concurrent, cur.concurrent)
|
|
42797
|
+
});
|
|
42798
|
+
const res = writeScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath, mutate: (l) => l.map((w, i) => i === index ? merged : w) });
|
|
42799
|
+
notify?.(res.ok ? `scoped limit #${index + 1} updated (live) \u2014 ${merged.scope} ${limitSummary(merged)}` : `edit rejected: ${res.invalid}`, res.ok ? "info" : "error");
|
|
42800
|
+
}
|
|
42801
|
+
async function deleteScopedLimitViaDialogs(paths, ui, notify) {
|
|
42802
|
+
const p = readScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath });
|
|
42803
|
+
const list = Array.isArray(p?.limits) ? p.limits : [];
|
|
42804
|
+
if (list.length === 0) {
|
|
42805
|
+
notify?.("no scoped limits to delete", "info");
|
|
42806
|
+
return;
|
|
42807
|
+
}
|
|
42808
|
+
const labels = list.map((l, i) => `#${i + 1} ${l.scope} ${limitSummary(l)}`);
|
|
42809
|
+
const picked = await ui.select("Delete which scoped limit", labels);
|
|
42810
|
+
if (!picked) return;
|
|
42811
|
+
const index = labels.indexOf(picked);
|
|
42812
|
+
if (index < 0) return;
|
|
42813
|
+
const ok = await ui.confirm("Delete scoped limit", `Remove ${labels[index]}?`);
|
|
42814
|
+
if (!ok) return;
|
|
42815
|
+
const res = writeScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath, mutate: (l) => l.filter((_, i) => i !== index) });
|
|
42816
|
+
notify?.(res.ok ? `scoped limit #${index + 1} deleted (live)` : `delete rejected: ${res.invalid}`, res.ok ? "info" : "error");
|
|
41971
42817
|
}
|
|
41972
42818
|
async function managePausesViaDialogs(paths, ui, notify) {
|
|
41973
42819
|
const action = await ui.select("Pause windows", ["Add a pause window", "Edit a pause window", "Delete a pause window"]);
|
|
@@ -42269,7 +43115,7 @@ function completeArguments(prefix) {
|
|
|
42269
43115
|
}
|
|
42270
43116
|
return null;
|
|
42271
43117
|
}
|
|
42272
|
-
var USED_API, SUPPORTED_PI_VERSION, piVersionAdvisory, piVersionCheckDone, runtimeSkewNoticed, CHANNEL, REBUILT_NOTICE, USAGE, KNOWN_SUBCOMMANDS, FORGE_PROMPT, PR_ACTION_VOCAB, PRICING, INSIGHTS_USAGE, VIEWPORT_LINES;
|
|
43118
|
+
var USED_API, SUPPORTED_PI_VERSION, piVersionAdvisory, piVersionCheckDone, runtimeSkewNoticed, CHANNEL, REBUILT_NOTICE, USAGE, KNOWN_SUBCOMMANDS, FORGE_PROMPT, ISSUE_CLOSE_WORD, PR_ACTION_VOCAB, PRICING, INSIGHTS_USAGE, VIEWPORT_LINES;
|
|
42273
43119
|
var init_index = __esm({
|
|
42274
43120
|
"src/index.ts"() {
|
|
42275
43121
|
init_build();
|
|
@@ -42312,10 +43158,14 @@ var init_index = __esm({
|
|
|
42312
43158
|
"secrets"
|
|
42313
43159
|
];
|
|
42314
43160
|
FORGE_PROMPT = "forge \u2014 github, gitlab, forgejo or azure";
|
|
43161
|
+
ISSUE_CLOSE_WORD = { github: "closed", gitlab: "close", forgejo: "closed" };
|
|
42315
43162
|
PR_ACTION_VOCAB = {
|
|
42316
|
-
|
|
42317
|
-
|
|
42318
|
-
|
|
43163
|
+
// The close words ride the hint too (issue #231): the dialog passes whatever is typed through the
|
|
43164
|
+
// shared validator, so a close-only rule IS authorable here, and a hint that omits the word reads
|
|
43165
|
+
// as the word not existing. The loader refuses a list mixing a close word with any other action.
|
|
43166
|
+
github: { hint: "labeled opened synchronize reopened review_submitted closed", dflt: "labeled" },
|
|
43167
|
+
gitlab: { hint: "open update reopen approved close", dflt: "update" },
|
|
43168
|
+
forgejo: { hint: "label_updated opened synchronized reopened closed", dflt: "label_updated" },
|
|
42319
43169
|
azure: { hint: "created updated", dflt: "updated" }
|
|
42320
43170
|
};
|
|
42321
43171
|
PRICING = { listPricedModels, getPricedModel, isZeroRated, reprice, piAiVersion };
|