@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.
@@ -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 !== "ENOENT") error = `${envPath} could not be read (${err?.code ?? err?.message})`;
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
- [JUDGE]: { url, context: where, ...judge ? { judge } : {}, ...freshContext && !context ? { freshContext: true } : {}, ...failFast ? { failFast: true } : {} },
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 credential this deployment keeps for the job itself",
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.0",
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",