@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/doctor.mjs
CHANGED
|
@@ -143,7 +143,7 @@ import { assignCheckIds, buildDoctorRecord, doctorIntervalHours, publishRedactor
|
|
|
143
143
|
import { olderStateFindings as olderStateFindingsReal, VALKEY_CONTAINER } from "./older-state.mjs";
|
|
144
144
|
import { DOCTOR_PUBLISH_SKIPPED_PREFIX } from "./doctor-spawner.mjs";
|
|
145
145
|
import { extensionsNotHere } from "./loaded-record.mjs";
|
|
146
|
-
import { buildHostLoads, flowProbes, flowTierOf, mcpRowsOf, pathIsThere, readOverlayExtensionRows, readOverlaySkillNames } from "./host-loads.mjs";
|
|
146
|
+
import { buildHostLoads, FLOW_INPUT_FIELDS, flowBasisOf, flowInputOf, flowProbes, flowTierOf, mcpRowsOf, pathIsThere, readOverlayExtensionRows, readOverlaySkillNames } from "./host-loads.mjs";
|
|
147
147
|
|
|
148
148
|
const NODE_FLOOR = [22, 19]; // pi's engine floor (22.19.0)
|
|
149
149
|
|
|
@@ -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
|
|
@@ -2055,7 +2191,8 @@ export async function collectChecks(shellVars, seams) {
|
|
|
2055
2191
|
const staged = readStagedSkills({ globalPiDir: env.PI_GLOBAL_PI_DIR, readFile: stagedProbes.readFile, fileExists: stagedProbes.fileExists, readDir: stagedProbes.readDir });
|
|
2056
2192
|
const groups = new Map();
|
|
2057
2193
|
for (const f of flows) {
|
|
2058
|
-
|
|
2194
|
+
// Keyed by every field `flowTierOf` reads, so a group shares one answer and one basis.
|
|
2195
|
+
const key = JSON.stringify(FLOW_INPUT_FIELDS.map((field) => f[field]));
|
|
2059
2196
|
if (!groups.has(key)) groups.set(key, { ...f, labels: [], triggers: [] });
|
|
2060
2197
|
groups.get(key).labels.push(f.label);
|
|
2061
2198
|
groups.get(key).triggers.push(f.index);
|
|
@@ -2073,7 +2210,7 @@ export async function collectChecks(shellVars, seams) {
|
|
|
2073
2210
|
fix: "a flow name must match the skill charset (lowercase alphanumerics, - and _, 64 max), or no tier can ever hold it -- fix run.flow",
|
|
2074
2211
|
});
|
|
2075
2212
|
// No tier can hold it, and a name that fails the charset is not published.
|
|
2076
|
-
for (const trigger of g.triggers) seams.flowRows.push({ trigger, flow: null, tier: "none", package: null, repo: "unknown" });
|
|
2213
|
+
for (const trigger of g.triggers) seams.flowRows.push({ trigger, flow: null, tier: "none", package: null, repo: "unknown", basis: g.basis });
|
|
2077
2214
|
continue;
|
|
2078
2215
|
}
|
|
2079
2216
|
const probes = flowProbes({ fileExists, fs: flowFs });
|
|
@@ -2083,7 +2220,7 @@ export async function collectChecks(shellVars, seams) {
|
|
|
2083
2220
|
// trigger that withholds the packages did not, and another trigger's unreadable folder says nothing of this one's.
|
|
2084
2221
|
const blind = probes.blind() || (g.packages !== false && stagedProbes.blind());
|
|
2085
2222
|
const tier = found.tier === "none" && blind ? "unknown" : found.tier;
|
|
2086
|
-
for (const trigger of g.triggers) seams.flowRows.push({ trigger, flow: g.flow, tier, package: found.package, repo: found.repo });
|
|
2223
|
+
for (const trigger of g.triggers) seams.flowRows.push({ trigger, flow: g.flow, tier, package: found.package, repo: found.repo, basis: g.basis });
|
|
2087
2224
|
if (resolved) {
|
|
2088
2225
|
checks.push({ ok: true, label: `Trigger flow "${g.flow}" resolves (${at}: ${resolved})` });
|
|
2089
2226
|
} else {
|
|
@@ -2343,9 +2480,13 @@ export async function collectChecks(shellVars, seams) {
|
|
|
2343
2480
|
// offers. The records are read only when there is a project to suggest for, and not at all when the scoped-limits
|
|
2344
2481
|
// file does not load: every size read without it would be the default, not the project's, and a project that has a
|
|
2345
2482
|
// row would be told to `dispatch_limit_add` one.
|
|
2346
|
-
const sizingProjects = readProjectFacts(
|
|
2483
|
+
const sizingProjects = readProjectFacts(bootEnv, fileExists).projects;
|
|
2347
2484
|
if (sizingProjects.length > 0 && !budgetView.error) {
|
|
2348
|
-
if (scopedLimitFacts.parseError
|
|
2485
|
+
if (scopedLimitFacts.parseError === "unread") {
|
|
2486
|
+
// Issue #859: the `.env` line naming the file could not be read (its own line says why), so which rows the service
|
|
2487
|
+
// has is not known, and a suggestion would tell a project that has one to add it.
|
|
2488
|
+
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)" });
|
|
2489
|
+
} else if (scopedLimitFacts.parseError !== null) {
|
|
2349
2490
|
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
2491
|
} else {
|
|
2351
2492
|
const nowMs = (typeof seams.wallClock === "function" ? seams.wallClock : Date.now)();
|
|
@@ -3140,12 +3281,12 @@ export async function collectChecks(shellVars, seams) {
|
|
|
3140
3281
|
// A malformed list is its own line below; the worker refuses to boot on it.
|
|
3141
3282
|
}
|
|
3142
3283
|
const usdHere = usdFingerprint(dollarsHere, scopedLimitFacts.parseError === null ? scopedLimitFacts.limits : [], envListHere);
|
|
3143
|
-
checks.push(...(await fleetDollarChecks(usdHere, peers, { dollarKeysExist: () => (seams.dollarKeysExist ?? defaultDollarKeysExist)(valkeyTalkUrl) })));
|
|
3284
|
+
if (scopedLimitFacts.parseError !== "unread") checks.push(...(await fleetDollarChecks(usdHere, peers, { dollarKeysExist: () => (seams.dollarKeysExist ?? defaultDollarKeysExist)(valkeyTalkUrl) })));
|
|
3144
3285
|
// Issue #499 part C: each host resolves its jobs' project from its OWN projects.json, while the project rows' counters
|
|
3145
3286
|
// are shared. This host's `fpProjects` from the file the service names, against every peer's published one. SKIPPED
|
|
3146
3287
|
// when this host's file does not load (PR #569's review): the BOOT_FILES line already fails on it, and comparing
|
|
3147
3288
|
// "no projects" against healthy peers would send the operator to the wrong host.
|
|
3148
|
-
const projectFactsHere = readProjectFacts(
|
|
3289
|
+
const projectFactsHere = readProjectFacts(bootEnv, fileExists);
|
|
3149
3290
|
if (projectFactsHere.parseError === null) checks.push(...fleetProjectsChecks(projectsFingerprint(projectFactsHere.projects), peers));
|
|
3150
3291
|
// Issue #596: a peer that predates version 3 keeps its last good scoped-limits file once this one is version 3, so
|
|
3151
3292
|
// no edit to the file applies on it. Judged on the DECLARED version, or the derived one if that is higher (gate
|
|
@@ -3161,7 +3302,7 @@ export async function collectChecks(shellVars, seams) {
|
|
|
3161
3302
|
// Issue #504 part B: one applied split, judged on every host against its own envelope. A host whose envelope digest
|
|
3162
3303
|
// differs refuses every governed job as `envelope-mismatch`. SKIPPED when this host's file does not load, for the
|
|
3163
3304
|
// projects check's reason above.
|
|
3164
|
-
const envelopeHere = readEnvelopeFacts(
|
|
3305
|
+
const envelopeHere = readEnvelopeFacts(bootEnv);
|
|
3165
3306
|
if (envelopeHere.parseError === null && appliedSplit === null) checks.push(...fleetEnvelopeChecks(envelopeHere.envelope ? envelopeDigest(envelopeHere.envelope) : NO_ENVELOPE_FINGERPRINT, peers));
|
|
3166
3307
|
} else if (fleet.unreachable) {
|
|
3167
3308
|
// Said, rather than silently absent: "no peers" and "could not ask" are different facts.
|
|
@@ -3186,7 +3327,7 @@ export async function collectChecks(shellVars, seams) {
|
|
|
3186
3327
|
// whose envelope differs refuses its governed jobs. Read whenever this command may talk to the Valkey (above), from
|
|
3187
3328
|
// the same Valkey the fleet was read from; with no split there, or no answer, nothing is said.
|
|
3188
3329
|
if (appliedSplit !== null) {
|
|
3189
|
-
const envelopeHere = readEnvelopeFacts(
|
|
3330
|
+
const envelopeHere = readEnvelopeFacts(bootEnv);
|
|
3190
3331
|
if (envelopeHere.parseError === null) checks.push(...appliedSplitChecks(appliedSplit, envelopeHere.envelope ? envelopeDigest(envelopeHere.envelope) : NO_ENVELOPE_FINGERPRINT, workerNameOf(declaredWorkerName), peers));
|
|
3191
3332
|
}
|
|
3192
3333
|
|
|
@@ -3861,7 +4002,7 @@ export async function collectChecks(shellVars, seams) {
|
|
|
3861
4002
|
}
|
|
3862
4003
|
}
|
|
3863
4004
|
|
|
3864
|
-
checks.push(...await doctorModelProposalChecks(env, { cwd, credentials: service.extra(["ANTHROPIC_API_KEY", "OPENAI_API_KEY"]).env, now: seams.wallClock ?? Date.now, ...(seams.modelProposals ?? {}) }));
|
|
4005
|
+
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
4006
|
section("secrets");
|
|
3866
4007
|
// REQ-TRIGGER-SECRETS. Only reported when a trigger actually binds one, on the run.resume block's
|
|
3867
4008
|
// reasoning below: a deployment that uses no secrets should not be told about a variable it has no
|
|
@@ -3997,7 +4138,7 @@ export async function collectChecks(shellVars, seams) {
|
|
|
3997
4138
|
// skip reason. Doctor cannot see another host's file, so it warns where the shape allows it: a multi-host worker
|
|
3998
4139
|
// (PI_WORKER_NAME) with projects and no triggers file of its own.
|
|
3999
4140
|
if (!anyBundle && typeof env.PI_WORKER_NAME === "string" && env.PI_WORKER_NAME.trim() !== "" && !fileExists(triggersPath(env, cwd))) {
|
|
4000
|
-
const fleetProjects = readProjectFacts(
|
|
4141
|
+
const fleetProjects = readProjectFacts(bootEnv, fileExists);
|
|
4001
4142
|
if (fleetProjects.parseError === null && fleetProjects.projects.length > 0) {
|
|
4002
4143
|
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
4144
|
}
|
|
@@ -4009,7 +4150,7 @@ export async function collectChecks(shellVars, seams) {
|
|
|
4009
4150
|
} catch {
|
|
4010
4151
|
realExecutablePath = null;
|
|
4011
4152
|
}
|
|
4012
|
-
const projectFacts = readProjectFacts(
|
|
4153
|
+
const projectFacts = readProjectFacts(bootEnv, fileExists);
|
|
4013
4154
|
let resolveBundle = null;
|
|
4014
4155
|
if (seams.secrets === true) {
|
|
4015
4156
|
try {
|
|
@@ -4027,6 +4168,7 @@ export async function collectChecks(shellVars, seams) {
|
|
|
4027
4168
|
triggersPresent: fileExists(triggersPath(env, cwd)),
|
|
4028
4169
|
projects: projectFacts.projects,
|
|
4029
4170
|
projectsError: projectFacts.parseError,
|
|
4171
|
+
projectsUnread: projectFacts.unread === true,
|
|
4030
4172
|
envRaw: env.PI_SECRET_BUNDLES,
|
|
4031
4173
|
overlayBundles,
|
|
4032
4174
|
roots: delimitedList(env.PI_SECRET_RESOLVER_ROOTS),
|
|
@@ -4233,7 +4375,7 @@ export async function collectChecks(shellVars, seams) {
|
|
|
4233
4375
|
// warning then cries wolf at a correctly configured deployment, and this module's own rule is that a
|
|
4234
4376
|
// check nobody can silence must never do that.
|
|
4235
4377
|
//
|
|
4236
|
-
// So doctor reads `<cwd>/.env` for EXACTLY the
|
|
4378
|
+
// So doctor reads `<cwd>/.env` for EXACTLY the boot-file keys (`ENV_FILE_READABLE_KEYS`), and never to configure anything.
|
|
4237
4379
|
// That narrowing is the whole licence: the project's stance is that nothing parses `.env`
|
|
4238
4380
|
// (`docs/secrets.md`), and `worker/test/service.test.mjs` pins that a `PI_ENV_SETUP` line in `./.env` is
|
|
4239
4381
|
// deliberately NOT honoured. Both stay true.
|
|
@@ -4243,7 +4385,8 @@ export async function collectChecks(shellVars, seams) {
|
|
|
4243
4385
|
// a deployment whose unit exits 1 in a restart loop is not a deployment that is merely unconfigured in
|
|
4244
4386
|
// this shell. Where the file merely configures what this shell does not, the line still says which
|
|
4245
4387
|
// process would honour it, and is still a warning.
|
|
4246
|
-
|
|
4388
|
+
// The read itself (`envFile`) is made once at the top of `collectChecks` since issue #859, so every check that reads
|
|
4389
|
+
// these files, before this block and after it, judges the same reading (`bootFileEnv`).
|
|
4247
4390
|
// ONE RULE FOR BOTH BOOT FILES, and it asks the worker's own loader rather than a second opinion
|
|
4248
4391
|
// (issue #384). Before this there were two hand-written copies of a shell-shaped check for
|
|
4249
4392
|
// `PI_PAUSE_WINDOWS_FILE`, a THIRD rule for a scoped-limits file that does not parse, and no rule at all
|
|
@@ -4264,12 +4407,15 @@ export async function collectChecks(shellVars, seams) {
|
|
|
4264
4407
|
// regular-file guard and the loaders' own two reads. A test drives a whole deployment through these
|
|
4265
4408
|
// without a real file, which is how the fixtures below stay honest about content.
|
|
4266
4409
|
const seamsForLoad = { env, statFile: statSeam, loaderIo: { existsSync: (p) => fileExists(p), readFileSync: readEnvFile ? (p, enc) => asText(readEnvFile(p), enc) : readFileSync } };
|
|
4410
|
+
// Issue #859: the SERVICE's subject is loaded with the boot keys as the service reads them, from the file alone, so an
|
|
4411
|
+
// envelope in `.env` is judged against the projects and limits the service names, never against this shell's.
|
|
4412
|
+
const serviceSeamsForLoad = { ...seamsForLoad, env: boot.service };
|
|
4267
4413
|
// ONCE PER FILE, not once per key (issue #396). This is a fact about the FILE -- one line the reader
|
|
4268
4414
|
// cannot model -- and it was announced inside the per-key loop, so a `.env` with one such line produced
|
|
4269
4415
|
// two near-identical warnings differing only in which key they named. The keys it prevents a verdict
|
|
4270
4416
|
// about are listed IN the line instead, which is what the reader actually knows.
|
|
4271
4417
|
//
|
|
4272
|
-
// ITS LIMIT, stated because the wording would otherwise imply more: this command reads
|
|
4418
|
+
// ITS LIMIT, stated because the wording would otherwise imply more: this command reads five keys, so the
|
|
4273
4419
|
// line says what it cannot answer about THOSE. The same hazard may also stop a sourcing shell reaching
|
|
4274
4420
|
// `WEBHOOK_SECRET`, which the receiver refuses to start without, and nothing here says so -- widening
|
|
4275
4421
|
// the read is how a narrow reader grows into "load the .env", which `envFileKeys`' own docblock and
|
|
@@ -4386,8 +4532,13 @@ export async function collectChecks(shellVars, seams) {
|
|
|
4386
4532
|
// boot key was never opened. `fileRaw` is set only for a line this reader vouches for the TEXT of, so
|
|
4387
4533
|
// there is always a real path here; whether it loads is a fact about the filesystem, not about the
|
|
4388
4534
|
// rest of the file.
|
|
4389
|
-
|
|
4390
|
-
|
|
4535
|
+
// Issue #859: the envelope is judged against the projects and limits the service names; where the `.env` line of one
|
|
4536
|
+
// of those cannot be read, neither can this verdict, and a "no projects" verdict would fail a service that starts.
|
|
4537
|
+
const contextUnread = spec.key === "PI_ENVELOPE_FILE" ? ["PI_PROJECTS_FILE", "PI_SCOPED_LIMITS_FILE"].filter((key) => boot.serviceUnread.has(key)) : [];
|
|
4538
|
+
if (envFile.hazard == null && fileRaw !== undefined && !blankInFile && contextUnread.length > 0) {
|
|
4539
|
+
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" });
|
|
4540
|
+
} else if (envFile.hazard == null && fileRaw !== undefined && !blankInFile) {
|
|
4541
|
+
const verdict = loadVerdict(spec, fileRaw, cwd, serviceSeamsForLoad, platform);
|
|
4391
4542
|
checks.push(
|
|
4392
4543
|
verdict.ok
|
|
4393
4544
|
? { 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 +4614,19 @@ export async function collectChecks(shellVars, seams) {
|
|
|
4463
4614
|
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
4615
|
});
|
|
4465
4616
|
}
|
|
4617
|
+
|
|
4618
|
+
// Issue #859: WHICH FILE THE OTHER CHECKS JUDGED, where the two subjects name different ones. A ✗, as a disagreement
|
|
4619
|
+
// about any other service key is (`serviceEnvLines`): the checks then judge files the service does not read, and a
|
|
4620
|
+
// warning let doctor exit 0 on a service its own `.env` stops (a project row the shell's projects hold and the
|
|
4621
|
+
// file's do not passed, while `checkProjectRows` refuses the boot).
|
|
4622
|
+
const differs = boot.differs.find((d) => d.key === spec.key);
|
|
4623
|
+
if (differs !== undefined) {
|
|
4624
|
+
checks.push({
|
|
4625
|
+
ok: false,
|
|
4626
|
+
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`,
|
|
4627
|
+
fix: `make them agree: change ${join(cwd, ".env")} (what the service reads), or unset ${spec.key} in this shell, then re-run doctor`,
|
|
4628
|
+
});
|
|
4629
|
+
}
|
|
4466
4630
|
}
|
|
4467
4631
|
|
|
4468
4632
|
// A scope that can only ever be a folder (issue #242's dead-scope rule): a forge repo always contains "/" and never
|
|
@@ -4502,14 +4666,14 @@ export async function collectChecks(shellVars, seams) {
|
|
|
4502
4666
|
// (start.mjs, `checkProjectRows`), so this is a FAILURE naming the row and the id. Skipped when either file does not
|
|
4503
4667
|
// load: that is the BOOT_FILES line's, and a zeroed list here would name every project row as dangling.
|
|
4504
4668
|
if (scopedLimitFacts.parseError === null && scopedLimitFacts.limits.length > 0) {
|
|
4505
|
-
const projectFacts = readProjectFacts(
|
|
4669
|
+
const projectFacts = readProjectFacts(bootEnv, fileExists);
|
|
4506
4670
|
if (projectFacts.parseError === null) checks.push(...projectRowChecks(scopedLimitFacts.limits, projectFacts.projects, scopedLimitFacts.path));
|
|
4507
4671
|
}
|
|
4508
4672
|
|
|
4509
4673
|
section("envelope");
|
|
4510
4674
|
// Issue #504 part B: the envelope's advisories, on the file the service names, when it loads (a file that does not is
|
|
4511
4675
|
// the BOOT_FILES line's). Warnings only: the worker boots on both.
|
|
4512
|
-
const envelopeFactsHere = readEnvelopeFacts(
|
|
4676
|
+
const envelopeFactsHere = readEnvelopeFacts(bootEnv);
|
|
4513
4677
|
if (envelopeFactsHere.envelope) checks.push(...envelopeChecks(envelopeFactsHere.envelope, envelopeFactsHere.projects, envelopeFactsHere.maxCostMicros));
|
|
4514
4678
|
|
|
4515
4679
|
section("console");
|
|
@@ -4813,7 +4977,9 @@ export async function collectChecks(shellVars, seams) {
|
|
|
4813
4977
|
// Nor XDG_CONFIG_HOME at its own default (`~/.config`, which a login shell often exports): unset, every CLI that
|
|
4814
4978
|
// reads it falls back to that same directory, so the service runs with the same value (measured on pd-fedora: the
|
|
4815
4979
|
// named line otherwise flagged the default).
|
|
4816
|
-
|
|
4980
|
+
// Issue #859: a boot-file key set in this shell alone (`bootFileEnv`) is the same fact about the same file, so it joins
|
|
4981
|
+
// this line: the checks judged this shell's file, and the service runs without one.
|
|
4982
|
+
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
4983
|
const unit = shellOnly.length > 0 ? serviceUnitFor(seams, cwd) : null;
|
|
4818
4984
|
if (unit) {
|
|
4819
4985
|
checks.push({
|
|
@@ -5415,6 +5581,7 @@ function parseSecretProfilesSafe(raw) {
|
|
|
5415
5581
|
*/
|
|
5416
5582
|
function readScopedLimitFacts(env, fileExists) {
|
|
5417
5583
|
const none = { limits: [], parseError: null, path: null };
|
|
5584
|
+
if (bootKeyUnread(env, "PI_SCOPED_LIMITS_FILE")) return { ...none, parseError: "unread" };
|
|
5418
5585
|
const path = env.PI_SCOPED_LIMITS_FILE;
|
|
5419
5586
|
if (typeof path !== "string" || path.trim() === "") return none;
|
|
5420
5587
|
if (!fileExists(path)) return { limits: [], parseError: `scoped-limits file does not exist: ${path}`, path };
|
|
@@ -5438,8 +5605,12 @@ function readScopedLimitFacts(env, fileExists) {
|
|
|
5438
5605
|
/**
|
|
5439
5606
|
* The projects facts (issue #499 part B): the parsed projects when PI_PROJECTS_FILE is set, `[]` when it is unset (no
|
|
5440
5607
|
* projects, so every project row dangles), or a parse error when it is set and does not load (the BOOT_FILES line's).
|
|
5608
|
+
* Handed `bootEnv` (issue #859), so the key is the service's where this shell sets none, and `unread` where the `.env`
|
|
5609
|
+
* line that sets it could not be read.
|
|
5441
5610
|
*/
|
|
5442
5611
|
function readProjectFacts(env, fileExists) {
|
|
5612
|
+
// Issue #859: a `.env` line doctor could not read is a file not known, said by its own line, never "no projects".
|
|
5613
|
+
if (bootKeyUnread(env, "PI_PROJECTS_FILE")) return { projects: [], parseError: "unread", unread: true };
|
|
5443
5614
|
const path = env.PI_PROJECTS_FILE;
|
|
5444
5615
|
if (typeof path !== "string" || path.trim() === "") return { projects: [], parseError: null };
|
|
5445
5616
|
try {
|
|
@@ -5695,9 +5866,13 @@ function consoleTokensInsideJobPaths(path, env, { cwd, fs, folders, skillsDirs,
|
|
|
5695
5866
|
* `envelope` is null when the key is unset.
|
|
5696
5867
|
*/
|
|
5697
5868
|
function readEnvelopeFacts(env) {
|
|
5869
|
+
if (bootKeyUnread(env, "PI_ENVELOPE_FILE")) return { envelope: null, projects: [], maxCostMicros: null, parseError: "unread" };
|
|
5698
5870
|
const path = nonEmpty(env.PI_ENVELOPE_FILE);
|
|
5699
5871
|
if (path === null) return { envelope: null, projects: [], maxCostMicros: null, parseError: null };
|
|
5700
5872
|
const io = { readFileSync, existsSync };
|
|
5873
|
+
// A REGULAR FILE or nothing, as every other reader of these files here (issue #859): since the `.env` names this path
|
|
5874
|
+
// too, a FIFO there hung doctor in a synchronous read no timeout interrupts.
|
|
5875
|
+
if (!regularOrAbsent(path)) return { envelope: null, projects: [], maxCostMicros: null, parseError: `envelope file is not a regular file: ${path}` };
|
|
5701
5876
|
try {
|
|
5702
5877
|
const context = envelopeContextOf(env, io);
|
|
5703
5878
|
return { envelope: loadEnvelopeAsTheWorker(path, io, env), projects: context.projects, maxCostMicros: context.maxCostMicros, parseError: null };
|
|
@@ -5888,6 +6063,7 @@ export function projectRowChecks(limits, projects, path) {
|
|
|
5888
6063
|
* module's own by default.
|
|
5889
6064
|
*/
|
|
5890
6065
|
function readPauseWindowFacts(env, fileExists, versions = {}) {
|
|
6066
|
+
if (bootKeyUnread(env, "PI_PAUSE_WINDOWS_FILE")) return { windows: [], versionNeed: 1, parseError: "unread" };
|
|
5891
6067
|
const path = env.PI_PAUSE_WINDOWS_FILE;
|
|
5892
6068
|
if (typeof path !== "string" || path.trim() === "") return { windows: [], versionNeed: 1, parseError: null };
|
|
5893
6069
|
try {
|
|
@@ -6708,18 +6884,12 @@ function readTriggerFacts(env, fileExists, cwd, declaredWorkerName, versionTable
|
|
|
6708
6884
|
// two triggers naming the same flow with different skillsDirs are two different questions.
|
|
6709
6885
|
// The label is how a line names its trigger: cron entries by their id, id-less webhook
|
|
6710
6886
|
// entries by raw file position (the admin's trigger:<index> identity).
|
|
6887
|
+
// Issue #869: what `flowTierOf` is asked and the basis each published row carries come from the one module the console
|
|
6888
|
+
// computes its basis with (`flowInputOf`, `flowBasisOf`), from the trigger as the loader parsed it, so the two cannot
|
|
6889
|
+
// read a field two ways. A command trigger and a disarmed one-shot name no flow and drop out here.
|
|
6711
6890
|
flows: triggers
|
|
6712
|
-
.map((t, index) => ({
|
|
6713
|
-
|
|
6714
|
-
label: triggerLabel(t, index),
|
|
6715
|
-
flow: t.run.flow,
|
|
6716
|
-
kind: t.run.kind,
|
|
6717
|
-
folder: typeof t.run.folder === "string" ? t.run.folder : null,
|
|
6718
|
-
skillsDir: typeof t.run.skillsDir === "string" ? t.run.skillsDir : null,
|
|
6719
|
-
packages: t.run.packages !== false,
|
|
6720
|
-
excludeExtensions: Array.isArray(t.run.excludeExtensions) ? t.run.excludeExtensions : [],
|
|
6721
|
-
}))
|
|
6722
|
-
.filter((f) => typeof f.flow === "string"),
|
|
6891
|
+
.map((t, index) => ({ index, label: triggerLabel(t, index), kind: t.run.kind, ...flowInputOf(t), basis: flowBasisOf(t) }))
|
|
6892
|
+
.filter((f) => f.flow !== null),
|
|
6723
6893
|
// Issue #800: each trigger's run.excludeExtensions, for the line that says a name is on neither side of this host.
|
|
6724
6894
|
excludedNames: triggers.flatMap((t, index) => (Array.isArray(t.run.excludeExtensions) ? [{ label: triggerLabel(t, index), names: t.run.excludeExtensions, packages: t.run.packages !== false }] : [])),
|
|
6725
6895
|
// Issue #501: every trigger that sets run.maxCostUsd, with the label the cap check names it by. The value
|
|
@@ -9629,7 +9799,12 @@ export function sizeSuggestionChecks({ projects = [], limits = [], env = {}, rec
|
|
|
9629
9799
|
const words = suggestionEvidence(s);
|
|
9630
9800
|
const size = `${formatMemory(current.memMiB)}, ${cpusText(current.cpuCenti)}`;
|
|
9631
9801
|
const call = suggestionCall(s, limits);
|
|
9632
|
-
|
|
9802
|
+
// Issue #865: `memoryCeiling` rides after the suggestion and BEFORE the two facts: it comes only with an OOM, whose
|
|
9803
|
+
// line is a warning, and a warn or fail label (the first DOCTOR_DETAILED_MAX, 80, of a run) is published cut at
|
|
9804
|
+
// DOCTOR_LABEL_MAX, so what a cut takes first is the facts (information with no call) and then this pointer's tail,
|
|
9805
|
+
// never a held raise, a refusal or a budget note, which come before it, nor the call: a raise carries its call in
|
|
9806
|
+
// its fix, and a lowering, an ok line with no model proposal, is published as its id and level only.
|
|
9807
|
+
const facts = [words.memoryCeiling, words.memoryFact, words.cpuFact].filter(Boolean);
|
|
9633
9808
|
const factTail = facts.length > 0 ? `; ${facts.join("; ")}` : "";
|
|
9634
9809
|
const held = s.memory.suggested === null && s.memory.held !== null;
|
|
9635
9810
|
if (call === null && !held) {
|
|
@@ -9653,7 +9828,12 @@ export function sizeSuggestionChecks({ projects = [], limits = [], env = {}, rec
|
|
|
9653
9828
|
const note = totals.length > 0 ? ` (note: ${totals.join(" and ")}; its budget is off or unknown, so the worker admits it)` : "";
|
|
9654
9829
|
const raise = (s.memory.suggested ?? 0) > current.memMiB || (s.cpu.suggested ?? 0) > current.cpuCenti;
|
|
9655
9830
|
const suggests = parts.length > 0 ? `its runs in the last ${SUGGEST_WINDOW_DAYS} days suggest ${parts.join(" and ")}` : "";
|
|
9656
|
-
|
|
9831
|
+
// The label without its facts, so a line that carries its call in the label (a lowering, below) prints the call
|
|
9832
|
+
// right after the suggestion it applies, and the no-call facts after that (#865, review). Reading order in the
|
|
9833
|
+
// terminal only: a lowering is an ok check with no model proposal, which `buildDoctorRecord` (doctor-results.mjs,
|
|
9834
|
+
// INT-DOCTOR-RESULTS-KEY) publishes as its id and level, with no label, so no publish cut ever reaches it.
|
|
9835
|
+
const head = `project ${id}: size ${size}; ${[heldText ? `in the last ${SUGGEST_WINDOW_DAYS} days ${heldText}` : "", suggests].filter(Boolean).join("; ")}${over.length > 0 ? `, but ${over.join(" and ")}, so a job of it would never fit this host` : ""}${note}`;
|
|
9836
|
+
const label = `${head}${factTail}`;
|
|
9657
9837
|
// a size that would never fit is never offered as a call: applying it would turn a refusal into the same refusal.
|
|
9658
9838
|
const apply = call === null ? "" : over.length > 0 ? "" : `apply it in the admin panel with ${call} (an operator confirms it; nothing applies a size by itself)`;
|
|
9659
9839
|
const never = over.length > 0 ? "no call is offered: a job of that size would never fit this host" : "";
|
|
@@ -9664,7 +9844,7 @@ export function sizeSuggestionChecks({ projects = [], limits = [], env = {}, rec
|
|
|
9664
9844
|
} else if (raise || over.length > 0 || totals.length > 0) {
|
|
9665
9845
|
checks.push({ ok: false, warn: true, label, fix: apply || never });
|
|
9666
9846
|
} else {
|
|
9667
|
-
checks.push({ ok: true, label: `${
|
|
9847
|
+
checks.push({ ok: true, label: `${head}; ${apply.replace(/ \(an operator confirms it; nothing applies a size by itself\)$/, "")}${factTail}` });
|
|
9668
9848
|
}
|
|
9669
9849
|
}
|
|
9670
9850
|
return checks;
|
package/src/env-allowlist.mjs
CHANGED
|
@@ -30,7 +30,7 @@ import { homedir } from "node:os";
|
|
|
30
30
|
import { join } from "node:path";
|
|
31
31
|
import { findEnvKeys } from "@earendil-works/pi-ai/compat";
|
|
32
32
|
import { getBuiltinProviders } from "@earendil-works/pi-ai/providers/all";
|
|
33
|
-
import { codemodeWorkersOf, DEFAULT_JOB_SIZE } from "./job-size.mjs";
|
|
33
|
+
import { codemodeWorkersOf, DEFAULT_JOB_SIZE, jobSizeEnv } from "./job-size.mjs";
|
|
34
34
|
import { egressEnv } from "./egress.mjs";
|
|
35
35
|
import { RECEIVER_ONLY_VARS, forgeSpec } from "./forges.mjs";
|
|
36
36
|
import { apiKeyVariable } from "./provider-key.mjs";
|
|
@@ -637,6 +637,14 @@ export function buildContainerEnv({ size = DEFAULT_JOB_SIZE, provider, secondary
|
|
|
637
637
|
// env-internal PI_CODEMODE_MAX_WORKERS: written into the job's closed env map here, never read from the worker's environment.
|
|
638
638
|
env.PI_CODEMODE_MAX_WORKERS = String(codemodeWorkersOf(size.memMiB));
|
|
639
639
|
|
|
640
|
+
// Issue #865: the job's size, `PI_JOB_SIZE_CPUS` (its CPUs rounded up) and `PI_JOB_SIZE_MEMORY_MIB`, from the same
|
|
641
|
+
// resolved size as the container's flags (`jobSizeEnv`, which the sandbox shell shares). Inside the container a CPU
|
|
642
|
+
// count reads as the host ceiling (`availableParallelism()`) or the whole runtime (`nproc`), never the size, so these
|
|
643
|
+
// are the one place a flow or an image can size a per-CPU worker pool from. Information, not a bound, and read from
|
|
644
|
+
// no payload. Set after forwarding and secrets, for PI_CODEMODE_MAX_WORKERS' reason: both names are reserved in
|
|
645
|
+
// CONTAINER_ENV_NAMES, and this is the backstop if a refusal upstream is ever bypassed.
|
|
646
|
+
Object.assign(env, jobSizeEnv(size));
|
|
647
|
+
|
|
640
648
|
// Issue #500: the runner sets these in its own environment, for its child processes, and no value from outside
|
|
641
649
|
// may arrive first. Both lists refuse them upstream (config.mjs for PI_FORWARD_ENV, the triggers loader for
|
|
642
650
|
// run.secrets, through RUNNER_ENV_NAMES); this is the line that holds if either refusal is ever bypassed.
|