@edgehero/pi-dispatch-admin 5.1.0 → 5.2.1
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/dist/console.mjs +32760 -32177
- package/dist/index.mjs +822 -464
- package/dist/sandbox-session-monitor.mjs +119 -8
- package/package.json +1 -1
|
@@ -102209,6 +102209,10 @@ var CONTAINER_ENV_NAMES = /* @__PURE__ */ new Set([
|
|
|
102209
102209
|
"PI_RUNNER_OBSERVABLE",
|
|
102210
102210
|
// Issue #693: derived from the resolved job memory, never chosen by a trigger or forwarded host value.
|
|
102211
102211
|
"PI_CODEMODE_MAX_WORKERS",
|
|
102212
|
+
// Issue #865: the job's size as the job reads it (job-size.mjs `jobSizeEnv`). A trigger binding one through
|
|
102213
|
+
// run.secrets, or a forwarded host value, would tell the job's tools a size the container was not given.
|
|
102214
|
+
"PI_JOB_SIZE_CPUS",
|
|
102215
|
+
"PI_JOB_SIZE_MEMORY_MIB",
|
|
102212
102216
|
"PI_PROVIDER",
|
|
102213
102217
|
"PI_MODEL",
|
|
102214
102218
|
"PI_FORK_ENTRY",
|
|
@@ -102285,6 +102289,7 @@ var DOLLAR_ENV_NAMES = Object.freeze({ maxCostUsd: "PI_MAX_COST_USD", dailyCostU
|
|
|
102285
102289
|
var DOLLAR_WINDOW_KEYS = Object.freeze(["dailyCostUsd", "weeklyCostUsd", "monthlyCostUsd"]);
|
|
102286
102290
|
|
|
102287
102291
|
// ../worker/src/config.mjs
|
|
102292
|
+
var DEFAULT_VALKEY_URL = "redis://127.0.0.1:6379";
|
|
102288
102293
|
var WORKER_ONLY_SECRET_VARS = /* @__PURE__ */ new Set(["GITHUB_APP_PRIVATE_KEY", "VALKEY_PASSWORD"]);
|
|
102289
102294
|
var WORKER_ONLY_WHY = Object.freeze({
|
|
102290
102295
|
GITHUB_APP_PRIVATE_KEY: "the App's signing key mints tokens for every repository the App is installed on",
|
|
@@ -103361,10 +103366,12 @@ function valkeyClientContext({ env: env2 = process.env, cwd = process.cwd(), pla
|
|
|
103361
103366
|
const envPath = join(cwd, ".env");
|
|
103362
103367
|
let content = null;
|
|
103363
103368
|
let error = null;
|
|
103369
|
+
let absent = false;
|
|
103364
103370
|
try {
|
|
103365
103371
|
content = readEnv3(envPath);
|
|
103366
103372
|
} catch (err) {
|
|
103367
|
-
if (err?.code
|
|
103373
|
+
if (err?.code === "ENOENT") absent = true;
|
|
103374
|
+
else error = `${envPath} could not be read (${err?.code ?? err?.message})`;
|
|
103368
103375
|
}
|
|
103369
103376
|
const fileKeys = {};
|
|
103370
103377
|
if (content !== null) {
|
|
@@ -103378,10 +103385,10 @@ function valkeyClientContext({ env: env2 = process.env, cwd = process.cwd(), pla
|
|
|
103378
103385
|
if (keys.error) error ??= keys.error;
|
|
103379
103386
|
else for (const k of [VALKEY_SHARED_KEY, VALKEY_PASSWORD_KEY, "VALKEY_URL"]) if (typeof keys.keys[k] === "string") fileKeys[k] = keys.keys[k];
|
|
103380
103387
|
}
|
|
103381
|
-
return valkeyContextFromKeys({ env: env2, envPath, fileKeys, error, platform, rootRefused });
|
|
103388
|
+
return valkeyContextFromKeys({ env: env2, envPath, fileKeys, error, absent, platform, rootRefused });
|
|
103382
103389
|
}
|
|
103383
103390
|
var VALKEY_CONTEXT_KEYS = Object.freeze(["VALKEY_URL", VALKEY_PASSWORD_KEY, VALKEY_SHARED_KEY, "PI_BACKENDS", "PI_OPENSHELL_ENGINE"]);
|
|
103384
|
-
function valkeyContextFromKeys({ env: env2 = process.env, envPath = null, fileKeys = {}, error = null, platform = process.platform, rootRefused } = {}) {
|
|
103391
|
+
function valkeyContextFromKeys({ env: env2 = process.env, envPath = null, fileKeys = {}, error = null, absent = false, platform = process.platform, rootRefused } = {}) {
|
|
103385
103392
|
const fileVenue = typeof fileKeys.PI_BACKENDS === "string" ? { PI_BACKENDS: fileKeys.PI_BACKENDS } : {};
|
|
103386
103393
|
const shared = valkeySharedOn(fileKeys[VALKEY_SHARED_KEY]);
|
|
103387
103394
|
const filePassword = fileKeys[VALKEY_PASSWORD_KEY] || null;
|
|
@@ -103395,7 +103402,7 @@ function valkeyContextFromKeys({ env: env2 = process.env, envPath = null, fileKe
|
|
|
103395
103402
|
const engine = typeof env2.PI_OPENSHELL_ENGINE === "string" ? env2.PI_OPENSHELL_ENGINE : fileKeys.PI_OPENSHELL_ENGINE;
|
|
103396
103403
|
const derived = platform === "linux" && (venues.podmanUsed && !venues.localUsed || openshellSetupOf(venues, { PI_OPENSHELL_ENGINE: engine }).valkeyEngine === "podman");
|
|
103397
103404
|
const envUrl = typeof env2.VALKEY_URL === "string" ? env2.VALKEY_URL : null;
|
|
103398
|
-
return { envPath, shared, rootRefused: typeof rootRefused === "boolean" ? rootRefused : derived, error, password: { environment: envPassword, file: filePassword }, url: { environment: envUrl, file: fileUrl } };
|
|
103405
|
+
return { envPath, shared, rootRefused: typeof rootRefused === "boolean" ? rootRefused : derived, error, absent: absent === true, password: { environment: envPassword, file: filePassword }, url: { environment: envUrl, file: fileUrl } };
|
|
103399
103406
|
}
|
|
103400
103407
|
function valkeyUrlProblem(url) {
|
|
103401
103408
|
let u;
|
|
@@ -103532,10 +103539,12 @@ if (!Redis.prototype[SELECT_HOOK]) {
|
|
|
103532
103539
|
};
|
|
103533
103540
|
Redis.prototype[SELECT_HOOK] = true;
|
|
103534
103541
|
}
|
|
103535
|
-
function parseConnection(url, { failFast = false, servername = null, context = null, judge = null, withoutPassword = false, freshContext = false } = {}) {
|
|
103542
|
+
function parseConnection(url, { failFast = false, servername = null, context = null, judge = null, withoutPassword = false, freshContext = false, commandTimeoutMs = null } = {}) {
|
|
103536
103543
|
const u = new URL(url);
|
|
103537
103544
|
const dbProblem = valkeyUrlProblem(url);
|
|
103538
103545
|
if (dbProblem) throw valkeyRefusal(dbProblem);
|
|
103546
|
+
const bounded = Number.isSafeInteger(commandTimeoutMs) && commandTimeoutMs > 0;
|
|
103547
|
+
if (bounded && !failFast) throw new TypeError("parseConnection: a command bound (commandTimeoutMs) is for a failFast client only, which has no offline queue to send a command that timed out from later");
|
|
103539
103548
|
const where = context ?? defaultValkeyContext();
|
|
103540
103549
|
const { password } = valkeyPasswordFor(url, where, { withoutPassword });
|
|
103541
103550
|
return {
|
|
@@ -103544,7 +103553,9 @@ function parseConnection(url, { failFast = false, servername = null, context = n
|
|
|
103544
103553
|
// reports. `context` is where the client stands (`valkeyClientContext`), the process's own when not given.
|
|
103545
103554
|
Connector: JudgedConnector,
|
|
103546
103555
|
// `judge` is a test seam only: the function asked for the address (judgedEndpoint when not given).
|
|
103547
|
-
|
|
103556
|
+
// `freshPassword` (issue #845): a client that judges by the context as it is at each connect sends the password that
|
|
103557
|
+
// context gives then, unless it was built to send none (`withoutPassword`, a probe of what another account sees).
|
|
103558
|
+
[JUDGE]: { url, context: where, ...judge ? { judge } : {}, ...freshContext && !context ? { freshContext: true, ...withoutPassword ? {} : { freshPassword: true } } : {}, ...failFast ? { failFast: true } : {}, ...bounded ? { commandBound: true } : {} },
|
|
103548
103559
|
// WHATWG URL keeps an IPv6 literal's brackets in `hostname` (`[::1]`), and net.connect then looks up the name
|
|
103549
103560
|
// "[::1]", which never resolves, so a `redis://[::1]:6379` VALKEY_URL never connected (issue #464, measured on
|
|
103550
103561
|
// Fedora 44: ETIMEDOUT). The address is the part inside them.
|
|
@@ -103566,9 +103577,29 @@ function parseConnection(url, { failFast = false, servername = null, context = n
|
|
|
103566
103577
|
// don't buffer commands while disconnected -- error now
|
|
103567
103578
|
retryStrategy: (attempts) => attempts > 2 ? null : 200
|
|
103568
103579
|
// give up after ~2 tries
|
|
103569
|
-
} : {}
|
|
103580
|
+
} : {},
|
|
103581
|
+
// ioredis' resend of what was on a connection that died is off with the bound: the caller was told such a command
|
|
103582
|
+
// failed, and a resend on the next connection would make it run after that.
|
|
103583
|
+
...bounded ? { commandTimeout: commandTimeoutMs, autoResendUnfulfilledCommands: false } : {}
|
|
103570
103584
|
};
|
|
103571
103585
|
}
|
|
103586
|
+
var COMMAND_BOUND_HOOK = /* @__PURE__ */ Symbol.for("pi-dispatch.valkey-command-bound");
|
|
103587
|
+
if (!Redis.prototype[COMMAND_BOUND_HOOK]) {
|
|
103588
|
+
const sendCommand = Redis.prototype.sendCommand;
|
|
103589
|
+
Redis.prototype.sendCommand = function sendCommandWithBound(command, stream11) {
|
|
103590
|
+
const answer = sendCommand.call(this, command, stream11);
|
|
103591
|
+
if (this.options?.[JUDGE]?.commandBound !== true) return answer;
|
|
103592
|
+
command._commandTimeoutTimer?.unref?.();
|
|
103593
|
+
if (this.commandQueue?.peekBack?.()?.command !== command) return answer;
|
|
103594
|
+
answer?.catch?.((err) => {
|
|
103595
|
+
if (!err || typeof err !== "object" || err.name === "ReplyError" || err.valkeyRefused) return;
|
|
103596
|
+
if (err.message === "Command timed out") err.code ??= "ETIMEDOUT";
|
|
103597
|
+
err.valkeyMayStillRun = true;
|
|
103598
|
+
});
|
|
103599
|
+
return answer;
|
|
103600
|
+
};
|
|
103601
|
+
Redis.prototype[COMMAND_BOUND_HOOK] = true;
|
|
103602
|
+
}
|
|
103572
103603
|
var BoundedRedis = class extends Redis {
|
|
103573
103604
|
sendCommand(command, stream11) {
|
|
103574
103605
|
const answer = super.sendCommand(command, stream11);
|
|
@@ -103594,6 +103625,21 @@ function makeRedisClient(url, { servername = null, context = null, failFast = fa
|
|
|
103594
103625
|
return bounded ? new BoundedRedis(options) : new Redis(options);
|
|
103595
103626
|
}
|
|
103596
103627
|
var JUDGE = "piDispatchValkeyJudge";
|
|
103628
|
+
var FRESH_PASSWORD = /* @__PURE__ */ Symbol.for("pi-dispatch.valkey-fresh-password");
|
|
103629
|
+
var PASSWORD_DECLINED = /* @__PURE__ */ Symbol.for("pi-dispatch.valkey-password-declined");
|
|
103630
|
+
function sameValkeyServer(a, b) {
|
|
103631
|
+
const x2 = valkeyTarget(a);
|
|
103632
|
+
const y = valkeyTarget(b);
|
|
103633
|
+
return !x2.error && !y.error && x2.host === y.host && x2.port === y.port;
|
|
103634
|
+
}
|
|
103635
|
+
function freshPasswordVerdict(judge, context, held) {
|
|
103636
|
+
if (!context || context.error || context.absent === true) return { take: false, declined: null };
|
|
103637
|
+
if ((context.envPath ?? null) !== (judge?.context?.envPath ?? null)) return { take: false, declined: null };
|
|
103638
|
+
const { password, from } = valkeyPasswordFor(judge.url, context);
|
|
103639
|
+
const fromFile2 = from !== "VALKEY_URL" && from !== "the environment";
|
|
103640
|
+
if (fromFile2 && !sameValkeyServer(context.url?.file ?? DEFAULT_VALKEY_URL, judge.url)) return { take: false, declined: (password ?? null) !== (held ?? null) };
|
|
103641
|
+
return { take: true, password: password ?? null };
|
|
103642
|
+
}
|
|
103597
103643
|
var JudgedConnector = class _JudgedConnector extends AbstractConnector {
|
|
103598
103644
|
constructor(options) {
|
|
103599
103645
|
super(options.disconnectTimeout);
|
|
@@ -103603,6 +103649,55 @@ var JudgedConnector = class _JudgedConnector extends AbstractConnector {
|
|
|
103603
103649
|
check() {
|
|
103604
103650
|
return !this[DB_REFUSED];
|
|
103605
103651
|
}
|
|
103652
|
+
/**
|
|
103653
|
+
* Issue #845: a `freshPassword` client's password, as `valkeyPasswordFor` reads it from the context as it is now, put
|
|
103654
|
+
* where ioredis reads it for the connect about to start (the hook below). Here and not in `connect()`: by the time
|
|
103655
|
+
* ioredis asks the connector for a stream it has already built that connect's AUTH from `options.password`. The options
|
|
103656
|
+
* are the client's own object (ioredis hands its `options` to the Connector it constructs), so this is what the client
|
|
103657
|
+
* sends. A password from VALKEY_URL's userinfo comes back as it is, so it stays fixed. The last password is kept by a
|
|
103658
|
+
* context that:
|
|
103659
|
+
* - throws, or names an `error` (a `.env` that cannot be read, or a line in it the service's loader reads
|
|
103660
|
+
* differently): it cannot say which password the file holds, and dropping the one that worked would refuse a client
|
|
103661
|
+
* that is fine;
|
|
103662
|
+
* - finds no file (`absent`): a `.env` gone, for good or for the moment an editor replaces it, is not a file that
|
|
103663
|
+
* sets no password, so it does not drop one either (the console says to put the file back before a restart);
|
|
103664
|
+
* - would answer from the `.env` while that file does not name the Valkey this client dials: a password the file
|
|
103665
|
+
* gives (or the lack of one) belongs to the Valkey the file's VALKEY_URL names, the default one when it names
|
|
103666
|
+
* none, and a held client never moves to another Valkey. Review of PR #878, measured with two Valkeys: one
|
|
103667
|
+
* deployment moving to another Valkey changes both lines together, a symlinked deployment folder repointed reads
|
|
103668
|
+
* another deployment's file through the same path, and a shell that exports VALKEY_URL keeps the client on its URL
|
|
103669
|
+
* whatever the file says; in each the new Valkey's password went to the old one, which refused it, and the pages
|
|
103670
|
+
* lost the old Valkey too. The check is against the URL the client dials, not the context's own choice of URL
|
|
103671
|
+
* (`valkeyUrlFor`, which prefers this shell's and so never moved), and by the server each names (`sameValkeyServer`:
|
|
103672
|
+
* `valkeyTarget`'s host and port, so a scheme alias, a database path or a trailing `/` is the same Valkey, while
|
|
103673
|
+
* `localhost` and `127.0.0.1` stay two names, as `valkeyEndpointTag` keeps them). A rotation keeps the file's URL,
|
|
103674
|
+
* so it is taken, and so is a password a bad line hid at start once the line is fixed, when the file names the
|
|
103675
|
+
* client's Valkey. A password declined here that is not the one the client holds is recorded (`PASSWORD_DECLINED`,
|
|
103676
|
+
* `passwordDeclinedOf`), so the console names VALKEY_PASSWORD as needing a restart rather than going on refused in
|
|
103677
|
+
* silence (a shell that exports `redis://localhost:6440` over a file's `redis://127.0.0.1:6440`, say);
|
|
103678
|
+
* - is read from another `.env` path than the one the client was built with: the pi extension's context follows the
|
|
103679
|
+
* deployment pointer, and that alone says the file is another deployment's.
|
|
103680
|
+
* A password from this process's environment, or from VALKEY_URL's own userinfo, is not the file's, and is taken as it
|
|
103681
|
+
* comes. A context that passes all of these and gives no password drops it, as a restart would.
|
|
103682
|
+
*/
|
|
103683
|
+
[FRESH_PASSWORD]() {
|
|
103684
|
+
const judge = this.options?.[JUDGE];
|
|
103685
|
+
if (judge?.freshPassword !== true) return;
|
|
103686
|
+
let context;
|
|
103687
|
+
try {
|
|
103688
|
+
context = defaultValkeyContext();
|
|
103689
|
+
} catch {
|
|
103690
|
+
return;
|
|
103691
|
+
}
|
|
103692
|
+
const verdict = freshPasswordVerdict(judge, context, this.options.password);
|
|
103693
|
+
if (!verdict.take) {
|
|
103694
|
+
if (verdict.declined !== null) this[PASSWORD_DECLINED] = verdict.declined;
|
|
103695
|
+
return;
|
|
103696
|
+
}
|
|
103697
|
+
this[PASSWORD_DECLINED] = false;
|
|
103698
|
+
if (verdict.password) this.options.password = verdict.password;
|
|
103699
|
+
else delete this.options.password;
|
|
103700
|
+
}
|
|
103606
103701
|
connect() {
|
|
103607
103702
|
const { options } = this;
|
|
103608
103703
|
if (this[DB_REFUSED]) return Promise.reject(this[DB_REFUSED]);
|
|
@@ -103634,6 +103729,18 @@ var JudgedConnector = class _JudgedConnector extends AbstractConnector {
|
|
|
103634
103729
|
}
|
|
103635
103730
|
};
|
|
103636
103731
|
JudgedConnector.dial = { net: netConnect2, tls: tlsConnect };
|
|
103732
|
+
var FRESH_PASSWORD_HOOK = /* @__PURE__ */ Symbol.for("pi-dispatch.valkey-fresh-password-hook");
|
|
103733
|
+
if (!Redis.prototype[FRESH_PASSWORD_HOOK]) {
|
|
103734
|
+
const connect = Redis.prototype.connect;
|
|
103735
|
+
Redis.prototype.connect = function connectWithFreshPassword(...args) {
|
|
103736
|
+
try {
|
|
103737
|
+
this.connector?.[FRESH_PASSWORD]?.();
|
|
103738
|
+
} catch {
|
|
103739
|
+
}
|
|
103740
|
+
return connect.apply(this, args);
|
|
103741
|
+
};
|
|
103742
|
+
Redis.prototype[FRESH_PASSWORD_HOOK] = true;
|
|
103743
|
+
}
|
|
103637
103744
|
|
|
103638
103745
|
// ../worker/src/sandbox.mjs
|
|
103639
103746
|
import { execFile as execFile6, spawn as spawn2 } from "node:child_process";
|
|
@@ -106450,7 +106557,7 @@ var MCP_REFUSAL_COMMENT = Object.freeze({
|
|
|
106450
106557
|
"mcp-list-invalid": "the list this job carries is not one the triggers file's loader writes (a service in this deployment may be at another version)",
|
|
106451
106558
|
"mcp-config-invalid": "this deployment's list of MCP servers (mcp.json in the overlay) cannot be used",
|
|
106452
106559
|
"mcp-server-unknown": "this deployment does not offer that server",
|
|
106453
|
-
"mcp-secret-reserved": "that server would be handed a
|
|
106560
|
+
"mcp-secret-reserved": "that server would be handed a variable this deployment keeps for the job itself",
|
|
106454
106561
|
"mcp-secret-unmapped": "a credential that server needs is not passed by this trigger",
|
|
106455
106562
|
"mcp-excluded": "this trigger's run.excludeTools removes every way the job could reach that server",
|
|
106456
106563
|
"mcp-egress-unreachable": "the egress policy does not let a job reach that server",
|
|
@@ -106721,6 +106828,10 @@ var VENUE_RUNTIME = Object.freeze({ [DEFAULT_BACKEND]: "docker", [PODMAN_BACKEND
|
|
|
106721
106828
|
var NON_SECRET_ENV_KEYS = /* @__PURE__ */ new Set([
|
|
106722
106829
|
// Issue #693: a memory-derived concurrency count, never a credential; keep it in the structural venue env.
|
|
106723
106830
|
"PI_CODEMODE_MAX_WORKERS",
|
|
106831
|
+
// Issue #865: the job's size, two integers, never a secret. Listed, or OpenShell would put each in the job's credential
|
|
106832
|
+
// provider and the agent would read a placeholder where a tool's worker count needs the number.
|
|
106833
|
+
"PI_JOB_SIZE_CPUS",
|
|
106834
|
+
"PI_JOB_SIZE_MEMORY_MIB",
|
|
106724
106835
|
"PI_PROVIDER",
|
|
106725
106836
|
"PI_MODEL",
|
|
106726
106837
|
"PI_FORK_ENTRY",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edgehero/pi-dispatch-admin",
|
|
3
|
-
"version": "5.1
|
|
3
|
+
"version": "5.2.1",
|
|
4
4
|
"description": "Go from sitting next to one AI coding agent to running many in the background: the web console and /dispatch panel of pi-dispatch, over the queue, spend caps, scoped limits, run history and insights, editable cron and GitHub, GitLab, Forgejo and Azure DevOps triggers, pause windows, and human-confirmed tools your AI can operate.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|