@edgehero/pi-dispatch 5.2.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/package.json +1 -1
- package/src/auth-cli.mjs +76 -5
- package/src/cancel-cli.mjs +95 -6
- package/src/cancel-state.mjs +76 -17
- package/src/connection.mjs +207 -6
- package/src/doctor.mjs +23 -18
- package/src/env-allowlist.mjs +9 -1
- package/src/github-app-setup.mjs +17 -4
- package/src/host-loads.mjs +58 -4
- package/src/job-size.mjs +27 -0
- package/src/json-duplicates.mjs +53 -42
- package/src/mcp-preflight.mjs +2 -2
- package/src/reason-catalog.mjs +1 -1
- package/src/redact-secrets.mjs +4 -0
- package/src/reserved-env.mjs +4 -0
- package/src/sandbox-cli.mjs +9 -3
- package/src/sandbox-deployment.mjs +38 -5
- package/src/sandbox-prepare.mjs +16 -2
- package/src/sandbox.mjs +10 -3
- package/src/service-env.mjs +4 -3
- package/src/size-suggest.mjs +118 -8
- package/src/touch-outbox.mjs +5 -0
- package/src/valkey-endpoint.mjs +9 -6
package/src/connection.mjs
CHANGED
|
@@ -2,8 +2,8 @@ import { AbstractConnector, Command, Redis } from "ioredis";
|
|
|
2
2
|
import { Socket, connect as netConnect } from "node:net";
|
|
3
3
|
import { connect as tlsConnect } from "node:tls";
|
|
4
4
|
import { valkeyAuthRefusal } from "./valkey-auth.mjs";
|
|
5
|
-
import { valkeySchemeOf } from "./podman-stack.mjs";
|
|
6
|
-
import { decodedUserinfo, judgeValkeyAtStart as judgeEndpointAtStart, judgedEndpoint, killSwitchValkeyUrls, defaultValkeyContext, urlShown, useValkeyContext, valkeyClientContext, valkeyContextFromKeys, VALKEY_CONTEXT_KEYS, valkeyPasswordFor, valkeyDbRangeSentence, valkeyRefusal, valkeyUrlFor, valkeyUrlProblem } from "./valkey-endpoint.mjs";
|
|
5
|
+
import { valkeySchemeOf, valkeyTarget } from "./podman-stack.mjs";
|
|
6
|
+
import { DEFAULT_VALKEY_URL, decodedUserinfo, judgeValkeyAtStart as judgeEndpointAtStart, judgedEndpoint, killSwitchValkeyUrls, defaultValkeyContext, urlShown, useValkeyContext, valkeyClientContext, valkeyContextFromKeys, VALKEY_CONTEXT_KEYS, valkeyPasswordFor, valkeyDbRangeSentence, valkeyRefusal, valkeyUrlFor, valkeyUrlProblem } from "./valkey-endpoint.mjs";
|
|
7
7
|
|
|
8
8
|
// Re-exported for the receiver and the admin, which import this module from the package (issues #464 and #468).
|
|
9
9
|
export { killSwitchValkeyUrls, defaultValkeyContext, urlShown, useValkeyContext, valkeyClientContext, valkeyContextFromKeys, VALKEY_CONTEXT_KEYS, valkeyPasswordFor, valkeyUrlFor, valkeyUrlProblem };
|
|
@@ -89,6 +89,18 @@ if (!Redis.prototype[SCRUBS]) {
|
|
|
89
89
|
*/
|
|
90
90
|
export const DB_REFUSED = Symbol.for("pi-dispatch.valkey-db-refused");
|
|
91
91
|
const SELECT_HOOK = Symbol.for("pi-dispatch.valkey-select-refusal");
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The database a client holds a refusal of (`DB_REFUSED`), as `{ db }` (null where the client no longer says which), or
|
|
95
|
+
* null when it holds none. Issue #845: the refusal is held for the client's life, so only a restart of a long-lived
|
|
96
|
+
* process tries the database again, and the console says so on every page from this.
|
|
97
|
+
*/
|
|
98
|
+
export function refusedDatabaseOf(client) {
|
|
99
|
+
if (!client?.[DB_REFUSED]) return null;
|
|
100
|
+
const db = client.condition?.select ?? client.options?.db;
|
|
101
|
+
return { db: Number.isSafeInteger(db) ? db : null };
|
|
102
|
+
}
|
|
103
|
+
|
|
92
104
|
/** Whether `err` is a server's refusal of a SELECT for a database it does not have. */
|
|
93
105
|
function isSelectRangeError(err) {
|
|
94
106
|
return Boolean(err) && typeof err === "object" && String(err.command?.name ?? "").toLowerCase() === "select" && /DB index is out of range|invalid DB index/i.test(String(err.message ?? ""));
|
|
@@ -172,16 +184,30 @@ export function givesUp(connection) {
|
|
|
172
184
|
*
|
|
173
185
|
* `freshContext` is for a persistent client of a long-running surface that names no context: each connect judges by the
|
|
174
186
|
* context as it is then, not as it was at construction, so an operator's fix of the deployment's `.env` reaches a client
|
|
175
|
-
* that has been refused since it started. Off by default, so a worker's judgement stays the one it booted on.
|
|
176
|
-
* is
|
|
187
|
+
* that has been refused since it started. Off by default, so a worker's judgement stays the one it booted on. Its password
|
|
188
|
+
* is read again before each connect too (issue #845, `freshPassword` below), so a password rotated in that `.env`, or one
|
|
189
|
+
* a bad line kept from being read at start, is what the next connect sends. Not its URL: a changed `VALKEY_URL` is
|
|
190
|
+
* another Valkey, which a running client is never moved to (the console says it needs a restart for that instead).
|
|
191
|
+
*
|
|
192
|
+
* `commandTimeoutMs` (issue #867) is a bound on each command of a `failFast` client, for a one-shot BullMQ queue that
|
|
193
|
+
* must not wait for as long as a Valkey that is connected and stopped answering (`kill -STOP`, a partition) stays so:
|
|
194
|
+
* `failFast` refuses while nothing is connected, and a command already sent had no bound. A command past it rejects with
|
|
195
|
+
* ioredis' "Command timed out"; one that was sent may still run when Valkey wakes, so its error says so
|
|
196
|
+
* (`valkeyMayStillRun`, the hook below), and it is never sent again on a later connection. It needs `failFast`: that
|
|
197
|
+
* client has no offline queue, and on one that has, ioredis sends a command that timed out there once Valkey returns.
|
|
198
|
+
* `makeRedisClient` takes such a command out of its own queue (`BoundedRedis`), but BullMQ builds this client itself, so
|
|
199
|
+
* nothing could. Never for a Worker: its blocking reads wait longer than any bound by design, and its connection is a
|
|
200
|
+
* persistent one, which this refuses.
|
|
177
201
|
*/
|
|
178
|
-
export function parseConnection(url, { failFast = false, servername = null, context = null, judge = null, withoutPassword = false, freshContext = false } = {}) {
|
|
202
|
+
export function parseConnection(url, { failFast = false, servername = null, context = null, judge = null, withoutPassword = false, freshContext = false, commandTimeoutMs = null } = {}) {
|
|
179
203
|
const u = new URL(url);
|
|
180
204
|
// PR #478's gate: a path that is not a database number was `db: NaN`, and the SELECT ioredis then sent failed outside
|
|
181
205
|
// every caller's await (an unhandled rejection). Refused here, where every client of the project is made, as the
|
|
182
206
|
// configuration refusal it is; `judgeValkeyAtStart` says it first at each start.
|
|
183
207
|
const dbProblem = valkeyUrlProblem(url);
|
|
184
208
|
if (dbProblem) throw valkeyRefusal(dbProblem);
|
|
209
|
+
const bounded = Number.isSafeInteger(commandTimeoutMs) && commandTimeoutMs > 0;
|
|
210
|
+
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");
|
|
185
211
|
const where = context ?? defaultValkeyContext();
|
|
186
212
|
// Issue #468: the password this client sends, by `valkeyPasswordFor`'s rule (the URL's own, else VALKEY_PASSWORD from
|
|
187
213
|
// the environment, else from the deployment `.env` for a loopback host). Here and nowhere else, so every client of the
|
|
@@ -194,7 +220,9 @@ export function parseConnection(url, { failFast = false, servername = null, cont
|
|
|
194
220
|
// reports. `context` is where the client stands (`valkeyClientContext`), the process's own when not given.
|
|
195
221
|
Connector: JudgedConnector,
|
|
196
222
|
// `judge` is a test seam only: the function asked for the address (judgedEndpoint when not given).
|
|
197
|
-
|
|
223
|
+
// `freshPassword` (issue #845): a client that judges by the context as it is at each connect sends the password that
|
|
224
|
+
// context gives then, unless it was built to send none (`withoutPassword`, a probe of what another account sees).
|
|
225
|
+
[JUDGE]: { url, context: where, ...(judge ? { judge } : {}), ...(freshContext && !context ? { freshContext: true, ...(withoutPassword ? {} : { freshPassword: true }) } : {}), ...(failFast ? { failFast: true } : {}), ...(bounded ? { commandBound: true } : {}) },
|
|
198
226
|
// WHATWG URL keeps an IPv6 literal's brackets in `hostname` (`[::1]`), and net.connect then looks up the name
|
|
199
227
|
// "[::1]", which never resolves, so a `redis://[::1]:6379` VALKEY_URL never connected (issue #464, measured on
|
|
200
228
|
// Fedora 44: ETIMEDOUT). The address is the part inside them.
|
|
@@ -216,7 +244,42 @@ export function parseConnection(url, { failFast = false, servername = null, cont
|
|
|
216
244
|
retryStrategy: (attempts) => (attempts > 2 ? null : 200), // give up after ~2 tries
|
|
217
245
|
}
|
|
218
246
|
: {}),
|
|
247
|
+
// ioredis' resend of what was on a connection that died is off with the bound: the caller was told such a command
|
|
248
|
+
// failed, and a resend on the next connection would make it run after that.
|
|
249
|
+
...(bounded ? { commandTimeout: commandTimeoutMs, autoResendUnfulfilledCommands: false } : {}),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* What `BoundedRedis` does for its own clients, done for a client `parseConnection` built with a command bound (issue
|
|
255
|
+
* #867), which BullMQ constructs itself as a plain ioredis client, so it cannot be a subclass. On the prototype, like the
|
|
256
|
+
* scrub above, and inert for every client without the bound (a worker's, a receiver's, every `makeRedisClient` client).
|
|
257
|
+
*
|
|
258
|
+
* Two things. The command's timer is unref'd, since ioredis' is ref'd and a one-shot that has said its last line must not
|
|
259
|
+
* wait out a bound on a command nobody awaits. And a command that was WRITTEN (it is the newest entry of the client's
|
|
260
|
+
* command queue right after the send; without an offline queue, one that was not is refused at once and never sent) and
|
|
261
|
+
* then rejected with anything but Valkey's own reply carries `valkeyMayStillRun`: it reached a socket, so Valkey may run
|
|
262
|
+
* it when it wakes, or ran it before its answer was lost. Measured against the pinned ioredis: a SET past the bound on a
|
|
263
|
+
* `kill -STOP`ed Valkey rejects with "Command timed out" and is applied once the process is continued. Besides the
|
|
264
|
+
* timeout that is "Connection is closed.", for a command on a connection whose retries ran out. A refusal this project
|
|
265
|
+
* raised itself (`valkeyRefused`, a database the server does not have) is no lost answer.
|
|
266
|
+
*/
|
|
267
|
+
const COMMAND_BOUND_HOOK = Symbol.for("pi-dispatch.valkey-command-bound");
|
|
268
|
+
if (!Redis.prototype[COMMAND_BOUND_HOOK]) {
|
|
269
|
+
const sendCommand = Redis.prototype.sendCommand;
|
|
270
|
+
Redis.prototype.sendCommand = function sendCommandWithBound(command, stream) {
|
|
271
|
+
const answer = sendCommand.call(this, command, stream);
|
|
272
|
+
if (this.options?.[JUDGE]?.commandBound !== true) return answer;
|
|
273
|
+
command._commandTimeoutTimer?.unref?.();
|
|
274
|
+
if (this.commandQueue?.peekBack?.()?.command !== command) return answer;
|
|
275
|
+
answer?.catch?.((err) => {
|
|
276
|
+
if (!err || typeof err !== "object" || err.name === "ReplyError" || err.valkeyRefused) return;
|
|
277
|
+
if (err.message === "Command timed out") err.code ??= "ETIMEDOUT";
|
|
278
|
+
err.valkeyMayStillRun = true;
|
|
279
|
+
});
|
|
280
|
+
return answer;
|
|
219
281
|
};
|
|
282
|
+
Redis.prototype[COMMAND_BOUND_HOOK] = true;
|
|
220
283
|
}
|
|
221
284
|
|
|
222
285
|
/**
|
|
@@ -382,6 +445,60 @@ export function authRefusalFor(state, url, context) {
|
|
|
382
445
|
*/
|
|
383
446
|
const JUDGE = "piDispatchValkeyJudge";
|
|
384
447
|
|
|
448
|
+
/** The connector's method that reads a `freshPassword` client's password again (`JudgedConnector`, the hook below it). */
|
|
449
|
+
const FRESH_PASSWORD = Symbol.for("pi-dispatch.valkey-fresh-password");
|
|
450
|
+
|
|
451
|
+
/** Set on a connector whose last refresh declined a `.env` password other than the one its client holds (see there). */
|
|
452
|
+
const PASSWORD_DECLINED = Symbol.for("pi-dispatch.valkey-password-declined");
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Whether two Valkey URLs name one Valkey server, by `valkeyTarget`'s host and port (the scheme alias, a database path
|
|
456
|
+
* and a trailing `/` aside): a password belongs to the server. False for one that does not parse. `localhost` and
|
|
457
|
+
* `127.0.0.1` are two names here, as for `valkeyEndpointTag`: which address a name reaches is the judge's to say.
|
|
458
|
+
*/
|
|
459
|
+
function sameValkeyServer(a, b) {
|
|
460
|
+
const x = valkeyTarget(a);
|
|
461
|
+
const y = valkeyTarget(b);
|
|
462
|
+
return !x.error && !y.error && x.host === y.host && x.port === y.port;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* What a `freshPassword` client's refresh does with `context` as it is now (`JudgedConnector`'s method below has the
|
|
467
|
+
* rules): `{ take: true, password }` to send `password` (null: none) from the next connect on; `{ take: false, declined }`
|
|
468
|
+
* to keep the one it holds (`held`), where `declined` is true when the `.env` gives another password than `held` for a
|
|
469
|
+
* Valkey the client does not dial as it dials it, false when nothing is being held back, and null when the context
|
|
470
|
+
* cannot tell (it could not be read, finds no file, or is another deployment's). Pure: one rule for the refresh before
|
|
471
|
+
* each connect and for `passwordDeclinedOf`, so the two never disagree about what is held back.
|
|
472
|
+
*/
|
|
473
|
+
function freshPasswordVerdict(judge, context, held) {
|
|
474
|
+
if (!context || context.error || context.absent === true) return { take: false, declined: null };
|
|
475
|
+
if ((context.envPath ?? null) !== (judge?.context?.envPath ?? null)) return { take: false, declined: null };
|
|
476
|
+
const { password, from } = valkeyPasswordFor(judge.url, context);
|
|
477
|
+
const fromFile = from !== "VALKEY_URL" && from !== "the environment";
|
|
478
|
+
if (fromFile && !sameValkeyServer(context.url?.file ?? DEFAULT_VALKEY_URL, judge.url)) return { take: false, declined: (password ?? null) !== (held ?? null) };
|
|
479
|
+
return { take: true, password: password ?? null };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Issue #845, review of PR #878: whether a `freshPassword` client holds back a password the deployment's `.env` gives,
|
|
484
|
+
* other than the one it holds, because the file does not name the Valkey the client dials as it dials it. True only
|
|
485
|
+
* while a reconnect has declined one (recorded by the refresh, which runs on connect alone) AND the `.env` as it is now
|
|
486
|
+
* still gives such a password (`freshPasswordVerdict` over the context read now): a file put back to the held password,
|
|
487
|
+
* or changed to name the client's Valkey, clears it at once, with no reconnect, and so does a file that cannot be read
|
|
488
|
+
* now (the console says that instead). Reads the client's context once; touches no Valkey.
|
|
489
|
+
*/
|
|
490
|
+
export function passwordDeclinedOf(client) {
|
|
491
|
+
const connector = client?.connector;
|
|
492
|
+
if (connector?.[PASSWORD_DECLINED] !== true) return false;
|
|
493
|
+
const judge = connector.options?.[JUDGE];
|
|
494
|
+
if (judge?.freshPassword !== true) return false;
|
|
495
|
+
try {
|
|
496
|
+
return freshPasswordVerdict(judge, defaultValkeyContext(), connector.options.password).declined === true;
|
|
497
|
+
} catch {
|
|
498
|
+
return false;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
385
502
|
/**
|
|
386
503
|
* ioredis' connector for every client of this project (issue #464, gate round 2 follow-up): on each connect it asks
|
|
387
504
|
* `judgedEndpoint` for the address, then dials that literal (with a `rediss:` URL's name kept as the TLS servername),
|
|
@@ -408,6 +525,57 @@ export class JudgedConnector extends AbstractConnector {
|
|
|
408
525
|
return !this[DB_REFUSED];
|
|
409
526
|
}
|
|
410
527
|
|
|
528
|
+
/**
|
|
529
|
+
* Issue #845: a `freshPassword` client's password, as `valkeyPasswordFor` reads it from the context as it is now, put
|
|
530
|
+
* where ioredis reads it for the connect about to start (the hook below). Here and not in `connect()`: by the time
|
|
531
|
+
* ioredis asks the connector for a stream it has already built that connect's AUTH from `options.password`. The options
|
|
532
|
+
* are the client's own object (ioredis hands its `options` to the Connector it constructs), so this is what the client
|
|
533
|
+
* sends. A password from VALKEY_URL's userinfo comes back as it is, so it stays fixed. The last password is kept by a
|
|
534
|
+
* context that:
|
|
535
|
+
* - throws, or names an `error` (a `.env` that cannot be read, or a line in it the service's loader reads
|
|
536
|
+
* differently): it cannot say which password the file holds, and dropping the one that worked would refuse a client
|
|
537
|
+
* that is fine;
|
|
538
|
+
* - finds no file (`absent`): a `.env` gone, for good or for the moment an editor replaces it, is not a file that
|
|
539
|
+
* sets no password, so it does not drop one either (the console says to put the file back before a restart);
|
|
540
|
+
* - would answer from the `.env` while that file does not name the Valkey this client dials: a password the file
|
|
541
|
+
* gives (or the lack of one) belongs to the Valkey the file's VALKEY_URL names, the default one when it names
|
|
542
|
+
* none, and a held client never moves to another Valkey. Review of PR #878, measured with two Valkeys: one
|
|
543
|
+
* deployment moving to another Valkey changes both lines together, a symlinked deployment folder repointed reads
|
|
544
|
+
* another deployment's file through the same path, and a shell that exports VALKEY_URL keeps the client on its URL
|
|
545
|
+
* whatever the file says; in each the new Valkey's password went to the old one, which refused it, and the pages
|
|
546
|
+
* lost the old Valkey too. The check is against the URL the client dials, not the context's own choice of URL
|
|
547
|
+
* (`valkeyUrlFor`, which prefers this shell's and so never moved), and by the server each names (`sameValkeyServer`:
|
|
548
|
+
* `valkeyTarget`'s host and port, so a scheme alias, a database path or a trailing `/` is the same Valkey, while
|
|
549
|
+
* `localhost` and `127.0.0.1` stay two names, as `valkeyEndpointTag` keeps them). A rotation keeps the file's URL,
|
|
550
|
+
* 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
|
|
551
|
+
* client's Valkey. A password declined here that is not the one the client holds is recorded (`PASSWORD_DECLINED`,
|
|
552
|
+
* `passwordDeclinedOf`), so the console names VALKEY_PASSWORD as needing a restart rather than going on refused in
|
|
553
|
+
* silence (a shell that exports `redis://localhost:6440` over a file's `redis://127.0.0.1:6440`, say);
|
|
554
|
+
* - is read from another `.env` path than the one the client was built with: the pi extension's context follows the
|
|
555
|
+
* deployment pointer, and that alone says the file is another deployment's.
|
|
556
|
+
* A password from this process's environment, or from VALKEY_URL's own userinfo, is not the file's, and is taken as it
|
|
557
|
+
* comes. A context that passes all of these and gives no password drops it, as a restart would.
|
|
558
|
+
*/
|
|
559
|
+
[FRESH_PASSWORD]() {
|
|
560
|
+
const judge = this.options?.[JUDGE];
|
|
561
|
+
if (judge?.freshPassword !== true) return;
|
|
562
|
+
let context;
|
|
563
|
+
try {
|
|
564
|
+
context = defaultValkeyContext();
|
|
565
|
+
} catch {
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
const verdict = freshPasswordVerdict(judge, context, this.options.password);
|
|
569
|
+
if (!verdict.take) {
|
|
570
|
+
// Recorded only where the context could tell; `passwordDeclinedOf` reads it against the file as it is then.
|
|
571
|
+
if (verdict.declined !== null) this[PASSWORD_DECLINED] = verdict.declined;
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
this[PASSWORD_DECLINED] = false;
|
|
575
|
+
if (verdict.password) this.options.password = verdict.password;
|
|
576
|
+
else delete this.options.password;
|
|
577
|
+
}
|
|
578
|
+
|
|
411
579
|
connect() {
|
|
412
580
|
const { options } = this;
|
|
413
581
|
// A database the server refused stays refused: no reconnect goes ready on another one.
|
|
@@ -445,6 +613,39 @@ export class JudgedConnector extends AbstractConnector {
|
|
|
445
613
|
/** How `JudgedConnector` dials, net and tls: a seam, so a test sees the options a pinned connect is made with. */
|
|
446
614
|
JudgedConnector.dial = { net: netConnect, tls: tlsConnect };
|
|
447
615
|
|
|
616
|
+
/**
|
|
617
|
+
* Where a `freshPassword` client's password is read again (issue #845): before EVERY connect, its first and each
|
|
618
|
+
* reconnect, on the prototype like the hooks above, so a client ioredis or BullMQ duplicates keeps it too. Measured
|
|
619
|
+
* against the pinned ioredis 5.11.1 (`worker/test/valkey-clients.test.mjs` holds each line): `connect()` runs
|
|
620
|
+
* `_connect()`, which builds the connect's AUTH (`this.condition.auth`) from `this.options.password` before it asks the
|
|
621
|
+
* connector for a stream; a refused AUTH (WRONGPASS) and a refused ready check (NOAUTH) go to `recoverFromFatalError`,
|
|
622
|
+
* which disconnects to reconnect, and the reconnect timer calls `self.connect()`. So a rotated password reaches a client
|
|
623
|
+
* that has been refused since the rotation at its next try, by its own retry strategy, with no rebuild.
|
|
624
|
+
*
|
|
625
|
+
* Delegated to the client's connector, so the context read is the one of the copy of this module that built the client
|
|
626
|
+
* (a bundle carries its own copy beside the shared ioredis, and the first copy loaded installs this hook). Inert for every
|
|
627
|
+
* other client. A throw here would escape ioredis' reconnect timer, which does not catch one, so it is caught: the last
|
|
628
|
+
* password is kept.
|
|
629
|
+
*
|
|
630
|
+
* REJECTED: rebuilding the client when its password changes, which every holder of one (the console's audit copy, the
|
|
631
|
+
* fleet reader, a page's reads) would have to learn to swap; wrapping `connect` on each client `makeRedisClient` makes,
|
|
632
|
+
* which misses a duplicate and a client BullMQ builds from `parseConnection`'s options; and reading the password in
|
|
633
|
+
* `JudgedConnector.connect`, which is too late for that connect's AUTH (above).
|
|
634
|
+
*/
|
|
635
|
+
const FRESH_PASSWORD_HOOK = Symbol.for("pi-dispatch.valkey-fresh-password-hook");
|
|
636
|
+
if (!Redis.prototype[FRESH_PASSWORD_HOOK]) {
|
|
637
|
+
const connect = Redis.prototype.connect;
|
|
638
|
+
Redis.prototype.connect = function connectWithFreshPassword(...args) {
|
|
639
|
+
try {
|
|
640
|
+
this.connector?.[FRESH_PASSWORD]?.();
|
|
641
|
+
} catch {
|
|
642
|
+
// The last password stays.
|
|
643
|
+
}
|
|
644
|
+
return connect.apply(this, args);
|
|
645
|
+
};
|
|
646
|
+
Redis.prototype[FRESH_PASSWORD_HOOK] = true;
|
|
647
|
+
}
|
|
648
|
+
|
|
448
649
|
/**
|
|
449
650
|
* Whether `connection` (options for BullMQ, or an ioredis client) connects through `JudgedConnector`. `makeQueue` and the
|
|
450
651
|
* worker refuse any other, so no Queue or Worker of this project can be built on an unjudged connection.
|
package/src/doctor.mjs
CHANGED
|
@@ -143,7 +143,7 @@ import { assignCheckIds, buildDoctorRecord, doctorIntervalHours, publishRedactor
|
|
|
143
143
|
import { olderStateFindings as olderStateFindingsReal, VALKEY_CONTAINER } from "./older-state.mjs";
|
|
144
144
|
import { DOCTOR_PUBLISH_SKIPPED_PREFIX } from "./doctor-spawner.mjs";
|
|
145
145
|
import { extensionsNotHere } from "./loaded-record.mjs";
|
|
146
|
-
import { buildHostLoads, flowProbes, flowTierOf, mcpRowsOf, pathIsThere, readOverlayExtensionRows, readOverlaySkillNames } from "./host-loads.mjs";
|
|
146
|
+
import { buildHostLoads, FLOW_INPUT_FIELDS, flowBasisOf, flowInputOf, flowProbes, flowTierOf, mcpRowsOf, pathIsThere, readOverlayExtensionRows, readOverlaySkillNames } from "./host-loads.mjs";
|
|
147
147
|
|
|
148
148
|
const NODE_FLOOR = [22, 19]; // pi's engine floor (22.19.0)
|
|
149
149
|
|
|
@@ -2191,7 +2191,8 @@ export async function collectChecks(shellVars, seams) {
|
|
|
2191
2191
|
const staged = readStagedSkills({ globalPiDir: env.PI_GLOBAL_PI_DIR, readFile: stagedProbes.readFile, fileExists: stagedProbes.fileExists, readDir: stagedProbes.readDir });
|
|
2192
2192
|
const groups = new Map();
|
|
2193
2193
|
for (const f of flows) {
|
|
2194
|
-
|
|
2194
|
+
// Keyed by every field `flowTierOf` reads, so a group shares one answer and one basis.
|
|
2195
|
+
const key = JSON.stringify(FLOW_INPUT_FIELDS.map((field) => f[field]));
|
|
2195
2196
|
if (!groups.has(key)) groups.set(key, { ...f, labels: [], triggers: [] });
|
|
2196
2197
|
groups.get(key).labels.push(f.label);
|
|
2197
2198
|
groups.get(key).triggers.push(f.index);
|
|
@@ -2209,7 +2210,7 @@ export async function collectChecks(shellVars, seams) {
|
|
|
2209
2210
|
fix: "a flow name must match the skill charset (lowercase alphanumerics, - and _, 64 max), or no tier can ever hold it -- fix run.flow",
|
|
2210
2211
|
});
|
|
2211
2212
|
// No tier can hold it, and a name that fails the charset is not published.
|
|
2212
|
-
for (const trigger of g.triggers) seams.flowRows.push({ trigger, flow: null, tier: "none", package: null, repo: "unknown" });
|
|
2213
|
+
for (const trigger of g.triggers) seams.flowRows.push({ trigger, flow: null, tier: "none", package: null, repo: "unknown", basis: g.basis });
|
|
2213
2214
|
continue;
|
|
2214
2215
|
}
|
|
2215
2216
|
const probes = flowProbes({ fileExists, fs: flowFs });
|
|
@@ -2219,7 +2220,7 @@ export async function collectChecks(shellVars, seams) {
|
|
|
2219
2220
|
// trigger that withholds the packages did not, and another trigger's unreadable folder says nothing of this one's.
|
|
2220
2221
|
const blind = probes.blind() || (g.packages !== false && stagedProbes.blind());
|
|
2221
2222
|
const tier = found.tier === "none" && blind ? "unknown" : found.tier;
|
|
2222
|
-
for (const trigger of g.triggers) seams.flowRows.push({ trigger, flow: g.flow, tier, package: found.package, repo: found.repo });
|
|
2223
|
+
for (const trigger of g.triggers) seams.flowRows.push({ trigger, flow: g.flow, tier, package: found.package, repo: found.repo, basis: g.basis });
|
|
2223
2224
|
if (resolved) {
|
|
2224
2225
|
checks.push({ ok: true, label: `Trigger flow "${g.flow}" resolves (${at}: ${resolved})` });
|
|
2225
2226
|
} else {
|
|
@@ -6883,18 +6884,12 @@ function readTriggerFacts(env, fileExists, cwd, declaredWorkerName, versionTable
|
|
|
6883
6884
|
// two triggers naming the same flow with different skillsDirs are two different questions.
|
|
6884
6885
|
// The label is how a line names its trigger: cron entries by their id, id-less webhook
|
|
6885
6886
|
// entries by raw file position (the admin's trigger:<index> identity).
|
|
6887
|
+
// Issue #869: what `flowTierOf` is asked and the basis each published row carries come from the one module the console
|
|
6888
|
+
// computes its basis with (`flowInputOf`, `flowBasisOf`), from the trigger as the loader parsed it, so the two cannot
|
|
6889
|
+
// read a field two ways. A command trigger and a disarmed one-shot name no flow and drop out here.
|
|
6886
6890
|
flows: triggers
|
|
6887
|
-
.map((t, index) => ({
|
|
6888
|
-
|
|
6889
|
-
label: triggerLabel(t, index),
|
|
6890
|
-
flow: t.run.flow,
|
|
6891
|
-
kind: t.run.kind,
|
|
6892
|
-
folder: typeof t.run.folder === "string" ? t.run.folder : null,
|
|
6893
|
-
skillsDir: typeof t.run.skillsDir === "string" ? t.run.skillsDir : null,
|
|
6894
|
-
packages: t.run.packages !== false,
|
|
6895
|
-
excludeExtensions: Array.isArray(t.run.excludeExtensions) ? t.run.excludeExtensions : [],
|
|
6896
|
-
}))
|
|
6897
|
-
.filter((f) => typeof f.flow === "string"),
|
|
6891
|
+
.map((t, index) => ({ index, label: triggerLabel(t, index), kind: t.run.kind, ...flowInputOf(t), basis: flowBasisOf(t) }))
|
|
6892
|
+
.filter((f) => f.flow !== null),
|
|
6898
6893
|
// Issue #800: each trigger's run.excludeExtensions, for the line that says a name is on neither side of this host.
|
|
6899
6894
|
excludedNames: triggers.flatMap((t, index) => (Array.isArray(t.run.excludeExtensions) ? [{ label: triggerLabel(t, index), names: t.run.excludeExtensions, packages: t.run.packages !== false }] : [])),
|
|
6900
6895
|
// Issue #501: every trigger that sets run.maxCostUsd, with the label the cap check names it by. The value
|
|
@@ -9804,7 +9799,12 @@ export function sizeSuggestionChecks({ projects = [], limits = [], env = {}, rec
|
|
|
9804
9799
|
const words = suggestionEvidence(s);
|
|
9805
9800
|
const size = `${formatMemory(current.memMiB)}, ${cpusText(current.cpuCenti)}`;
|
|
9806
9801
|
const call = suggestionCall(s, limits);
|
|
9807
|
-
|
|
9802
|
+
// Issue #865: `memoryCeiling` rides after the suggestion and BEFORE the two facts: it comes only with an OOM, whose
|
|
9803
|
+
// line is a warning, and a warn or fail label (the first DOCTOR_DETAILED_MAX, 80, of a run) is published cut at
|
|
9804
|
+
// DOCTOR_LABEL_MAX, so what a cut takes first is the facts (information with no call) and then this pointer's tail,
|
|
9805
|
+
// never a held raise, a refusal or a budget note, which come before it, nor the call: a raise carries its call in
|
|
9806
|
+
// its fix, and a lowering, an ok line with no model proposal, is published as its id and level only.
|
|
9807
|
+
const facts = [words.memoryCeiling, words.memoryFact, words.cpuFact].filter(Boolean);
|
|
9808
9808
|
const factTail = facts.length > 0 ? `; ${facts.join("; ")}` : "";
|
|
9809
9809
|
const held = s.memory.suggested === null && s.memory.held !== null;
|
|
9810
9810
|
if (call === null && !held) {
|
|
@@ -9828,7 +9828,12 @@ export function sizeSuggestionChecks({ projects = [], limits = [], env = {}, rec
|
|
|
9828
9828
|
const note = totals.length > 0 ? ` (note: ${totals.join(" and ")}; its budget is off or unknown, so the worker admits it)` : "";
|
|
9829
9829
|
const raise = (s.memory.suggested ?? 0) > current.memMiB || (s.cpu.suggested ?? 0) > current.cpuCenti;
|
|
9830
9830
|
const suggests = parts.length > 0 ? `its runs in the last ${SUGGEST_WINDOW_DAYS} days suggest ${parts.join(" and ")}` : "";
|
|
9831
|
-
|
|
9831
|
+
// The label without its facts, so a line that carries its call in the label (a lowering, below) prints the call
|
|
9832
|
+
// right after the suggestion it applies, and the no-call facts after that (#865, review). Reading order in the
|
|
9833
|
+
// terminal only: a lowering is an ok check with no model proposal, which `buildDoctorRecord` (doctor-results.mjs,
|
|
9834
|
+
// INT-DOCTOR-RESULTS-KEY) publishes as its id and level, with no label, so no publish cut ever reaches it.
|
|
9835
|
+
const head = `project ${id}: size ${size}; ${[heldText ? `in the last ${SUGGEST_WINDOW_DAYS} days ${heldText}` : "", suggests].filter(Boolean).join("; ")}${over.length > 0 ? `, but ${over.join(" and ")}, so a job of it would never fit this host` : ""}${note}`;
|
|
9836
|
+
const label = `${head}${factTail}`;
|
|
9832
9837
|
// a size that would never fit is never offered as a call: applying it would turn a refusal into the same refusal.
|
|
9833
9838
|
const apply = call === null ? "" : over.length > 0 ? "" : `apply it in the admin panel with ${call} (an operator confirms it; nothing applies a size by itself)`;
|
|
9834
9839
|
const never = over.length > 0 ? "no call is offered: a job of that size would never fit this host" : "";
|
|
@@ -9839,7 +9844,7 @@ export function sizeSuggestionChecks({ projects = [], limits = [], env = {}, rec
|
|
|
9839
9844
|
} else if (raise || over.length > 0 || totals.length > 0) {
|
|
9840
9845
|
checks.push({ ok: false, warn: true, label, fix: apply || never });
|
|
9841
9846
|
} else {
|
|
9842
|
-
checks.push({ ok: true, label: `${
|
|
9847
|
+
checks.push({ ok: true, label: `${head}; ${apply.replace(/ \(an operator confirms it; nothing applies a size by itself\)$/, "")}${factTail}` });
|
|
9843
9848
|
}
|
|
9844
9849
|
}
|
|
9845
9850
|
return checks;
|
package/src/env-allowlist.mjs
CHANGED
|
@@ -30,7 +30,7 @@ import { homedir } from "node:os";
|
|
|
30
30
|
import { join } from "node:path";
|
|
31
31
|
import { findEnvKeys } from "@earendil-works/pi-ai/compat";
|
|
32
32
|
import { getBuiltinProviders } from "@earendil-works/pi-ai/providers/all";
|
|
33
|
-
import { codemodeWorkersOf, DEFAULT_JOB_SIZE } from "./job-size.mjs";
|
|
33
|
+
import { codemodeWorkersOf, DEFAULT_JOB_SIZE, jobSizeEnv } from "./job-size.mjs";
|
|
34
34
|
import { egressEnv } from "./egress.mjs";
|
|
35
35
|
import { RECEIVER_ONLY_VARS, forgeSpec } from "./forges.mjs";
|
|
36
36
|
import { apiKeyVariable } from "./provider-key.mjs";
|
|
@@ -637,6 +637,14 @@ export function buildContainerEnv({ size = DEFAULT_JOB_SIZE, provider, secondary
|
|
|
637
637
|
// env-internal PI_CODEMODE_MAX_WORKERS: written into the job's closed env map here, never read from the worker's environment.
|
|
638
638
|
env.PI_CODEMODE_MAX_WORKERS = String(codemodeWorkersOf(size.memMiB));
|
|
639
639
|
|
|
640
|
+
// Issue #865: the job's size, `PI_JOB_SIZE_CPUS` (its CPUs rounded up) and `PI_JOB_SIZE_MEMORY_MIB`, from the same
|
|
641
|
+
// resolved size as the container's flags (`jobSizeEnv`, which the sandbox shell shares). Inside the container a CPU
|
|
642
|
+
// count reads as the host ceiling (`availableParallelism()`) or the whole runtime (`nproc`), never the size, so these
|
|
643
|
+
// are the one place a flow or an image can size a per-CPU worker pool from. Information, not a bound, and read from
|
|
644
|
+
// no payload. Set after forwarding and secrets, for PI_CODEMODE_MAX_WORKERS' reason: both names are reserved in
|
|
645
|
+
// CONTAINER_ENV_NAMES, and this is the backstop if a refusal upstream is ever bypassed.
|
|
646
|
+
Object.assign(env, jobSizeEnv(size));
|
|
647
|
+
|
|
640
648
|
// Issue #500: the runner sets these in its own environment, for its child processes, and no value from outside
|
|
641
649
|
// may arrive first. Both lists refuse them upstream (config.mjs for PI_FORWARD_ENV, the triggers loader for
|
|
642
650
|
// run.secrets, through RUNNER_ENV_NAMES); this is the line that holds if either refusal is ever bypassed.
|
package/src/github-app-setup.mjs
CHANGED
|
@@ -49,6 +49,11 @@ const API_ROOT = "https://api.github.com";
|
|
|
49
49
|
const USER_AGENT = "pi-dispatch-setup-github";
|
|
50
50
|
// The repo homepage, shown on the App's about page — informational, not a trust anchor.
|
|
51
51
|
const HOMEPAGE = "https://github.com/edgehero/pi-dispatch";
|
|
52
|
+
// The webhook URL of an App made with --no-webhook (issue #874). GitHub requires one even for an inactive hook. A
|
|
53
|
+
// `.invalid` name (RFC 6761) never resolves, so nothing GitHub sends there reaches any host: not the ping an inactive
|
|
54
|
+
// hook still got at creation (in the measurement that `buildManifest`'s comment cites), and not an event if someone
|
|
55
|
+
// ticks Active later without changing the URL. A real host would get both, and a domain can change owner.
|
|
56
|
+
export const NO_WEBHOOK_URL = "https://no-webhook.invalid/";
|
|
52
57
|
// GitHub app names are GLOBALLY unique and capped at 34 characters.
|
|
53
58
|
const NAME_MAX = 34;
|
|
54
59
|
|
|
@@ -363,7 +368,11 @@ function printSummary(say, summary, { noWebhook }) {
|
|
|
363
368
|
say("\nNext:\n");
|
|
364
369
|
if (noWebhook) {
|
|
365
370
|
say(" the App was created with its webhook INACTIVE (--no-webhook) — the polling-ready shape.\n");
|
|
366
|
-
say("
|
|
371
|
+
say(" start the poller so events reach the queue: `pi-dispatch-receiver poll`. Under GITHUB_AUTH_SOURCE=app it\n");
|
|
372
|
+
say(" watches the repositories of the installation GITHUB_APP_INSTALLATION_ID names; POLL_REPOS names a list of\n");
|
|
373
|
+
say(" your own instead. See docs/polling.md.\n");
|
|
374
|
+
say(` the App's webhook URL is a placeholder, ${NO_WEBHOOK_URL}, which nothing can receive. Moving to webhooks\n`);
|
|
375
|
+
say(" later takes a webhook secret and an order of steps: docs/polling.md, \"Moving to webhooks later\".\n");
|
|
367
376
|
} else {
|
|
368
377
|
say(" start the receiver so deliveries have somewhere to land: `pi-dispatch-receiver`\n");
|
|
369
378
|
say(" (or, where the folder holds deploy/docker-compose.yml, its receiver profile), reachable at the --webhook-url you gave GitHub.\n");
|
|
@@ -402,8 +411,12 @@ export function sanitizeAppName(raw) {
|
|
|
402
411
|
* The manifest GitHub converts into the App — the narrowest shape this system needs
|
|
403
412
|
* (SECURITY.md's auth-source ladder): write on exactly the three surfaces jobs touch, metadata read
|
|
404
413
|
* because the API requires it, private, and only the event families the triggers file can name.
|
|
405
|
-
* `hook_attributes` carries the receiver URL, or
|
|
406
|
-
* no
|
|
414
|
+
* `hook_attributes` carries the receiver URL, or under --no-webhook an INACTIVE hook at `NO_WEBHOOK_URL`. GitHub
|
|
415
|
+
* refuses a manifest whose `hook_attributes` has no `url`, inactive or not ("url" wasn't supplied, issue #874), and one
|
|
416
|
+
* that leaves `hook_attributes` out while it subscribes events ("Hook url cannot be blank"). What it accepts was
|
|
417
|
+
* measured against github.com on 2026-11-09 by another project's throwaway Apps: a `.invalid` URL with
|
|
418
|
+
* `active: false` was accepted, only `localhost` and `127.0.0.1` were refused as not reachable over the public
|
|
419
|
+
* Internet, and the App with the inactive hook recorded one delivery, its creation's ping, and no event after it.
|
|
407
420
|
*
|
|
408
421
|
* `pull_request_review` (issue #66) is subscribed for every new App, armed trigger or not. That is the
|
|
409
422
|
* posture `pull_request` already has for a label-only deployment: an unsubscribed event cannot be turned
|
|
@@ -415,7 +428,7 @@ export function buildManifest({ name, redirectUrl, webhookUrl }) {
|
|
|
415
428
|
name,
|
|
416
429
|
url: HOMEPAGE,
|
|
417
430
|
redirect_url: redirectUrl,
|
|
418
|
-
hook_attributes: webhookUrl ? { url: webhookUrl } : { active: false },
|
|
431
|
+
hook_attributes: webhookUrl ? { url: webhookUrl } : { url: NO_WEBHOOK_URL, active: false },
|
|
419
432
|
public: false,
|
|
420
433
|
default_permissions: { contents: "write", pull_requests: "write", issues: "write", metadata: "read" },
|
|
421
434
|
default_events: ["issues", "issue_comment", "pull_request", "pull_request_review"],
|
package/src/host-loads.mjs
CHANGED
|
@@ -14,9 +14,11 @@
|
|
|
14
14
|
* charset, on the way in AND on the way out, since another host wrote what a reader parses. An overlay extension's first
|
|
15
15
|
* path segment and whether it is a file or a folder; a skill's folder name; a server's name, transport, where it is
|
|
16
16
|
* (`mcpServerWhere`: a url's host and port, or a command's base name, never a header, an argument or a value), exposure,
|
|
17
|
-
* whether it is on, and the variable NAMES it references; a package's name and version; a trigger's position, flow name
|
|
18
|
-
* the tier it resolved in
|
|
19
|
-
*
|
|
17
|
+
* whether it is on, and the variable NAMES it references; a package's name and version; a trigger's position, flow name,
|
|
18
|
+
* the tier it resolved in and a short hex digest of the trigger fields that answer was judged from (`flowBasisOf`). No path,
|
|
19
|
+
* no value and no tool name: what a server or an extension offers is known only once it runs, and the run's own record says
|
|
20
|
+
* that. The digest holds a path (`run.folder`, `run.skillsDir`) only as a hash cut to 8 hex digits, which names nothing a
|
|
21
|
+
* reader can read back. These are the same facts the viewer's Connections page shows; a model's
|
|
20
22
|
* projection (`modelConnections`) carries counts in place of `where` and the key names, and one of these rows gets the
|
|
21
23
|
* same treatment when a tool reads it.
|
|
22
24
|
*
|
|
@@ -27,6 +29,7 @@
|
|
|
27
29
|
* folders on the host and runs nothing: a frontmatter `name:` rename is invisible, and what a run really loaded is its
|
|
28
30
|
* own record's `loaded`. Where a read cannot say, it says so (`unread`), never an empty list that reads as none.
|
|
29
31
|
*/
|
|
32
|
+
import { createHash } from "node:crypto";
|
|
30
33
|
import * as nodeFs from "node:fs";
|
|
31
34
|
import { join, resolve, sep } from "node:path";
|
|
32
35
|
import { DOCTOR_HOSTS, DOCTOR_HOSTS_MAX, DOCTOR_RESULTS_TTL_MS } from "./doctor-results.mjs";
|
|
@@ -73,6 +76,52 @@ const KEY_NAME_RE = /^[A-Z_][A-Z0-9_]{0,63}$/;
|
|
|
73
76
|
const WHERE_RE = /^[A-Za-z0-9._:[\]-]{1,255}$/;
|
|
74
77
|
const INDEX_MAX = 1_000_000;
|
|
75
78
|
|
|
79
|
+
/**
|
|
80
|
+
* The trigger fields `flowTierOf` reads, by the names its first argument gives them (issue #869). A flow row's `basis` is a
|
|
81
|
+
* digest of exactly these, so a test holds this list to that argument's own names: a field `flowTierOf` came to read that
|
|
82
|
+
* this list left out would let an edit to it keep the console answering for the file as it was.
|
|
83
|
+
*/
|
|
84
|
+
export const FLOW_INPUT_FIELDS = Object.freeze(["flow", "folder", "skillsDir", "packages", "excludeExtensions"]);
|
|
85
|
+
/**
|
|
86
|
+
* How long a basis is, in hex digits: 32 bits. A collision costs one stale answer read as current, which is what every edit
|
|
87
|
+
* cost before the basis, and the triggers file has no author but the operator, so nobody gains by aiming for one. Per edit the
|
|
88
|
+
* odds are one in about four billion. REJECTED: a longer digest, which a flow row carries 128 times over in a record that already
|
|
89
|
+
* trims its flow rows at its worst (`FIT_ORDER`), so each digit would put a trigger's answer out of the record on such a host.
|
|
90
|
+
*/
|
|
91
|
+
export const FLOW_BASIS_HEX = 8;
|
|
92
|
+
const FLOW_BASIS_RE = new RegExp(`^[0-9a-f]{${FLOW_BASIS_HEX}}$`);
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* What `flowTierOf` is asked of a trigger as the loader parsed it (`parseTriggers`' entry): doctor's flow lines and its published
|
|
96
|
+
* rows ask it this, and nothing else does. A field that is not there reads as `flowTierOf`'s own default. A disarmed one-shot is
|
|
97
|
+
* the loader's sentinel, whose `run` is empty, so it names no flow and doctor publishes no row for it.
|
|
98
|
+
*/
|
|
99
|
+
export function flowInputOf(trigger) {
|
|
100
|
+
const run = isObject(trigger?.run) ? trigger.run : {};
|
|
101
|
+
return {
|
|
102
|
+
flow: typeof run.flow === "string" ? run.flow : null,
|
|
103
|
+
folder: typeof run.folder === "string" ? run.folder : null,
|
|
104
|
+
skillsDir: typeof run.skillsDir === "string" ? run.skillsDir : null,
|
|
105
|
+
packages: run.packages !== false,
|
|
106
|
+
excludeExtensions: Array.isArray(run.excludeExtensions) ? run.excludeExtensions.filter((name) => typeof name === "string") : [],
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The basis of a flow row (issue #869): a digest of what `flowTierOf` was asked of this trigger, from the trigger as the loader
|
|
112
|
+
* parsed it. Doctor writes it beside each answer, and the console computes it from its own parse of the file it reads, through
|
|
113
|
+
* this one function, so the two cannot compute it two ways. Where they differ, the answer was judged for another version of the file.
|
|
114
|
+
* `flowTierOf` only asks whether a name is in `excludeExtensions`, so the list's order and a repeat change nothing here either.
|
|
115
|
+
* REJECTED: leaving the list out under `"packages": false`, where `flowTierOf` does not look at it. An edit there would then keep
|
|
116
|
+
* the answer, at the price of a copy of `flowTierOf`'s branches here that would have to move with them, and the cost of not doing
|
|
117
|
+
* it is an answer read as unknown until doctor runs on the file as it is, the safe direction.
|
|
118
|
+
*/
|
|
119
|
+
export function flowBasisOf(trigger) {
|
|
120
|
+
const input = flowInputOf(trigger);
|
|
121
|
+
const canonical = FLOW_INPUT_FIELDS.map((field) => (field === "excludeExtensions" ? [...new Set(input[field])].sort() : input[field]));
|
|
122
|
+
return createHash("sha256").update(JSON.stringify(canonical)).digest("hex").slice(0, FLOW_BASIS_HEX);
|
|
123
|
+
}
|
|
124
|
+
|
|
76
125
|
/** A first path segment: the shape a run's record holds, less the two a folder listing never returns and a path could. */
|
|
77
126
|
const extensionOf = (row) => (isObject(row) && typeof row.name === "string" && EXTENSION_NAME_RE.test(row.name) && row.name !== "." && row.name !== ".." && LOADS_EXTENSION_KINDS.includes(row.kind) ? { name: row.name, kind: row.kind } : null);
|
|
78
127
|
const skillOf = (name) => (typeof name === "string" && SKILL_NAME_RE.test(name) ? name : null);
|
|
@@ -83,7 +132,10 @@ const flowOf = (row) => {
|
|
|
83
132
|
if (flow === null && row.flow !== null) return null;
|
|
84
133
|
const pkg = row.package === null ? null : isPackageName(row.package) ? row.package : undefined;
|
|
85
134
|
if (pkg === undefined || (pkg !== null && row.tier !== "package")) return null;
|
|
86
|
-
|
|
135
|
+
// Absent is a row from a release before issue #869, which a reader answers from as it did. Any other shape is refused, never
|
|
136
|
+
// read as absent: that would put a row of unknown basis back on the older rule, which answers whatever the file now says.
|
|
137
|
+
if (row.basis !== undefined && (typeof row.basis !== "string" || !FLOW_BASIS_RE.test(row.basis))) return null;
|
|
138
|
+
return { trigger: row.trigger, flow, tier: row.tier, package: pkg, repo: row.repo, ...(row.basis === undefined ? {} : { basis: row.basis }) };
|
|
87
139
|
};
|
|
88
140
|
/** One server's row, or null, with how many of its keys its cap left out. */
|
|
89
141
|
const serverOf = (row) => {
|
|
@@ -346,6 +398,8 @@ export function hostsAgree(rows) {
|
|
|
346
398
|
* resolved, checked, unknown the words of doctor's own line, which name folders and so are never published
|
|
347
399
|
*
|
|
348
400
|
* `repoFlowAtHead(folder, flow)` answers `present`, `absent` or anything else for not known. `staged` is `readStagedSkills`.
|
|
401
|
+
* The first argument is `flowInputOf`'s, and its names are `FLOW_INPUT_FIELDS`: a new one goes in both, or a flow row's basis
|
|
402
|
+
* would not change when it does (`worker/test/host-loads.test.mjs` reads them off this signature).
|
|
349
403
|
*/
|
|
350
404
|
export async function flowTierOf({ flow, folder = null, skillsDir = null, packages = true, excludeExtensions = [] }, { repoFlowAtHead, fileExists, overlay = null, staged = { skills: [], unenumerable: [] } }) {
|
|
351
405
|
let tier = null;
|
package/src/job-size.mjs
CHANGED
|
@@ -106,6 +106,33 @@ export function codemodeWorkersOf(memMiB) {
|
|
|
106
106
|
return Math.max(1, Math.floor(memMiB / (2 * CODEMODE_WORKER_HEAP_MIB)));
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Issue #865: a size's CPUs as a whole count, rounded UP (`1.5` is 2, `0.25` is 1), for a tool's `-j` or worker flag,
|
|
111
|
+
* which takes no fraction. Up, not down, so no size reads as 0 and a size between two counts gets the larger one: it is
|
|
112
|
+
* a pool size under a CPU WEIGHT, and the job may already use idle cores beyond its size (the `--cpus` ceiling above).
|
|
113
|
+
*/
|
|
114
|
+
export function wholeCpusOf(cpuCenti) {
|
|
115
|
+
return Math.ceil(cpuCenti / 100);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Issue #865: the job's size as its own processes can read it, `{ PI_JOB_SIZE_CPUS, PI_JOB_SIZE_MEMORY_MIB }`, both
|
|
120
|
+
* decimal strings. A job's CPU size is a weight that nothing inside the container can see: `os.availableParallelism()`
|
|
121
|
+
* reads the cgroup's `cpu.max`, which is the host ceiling (`--cpus`, the same for every job), and `nproc` reads the
|
|
122
|
+
* affinity mask, which is the whole runtime. So a tool that starts one worker per CPU it sees (jest and vitest by
|
|
123
|
+
* default) starts one per CEILING CPU, all inside the size's memory, which is the hard bound. These two values are what
|
|
124
|
+
* a flow, an image or the agent's commands size such a pool from instead.
|
|
125
|
+
*
|
|
126
|
+
* INFORMATION, NOT A BOUND: the size is resolved at pickup and enforced by the container's flags; nothing reads these
|
|
127
|
+
* back. One function, so the job's closed env map (`env-allowlist.mjs`) and the operator's sandbox shell
|
|
128
|
+
* (`sandbox.mjs`) cannot spell them two ways. Not `PI_JOB_CPUS` and `PI_JOB_MEMORY`: those name the DEPLOYMENT's default
|
|
129
|
+
* size in `.env`, and a project row can give a job another one.
|
|
130
|
+
*/
|
|
131
|
+
export function jobSizeEnv(size) {
|
|
132
|
+
assertJobSize(size);
|
|
133
|
+
return { PI_JOB_SIZE_CPUS: String(wholeCpusOf(size.cpuCenti)), PI_JOB_SIZE_MEMORY_MIB: String(size.memMiB) };
|
|
134
|
+
}
|
|
135
|
+
|
|
109
136
|
/** The scope prefix of a project row, as `scoped-limits.mjs` spells it (`PROJECT_SCOPE_PREFIX`; a test holds them equal). */
|
|
110
137
|
const PROJECT_ROW_PREFIX = "project:";
|
|
111
138
|
|