@edgehero/pi-dispatch 5.1.0 → 5.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cancel-cli.mjs +102 -14
- package/src/cancel-state.mjs +9 -3
- package/src/connection.mjs +7 -0
- package/src/doctor-bundles.mjs +6 -2
- package/src/doctor-model-proposals.mjs +4 -2
- package/src/doctor.mjs +202 -27
- package/src/pause-windows.mjs +19 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edgehero/pi-dispatch",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The pi-dispatch worker and CLI: go from one AI coding agent to many in the background, running the pi coding agent as a self-hosted service, one locked down Docker or Podman container per job, with spend caps checked before anything is spent, plus init, up, doctor and service install.",
|
|
6
6
|
"keywords": [
|
package/src/cancel-cli.mjs
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* job's attempt count.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
-
import { cancelReqKey, ranBefore, removeQueuedJob, requestCancel } from "./cancel-state.mjs";
|
|
27
|
+
import { CANCEL_REQ_TTL_MS, cancelAckKey, cancelReqKey, ranBefore, removeQueuedJob, requestCancel } from "./cancel-state.mjs";
|
|
28
28
|
|
|
29
29
|
/** Same budget the kill switch gives the host registry before acting on what the keyspace alone says. */
|
|
30
30
|
const FLEET_READ_TIMEOUT_MS = 2_000;
|
|
@@ -42,11 +42,43 @@ export function boundedText(text) {
|
|
|
42
42
|
return String(text ?? "no reason given").replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").slice(0, 200);
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/** Whether `error` is Valkey's own refusal (a reply), as against no answer at all: the two are different sentences. */
|
|
46
|
+
function refusedByValkey(error) {
|
|
47
|
+
return error?.name === "ReplyError";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** What went wrong, as the start of a line: Valkey refused a command (it answered), or it did not answer. */
|
|
51
|
+
function valkeyFault(error, shown) {
|
|
52
|
+
const command = typeof error?.command?.name === "string" ? error.command.name.toUpperCase() : "a command";
|
|
53
|
+
return refusedByValkey(error) ? `Valkey at ${shown} refused ${boundedText(command)}: ${boundedText(error?.message)}` : `Valkey at ${shown} stopped answering (${boundedText(error?.message)})`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The job's state, read under the same bound as every other command here: `{ state }`, or `{ unread }` with why it could
|
|
58
|
+
* not be read. Issue #846: a read that failed was `"unknown"`, which is also what BullMQ says of a job that left its
|
|
59
|
+
* queue, so a job still running was reported as gone "and nothing was changed".
|
|
60
|
+
*/
|
|
61
|
+
async function boundedState(job, timeoutMs) {
|
|
62
|
+
let timer;
|
|
63
|
+
try {
|
|
64
|
+
return await Promise.race([
|
|
65
|
+
Promise.resolve(job.getState()).then((state) => ({ state })),
|
|
66
|
+
new Promise((resolve) => {
|
|
67
|
+
timer = setTimeout(() => resolve({ unread: `no answer within ${Math.round(timeoutMs / 1000)}s` }), timeoutMs);
|
|
68
|
+
}),
|
|
69
|
+
]);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
return { unread: error?.message ?? String(error) };
|
|
72
|
+
} finally {
|
|
73
|
+
clearTimeout(timer);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
45
77
|
/**
|
|
46
78
|
* Run the cancel. Returns the process exit code. Every collaborator is a seam with the production default,
|
|
47
79
|
* so tests drive states and races without a queue; `write` is the stdout seam cli.mjs already injects.
|
|
48
80
|
*/
|
|
49
|
-
export async function runCancel(jobId, url, { write = (chunk) => process.stdout.write(chunk), errWrite = (chunk) => process.stderr.write(chunk), redisFn, queueFn, parseConnectionFn, readLiveHostsFn, discoverHostQueuesFn, fleetQueueNamesFn, unionQueueNamesFn, ackTimeoutMs = 10_000, pollMs = 250, sleep, refusalFn, touchStore, touchOutbox = null, now = Date.now } = {}) {
|
|
81
|
+
export async function runCancel(jobId, url, { write = (chunk) => process.stdout.write(chunk), errWrite = (chunk) => process.stderr.write(chunk), redisFn, queueFn, parseConnectionFn, readLiveHostsFn, discoverHostQueuesFn, fleetQueueNamesFn, unionQueueNamesFn, ackTimeoutMs = 10_000, pollMs = 250, sleep, refusalFn, touchStore, touchOutbox = null, now = Date.now, commandTimeoutMs = null, closeTimeoutMs = 1000 } = {}) {
|
|
50
82
|
const fail = (message) => {
|
|
51
83
|
errWrite(`error: ${message}\n`);
|
|
52
84
|
return 1;
|
|
@@ -55,7 +87,9 @@ export async function runCancel(jobId, url, { write = (chunk) => process.stdout.
|
|
|
55
87
|
return fail("a job id is required: pi-dispatch cancel <jobId> (ids are in `pi-dispatch status`, the panel, and the run log)");
|
|
56
88
|
}
|
|
57
89
|
// Lazy imports, the pause verb's shape: a mistyped id must not load bullmq before it can be refused.
|
|
58
|
-
const { parseConnection, makeRedisClient } = await import("./connection.mjs");
|
|
90
|
+
const { parseConnection, makeRedisClient, VALKEY_COMMAND_TIMEOUT_MS } = await import("./connection.mjs");
|
|
91
|
+
// The bound on each command and on the state reads: the console's, unless a test asks for another.
|
|
92
|
+
const bound = Number.isSafeInteger(commandTimeoutMs) && commandTimeoutMs > 0 ? commandTimeoutMs : VALKEY_COMMAND_TIMEOUT_MS;
|
|
59
93
|
const { fleetQueueNames, discoverHostQueues, unionQueueNames, makeQueue } = await import("./queue.mjs");
|
|
60
94
|
const { readLiveHosts } = await import("./host-registry.mjs");
|
|
61
95
|
const parseConn = parseConnectionFn ?? parseConnection;
|
|
@@ -83,10 +117,14 @@ export async function runCancel(jobId, url, { write = (chunk) => process.stdout.
|
|
|
83
117
|
});
|
|
84
118
|
const refused = await refusalOf(url);
|
|
85
119
|
if (refused) return fail(refused);
|
|
86
|
-
|
|
120
|
+
// BOUNDED (issue #846): each command waits at most VALKEY_COMMAND_TIMEOUT_MS for its answer, and one that timed out is
|
|
121
|
+
// never sent again. The unbounded client this was waited for as long as Valkey was gone: killed during the ack poll it
|
|
122
|
+
// printed nothing until the operator gave up, and on a partition its DEL never reached the catch below.
|
|
123
|
+
const probe = mkRedis(url, { commandTimeoutMs: bound, disconnectTimeoutMs: 0 });
|
|
87
124
|
probe.on?.("error", () => {});
|
|
88
125
|
const queues = [];
|
|
89
126
|
let requested = false;
|
|
127
|
+
let requestedAt = null;
|
|
90
128
|
try {
|
|
91
129
|
// The job could sit on the shared queue or on any host's own (issue #57): find it the way pause
|
|
92
130
|
// spans them, and fail OPEN but LOUDLY on a degraded registry read, exactly as the kill switch does.
|
|
@@ -138,6 +176,12 @@ export async function runCancel(jobId, url, { write = (chunk) => process.stdout.
|
|
|
138
176
|
return 0;
|
|
139
177
|
}
|
|
140
178
|
const state = queued.state;
|
|
179
|
+
// Issue #846: `removeQueuedJob` reads a state it could not read as "unknown" (the console's sequence shares it), which
|
|
180
|
+
// BullMQ also says of a job that left its queue. Asked again under the bound, so a read that failed says so.
|
|
181
|
+
if (state === "unknown") {
|
|
182
|
+
const again = await boundedState(job, bound);
|
|
183
|
+
if (again.unread !== undefined) return fail(`could not read the state of ${jobId} (${boundedText(again.unread)}), so whether it is running is not known; nothing was changed. Run \`pi-dispatch cancel ${jobId}\` again once Valkey answers`);
|
|
184
|
+
}
|
|
141
185
|
// The one race worth handling by name: picked up between getState and remove. remove() throws on a locked job, so
|
|
142
186
|
// the state is re-read and an active job falls through to the active path rather than failing an operator whose
|
|
143
187
|
// job is now exactly the case the active path exists for. Otherwise the re-read state and the throw's own words
|
|
@@ -147,7 +191,8 @@ export async function runCancel(jobId, url, { write = (chunk) => process.stdout.
|
|
|
147
191
|
|
|
148
192
|
if (state === "active") {
|
|
149
193
|
requested = true;
|
|
150
|
-
|
|
194
|
+
requestedAt = now();
|
|
195
|
+
const res = await requestCancel({ redis: probe, jobId, ackTimeoutMs, pollMs, now, onWritten: () => (requestedAt = now()), ...(sleep ? { sleep } : {}) });
|
|
151
196
|
if (res.ack !== undefined) {
|
|
152
197
|
write(`cancel accepted by ${res.ack === "" ? "the worker" : boundedText(res.ack)} — stopping the container (allow ~30s); the run record will say operator-cancel\n`);
|
|
153
198
|
return 0;
|
|
@@ -155,8 +200,11 @@ export async function runCancel(jobId, url, { write = (chunk) => process.stdout.
|
|
|
155
200
|
// Gate round 2 of PR #479: the worker stops its cancel poll before it holds or retries a job, so a request that
|
|
156
201
|
// lands in that moment is never acknowledged and the job is back in the queue. Re-read, so the words are true.
|
|
157
202
|
// Gate round 3: by the state, since a job that finished or left the queue cannot be removed by a second cancel.
|
|
158
|
-
const
|
|
203
|
+
const read = await boundedState(job, bound);
|
|
159
204
|
const waited = `no worker acknowledged within ${Math.round(ackTimeoutMs / 1000)}s`;
|
|
205
|
+
// Issue #846: a state that could not be read is said as that, never as BullMQ's "unknown" (a job that left).
|
|
206
|
+
if (read.unread !== undefined) return fail(`${waited}, and the job's state could not be read (${boundedText(read.unread)}), so whether it still runs is not known. The cancel request was withdrawn, so nothing was changed: run \`pi-dispatch status\`, and \`pi-dispatch cancel ${jobId}\` again if it still runs`);
|
|
207
|
+
const after = read.state;
|
|
160
208
|
if (after === "waiting" || after === "delayed" || after === "prioritized" || after === "paused") return fail(`${waited}: the job went back to the queue before its worker read the cancel (it is now ${after}), and nothing was changed. Run \`pi-dispatch cancel ${jobId}\` again to remove it`);
|
|
161
209
|
if (after === "completed" || after === "failed") return fail(`${waited}: the job finished (${after}) before its worker read the cancel, and nothing was changed`);
|
|
162
210
|
if (after !== "active") return fail(`${waited}: the job is no longer in the queue (its state reads ${after}), and nothing was changed`);
|
|
@@ -170,18 +218,58 @@ export async function runCancel(jobId, url, { write = (chunk) => process.stdout.
|
|
|
170
218
|
// del would sit in ioredis's retry queue and hold this process open long past the error message.
|
|
171
219
|
// The bound timer is CLEARED when the del wins the race (discoverHostQueues' own rule): cli.mjs
|
|
172
220
|
// sets exitCode rather than exiting, so a stray pending timer is a second of pure hang.
|
|
221
|
+
// What the withdrawing DEL ANSWERED decides the sentence (issue #846, the second adversary round): 1, it was taken back;
|
|
222
|
+
// 0, it was already gone, because its worker read it (and may have acknowledged) or it expired; no answer, not known.
|
|
223
|
+
// Reading "answered" as "withdrawn" said "nothing was changed" of a cancel its worker had taken and acted on, when a
|
|
224
|
+
// poll was lost to a Valkey that died and came back within the bound.
|
|
225
|
+
const within = (promise, ms) => {
|
|
226
|
+
let timer;
|
|
227
|
+
return Promise.race([Promise.resolve(promise).then((value) => ({ value }), (error) => ({ lost: true, error })), new Promise((r) => (timer = setTimeout(() => r({ lost: true }), ms)))]).finally(() => clearTimeout(timer));
|
|
228
|
+
};
|
|
229
|
+
let withdrawal = null;
|
|
230
|
+
let acked = null;
|
|
173
231
|
if (requested) {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
clearTimeout(bound);
|
|
232
|
+
withdrawal = await within(probe.del?.(cancelReqKey(jobId)), 1000);
|
|
233
|
+
if (withdrawal.value !== undefined && Number(withdrawal.value) === 0) {
|
|
234
|
+
const ack = await within(probe.get?.(cancelAckKey(jobId)), 1000);
|
|
235
|
+
if (typeof ack.value === "string") acked = ack.value;
|
|
236
|
+
}
|
|
180
237
|
}
|
|
181
|
-
|
|
238
|
+
if (acked !== null) {
|
|
239
|
+
write(`cancel accepted by ${acked === "" ? "the worker" : boundedText(acked)} — stopping the container (allow ~30s); the run record will say operator-cancel\n`);
|
|
240
|
+
return 0;
|
|
241
|
+
}
|
|
242
|
+
if (error?.valkeyRefused) return fail(error.message);
|
|
243
|
+
const shown = (await import("./connection.mjs")).urlShown(url);
|
|
244
|
+
// Issue #846: after the request went out, what is said is what became of it. The old line said only "could not reach
|
|
245
|
+
// Valkey ... is it running?", also when Valkey answered with a refusal, and never that a request may still stand.
|
|
246
|
+
if (requested && error?.valkeyMayStillRun) {
|
|
247
|
+
const removed = withdrawal?.value !== undefined ? Number(withdrawal.value) : null;
|
|
248
|
+
if (removed !== null && removed > 0) return fail(`${valkeyFault(error, shown)} after the cancel request for ${jobId} was written. The request was withdrawn again, so nothing was changed, unless its worker read it in that moment: run \`pi-dispatch status\` to see whether ${jobId} still runs`);
|
|
249
|
+
if (removed === 0) return fail(`${valkeyFault(error, shown)} after the cancel request for ${jobId} was written, and the request was gone when the CLI went to withdraw it: its worker may have read it, or it expired, and no acknowledgment was found. Run \`pi-dispatch status\` to see whether ${jobId} still runs`);
|
|
250
|
+
const until = new Date((requestedAt ?? now()) + CANCEL_REQ_TTL_MS).toISOString();
|
|
251
|
+
const then = refusedByValkey(error) || refusedByValkey(withdrawal?.error) ? "run `pi-dispatch status` to see whether it did" : "run `pi-dispatch status` once Valkey answers to see whether it did";
|
|
252
|
+
return fail(`${valkeyFault(error, shown)} after the cancel request for ${jobId} was written, and its withdrawal was not answered, so it may stand until ${until}, ${Math.round(CANCEL_REQ_TTL_MS / 1000)}s after it was written: a worker that reads it before then stops the job. ${then.charAt(0).toUpperCase()}${then.slice(1)}`);
|
|
253
|
+
}
|
|
254
|
+
if (refusedByValkey(error)) return fail(`${valkeyFault(error, shown)}; nothing was changed`);
|
|
255
|
+
return fail(`could not reach Valkey at ${shown}: ${(await import("./valkey-auth.mjs")).valkeyDownHint(url)}\n ${boundedText(error?.message)}`);
|
|
182
256
|
} finally {
|
|
183
257
|
probe.disconnect?.();
|
|
184
|
-
|
|
258
|
+
// BOUNDED (issue #846): `close()` waits for the answer to QUIT, so on a Valkey that stopped answering the verb could hold
|
|
259
|
+
// its process after its last line. Past the bound the queue is dropped (`disconnect`, no QUIT; ioredis may still take
|
|
260
|
+
// its own disconnect timeout to let the socket go), which nothing here needs an answer to.
|
|
261
|
+
await Promise.all(queues.map((q) => closeWithin(q, closeTimeoutMs)));
|
|
185
262
|
}
|
|
186
263
|
}
|
|
187
264
|
|
|
265
|
+
/** Close `queue`, or drop its connection when the close is not answered within `ms`. Never throws. */
|
|
266
|
+
async function closeWithin(queue, ms) {
|
|
267
|
+
let timer;
|
|
268
|
+
const closed = await Promise.race([
|
|
269
|
+
Promise.resolve(queue.close()).then(() => true, () => true),
|
|
270
|
+
new Promise((resolve) => (timer = setTimeout(() => resolve(false), ms))),
|
|
271
|
+
]);
|
|
272
|
+
clearTimeout(timer);
|
|
273
|
+
if (!closed) await Promise.resolve(queue.disconnect?.()).catch(() => {});
|
|
274
|
+
}
|
|
275
|
+
|
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
|
package/src/connection.mjs
CHANGED
|
@@ -250,6 +250,13 @@ class BoundedRedis extends Redis {
|
|
|
250
250
|
}
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
+
/**
|
|
254
|
+
* How long one command of a short-lived client may wait for its answer (`commandTimeoutMs`), in the offline queue or on a
|
|
255
|
+
* Valkey that stopped answering: the console's own clients wait as long, and `pi-dispatch cancel` (issue #846) takes it
|
|
256
|
+
* from here, so a cancel against a Valkey that went away ends with a sentence rather than waiting as long as it is gone.
|
|
257
|
+
*/
|
|
258
|
+
export const VALKEY_COMMAND_TIMEOUT_MS = 5_000;
|
|
259
|
+
|
|
253
260
|
/**
|
|
254
261
|
* A raw ioredis client (the budget's INCR/EXPIRE, the registry and fleet reads, doctor's PING). Built from
|
|
255
262
|
* `parseConnection`'s options, so it connects through `JudgedConnector` like every other client. `lazyConnect` and
|
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;
|
package/src/doctor.mjs
CHANGED
|
@@ -390,7 +390,8 @@ export async function runDoctor(shellVars = process.env, deps = {}) {
|
|
|
390
390
|
// Measured: a CONTAINERS_CONF in it naming a containers.conf with a `conmon_path` made doctor's own `podman info` run
|
|
391
391
|
// that program. So from such a file no value is taken at all (the venue keys included), and that is a ✗: the service's
|
|
392
392
|
// loader has no such rule, so the deployment runs on a file someone else chose. The read above still happens, for the
|
|
393
|
-
// two-subject
|
|
393
|
+
// BOOT_FILES lines' two-subject reading (`envFileKeys`), which only decides what doctor SAYS; from such a file
|
|
394
|
+
// `bootFileEnv` takes nothing (issue #859, `envUntrusted`), so the other checks judge this shell's values as the ✗ says.
|
|
394
395
|
const envUntrusted = envText !== null ? envRead.untrusted : null;
|
|
395
396
|
const envValues = envUntrusted ? null : envText;
|
|
396
397
|
let envTextUnused = envText !== null;
|
|
@@ -454,7 +455,7 @@ export async function runDoctor(shellVars = process.env, deps = {}) {
|
|
|
454
455
|
return { ...(await valkeyAuthState(url, { context, withoutPassword })), passwordSet: Boolean(sent.password), from: sent.from };
|
|
455
456
|
}
|
|
456
457
|
: null;
|
|
457
|
-
const seams = { setupReadOnly: setupReadOnly === true, ...(subscriptionFactory ? { subscriptionFactory } : {}), ...(modelProposals ? { modelProposals } : {}), secrets: secrets === true, ...(bundleSpawn ? { bundleSpawn } : {}), openshell: openshell === true, ...(openshellRun ? { openshellRun } : {}), cwd, out, spawn, probeValkey, valkeyAuth: valkeyAuthSeam, readHosts, ...(modelCatalog ? { modelCatalog } : {}), ...(piModelLoader ? { piModelLoader } : {}), ...(dollarKeysExist ? { dollarKeysExist } : {}), ...(readAppliedSplit ? { readAppliedSplit } : {}), ...(readCapacity ? { readCapacity } : {}), ...(readDollarRecords ? { readDollarRecords } : {}), ...(readDollarCounters ? { readDollarCounters } : {}), ...(olderStateFindings ? { olderStateFindings } : {}), ...(consoleProbe ? { consoleProbe } : {}), fileExists, nodeVersion, mkdir, chmod, rm, agentDir, platform, home, providerOracle, facts, jobUserIdentity, stat, passwd, readUnit, readEnvFile: readEnvFileShared, observationFs, flowFs, jobsDirFs, jobsDirUid, valkeyOwner: valkeyOwnerSeam, isAlive, pid, runTimeouts, live: live === true, wallClock, ...(readRunRecords ? { readRunRecords } : {}), venueChecks, userName, proxyFilesExist, proxyFileIsDirectory, ...(includeNeeds ? { includeNeeds } : {}), ...(declaredEndpoints ? { declaredEndpoints } : {}), ...(readOverlayFile ? { readOverlayFile } : {}), ...(lstatOverlayFile ? { lstatOverlayFile } : {}), ...(hostAddresses ? { hostAddresses } : {}), ...(readProxyConf ? { readProxyConf } : {}), ...(readPackagedConf ? { readPackagedProxyConf: readPackagedConf } : {}), ...(readPodmanService ? { readPodmanService } : {}), serviceEnvFile: envValues === null ? null : serviceEnvFileOf(envValues, envPath, serviceEnvLoader(platform)) };
|
|
458
|
+
const seams = { setupReadOnly: setupReadOnly === true, ...(subscriptionFactory ? { subscriptionFactory } : {}), ...(modelProposals ? { modelProposals } : {}), secrets: secrets === true, ...(bundleSpawn ? { bundleSpawn } : {}), openshell: openshell === true, ...(openshellRun ? { openshellRun } : {}), cwd, out, spawn, probeValkey, valkeyAuth: valkeyAuthSeam, readHosts, ...(modelCatalog ? { modelCatalog } : {}), ...(piModelLoader ? { piModelLoader } : {}), ...(dollarKeysExist ? { dollarKeysExist } : {}), ...(readAppliedSplit ? { readAppliedSplit } : {}), ...(readCapacity ? { readCapacity } : {}), ...(readDollarRecords ? { readDollarRecords } : {}), ...(readDollarCounters ? { readDollarCounters } : {}), ...(olderStateFindings ? { olderStateFindings } : {}), ...(consoleProbe ? { consoleProbe } : {}), fileExists, nodeVersion, mkdir, chmod, rm, agentDir, platform, home, providerOracle, facts, jobUserIdentity, stat, passwd, readUnit, readEnvFile: readEnvFileShared, observationFs, flowFs, jobsDirFs, jobsDirUid, valkeyOwner: valkeyOwnerSeam, isAlive, pid, runTimeouts, live: live === true, wallClock, ...(readRunRecords ? { readRunRecords } : {}), venueChecks, userName, proxyFilesExist, proxyFileIsDirectory, ...(includeNeeds ? { includeNeeds } : {}), ...(declaredEndpoints ? { declaredEndpoints } : {}), ...(readOverlayFile ? { readOverlayFile } : {}), ...(lstatOverlayFile ? { lstatOverlayFile } : {}), ...(hostAddresses ? { hostAddresses } : {}), ...(readProxyConf ? { readProxyConf } : {}), ...(readPackagedConf ? { readPackagedProxyConf: readPackagedConf } : {}), ...(readPodmanService ? { readPodmanService } : {}), ...(envUntrusted ? { envUntrusted: true } : {}), serviceEnvFile: envValues === null ? null : serviceEnvFileOf(envValues, envPath, serviceEnvLoader(platform)) };
|
|
458
459
|
// Issue #471: every other service key, resolved ONCE for the whole run (the fix pass's re-collect and `--live` judge the
|
|
459
460
|
// same resolution). THE RULE (PR #474's round cap, after three rounds of trust patches): no program doctor starts is
|
|
460
461
|
// handed anything from `.env`. Every child gets this shell's own environment, the one it had before #471; a `.env`
|
|
@@ -758,11 +759,13 @@ function asText(content, enc) {
|
|
|
758
759
|
* The values `<cwd>/.env` sets for NAMED keys, or `{}`. Doctor's first read of a `.env` (issue #357), and the
|
|
759
760
|
* narrowing is what makes it an addition rather than a reversal. It is NOT the only reader: `up` and `service
|
|
760
761
|
* install` read the venue keys since #430 (`readStackKeys`), and since #453 doctor reads `SERVICE_ENV_KEYS` below,
|
|
761
|
-
* whose values DO steer what doctor spawns, connects to and offers as fixes. For these
|
|
762
|
+
* whose values DO steer what doctor spawns, connects to and offers as fixes. For these five keys:
|
|
762
763
|
*
|
|
763
764
|
* - it reads to decide WHAT DOCTOR SAYS, never to configure anything. No value from here reaches a
|
|
764
765
|
* config, an argv, a container env, or a fix that writes;
|
|
765
|
-
* - the
|
|
766
|
+
* - the keys are the five frozen here and a caller's list can only narrow them (`envFileKeys`), so this cannot grow
|
|
767
|
+
* into "load .env". Since issue #859 what is read for them also reaches the checks that read those files
|
|
768
|
+
* (`bootFileEnv`), still only to decide what doctor says and whether it fails;
|
|
766
769
|
* - it is best effort, and it says WHICH kind of nothing it has. A file that is not there is `{}` and
|
|
767
770
|
* changes no message. Something that is there and cannot be read -- a directory, a pipe, a mode this
|
|
768
771
|
* account cannot open -- is `{ unreadable: true }`, which gets a line of its own, because "the key is
|
|
@@ -1154,7 +1157,7 @@ export function envFileKeys(path, keys, { fileExists, readEnvFile, statFile = st
|
|
|
1154
1157
|
}
|
|
1155
1158
|
// The narrowing is STRUCTURAL rather than a convention the caller keeps. A caller's key list can only
|
|
1156
1159
|
// narrow this further, never widen it: the licence for reading a `.env` at all is that it decides what
|
|
1157
|
-
//
|
|
1160
|
+
// doctor SAYS about five named files, and "the caller passes the right keys" is the kind of rule that holds until the
|
|
1158
1161
|
// third check wants the same softening and adds its own key to an array. A `.env` also holds
|
|
1159
1162
|
// `WEBHOOK_SECRET` and provider keys, and `up.mjs` states the standard for those: a webhook secret in a
|
|
1160
1163
|
// scrollback is a webhook secret in a pastebin. Add a key here and the addition is the review.
|
|
@@ -1255,6 +1258,107 @@ export function envFileKeys(path, keys, { fileExists, readEnvFile, statFile = st
|
|
|
1255
1258
|
}
|
|
1256
1259
|
}
|
|
1257
1260
|
|
|
1261
|
+
/**
|
|
1262
|
+
* The boot-file keys `bootFileEnv` resolves for doctor's other checks: every `ENV_FILE_READABLE_KEYS` key but
|
|
1263
|
+
* `PI_MODEL_ENDPOINTS_FILE`, whose checks keep their own rule (`endpointsFileSetting`: this shell's, else the `.env`'s,
|
|
1264
|
+
* and a disagreement is no endpoints), read where they are used.
|
|
1265
|
+
*/
|
|
1266
|
+
export const BOOT_FACT_KEYS = Object.freeze(ENV_FILE_READABLE_KEYS.filter((key) => key !== "PI_MODEL_ENDPOINTS_FILE"));
|
|
1267
|
+
|
|
1268
|
+
/**
|
|
1269
|
+
* On the environment `bootFileEnv` returns, the keys whose `.env` value doctor could not read while this shell sets none
|
|
1270
|
+
* (a hazard in the file, a line not in the plain form, a file it could not open). A fact reader says such a file is not
|
|
1271
|
+
* known (`bootKeyUnread`), never that it holds nothing: "no projects" there FAILED a bundle bound to a project, issue
|
|
1272
|
+
* #859's own false ✗ reached another way. A symbol, so no scan of `env.NAME` reads mistakes it for a key.
|
|
1273
|
+
*/
|
|
1274
|
+
export const UNREAD_BOOT_KEYS = Symbol("boot-file keys whose .env value could not be read");
|
|
1275
|
+
|
|
1276
|
+
/** Whether `key` is one whose `.env` value doctor could not read (`UNREAD_BOOT_KEYS`). */
|
|
1277
|
+
function bootKeyUnread(env, key) {
|
|
1278
|
+
return env?.[UNREAD_BOOT_KEYS]?.has(key) === true;
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
/**
|
|
1282
|
+
* Issue #859: the boot-file keys (`BOOT_FACT_KEYS`) as doctor's other checks judge them, from this shell and from the one
|
|
1283
|
+
* reading of the deployment `.env` that `envFileKeys` made. Until this, the checks that read the projects, the scoped
|
|
1284
|
+
* limits, the pause windows and the envelope took these keys from this shell alone, and the shell a service deployment's
|
|
1285
|
+
* operator runs doctor from sets none of them: doctor judged a deployment with no projects, FAILED every secret bundle
|
|
1286
|
+
* bound to one as naming a project projects.json does not hold, left the size suggestions out without a word, and
|
|
1287
|
+
* compared "no projects" with healthy peers. Only the BOOT_FILES lines read the file, and they kept what they read.
|
|
1288
|
+
*
|
|
1289
|
+
* These keys stay outside `resolveDoctorEnv` (the BOOT_FILES lines judge two subjects, the service's file and this
|
|
1290
|
+
* shell, and a test pins `SERVICE_ENV_KEYS`), but the precedence is that resolver's: this shell's value where it sets
|
|
1291
|
+
* one, else the file's, and where both are set and name different files that is a ✗ (`differs`), as a disagreement
|
|
1292
|
+
* about any other service key is (`serviceEnvLines`), since the checks then judge files the service does not read. A
|
|
1293
|
+
* key set in this shell alone, beside a `.env` that could be read and does not set it, joins the #471 shell-only line
|
|
1294
|
+
* (`shellOnly`). A BLANK value in this shell has its own ✗ in the BOOT_FILES block and the checks then judge the
|
|
1295
|
+
* service's file. Paths are resolved against the deployment folder, where the service runs and where `loadVerdict`
|
|
1296
|
+
* resolves them, and compared resolved (case-folded on win32).
|
|
1297
|
+
*
|
|
1298
|
+
* What the file gives, and when it gives nothing:
|
|
1299
|
+
* - a value only where `envFileKeys` vouches for its line (plain, no hazard in the file);
|
|
1300
|
+
* - nothing from a `.env` another account can write (`untrusted`): doctor says it took no value from it, and it takes
|
|
1301
|
+
* none here either, so the checks judge this shell's values as that line says;
|
|
1302
|
+
* - "not known" (`UNREAD_BOOT_KEYS`) for a key this shell does not set when the file has a hazard, the key's line is
|
|
1303
|
+
* not in the plain form, or the file could not be read: the BOOT_FILES lines name why, and the checks say the file
|
|
1304
|
+
* is not known rather than empty.
|
|
1305
|
+
*
|
|
1306
|
+
* Read to decide what doctor SAYS, the licence `ENV_FILE_READABLE_KEYS` gives: what this returns goes to the fact readers
|
|
1307
|
+
* and the load verdicts and nowhere else. Returns `{ env, service, differs, shellOnly }`: `env` is `shell` with these keys
|
|
1308
|
+
* as the checks judge them (and `UNREAD_BOOT_KEYS`); `service` the same with the keys as the SERVICE reads them, from the
|
|
1309
|
+
* file alone, or this shell's where the file's line cannot be read (the load verdict of the service's own subject, whose
|
|
1310
|
+
* envelope is judged against the service's projects and limits); `differs` `[{ key, shell, file }]`; `shellOnly` keys.
|
|
1311
|
+
*/
|
|
1312
|
+
export function bootFileEnv(shell, envFile, cwd, platform = process.platform, { untrusted = false } = {}) {
|
|
1313
|
+
const win = platform === "win32";
|
|
1314
|
+
const at = (path) => (win ? win32.resolve(cwd, path) : posix.resolve(cwd, path));
|
|
1315
|
+
// ONE FILE NAMED TWO WAYS IS NO DIFFERENCE: where both are there, by what they are (a symlinked folder, `/tmp` and
|
|
1316
|
+
// `/private/tmp` on a Mac, a case-insensitive disk), else by the resolved text, case-folded on Windows.
|
|
1317
|
+
const identity = (path) => {
|
|
1318
|
+
try {
|
|
1319
|
+
const st = statSync(at(path));
|
|
1320
|
+
return `${st.dev}:${st.ino}`;
|
|
1321
|
+
} catch {
|
|
1322
|
+
return null;
|
|
1323
|
+
}
|
|
1324
|
+
};
|
|
1325
|
+
const same = (a, b) => {
|
|
1326
|
+
const ia = identity(a);
|
|
1327
|
+
const ib = ia === null ? null : identity(b);
|
|
1328
|
+
if (ia !== null && ib !== null) return ia === ib;
|
|
1329
|
+
return win ? at(a).toLowerCase() === at(b).toLowerCase() : at(a) === at(b);
|
|
1330
|
+
};
|
|
1331
|
+
// The SERVICE's subject reads the file whoever can write it, as the BOOT_FILES lines do (they only decide what doctor
|
|
1332
|
+
// says); the checks take nothing from an untrusted one.
|
|
1333
|
+
const whole = envFile ?? {};
|
|
1334
|
+
const file = untrusted ? {} : whole;
|
|
1335
|
+
const out = { env: { ...shell }, service: { ...shell }, differs: [], shellOnly: [], serviceUnread: new Set() };
|
|
1336
|
+
const unread = new Set();
|
|
1337
|
+
for (const key of BOOT_FACT_KEYS) {
|
|
1338
|
+
const own = typeof shell[key] === "string" && shell[key].trim() !== "" ? shell[key] : undefined;
|
|
1339
|
+
const lineOf = (f) => (f.hazard == null && typeof f[key] === "string" && f[key] !== "" ? f[key] : undefined);
|
|
1340
|
+
const unreadIn = (f) => f.unreadable === true || f.hazard != null || f.notPlain?.[key] !== undefined || f.insideValue?.[key] !== undefined;
|
|
1341
|
+
const raw = lineOf(file);
|
|
1342
|
+
const cannotRead = unreadIn(file);
|
|
1343
|
+
const sets = raw !== undefined || cannotRead || file.exported?.[key] !== undefined || file.blankInFile?.[key] === true;
|
|
1344
|
+
const serviceRaw = lineOf(whole);
|
|
1345
|
+
if (serviceRaw !== undefined) out.service[key] = at(serviceRaw);
|
|
1346
|
+
else if (unreadIn(whole)) out.serviceUnread.add(key);
|
|
1347
|
+
else delete out.service[key];
|
|
1348
|
+
if (own !== undefined) {
|
|
1349
|
+
out.env[key] = at(own);
|
|
1350
|
+
if (raw !== undefined && !same(own, raw)) out.differs.push({ key, shell: own, file: raw });
|
|
1351
|
+
else if (!sets && !untrusted && Object.keys(file).length > 0) out.shellOnly.push(key);
|
|
1352
|
+
} else if (raw !== undefined) {
|
|
1353
|
+
out.env[key] = at(raw);
|
|
1354
|
+
} else if (cannotRead) {
|
|
1355
|
+
unread.add(key);
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
out.env[UNREAD_BOOT_KEYS] = unread;
|
|
1359
|
+
return out;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1258
1362
|
/**
|
|
1259
1363
|
* The files a worker LOADS at boot, and the one place that knows what each is and how to ask. The model endpoints file
|
|
1260
1364
|
* (issue #503) defaults to the deployment folder's copy where the other three are off when unset.
|
|
@@ -1372,6 +1476,19 @@ export function bootFileId(spec, subject = "service") {
|
|
|
1372
1476
|
return `${spec.scaffold.replace(/\.json$/, "")}.file${subject === "shell" ? "-shell" : ""}`;
|
|
1373
1477
|
}
|
|
1374
1478
|
|
|
1479
|
+
/**
|
|
1480
|
+
* Whether `path` is a regular file or nothing at all, by the real `statSync`: a FIFO or a device is neither, and a
|
|
1481
|
+
* synchronous read of a FIFO never returns. A stat that fails (absent, or a path only a seam knows) is left to the
|
|
1482
|
+
* loader, which says so in its own words.
|
|
1483
|
+
*/
|
|
1484
|
+
function regularOrAbsent(path) {
|
|
1485
|
+
try {
|
|
1486
|
+
return statSync(path).isFile();
|
|
1487
|
+
} catch {
|
|
1488
|
+
return true;
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1375
1492
|
/** A key's value when it is a non-empty string, else null: the boot reads an unset key as off. */
|
|
1376
1493
|
function nonEmpty(value) {
|
|
1377
1494
|
return typeof value === "string" && value.trim() !== "" ? value : null;
|
|
@@ -1396,8 +1513,13 @@ function envelopeContextOf(env, io = {}) {
|
|
|
1396
1513
|
return [];
|
|
1397
1514
|
}
|
|
1398
1515
|
};
|
|
1399
|
-
|
|
1400
|
-
|
|
1516
|
+
// A FIFO or a device named here is none, never a read (issue #859): `loadVerdict` guards the envelope's own path, and
|
|
1517
|
+
// these two are read inside the envelope's load, from the `.env` since #859 as well as from this shell.
|
|
1518
|
+
const guarded = (path) => (path !== null && !regularOrAbsent(path) ? () => [] : null);
|
|
1519
|
+
const projectsFile = nonEmpty(env.PI_PROJECTS_FILE);
|
|
1520
|
+
const limitsFile = nonEmpty(env.PI_SCOPED_LIMITS_FILE);
|
|
1521
|
+
const projects = quiet(guarded(projectsFile) ?? (() => loadProjects({ projectsFile }, io)));
|
|
1522
|
+
const limits = quiet(guarded(limitsFile) ?? (() => loadScopedLimits({ scopedLimitsFile: limitsFile }, io)));
|
|
1401
1523
|
let maxCostMicros = null;
|
|
1402
1524
|
try {
|
|
1403
1525
|
const settings = deploymentSettingsOf(env, settingsFilePath(env), (p) => (io.existsSync ?? existsSync)(p));
|
|
@@ -1661,6 +1783,13 @@ export async function collectChecks(shellVars, seams) {
|
|
|
1661
1783
|
// is this shell's, never the file's. The one thing the worker does read there, auth.json, follows the resolution
|
|
1662
1784
|
// (`keyAgentDir`).
|
|
1663
1785
|
const { cwd, spawn, probeValkey, readHosts = defaultReadHosts, fileExists, nodeVersion, platform, agentDir = agentDirFrom(spawnEnv), readHostPiFn = readHostPi, home = safeHomeDir(), underTemp = underOsTempDir, providerOracle = defaultProviderOracle, facts = null, readEnvFile = null, stat: statSeam = statSync, runTimeouts = RUN_TIMEOUTS } = seams;
|
|
1786
|
+
// Issue #859: the deployment `.env`'s reading of the five boot-file keys, made ONCE and here, before the first check that
|
|
1787
|
+
// reads a projects, scoped-limits, pause-windows or envelope file (a test fails a second read). The BOOT_FILES lines
|
|
1788
|
+
// further down judge its two subjects from it, as they did when it was read there; `bootEnv` is what every other
|
|
1789
|
+
// check judges those keys by (`bootFileEnv`), never this shell's value alone.
|
|
1790
|
+
const envFile = envFileKeys(join(cwd, ".env"), BOOT_FILES.map((spec) => spec.key), { fileExists, readEnvFile, platform });
|
|
1791
|
+
const boot = bootFileEnv(env, envFile, cwd, platform, { untrusted: seams.envUntrusted === true });
|
|
1792
|
+
const bootEnv = boot.env;
|
|
1664
1793
|
|
|
1665
1794
|
// The job image as the worker takes it (`config.mjs` `jobImageFrom`: `||`, so an empty value is the default, where
|
|
1666
1795
|
// doctor's `??` inspected an image named ""), and judged BEFORE doctor inspects or runs it (issue #471) by the one
|
|
@@ -1896,10 +2025,17 @@ export async function collectChecks(shellVars, seams) {
|
|
|
1896
2025
|
// drives a need above 1 through the real collection, for a store whose table has no row of its own and at a version no row holds yet.
|
|
1897
2026
|
const versionTables = seams.versionTables ?? {};
|
|
1898
2027
|
const { watchSources: triggerWatchSources, watchRules: triggerWatchRules, modelRoles: triggerModelRoles, secretBundles: triggerBundles, requiring, waiting, listing, waitProfiles, waitAfters, optingOut, resuming, recovering, replicating, instructing, commands, secreting, onceArmed, onceSpent, secretProfiles, localSecretFolders, secretNames, folders, images, imageRoutes, namedBackends, skillsDirs, forges, repositories, flows, excludedNames, costCaps, modelRuns, triggersVersionNeed, jobDataVersionNeed, parseError, parseNewer: triggersFileIsNewer, path: triggersFilePath } = readTriggerFacts(env, fileExists, cwd, declaredWorkerName, versionTables);
|
|
1899
|
-
const modelRoleProjects = readProjectFacts(
|
|
1900
|
-
const scopedLimitFacts = readScopedLimitFacts(
|
|
1901
|
-
const pauseWindowFacts = readPauseWindowFacts(
|
|
2028
|
+
const modelRoleProjects = readProjectFacts(bootEnv, fileExists).projects;
|
|
2029
|
+
const scopedLimitFacts = readScopedLimitFacts(bootEnv, fileExists);
|
|
2030
|
+
const pauseWindowFacts = readPauseWindowFacts(bootEnv, fileExists, versionTables.pauseWindows);
|
|
1902
2031
|
section("triggers");
|
|
2032
|
+
// Issue #859: a boot file the `.env` names on a line doctor could not read is not known, and the checks that read it are
|
|
2033
|
+
// left out rather than judged against nothing; this line says which, once, so none of them goes quiet without a word. A
|
|
2034
|
+
// `.env` that could not be read at all has a line per key already ("cannot be answered here"), so this adds nothing there.
|
|
2035
|
+
const unreadBoot = [...bootEnv[UNREAD_BOOT_KEYS]];
|
|
2036
|
+
if (unreadBoot.length > 0 && envFile.unreadable !== true) {
|
|
2037
|
+
checks.push({ ok: false, warn: true, label: `not judged here, since doctor could not read what the deployment .env sets for ${unreadBoot.join(", ")}: the checks that read ${unreadBoot.length === 1 ? "that file" : "those files"} (a project's model roles, size and bundles, the scoped limits and budgets, the pause windows, the envelope)`, fix: "fix what the boot-file line above names, then re-run doctor" });
|
|
2038
|
+
}
|
|
1903
2039
|
checks.push(...watchDoctorChecks({ sources: triggerWatchSources, rules: triggerWatchRules, env }));
|
|
1904
2040
|
// FIRST, and fail rather than warn: every check below this line reads counts that a parse failure
|
|
1905
2041
|
// zeroed, so a green run here would be reporting on a file nobody could read. The receiver loads this
|
|
@@ -2343,9 +2479,13 @@ export async function collectChecks(shellVars, seams) {
|
|
|
2343
2479
|
// offers. The records are read only when there is a project to suggest for, and not at all when the scoped-limits
|
|
2344
2480
|
// file does not load: every size read without it would be the default, not the project's, and a project that has a
|
|
2345
2481
|
// row would be told to `dispatch_limit_add` one.
|
|
2346
|
-
const sizingProjects = readProjectFacts(
|
|
2482
|
+
const sizingProjects = readProjectFacts(bootEnv, fileExists).projects;
|
|
2347
2483
|
if (sizingProjects.length > 0 && !budgetView.error) {
|
|
2348
|
-
if (scopedLimitFacts.parseError
|
|
2484
|
+
if (scopedLimitFacts.parseError === "unread") {
|
|
2485
|
+
// Issue #859: the `.env` line naming the file could not be read (its own line says why), so which rows the service
|
|
2486
|
+
// has is not known, and a suggestion would tell a project that has one to add it.
|
|
2487
|
+
checks.push({ ok: true, label: "size suggestions: off, because the scoped-limits file the service reads is not known (doctor could not read what the deployment .env sets for it)" });
|
|
2488
|
+
} else if (scopedLimitFacts.parseError !== null) {
|
|
2349
2489
|
checks.push({ ok: true, label: "size suggestions: off until the scoped-limits file loads (a size read without it would be the default, not the project's)" });
|
|
2350
2490
|
} else {
|
|
2351
2491
|
const nowMs = (typeof seams.wallClock === "function" ? seams.wallClock : Date.now)();
|
|
@@ -3140,12 +3280,12 @@ export async function collectChecks(shellVars, seams) {
|
|
|
3140
3280
|
// A malformed list is its own line below; the worker refuses to boot on it.
|
|
3141
3281
|
}
|
|
3142
3282
|
const usdHere = usdFingerprint(dollarsHere, scopedLimitFacts.parseError === null ? scopedLimitFacts.limits : [], envListHere);
|
|
3143
|
-
checks.push(...(await fleetDollarChecks(usdHere, peers, { dollarKeysExist: () => (seams.dollarKeysExist ?? defaultDollarKeysExist)(valkeyTalkUrl) })));
|
|
3283
|
+
if (scopedLimitFacts.parseError !== "unread") checks.push(...(await fleetDollarChecks(usdHere, peers, { dollarKeysExist: () => (seams.dollarKeysExist ?? defaultDollarKeysExist)(valkeyTalkUrl) })));
|
|
3144
3284
|
// Issue #499 part C: each host resolves its jobs' project from its OWN projects.json, while the project rows' counters
|
|
3145
3285
|
// are shared. This host's `fpProjects` from the file the service names, against every peer's published one. SKIPPED
|
|
3146
3286
|
// when this host's file does not load (PR #569's review): the BOOT_FILES line already fails on it, and comparing
|
|
3147
3287
|
// "no projects" against healthy peers would send the operator to the wrong host.
|
|
3148
|
-
const projectFactsHere = readProjectFacts(
|
|
3288
|
+
const projectFactsHere = readProjectFacts(bootEnv, fileExists);
|
|
3149
3289
|
if (projectFactsHere.parseError === null) checks.push(...fleetProjectsChecks(projectsFingerprint(projectFactsHere.projects), peers));
|
|
3150
3290
|
// Issue #596: a peer that predates version 3 keeps its last good scoped-limits file once this one is version 3, so
|
|
3151
3291
|
// no edit to the file applies on it. Judged on the DECLARED version, or the derived one if that is higher (gate
|
|
@@ -3161,7 +3301,7 @@ export async function collectChecks(shellVars, seams) {
|
|
|
3161
3301
|
// Issue #504 part B: one applied split, judged on every host against its own envelope. A host whose envelope digest
|
|
3162
3302
|
// differs refuses every governed job as `envelope-mismatch`. SKIPPED when this host's file does not load, for the
|
|
3163
3303
|
// projects check's reason above.
|
|
3164
|
-
const envelopeHere = readEnvelopeFacts(
|
|
3304
|
+
const envelopeHere = readEnvelopeFacts(bootEnv);
|
|
3165
3305
|
if (envelopeHere.parseError === null && appliedSplit === null) checks.push(...fleetEnvelopeChecks(envelopeHere.envelope ? envelopeDigest(envelopeHere.envelope) : NO_ENVELOPE_FINGERPRINT, peers));
|
|
3166
3306
|
} else if (fleet.unreachable) {
|
|
3167
3307
|
// Said, rather than silently absent: "no peers" and "could not ask" are different facts.
|
|
@@ -3186,7 +3326,7 @@ export async function collectChecks(shellVars, seams) {
|
|
|
3186
3326
|
// whose envelope differs refuses its governed jobs. Read whenever this command may talk to the Valkey (above), from
|
|
3187
3327
|
// the same Valkey the fleet was read from; with no split there, or no answer, nothing is said.
|
|
3188
3328
|
if (appliedSplit !== null) {
|
|
3189
|
-
const envelopeHere = readEnvelopeFacts(
|
|
3329
|
+
const envelopeHere = readEnvelopeFacts(bootEnv);
|
|
3190
3330
|
if (envelopeHere.parseError === null) checks.push(...appliedSplitChecks(appliedSplit, envelopeHere.envelope ? envelopeDigest(envelopeHere.envelope) : NO_ENVELOPE_FINGERPRINT, workerNameOf(declaredWorkerName), peers));
|
|
3191
3331
|
}
|
|
3192
3332
|
|
|
@@ -3861,7 +4001,7 @@ export async function collectChecks(shellVars, seams) {
|
|
|
3861
4001
|
}
|
|
3862
4002
|
}
|
|
3863
4003
|
|
|
3864
|
-
checks.push(...await doctorModelProposalChecks(env, { cwd, credentials: service.extra(["ANTHROPIC_API_KEY", "OPENAI_API_KEY"]).env, now: seams.wallClock ?? Date.now, ...(seams.modelProposals ?? {}) }));
|
|
4004
|
+
checks.push(...await doctorModelProposalChecks(env, { cwd, credentials: service.extra(["ANTHROPIC_API_KEY", "OPENAI_API_KEY"]).env, declaredEndpoints: () => (seams.declaredEndpoints ?? ((a) => declaredEndpointsIn({ ...a, fs: keylessIo })))({ env, cwd: seams.cwd, platform: seams.platform ?? process.platform }), now: seams.wallClock ?? Date.now, ...(seams.modelProposals ?? {}) }));
|
|
3865
4005
|
section("secrets");
|
|
3866
4006
|
// REQ-TRIGGER-SECRETS. Only reported when a trigger actually binds one, on the run.resume block's
|
|
3867
4007
|
// reasoning below: a deployment that uses no secrets should not be told about a variable it has no
|
|
@@ -3997,7 +4137,7 @@ export async function collectChecks(shellVars, seams) {
|
|
|
3997
4137
|
// skip reason. Doctor cannot see another host's file, so it warns where the shape allows it: a multi-host worker
|
|
3998
4138
|
// (PI_WORKER_NAME) with projects and no triggers file of its own.
|
|
3999
4139
|
if (!anyBundle && typeof env.PI_WORKER_NAME === "string" && env.PI_WORKER_NAME.trim() !== "" && !fileExists(triggersPath(env, cwd))) {
|
|
4000
|
-
const fleetProjects = readProjectFacts(
|
|
4140
|
+
const fleetProjects = readProjectFacts(bootEnv, fileExists);
|
|
4001
4141
|
if (fleetProjects.parseError === null && fleetProjects.projects.length > 0) {
|
|
4002
4142
|
checks.push({ ok: true, warn: true, label: "this fleet worker has projects but no triggers file, so it applies no project secret bundle: a project another host's triggers.json binds would run here without its environment", fix: "keep the same triggers.json on every host (PI_TRIGGERS_FILE), or declare the bundle profiles here, which makes a missing file a retry instead" });
|
|
4003
4143
|
}
|
|
@@ -4009,7 +4149,7 @@ export async function collectChecks(shellVars, seams) {
|
|
|
4009
4149
|
} catch {
|
|
4010
4150
|
realExecutablePath = null;
|
|
4011
4151
|
}
|
|
4012
|
-
const projectFacts = readProjectFacts(
|
|
4152
|
+
const projectFacts = readProjectFacts(bootEnv, fileExists);
|
|
4013
4153
|
let resolveBundle = null;
|
|
4014
4154
|
if (seams.secrets === true) {
|
|
4015
4155
|
try {
|
|
@@ -4027,6 +4167,7 @@ export async function collectChecks(shellVars, seams) {
|
|
|
4027
4167
|
triggersPresent: fileExists(triggersPath(env, cwd)),
|
|
4028
4168
|
projects: projectFacts.projects,
|
|
4029
4169
|
projectsError: projectFacts.parseError,
|
|
4170
|
+
projectsUnread: projectFacts.unread === true,
|
|
4030
4171
|
envRaw: env.PI_SECRET_BUNDLES,
|
|
4031
4172
|
overlayBundles,
|
|
4032
4173
|
roots: delimitedList(env.PI_SECRET_RESOLVER_ROOTS),
|
|
@@ -4233,7 +4374,7 @@ export async function collectChecks(shellVars, seams) {
|
|
|
4233
4374
|
// warning then cries wolf at a correctly configured deployment, and this module's own rule is that a
|
|
4234
4375
|
// check nobody can silence must never do that.
|
|
4235
4376
|
//
|
|
4236
|
-
// So doctor reads `<cwd>/.env` for EXACTLY the
|
|
4377
|
+
// So doctor reads `<cwd>/.env` for EXACTLY the boot-file keys (`ENV_FILE_READABLE_KEYS`), and never to configure anything.
|
|
4237
4378
|
// That narrowing is the whole licence: the project's stance is that nothing parses `.env`
|
|
4238
4379
|
// (`docs/secrets.md`), and `worker/test/service.test.mjs` pins that a `PI_ENV_SETUP` line in `./.env` is
|
|
4239
4380
|
// deliberately NOT honoured. Both stay true.
|
|
@@ -4243,7 +4384,8 @@ export async function collectChecks(shellVars, seams) {
|
|
|
4243
4384
|
// a deployment whose unit exits 1 in a restart loop is not a deployment that is merely unconfigured in
|
|
4244
4385
|
// this shell. Where the file merely configures what this shell does not, the line still says which
|
|
4245
4386
|
// process would honour it, and is still a warning.
|
|
4246
|
-
|
|
4387
|
+
// The read itself (`envFile`) is made once at the top of `collectChecks` since issue #859, so every check that reads
|
|
4388
|
+
// these files, before this block and after it, judges the same reading (`bootFileEnv`).
|
|
4247
4389
|
// ONE RULE FOR BOTH BOOT FILES, and it asks the worker's own loader rather than a second opinion
|
|
4248
4390
|
// (issue #384). Before this there were two hand-written copies of a shell-shaped check for
|
|
4249
4391
|
// `PI_PAUSE_WINDOWS_FILE`, a THIRD rule for a scoped-limits file that does not parse, and no rule at all
|
|
@@ -4264,12 +4406,15 @@ export async function collectChecks(shellVars, seams) {
|
|
|
4264
4406
|
// regular-file guard and the loaders' own two reads. A test drives a whole deployment through these
|
|
4265
4407
|
// without a real file, which is how the fixtures below stay honest about content.
|
|
4266
4408
|
const seamsForLoad = { env, statFile: statSeam, loaderIo: { existsSync: (p) => fileExists(p), readFileSync: readEnvFile ? (p, enc) => asText(readEnvFile(p), enc) : readFileSync } };
|
|
4409
|
+
// Issue #859: the SERVICE's subject is loaded with the boot keys as the service reads them, from the file alone, so an
|
|
4410
|
+
// envelope in `.env` is judged against the projects and limits the service names, never against this shell's.
|
|
4411
|
+
const serviceSeamsForLoad = { ...seamsForLoad, env: boot.service };
|
|
4267
4412
|
// ONCE PER FILE, not once per key (issue #396). This is a fact about the FILE -- one line the reader
|
|
4268
4413
|
// cannot model -- and it was announced inside the per-key loop, so a `.env` with one such line produced
|
|
4269
4414
|
// two near-identical warnings differing only in which key they named. The keys it prevents a verdict
|
|
4270
4415
|
// about are listed IN the line instead, which is what the reader actually knows.
|
|
4271
4416
|
//
|
|
4272
|
-
// ITS LIMIT, stated because the wording would otherwise imply more: this command reads
|
|
4417
|
+
// ITS LIMIT, stated because the wording would otherwise imply more: this command reads five keys, so the
|
|
4273
4418
|
// line says what it cannot answer about THOSE. The same hazard may also stop a sourcing shell reaching
|
|
4274
4419
|
// `WEBHOOK_SECRET`, which the receiver refuses to start without, and nothing here says so -- widening
|
|
4275
4420
|
// the read is how a narrow reader grows into "load the .env", which `envFileKeys`' own docblock and
|
|
@@ -4386,8 +4531,13 @@ export async function collectChecks(shellVars, seams) {
|
|
|
4386
4531
|
// boot key was never opened. `fileRaw` is set only for a line this reader vouches for the TEXT of, so
|
|
4387
4532
|
// there is always a real path here; whether it loads is a fact about the filesystem, not about the
|
|
4388
4533
|
// rest of the file.
|
|
4389
|
-
|
|
4390
|
-
|
|
4534
|
+
// Issue #859: the envelope is judged against the projects and limits the service names; where the `.env` line of one
|
|
4535
|
+
// of those cannot be read, neither can this verdict, and a "no projects" verdict would fail a service that starts.
|
|
4536
|
+
const contextUnread = spec.key === "PI_ENVELOPE_FILE" ? ["PI_PROJECTS_FILE", "PI_SCOPED_LIMITS_FILE"].filter((key) => boot.serviceUnread.has(key)) : [];
|
|
4537
|
+
if (envFile.hazard == null && fileRaw !== undefined && !blankInFile && contextUnread.length > 0) {
|
|
4538
|
+
checks.push({ id: bootFileId(spec), ok: false, warn: true, label: `${spec.key} is set in ${join(cwd, ".env")} (${envValueShown(fileRaw)}), and whether the service loads it cannot be judged: doctor could not read what that file sets for ${contextUnread.join(" and ")}, which it is judged against`, fix: "fix that line (its own line here says why), then re-run doctor" });
|
|
4539
|
+
} else if (envFile.hazard == null && fileRaw !== undefined && !blankInFile) {
|
|
4540
|
+
const verdict = loadVerdict(spec, fileRaw, cwd, serviceSeamsForLoad, platform);
|
|
4391
4541
|
checks.push(
|
|
4392
4542
|
verdict.ok
|
|
4393
4543
|
? { id: bootFileId(spec), ok: true, label: `${spec.key} is set in ${join(cwd, ".env")} (${envValueShown(fileRaw)})${alsoExported === undefined ? "" : ", for this platform's loader,"} and loads: the service reads that file, this shell does not` }
|
|
@@ -4463,6 +4613,19 @@ export async function collectChecks(shellVars, seams) {
|
|
|
4463
4613
|
fix: `set ${fixLineFor(spec.key, scaffolded)} in .env and restart the worker -- unset means ${spec.unsetMeans}${spec.panelWrites === false ? "" : `, while the admin panel defaults to this same file and reports each ${spec.unit} it writes as applied live`}; delete the file if this deployment has no ${spec.nothing}`,
|
|
4464
4614
|
});
|
|
4465
4615
|
}
|
|
4616
|
+
|
|
4617
|
+
// Issue #859: WHICH FILE THE OTHER CHECKS JUDGED, where the two subjects name different ones. A ✗, as a disagreement
|
|
4618
|
+
// about any other service key is (`serviceEnvLines`): the checks then judge files the service does not read, and a
|
|
4619
|
+
// warning let doctor exit 0 on a service its own `.env` stops (a project row the shell's projects hold and the
|
|
4620
|
+
// file's do not passed, while `checkProjectRows` refuses the boot).
|
|
4621
|
+
const differs = boot.differs.find((d) => d.key === spec.key);
|
|
4622
|
+
if (differs !== undefined) {
|
|
4623
|
+
checks.push({
|
|
4624
|
+
ok: false,
|
|
4625
|
+
label: `${spec.key} names different files in this shell (${envValueShown(differs.shell)}) and in ${join(cwd, ".env")} (${envValueShown(differs.file)}): doctor's other checks judged this shell's, which a worker started by hand from this shell runs, while the service runs the file's`,
|
|
4626
|
+
fix: `make them agree: change ${join(cwd, ".env")} (what the service reads), or unset ${spec.key} in this shell, then re-run doctor`,
|
|
4627
|
+
});
|
|
4628
|
+
}
|
|
4466
4629
|
}
|
|
4467
4630
|
|
|
4468
4631
|
// A scope that can only ever be a folder (issue #242's dead-scope rule): a forge repo always contains "/" and never
|
|
@@ -4502,14 +4665,14 @@ export async function collectChecks(shellVars, seams) {
|
|
|
4502
4665
|
// (start.mjs, `checkProjectRows`), so this is a FAILURE naming the row and the id. Skipped when either file does not
|
|
4503
4666
|
// load: that is the BOOT_FILES line's, and a zeroed list here would name every project row as dangling.
|
|
4504
4667
|
if (scopedLimitFacts.parseError === null && scopedLimitFacts.limits.length > 0) {
|
|
4505
|
-
const projectFacts = readProjectFacts(
|
|
4668
|
+
const projectFacts = readProjectFacts(bootEnv, fileExists);
|
|
4506
4669
|
if (projectFacts.parseError === null) checks.push(...projectRowChecks(scopedLimitFacts.limits, projectFacts.projects, scopedLimitFacts.path));
|
|
4507
4670
|
}
|
|
4508
4671
|
|
|
4509
4672
|
section("envelope");
|
|
4510
4673
|
// Issue #504 part B: the envelope's advisories, on the file the service names, when it loads (a file that does not is
|
|
4511
4674
|
// the BOOT_FILES line's). Warnings only: the worker boots on both.
|
|
4512
|
-
const envelopeFactsHere = readEnvelopeFacts(
|
|
4675
|
+
const envelopeFactsHere = readEnvelopeFacts(bootEnv);
|
|
4513
4676
|
if (envelopeFactsHere.envelope) checks.push(...envelopeChecks(envelopeFactsHere.envelope, envelopeFactsHere.projects, envelopeFactsHere.maxCostMicros));
|
|
4514
4677
|
|
|
4515
4678
|
section("console");
|
|
@@ -4813,7 +4976,9 @@ export async function collectChecks(shellVars, seams) {
|
|
|
4813
4976
|
// Nor XDG_CONFIG_HOME at its own default (`~/.config`, which a login shell often exports): unset, every CLI that
|
|
4814
4977
|
// reads it falls back to that same directory, so the service runs with the same value (measured on pd-fedora: the
|
|
4815
4978
|
// named line otherwise flagged the default).
|
|
4816
|
-
|
|
4979
|
+
// Issue #859: a boot-file key set in this shell alone (`bootFileEnv`) is the same fact about the same file, so it joins
|
|
4980
|
+
// this line: the checks judged this shell's file, and the service runs without one.
|
|
4981
|
+
const shellOnly = [...service.shellOnly.filter((k) => !AMBIENT_SERVICE_KEYS.includes(k) && !(k === "XDG_CONFIG_HOME" && typeof home === "string" && env.XDG_CONFIG_HOME === join(home, ".config"))), ...boot.shellOnly];
|
|
4817
4982
|
const unit = shellOnly.length > 0 ? serviceUnitFor(seams, cwd) : null;
|
|
4818
4983
|
if (unit) {
|
|
4819
4984
|
checks.push({
|
|
@@ -5415,6 +5580,7 @@ function parseSecretProfilesSafe(raw) {
|
|
|
5415
5580
|
*/
|
|
5416
5581
|
function readScopedLimitFacts(env, fileExists) {
|
|
5417
5582
|
const none = { limits: [], parseError: null, path: null };
|
|
5583
|
+
if (bootKeyUnread(env, "PI_SCOPED_LIMITS_FILE")) return { ...none, parseError: "unread" };
|
|
5418
5584
|
const path = env.PI_SCOPED_LIMITS_FILE;
|
|
5419
5585
|
if (typeof path !== "string" || path.trim() === "") return none;
|
|
5420
5586
|
if (!fileExists(path)) return { limits: [], parseError: `scoped-limits file does not exist: ${path}`, path };
|
|
@@ -5438,8 +5604,12 @@ function readScopedLimitFacts(env, fileExists) {
|
|
|
5438
5604
|
/**
|
|
5439
5605
|
* The projects facts (issue #499 part B): the parsed projects when PI_PROJECTS_FILE is set, `[]` when it is unset (no
|
|
5440
5606
|
* projects, so every project row dangles), or a parse error when it is set and does not load (the BOOT_FILES line's).
|
|
5607
|
+
* Handed `bootEnv` (issue #859), so the key is the service's where this shell sets none, and `unread` where the `.env`
|
|
5608
|
+
* line that sets it could not be read.
|
|
5441
5609
|
*/
|
|
5442
5610
|
function readProjectFacts(env, fileExists) {
|
|
5611
|
+
// Issue #859: a `.env` line doctor could not read is a file not known, said by its own line, never "no projects".
|
|
5612
|
+
if (bootKeyUnread(env, "PI_PROJECTS_FILE")) return { projects: [], parseError: "unread", unread: true };
|
|
5443
5613
|
const path = env.PI_PROJECTS_FILE;
|
|
5444
5614
|
if (typeof path !== "string" || path.trim() === "") return { projects: [], parseError: null };
|
|
5445
5615
|
try {
|
|
@@ -5695,9 +5865,13 @@ function consoleTokensInsideJobPaths(path, env, { cwd, fs, folders, skillsDirs,
|
|
|
5695
5865
|
* `envelope` is null when the key is unset.
|
|
5696
5866
|
*/
|
|
5697
5867
|
function readEnvelopeFacts(env) {
|
|
5868
|
+
if (bootKeyUnread(env, "PI_ENVELOPE_FILE")) return { envelope: null, projects: [], maxCostMicros: null, parseError: "unread" };
|
|
5698
5869
|
const path = nonEmpty(env.PI_ENVELOPE_FILE);
|
|
5699
5870
|
if (path === null) return { envelope: null, projects: [], maxCostMicros: null, parseError: null };
|
|
5700
5871
|
const io = { readFileSync, existsSync };
|
|
5872
|
+
// A REGULAR FILE or nothing, as every other reader of these files here (issue #859): since the `.env` names this path
|
|
5873
|
+
// too, a FIFO there hung doctor in a synchronous read no timeout interrupts.
|
|
5874
|
+
if (!regularOrAbsent(path)) return { envelope: null, projects: [], maxCostMicros: null, parseError: `envelope file is not a regular file: ${path}` };
|
|
5701
5875
|
try {
|
|
5702
5876
|
const context = envelopeContextOf(env, io);
|
|
5703
5877
|
return { envelope: loadEnvelopeAsTheWorker(path, io, env), projects: context.projects, maxCostMicros: context.maxCostMicros, parseError: null };
|
|
@@ -5888,6 +6062,7 @@ export function projectRowChecks(limits, projects, path) {
|
|
|
5888
6062
|
* module's own by default.
|
|
5889
6063
|
*/
|
|
5890
6064
|
function readPauseWindowFacts(env, fileExists, versions = {}) {
|
|
6065
|
+
if (bootKeyUnread(env, "PI_PAUSE_WINDOWS_FILE")) return { windows: [], versionNeed: 1, parseError: "unread" };
|
|
5891
6066
|
const path = env.PI_PAUSE_WINDOWS_FILE;
|
|
5892
6067
|
if (typeof path !== "string" || path.trim() === "") return { windows: [], versionNeed: 1, parseError: null };
|
|
5893
6068
|
try {
|
package/src/pause-windows.mjs
CHANGED
|
@@ -83,8 +83,13 @@ export function pauseWindowsVersionRefusal(doc, { supported = PAUSE_WINDOWS_VERS
|
|
|
83
83
|
export function heldPauseWindows(text, path) {
|
|
84
84
|
if ((text ?? "").trim() === "") return { doc: null, windows: [] };
|
|
85
85
|
const { value, problem } = heldDocument(text, "windows");
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
// Whenever the text parsed, a problem with its shape included (`"windows": {}`), as the loader judges the duplicate first.
|
|
87
|
+
const doubled = value !== undefined && findDuplicateKey(text) !== null;
|
|
88
|
+
const refused = doubled ? "has a key written twice" : problem ?? null;
|
|
89
|
+
// A key written twice hands back no document (issue #846): the callers ask the document whether a newer release wrote
|
|
90
|
+
// it before they read `problem`, and `{"version":1,"version":2}` would send the operator to upgrade to a release that
|
|
91
|
+
// refuses the file too. Broken, never newer, as the loader reads it and as the triggers writer does.
|
|
92
|
+
if (refused) return { doc: doubled ? null : value ?? null, problem: `the pause-windows file ${path} ${refused}, so its windows cannot be kept` };
|
|
88
93
|
return { doc: value, windows: Array.isArray(value.windows) ? value.windows : [] };
|
|
89
94
|
}
|
|
90
95
|
|
|
@@ -158,7 +163,7 @@ function validateTz(tz, at, path) {
|
|
|
158
163
|
* (unknown fields dropped; `fromMin`/`toMin` precomputed). Throws `configError` (fail-loud) on any malformed
|
|
159
164
|
* entry. `path` is for messages only.
|
|
160
165
|
*
|
|
161
|
-
* The file's `version` is judged
|
|
166
|
+
* The file's `version` is judged right after the duplicate-key check (issue #846) and before the windows (issue #761): a newer file's windows may use a shape this
|
|
162
167
|
* build would call malformed, and the true message is "upgrade". A newer file throws a configError with
|
|
163
168
|
* `.newer === true` (the worker defers jobs rather than misread a pause); a broken `version`, or a window using a field
|
|
164
169
|
* or a value that needs a higher version than the file declares, throws the ordinary configError. `supported`, `table`
|
|
@@ -171,6 +176,17 @@ export function parsePauseWindows(text, path, { supported = PAUSE_WINDOWS_VERSIO
|
|
|
171
176
|
} catch (error) {
|
|
172
177
|
throw configError(`pause-windows file is not valid JSON: ${path} (${error.message})`);
|
|
173
178
|
}
|
|
179
|
+
// Issue #846: a key written twice is REFUSED, after the parse and before anything else, as the triggers loader refuses
|
|
180
|
+
// one (`triggers.mjs`, issue #313). `JSON.parse` keeps the LAST value, so `{"windows":[...],"windows":[]}` loaded no
|
|
181
|
+
// window while a person reading the file sees two, and a reload swapped a live list down to none with nothing said:
|
|
182
|
+
// quiet hours stopped applying, which costs money in exactly the hours someone believes are paused. Refused, the boot
|
|
183
|
+
// does not start and a reload keeps the last good windows and logs why (`reloadPauseWindows`). The writers have
|
|
184
|
+
// refused such a file since #841 (`heldPauseWindows`), so only a hand edit makes one. BEFORE the version, the triggers
|
|
185
|
+
// loader's order: a doubled `version` is broken, never newer, and a newer release refuses the duplicate as well.
|
|
186
|
+
const duplicate = findDuplicateKey(text);
|
|
187
|
+
if (duplicate) {
|
|
188
|
+
throw configError(`pause-windows file has a duplicate key ${JSON.stringify(duplicate.key)} at ${duplicate.at}: JSON keeps the LAST value, so the file a person reads and the file that applies are not the same: ${path}`);
|
|
189
|
+
}
|
|
174
190
|
const problem = pauseWindowsVersionRefusal(parsed, { supported });
|
|
175
191
|
if (problem) {
|
|
176
192
|
const error = configError(`${problem.message}: ${path}`);
|