@edgehero/pi-dispatch 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/package.json +1 -1
- package/src/auth-cli.mjs +76 -5
- package/src/cancel-cli.mjs +193 -16
- package/src/cancel-state.mjs +85 -20
- package/src/connection.mjs +214 -6
- package/src/doctor-bundles.mjs +6 -2
- package/src/doctor-model-proposals.mjs +4 -2
- package/src/doctor.mjs +225 -45
- 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/pause-windows.mjs +19 -3
- 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/cancel-state.mjs
CHANGED
|
@@ -86,12 +86,18 @@ export function withdrawCancel({ redis, jobId, interventionOrigin = "human" }) {
|
|
|
86
86
|
* already own a could-not-reach-Valkey message, and swallowing here would turn an unreachable Valkey into
|
|
87
87
|
* a lying "no worker acknowledged".
|
|
88
88
|
*
|
|
89
|
-
* `pollMs`/`sleep` are injectable so tests never wait on a wall clock.
|
|
89
|
+
* `pollMs`/`sleep` are injectable so tests never wait on a wall clock. The wait ends at `ackTimeoutMs` by whichever runs
|
|
90
|
+
* out first: the sleeps it took, or the time `now` says has passed (issue #846). Counting sleeps alone, a GET that waited
|
|
91
|
+
* out a Valkey restart in the client's queue was not counted at all, so a 10 s wait could last as long as the outage did.
|
|
90
92
|
*/
|
|
91
|
-
export async function requestCancel({ redis, jobId, interventionOrigin = "human", ackTimeoutMs = 10_000, pollMs = 250, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) }) {
|
|
93
|
+
export async function requestCancel({ redis, jobId, interventionOrigin = "human", ackTimeoutMs = 10_000, pollMs = 250, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = Date.now, onWritten = null }) {
|
|
92
94
|
const requestKey = cancelRequestKeyOf(jobId, interventionOrigin);
|
|
93
95
|
const ackKey = interventionOrigin === "agent" ? agentCancelAckKey(jobId) : cancelAckKey(jobId);
|
|
94
96
|
await redis.set(requestKey, interventionOrigin === "agent" ? "agent-cancel" : "operator-cancel", "PX", CANCEL_REQ_TTL_MS);
|
|
97
|
+
// When the request's TTL started, for a caller that names until when it may stand (the CLI, issue #846), and the start
|
|
98
|
+
// of the wait for its acknowledgment, so a slow SET is not counted against it.
|
|
99
|
+
onWritten?.();
|
|
100
|
+
const deadline = now() + ackTimeoutMs;
|
|
95
101
|
for (let waited = 0; ; waited += pollMs) {
|
|
96
102
|
let host;
|
|
97
103
|
try {
|
|
@@ -102,7 +108,7 @@ export async function requestCancel({ redis, jobId, interventionOrigin = "human"
|
|
|
102
108
|
throw err;
|
|
103
109
|
}
|
|
104
110
|
if (host !== null && host !== undefined) return { ack: host };
|
|
105
|
-
if (waited + pollMs > ackTimeoutMs) break;
|
|
111
|
+
if (waited + pollMs > ackTimeoutMs || now() >= deadline) break;
|
|
106
112
|
await sleep(pollMs);
|
|
107
113
|
}
|
|
108
114
|
// Losing this DEL leaves the request standing for its 60s TTL, where a worker can still read it, so that is no "nothing was
|
|
@@ -137,17 +143,38 @@ export function ranBefore(job) {
|
|
|
137
143
|
|
|
138
144
|
/**
|
|
139
145
|
* Whether the hold is still there, read back after its delete failed: its CLOCK (`since`), the field that makes the hash
|
|
140
|
-
* a hold, since the worker's own counters can re-create the key without it.
|
|
146
|
+
* a hold, since the worker's own counters can re-create the key without it. True or false as read, null when it cannot
|
|
147
|
+
* be read (issue #867: a hold read back gone is known gone, one not read back is not known either way).
|
|
141
148
|
*/
|
|
142
149
|
async function holdInPlace(redis, jobId) {
|
|
143
|
-
if (typeof redis?.hexists !== "function") return
|
|
150
|
+
if (typeof redis?.hexists !== "function") return null;
|
|
144
151
|
try {
|
|
145
152
|
return (await redis.hexists(jobKey(jobId), "since")) === 1;
|
|
146
153
|
} catch {
|
|
147
|
-
return
|
|
154
|
+
return null;
|
|
148
155
|
}
|
|
149
156
|
}
|
|
150
157
|
|
|
158
|
+
/**
|
|
159
|
+
* Whether a command's failure means it was never sent: a bounded client's timeout that came while the command still
|
|
160
|
+
* waited for a connection (`makeRedisClient`'s `BoundedRedis` takes such a command out of its queue, so it is never sent
|
|
161
|
+
* later either), told apart from one that reached a socket by the missing `valkeyMayStillRun`.
|
|
162
|
+
*/
|
|
163
|
+
function neverSent(err) {
|
|
164
|
+
return err?.code === "ETIMEDOUT" && err?.message === "Command timed out" && err?.valkeyMayStillRun !== true;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Whether a failure is Valkey giving no answer (issue #867): a bounded client's timeout, or a command it sent whose answer
|
|
169
|
+
* was lost (`valkeyMayStillRun`). An answer that changed nothing carries the failure's words as `noAnswer`, so a caller can
|
|
170
|
+
* say Valkey did not answer, and when to ask again, rather than quote it as a refusal; a caller that does not read it
|
|
171
|
+
* says what it said before.
|
|
172
|
+
*/
|
|
173
|
+
function noAnswer(err) {
|
|
174
|
+
return err?.code === "ETIMEDOUT" || err?.valkeyMayStillRun === true;
|
|
175
|
+
}
|
|
176
|
+
const noAnswerOf = (err) => (noAnswer(err) ? { noAnswer: err?.message ?? String(err) } : {});
|
|
177
|
+
|
|
151
178
|
/**
|
|
152
179
|
* Remove a job that is held on a `run.waitFor` condition: hold keys first, then the job (issue #230's
|
|
153
180
|
* sequence, issue #287's shared home). This is the inner body of the admin's `cancelHeldJob`, moved here
|
|
@@ -159,8 +186,14 @@ async function holdInPlace(redis, jobId) {
|
|
|
159
186
|
* been sent for deletion (most often `remove` refusing a job a worker locked in between): the hold is gone, or partly
|
|
160
187
|
* gone, while the job's own removal failed, which a caller must never word as "nothing was changed". A first delete
|
|
161
188
|
* that failed answers `{ invalid }` only when the hold is read back in place afterwards (Valkey refused the command:
|
|
162
|
-
* NOPERM, READONLY, MISCONF); a hold not read back, or a read that fails, keeps
|
|
163
|
-
* resend of a delete that already ran. Never throws.
|
|
189
|
+
* NOPERM, READONLY, MISCONF), or when it was never sent (`neverSent`); a hold not read back, or a read that fails, keeps
|
|
190
|
+
* the mark, since a refusal can answer a resend of a delete that already ran. Never throws.
|
|
191
|
+
* Issue #867: a cleared answer whose failure was a lost answer (`valkeyMayStillRun`: the command reached a Valkey that
|
|
192
|
+
* then did not answer, so it may still run) says which write that was, `unanswered`: "hold" for the hold's first delete,
|
|
193
|
+
* when the hold could not be read back either (the hold may or may not be gone, and the job was not asked to go), "job"
|
|
194
|
+
* for the job's own removal (the hold is gone, and the job may or may not be). A job whose state cannot be read is
|
|
195
|
+
* refused as that, never as one that left the hold. An answer that changed nothing because Valkey gave none (a read past
|
|
196
|
+
* its bound, a delete never sent) carries the failure's words as `noAnswer`.
|
|
164
197
|
* Writes no run record of its own (INT-RUN-HISTORY-FILE-CONTRACT records terminal states of runs, and a removed hold
|
|
165
198
|
* is none); `attemptsMade` is what the job had done before, for the caller's sentence (`ranBefore`, issue #477): a job
|
|
166
199
|
* held again on a retry has made attempts, and whatever they recorded stays. Once the job is removed, its model attempt
|
|
@@ -171,6 +204,9 @@ async function holdInPlace(redis, jobId) {
|
|
|
171
204
|
export async function removeHeldJob({ redis, queue, jobId, timeoutMs = 2_000, dropDefer = false, touchStore = undefined, touchOutbox = null, now = Date.now, interventionOrigin = "human" }) {
|
|
172
205
|
// Set as the first hold key's delete is sent, before it is answered: a delete lost in transit may still have landed.
|
|
173
206
|
let cleared = false;
|
|
207
|
+
// The write in flight, for `unanswered`: the hold's first delete, then the job's removal; null between them, where a
|
|
208
|
+
// failure leaves the hold known gone and the job not asked.
|
|
209
|
+
let step = null;
|
|
174
210
|
try {
|
|
175
211
|
const hash = await redis.hgetall(jobKey(jobId));
|
|
176
212
|
// A hold is a hash with a CLOCK, not merely a non-empty hash: the worker's own counters create this
|
|
@@ -182,7 +218,12 @@ export async function removeHeldJob({ redis, queue, jobId, timeoutMs = 2_000, dr
|
|
|
182
218
|
// while the job wakes, runs and completes -- and bullmq will happily `remove()` a completed job. That
|
|
183
219
|
// would answer `applied: true` to an operator who approved a dialog reading "It will never run", for
|
|
184
220
|
// a job whose run record is already on disk.
|
|
185
|
-
|
|
221
|
+
let unread = null;
|
|
222
|
+
const state = await job.getState().catch((err) => ((unread = err), null));
|
|
223
|
+
// Issue #867: a read that failed (on a bounded queue, one Valkey did not answer) says nothing of where the job is,
|
|
224
|
+
// so it is no job that "has already left the hold": nothing was sent, and that is what is said, before the
|
|
225
|
+
// failure's own words, which a caller may cut.
|
|
226
|
+
if (unread !== null) return { invalid: `the state of job ${jobId} could not be read, so nothing was changed: ${unread?.message ?? String(unread)}`, ...noAnswerOf(unread) };
|
|
186
227
|
if (state !== "delayed" && state !== "waiting" && state !== "prioritized") {
|
|
187
228
|
return { invalid: `job ${jobId} is ${state ?? "in an unknown state"}, not waiting -- it has already left the hold` };
|
|
188
229
|
}
|
|
@@ -190,28 +231,40 @@ export async function removeHeldJob({ redis, queue, jobId, timeoutMs = 2_000, dr
|
|
|
190
231
|
// mid-sequence, an orphaned hash would keep a row on the panel for a job that no longer exists; an
|
|
191
232
|
// orphaned JOB is merely a job that still runs, which is the state the operator was already in.
|
|
192
233
|
cleared = true;
|
|
234
|
+
step = "hold";
|
|
193
235
|
try {
|
|
194
236
|
await redis.del(jobKey(jobId));
|
|
195
237
|
} catch (err) {
|
|
196
238
|
// A failed DEL may still have run: ioredis resends an unanswered command after a reconnect, and the refusal
|
|
197
239
|
// (an ACL without DEL, a read-only replica, a failed save) can be the answer to that resend. So the hold is
|
|
198
240
|
// read back, and only a hold still in place makes "nothing was changed" true; a read that fails keeps the mark.
|
|
199
|
-
|
|
241
|
+
// A DEL never sent changed nothing either, whatever the read says. One read back gone ran.
|
|
242
|
+
const inPlace = await holdInPlace(redis, jobId);
|
|
243
|
+
if (inPlace === true || neverSent(err)) cleared = false;
|
|
244
|
+
else if (inPlace === false) step = null;
|
|
200
245
|
throw err;
|
|
201
246
|
}
|
|
247
|
+
step = null;
|
|
202
248
|
await redis.srem(HELD_SET, jobId).catch(() => {});
|
|
203
249
|
if (hash.dedupId) {
|
|
204
250
|
const holder = await redis.get(leaseKey(hash.dedupId));
|
|
205
251
|
if (holder === jobId) await redis.del(leaseKey(hash.dedupId));
|
|
206
252
|
}
|
|
207
253
|
const store = touchStore === undefined ? (typeof redis?.eval === "function" ? makeTouchStore({ redis, now }) : null) : touchStore;
|
|
208
|
-
const
|
|
254
|
+
const remove = async () => {
|
|
255
|
+
step = "job";
|
|
256
|
+
await job.remove();
|
|
257
|
+
return { ok: true };
|
|
258
|
+
};
|
|
259
|
+
const removal = interventionOrigin === "agent" ? await remove() : await trackTouchAction({ store, outbox: touchOutbox, event: stopTouch(job, queue, jobId, now), act: remove, succeeded: () => !(job.attemptsMade > 0) && isWorkload(job) });
|
|
260
|
+
step = null;
|
|
209
261
|
await Promise.all([dropDefer ? dropDeferRecord(redis, jobId, timeoutMs) : null, dropAttemptOf(redis, queue, jobId, timeoutMs)]);
|
|
210
262
|
// Issue #477: what the job had done, for the caller's sentence (`ranBefore`); read off the job just removed.
|
|
211
263
|
return { ok: true, jobId, attemptsMade: Number.isInteger(job.attemptsMade) && job.attemptsMade > 0 ? job.attemptsMade : 0, ...(removal.touchWarning ? { touchWarning: removal.touchWarning } : {}) };
|
|
212
264
|
} catch (err) {
|
|
213
265
|
const invalid = err?.message ?? String(err);
|
|
214
|
-
|
|
266
|
+
if (!cleared) return { invalid, ...noAnswerOf(err) };
|
|
267
|
+
return { invalid, cleared: true, ...(err?.valkeyMayStillRun === true && step !== null ? { unanswered: step } : {}) };
|
|
215
268
|
}
|
|
216
269
|
}
|
|
217
270
|
|
|
@@ -232,8 +285,10 @@ export const QUEUED_STATES = Object.freeze(["delayed", "waiting", "prioritized",
|
|
|
232
285
|
* any other state -> nothing is changed.
|
|
233
286
|
*
|
|
234
287
|
* A HOLD IS JUDGED FIRST: a held job IS delayed, and the plain path would strand its `wait:*` keys as a panel row for a
|
|
235
|
-
* job that no longer exists.
|
|
236
|
-
*
|
|
288
|
+
* job that no longer exists. So a hash that cannot be read refuses, nothing changed (issue #867): it was read as no hold,
|
|
289
|
+
* and a read that timed out on a Valkey that then woke removed a held job by the plain path, leaving its hash and its
|
|
290
|
+
* `wait:held` member as a panel row for a job that was gone. A client with no `hgetall` (a bare test wiring) has no hold
|
|
291
|
+
* to read. `state` is the state the caller already read, or null to read it here.
|
|
237
292
|
*
|
|
238
293
|
* `guard` (optional; the CLI passes none) is a caller's last check, asked with `job` right before the removal is sent:
|
|
239
294
|
* before `removeHeldJob` for a held job, and after the state check, before `remove()`, otherwise. It answers null to go
|
|
@@ -247,10 +302,13 @@ export const QUEUED_STATES = Object.freeze(["delayed", "waiting", "prioritized",
|
|
|
247
302
|
* (issue #812), beside the record, so the two deletes share one bound: here for a waiting job, inside `removeHeldJob`
|
|
248
303
|
* (`dropDefer`) for a held one. Returns, and never throws:
|
|
249
304
|
* `{ ok: true, held: true, jobId, attemptsMade }` a held job, removed;
|
|
250
|
-
* `{ invalid }`
|
|
251
|
-
*
|
|
305
|
+
* `{ invalid, holdUnread, noAnswer? }` the hold's hash could not be read (`holdUnread`, the failure's
|
|
306
|
+
* words; `noAnswer` when Valkey gave none): nothing was sent;
|
|
307
|
+
* `{ invalid, noAnswer? }` `removeHeldJob`'s own refusal of a held job, nothing changed;
|
|
308
|
+
* `{ invalid, cleared: true, unanswered? }` `removeHeldJob` failed after its hold's keys were deleted: the
|
|
252
309
|
* hold is gone and the job's own removal failed (never "nothing
|
|
253
|
-
* was changed")
|
|
310
|
+
* was changed"), or a write of it was not answered
|
|
311
|
+
* (`unanswered`, removeHeldJob's);
|
|
254
312
|
* `{ refused, error? }` the guard's answer (`refused`), or its throw (`refused` null,
|
|
255
313
|
* `error` the throw); nothing was sent;
|
|
256
314
|
* `{ notQueued: true, state }` not waiting to start (`active`, `completed`, `unknown`...);
|
|
@@ -258,17 +316,24 @@ export const QUEUED_STATES = Object.freeze(["delayed", "waiting", "prioritized",
|
|
|
258
316
|
* `{ raced: true, state, error }` `remove()` threw (most often: a worker took the job between
|
|
259
317
|
* the read and the remove, or it is a job scheduler's next
|
|
260
318
|
* run, which BullMQ refuses to remove); `state` is read again.
|
|
319
|
+
* An `error` with `valkeyMayStillRun` was sent and not answered,
|
|
320
|
+
* so the remove may yet run (issue #867).
|
|
261
321
|
* `attemptsMade` is the job's own count for the caller's sentence (`ranBefore`), 0 when it is not a positive whole number.
|
|
262
322
|
*/
|
|
263
323
|
export async function removeQueuedJob({ redis, queue, job, jobId, state = null, timeoutMs = 2_000, guard = null, touchStore = undefined, touchOutbox = null, now = Date.now, interventionOrigin = "human" }) {
|
|
264
|
-
|
|
324
|
+
let holdUnread = null;
|
|
325
|
+
const hash = typeof redis?.hgetall !== "function" ? null : await Promise.resolve()
|
|
265
326
|
.then(() => redis.hgetall(jobKey(jobId)))
|
|
266
|
-
.catch(() => null);
|
|
327
|
+
.catch((err) => ((holdUnread = err), null));
|
|
328
|
+
if (holdUnread !== null) {
|
|
329
|
+
const words = holdUnread?.message ?? String(holdUnread);
|
|
330
|
+
return { invalid: `could not read whether job ${jobId} is held on a wait condition, so nothing was changed: ${words}`, holdUnread: words, ...noAnswerOf(holdUnread) };
|
|
331
|
+
}
|
|
267
332
|
if (hash?.since) {
|
|
268
333
|
const stop = await guarded(guard, job);
|
|
269
334
|
if (stop !== null) return stop;
|
|
270
335
|
const res = await removeHeldJob({ redis, queue, jobId, timeoutMs, dropDefer: true, touchStore, touchOutbox, now, interventionOrigin });
|
|
271
|
-
if (!res.ok) return res.cleared === true ? { invalid: res.invalid, cleared: true } : { invalid: res.invalid };
|
|
336
|
+
if (!res.ok) return res.cleared === true ? { invalid: res.invalid, cleared: true, ...(res.unanswered ? { unanswered: res.unanswered } : {}) } : { invalid: res.invalid, ...(res.noAnswer !== undefined ? { noAnswer: res.noAnswer } : {}) };
|
|
272
337
|
return { ok: true, held: true, jobId: res.jobId, attemptsMade: res.attemptsMade, ...(res.touchWarning ? { touchWarning: res.touchWarning } : {}) };
|
|
273
338
|
}
|
|
274
339
|
if (!job) return { notQueued: true, state: "unknown" };
|
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,9 +244,44 @@ 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 } : {}),
|
|
219
250
|
};
|
|
220
251
|
}
|
|
221
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;
|
|
281
|
+
};
|
|
282
|
+
Redis.prototype[COMMAND_BOUND_HOOK] = true;
|
|
283
|
+
}
|
|
284
|
+
|
|
222
285
|
/**
|
|
223
286
|
* A client whose commands have a bound. Its caller has been told a command that timed out failed, so that command must
|
|
224
287
|
* not go out later, and ioredis would send it: the timer rejects it and leaves it in the offline queue for when Valkey
|
|
@@ -250,6 +313,13 @@ class BoundedRedis extends Redis {
|
|
|
250
313
|
}
|
|
251
314
|
}
|
|
252
315
|
|
|
316
|
+
/**
|
|
317
|
+
* How long one command of a short-lived client may wait for its answer (`commandTimeoutMs`), in the offline queue or on a
|
|
318
|
+
* Valkey that stopped answering: the console's own clients wait as long, and `pi-dispatch cancel` (issue #846) takes it
|
|
319
|
+
* from here, so a cancel against a Valkey that went away ends with a sentence rather than waiting as long as it is gone.
|
|
320
|
+
*/
|
|
321
|
+
export const VALKEY_COMMAND_TIMEOUT_MS = 5_000;
|
|
322
|
+
|
|
253
323
|
/**
|
|
254
324
|
* A raw ioredis client (the budget's INCR/EXPIRE, the registry and fleet reads, doctor's PING). Built from
|
|
255
325
|
* `parseConnection`'s options, so it connects through `JudgedConnector` like every other client. `lazyConnect` and
|
|
@@ -375,6 +445,60 @@ export function authRefusalFor(state, url, context) {
|
|
|
375
445
|
*/
|
|
376
446
|
const JUDGE = "piDispatchValkeyJudge";
|
|
377
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
|
+
|
|
378
502
|
/**
|
|
379
503
|
* ioredis' connector for every client of this project (issue #464, gate round 2 follow-up): on each connect it asks
|
|
380
504
|
* `judgedEndpoint` for the address, then dials that literal (with a `rediss:` URL's name kept as the TLS servername),
|
|
@@ -401,6 +525,57 @@ export class JudgedConnector extends AbstractConnector {
|
|
|
401
525
|
return !this[DB_REFUSED];
|
|
402
526
|
}
|
|
403
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
|
+
|
|
404
579
|
connect() {
|
|
405
580
|
const { options } = this;
|
|
406
581
|
// A database the server refused stays refused: no reconnect goes ready on another one.
|
|
@@ -438,6 +613,39 @@ export class JudgedConnector extends AbstractConnector {
|
|
|
438
613
|
/** How `JudgedConnector` dials, net and tls: a seam, so a test sees the options a pinned connect is made with. */
|
|
439
614
|
JudgedConnector.dial = { net: netConnect, tls: tlsConnect };
|
|
440
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
|
+
|
|
441
649
|
/**
|
|
442
650
|
* Whether `connection` (options for BullMQ, or an ioredis client) connects through `JudgedConnector`. `makeQueue` and the
|
|
443
651
|
* worker refuse any other, so no Queue or Worker of this project can be built on an unjudged connection.
|
package/src/doctor-bundles.mjs
CHANGED
|
@@ -49,7 +49,7 @@ export function parseSecretBundleProfilesSafe(raw) {
|
|
|
49
49
|
* - `realExecutablePath`: the resolver's own probe (secrets.mjs `realExecutable`), or null when pi did not load;
|
|
50
50
|
* - `resolveBundle`: `(binding) => resolve result`, only with `--secrets`.
|
|
51
51
|
*/
|
|
52
|
-
export async function secretBundleChecks({ bundles = {}, triggersPresent = true, projects = [], projectsError = null, envRaw, overlayBundles = {}, roots = [], realExecutablePath = null, resolveBundle = null } = {}) {
|
|
52
|
+
export async function secretBundleChecks({ bundles = {}, triggersPresent = true, projects = [], projectsError = null, projectsUnread = false, envRaw, overlayBundles = {}, roots = [], realExecutablePath = null, resolveBundle = null } = {}) {
|
|
53
53
|
const checks = [];
|
|
54
54
|
const { bundles: envBundles, error } = parseSecretBundleProfilesSafe(envRaw);
|
|
55
55
|
if (error) {
|
|
@@ -91,7 +91,11 @@ export async function secretBundleChecks({ bundles = {}, triggersPresent = true,
|
|
|
91
91
|
} else if (!fromEnv && !Object.hasOwn(usableOverlay, b.profile)) {
|
|
92
92
|
checks.push({ ok: false, label: `secret bundle ${id} names profile ${b.profile}, declared in the settings overlay but not usable`, fix: roots.length === 0 ? "PI_SECRET_RESOLVER_ROOTS is unset, so the worker uses no overlay-declared resolver: set it, or declare the profile in PI_SECRET_BUNDLES" : "the resolver must be an executable file whose real path sits inside PI_SECRET_RESOLVER_ROOTS" });
|
|
93
93
|
}
|
|
94
|
-
if (
|
|
94
|
+
if (projectsUnread) {
|
|
95
|
+
// Issue #859: the `.env` line naming the projects file could not be read (its own line says why), so whether the
|
|
96
|
+
// service's file holds the project is not known: a warning, never the ✗ a file with no such project earns.
|
|
97
|
+
checks.push({ ok: false, warn: true, label: `secret bundle ${id} names project ${b.project}, and whether the projects file the service reads holds it is not known: doctor could not read what the deployment .env sets for PI_PROJECTS_FILE`, fix: "fix what the boot-file line above names, then re-run doctor" });
|
|
98
|
+
} else if (projectsError !== null) {
|
|
95
99
|
checks.push({ ok: false, label: `secret bundle ${id} names project ${b.project}, and projects.json does not load`, fix: "fix projects.json (its own line above says why) -- a bundle applies to a project's jobs only once the worker knows the project" });
|
|
96
100
|
} else if (!projectIds.has(b.project)) {
|
|
97
101
|
checks.push({ ok: false, label: `secret bundle ${id} names project ${b.project}, which projects.json does not hold`, fix: "add the project to PI_PROJECTS_FILE, or correct the bundle's project -- until then the bundle reaches no job, while the file reads as granting it" });
|
|
@@ -77,7 +77,7 @@ export function cheaperRouteChecks(roles, routes, models, endpoints, builtin = g
|
|
|
77
77
|
return checks;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
export async function doctorModelProposalChecks(env, { cwd, credentials = {}, now = Date.now, read = safeRead, fetch = fetchLifecycle, endpoints: suppliedEndpoints } = {}) {
|
|
80
|
+
export async function doctorModelProposalChecks(env, { cwd, credentials = {}, now = Date.now, read = safeRead, fetch = fetchLifecycle, endpoints: suppliedEndpoints, declaredEndpoints = null } = {}) {
|
|
81
81
|
let config;
|
|
82
82
|
try { config = parseProposalConfig(read(join(cwd, "model-proposals.json"))); }
|
|
83
83
|
catch (error) {
|
|
@@ -102,7 +102,9 @@ export async function doctorModelProposalChecks(env, { cwd, credentials = {}, no
|
|
|
102
102
|
if (config.routes?.length) try {
|
|
103
103
|
const parsed = parseModelsJson(read(join(env.PI_GLOBAL_PI_DIR, "models.json")));
|
|
104
104
|
if (parsed.error) throw new Error("overlay unavailable");
|
|
105
|
-
|
|
105
|
+
// Issue #859: the endpoints as doctor's other endpoint checks read them (`declaredEndpointsIn`: this shell's file, else
|
|
106
|
+
// the deployment `.env`'s), handed in and asked only here; this shell's alone when a caller hands none.
|
|
107
|
+
const endpoints = suppliedEndpoints ?? (typeof declaredEndpoints === "function" ? declaredEndpoints() : loadModelEndpoints({ modelEndpointsFile: env.PI_MODEL_ENDPOINTS_FILE }, { cwd }));
|
|
106
108
|
checks.push(...cheaperRouteChecks(roles, config.routes, parsed.value, endpoints));
|
|
107
109
|
} catch { checks.push({ proposal: { kind: "unavailable" }, ok: false, warn: true, label: "Model proposals: declared route prices or endpoints unavailable", fix: "check the overlay models.json and declared endpoints; no route was changed" }); }
|
|
108
110
|
return checks;
|