@edgehero/pi-dispatch 4.3.1 → 5.0.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/.env.example +84 -3
- package/README.md +20 -7
- package/deploy/console.service +52 -0
- package/deploy/docker-compose.yml +3 -1
- package/deploy/pi-dispatch-valkey.container +4 -2
- package/deploy/sandbox-gateway.service +23 -0
- package/deploy/worker-env-wrapper.cmd +6 -0
- package/deploy/worker-env-wrapper.sh +10 -1
- package/package.json +117 -19
- package/src/agent-snapshot.mjs +49 -0
- package/src/answerer-runtime.mjs +194 -0
- package/src/answerer.mjs +178 -0
- package/src/approval-policy.mjs +76 -0
- package/src/approval-runtime.mjs +188 -0
- package/src/approval-session.mjs +137 -0
- package/src/approval-state.mjs +170 -0
- package/src/auth-cli.mjs +86 -0
- package/src/auth-connections.mjs +145 -0
- package/src/auth-policy.mjs +32 -0
- package/src/auth-store.mjs +97 -0
- package/src/azure-host.mjs +66 -4
- package/src/azure-prompt.mjs +5 -4
- package/src/backend-conformance.mjs +34 -2
- package/src/backend-local.mjs +103 -34
- package/src/backend-openshell.mjs +1540 -0
- package/src/backend-podman.mjs +63 -2
- package/src/backend-registry.mjs +61 -4
- package/src/backends.mjs +395 -6
- package/src/bullmq-internals.mjs +208 -0
- package/src/canary-cli.mjs +28 -0
- package/src/canary-lock.mjs +41 -0
- package/src/canary-runtime.mjs +237 -0
- package/src/canary.mjs +53 -0
- package/src/cancel-cli.mjs +47 -34
- package/src/cancel-state.mjs +213 -15
- package/src/capabilities.mjs +36 -1
- package/src/capacity-cli.mjs +28 -8
- package/src/capacity-records.mjs +75 -16
- package/src/capacity.mjs +65 -0
- package/src/chat-cli.mjs +288 -0
- package/src/chat-fork.mjs +547 -0
- package/src/chat-mode.mjs +156 -0
- package/src/chat-view.mjs +720 -0
- package/src/checkpoint-paths.mjs +113 -0
- package/src/cli.mjs +128 -41
- package/src/comment-tables.mjs +322 -0
- package/src/community-auth-adapters.mjs +87 -0
- package/src/community-auth-helper.mjs +76 -0
- package/src/community-auth-metadata.mjs +6 -0
- package/src/community-auth-output-pins.mjs +38 -0
- package/src/community-auth-patches.mjs +168 -0
- package/src/community-auth-pins.mjs +38 -0
- package/src/config.mjs +190 -29
- package/src/connection.mjs +83 -7
- package/src/console-answer.mjs +113 -0
- package/src/console-audit.mjs +604 -0
- package/src/console-bootstrap.mjs +159 -0
- package/src/console-client.mjs +848 -0
- package/src/console-probe.mjs +106 -0
- package/src/console-tokens.mjs +459 -0
- package/src/container-names.mjs +51 -0
- package/src/container-spec.mjs +20 -1
- package/src/cpu-reserve.mjs +4 -1
- package/src/defer-reasons.mjs +200 -0
- package/src/defer-write.mjs +208 -0
- package/src/deployment-venue.mjs +23 -6
- package/src/docker-run.mjs +11 -0
- package/src/doctor-bundles.mjs +130 -0
- package/src/doctor-model-proposals.mjs +109 -0
- package/src/doctor-openshell.mjs +238 -0
- package/src/doctor-results.mjs +602 -0
- package/src/doctor-spawner.mjs +215 -0
- package/src/doctor.mjs +1639 -196
- package/src/dollar-budget.mjs +212 -3
- package/src/endpoint-status.mjs +100 -0
- package/src/env-allowlist.mjs +96 -6
- package/src/env-file.mjs +6 -4
- package/src/file-expect.mjs +47 -0
- package/src/forgejo-host.mjs +42 -5
- package/src/forgejo-prompt.mjs +5 -4
- package/src/forges.mjs +33 -3
- package/src/fork-job.mjs +58 -0
- package/src/fork-policy.mjs +23 -0
- package/src/fork-store.mjs +338 -0
- package/src/get-token.mjs +1 -1
- package/src/git-dirty.mjs +119 -14
- package/src/git-hardening.mjs +85 -0
- package/src/github-host.mjs +31 -6
- package/src/github-prompt.mjs +7 -16
- package/src/gitlab-host.mjs +42 -3
- package/src/gitlab-prompt.mjs +5 -4
- package/src/held-document.mjs +23 -0
- package/src/held-replies.mjs +325 -0
- package/src/held-settle.mjs +266 -0
- package/src/hook-runs.mjs +188 -0
- package/src/host-budget.mjs +8 -5
- package/src/host-clone.mjs +72 -0
- package/src/host-loads.mjs +557 -0
- package/src/host-registry.mjs +66 -6
- package/src/host-telemetry.mjs +92 -0
- package/src/http-body.mjs +78 -0
- package/src/image-preflight.mjs +156 -13
- package/src/import-pi.mjs +60 -172
- package/src/inbox-relay.mjs +482 -0
- package/src/inbox-settle.mjs +196 -0
- package/src/index.mjs +734 -129
- package/src/init.mjs +35 -5
- package/src/job-data-version.mjs +75 -0
- package/src/job-id.mjs +30 -15
- package/src/job-layout.mjs +22 -0
- package/src/job-size.mjs +16 -0
- package/src/live-jobs.mjs +14 -6
- package/src/live-probes.mjs +6 -2
- package/src/live-transcript.mjs +872 -0
- package/src/loaded-record.mjs +609 -0
- package/src/mcp-config.mjs +1 -1
- package/src/mcp-preflight.mjs +36 -5
- package/src/model-attempts.mjs +84 -0
- package/src/model-catalog.mjs +83 -0
- package/src/model-cooldowns.mjs +22 -0
- package/src/model-failover-protocol.mjs +11 -0
- package/src/model-failover.mjs +134 -0
- package/src/model-observation.mjs +50 -0
- package/src/model-proposal-protocol.mjs +21 -0
- package/src/model-proposals.mjs +75 -0
- package/src/model-ref.mjs +67 -10
- package/src/model-roles-file.mjs +24 -0
- package/src/model-roles.mjs +73 -0
- package/src/model-status.mjs +34 -0
- package/src/models-json.mjs +4 -1
- package/src/money.mjs +1 -1
- package/src/oauth-openai.mjs +121 -0
- package/src/older-state.mjs +249 -0
- package/src/on-failure.mjs +17 -13
- package/src/open-browser.mjs +31 -2
- package/src/opener-cli.mjs +135 -0
- package/src/opener-protocol.mjs +27 -0
- package/src/opener-terminal.mjs +128 -0
- package/src/openshell-cli.mjs +155 -0
- package/src/openshell-policy.mjs +163 -0
- package/src/outbox.mjs +14 -0
- package/src/output-cap.mjs +1 -0
- package/src/package-canary-record.mjs +26 -0
- package/src/package-canary.mjs +135 -0
- package/src/package-names.mjs +9 -0
- package/src/package-stage.mjs +418 -0
- package/src/packages.mjs +24 -16
- package/src/parked.mjs +2 -1
- package/src/pause-windows.mjs +136 -17
- package/src/podman-stack.mjs +40 -14
- package/src/portfolio-snapshot.mjs +26 -7
- package/src/prepare-github.mjs +41 -77
- package/src/prepare-local.mjs +32 -2
- package/src/prepare.mjs +45 -11
- package/src/private-store-boundary.mjs +70 -0
- package/src/processor.mjs +1550 -173
- package/src/projects.mjs +23 -0
- package/src/prompt-fence.mjs +18 -0
- package/src/provider-key.mjs +7 -0
- package/src/provider-steering.mjs +1 -0
- package/src/question-context.mjs +35 -0
- package/src/question-group-runtime.mjs +91 -0
- package/src/question-groups.mjs +171 -0
- package/src/queue.mjs +264 -55
- package/src/reason-catalog.mjs +448 -0
- package/src/receiver-status.mjs +230 -0
- package/src/record-reason.mjs +37 -0
- package/src/recovery-queue.mjs +566 -0
- package/src/redact-secrets.mjs +219 -0
- package/src/reserve-estimate.mjs +101 -0
- package/src/reserved-env.mjs +36 -1
- package/src/retention.mjs +67 -0
- package/src/run-budget-ledger.mjs +283 -0
- package/src/run-checkpoint.mjs +627 -0
- package/src/run-container.mjs +749 -33
- package/src/run-history.mjs +791 -45
- package/src/run-mirror.mjs +75 -7
- package/src/run-progress.mjs +751 -0
- package/src/runtime-observations.mjs +3 -1
- package/src/runtime-settings.mjs +272 -37
- package/src/sandbox-cli.mjs +78 -2
- package/src/sandbox-deployment.mjs +51 -0
- package/src/sandbox-gateway-protocol.mjs +378 -0
- package/src/sandbox-hosts.mjs +100 -0
- package/src/sandbox-inventory.mjs +83 -0
- package/src/sandbox-policy.mjs +225 -0
- package/src/sandbox-prepare-requests.mjs +145 -0
- package/src/sandbox-prepare.mjs +285 -0
- package/src/sandbox-refs.mjs +70 -0
- package/src/sandbox-session-monitor.mjs +4 -0
- package/src/sandbox-session-watch.mjs +124 -0
- package/src/sandbox-sessions.mjs +161 -0
- package/src/sandbox-store.mjs +63 -6
- package/src/sandbox-workspace-guard.mjs +39 -0
- package/src/sandbox.mjs +27 -200
- package/src/scheduler-stall-guard.mjs +14 -8
- package/src/schedules.mjs +19 -6
- package/src/secret-bundles.mjs +245 -0
- package/src/secret-profiles.mjs +103 -3
- package/src/secrets.mjs +19 -9
- package/src/service-env.mjs +38 -5
- package/src/service.mjs +638 -106
- package/src/session-key.mjs +60 -0
- package/src/session-store.mjs +168 -10
- package/src/setup-contract.mjs +40 -0
- package/src/setup-egress.mjs +113 -0
- package/src/setup-host-entry.mjs +33 -0
- package/src/setup-host.mjs +242 -0
- package/src/setup-lock.mjs +41 -0
- package/src/size-records.mjs +15 -3
- package/src/start.mjs +1353 -135
- package/src/status-comment.mjs +415 -0
- package/src/stderr-transcript.mjs +318 -0
- package/src/steer-ack.mjs +122 -0
- package/src/steer-cli.mjs +213 -0
- package/src/steer-framing.mjs +185 -0
- package/src/steer-inbox.mjs +693 -0
- package/src/store-version.mjs +139 -0
- package/src/subscription-config.mjs +56 -0
- package/src/subscription-limits.mjs +3 -0
- package/src/subscription-quota.mjs +259 -0
- package/src/subscription-runtime.mjs +123 -0
- package/src/subscription-status.mjs +42 -0
- package/src/subscription-wait.mjs +11 -0
- package/src/subscriptions.mjs +7 -1
- package/src/thinking-level.mjs +19 -0
- package/src/touch-outbox.mjs +61 -0
- package/src/touch-state.mjs +133 -0
- package/src/triggers-file.mjs +299 -39
- package/src/triggers.mjs +780 -38
- package/src/trust-paths.mjs +171 -0
- package/src/up.mjs +398 -51
- package/src/updates-cli.mjs +61 -0
- package/src/updates-look.mjs +127 -0
- package/src/updates-reconcile.mjs +182 -0
- package/src/updates-subscription-adapters.mjs +8 -0
- package/src/updates.mjs +252 -0
- package/src/valkey-auth.mjs +32 -9
- package/src/valkey-endpoint.mjs +14 -4
- package/src/wait-for.mjs +1 -1
- package/src/wait-state.mjs +62 -3
- package/src/watch-board.mjs +38 -0
- package/src/watch-closer.mjs +1 -1
- package/src/watch-contract.mjs +102 -0
- package/src/watch-decision.mjs +78 -0
- package/src/watch-drainer.mjs +14 -0
- package/src/watch-issue-adapter.mjs +44 -0
- package/src/watch-issue.mjs +111 -0
- package/src/watch-record.mjs +10 -0
- package/src/watch-runtime.mjs +167 -0
- package/src/watch-store.mjs +183 -0
- package/src/watch-trace.mjs +34 -0
- package/src/workspace-checkpoint.mjs +1750 -0
- package/src/zero-touch-fixes.mjs +99 -0
- package/src/zero-touch.mjs +225 -0
- package/src/repeat-slot.mjs +0 -13
package/src/packages.mjs
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
* manifest must degrade to "no staged packages", not crash the worker mid-queue.
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
|
+
import { EXACT_VERSION_RE, NPM_NAME_RE } from "./package-names.mjs";
|
|
30
31
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
31
32
|
import { basename, dirname, join, resolve } from "node:path";
|
|
32
33
|
import { configError } from "./config.mjs";
|
|
@@ -50,9 +51,7 @@ export const STAGE_MANIFEST = "packages.json";
|
|
|
50
51
|
export const CONTAINER_PACKAGES_ROOT = `${CONTAINER_GLOBAL_PI_DIR}/${PACKAGES_SUBDIR}`;
|
|
51
52
|
|
|
52
53
|
/** An exact semver -- prerelease and build metadata allowed, ranges/tags/wildcards are not. */
|
|
53
|
-
export
|
|
54
|
-
/** An npm package name: lowercase, optionally `@scope/`-prefixed. */
|
|
55
|
-
export const NPM_NAME_RE = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
|
|
54
|
+
export { EXACT_VERSION_RE, NPM_NAME_RE };
|
|
56
55
|
|
|
57
56
|
/**
|
|
58
57
|
* The pi resource kinds a package may contribute by convention dir, when it carries no `pi` manifest.
|
|
@@ -61,8 +60,8 @@ export const NPM_NAME_RE = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
|
|
|
61
60
|
* would add an edge to the import-pi <-> packages cycle that only survives because both sides use their
|
|
62
61
|
* bindings at call time.
|
|
63
62
|
*
|
|
64
|
-
* This list is pi's, not ours: at the
|
|
65
|
-
*
|
|
63
|
+
* This list is pi's, not ours: at the pin `collectPackageResources` falls through to exactly these four directory
|
|
64
|
+
* names when `readPiManifest` returns null or lists no resource (1.5.0), so a package with no `pi` key and a `skills/`
|
|
66
65
|
* dir IS a pi package. See host-pi.mjs's PINNED_PI_NEEDLES for the assertion that keeps that true.
|
|
67
66
|
*/
|
|
68
67
|
export const RESOURCE_DIRS = ["extensions", "skills", "prompts", "themes"];
|
|
@@ -76,6 +75,12 @@ const MAX_NAME_LENGTH = 214;
|
|
|
76
75
|
/** The staged dir name doubles as a path segment in the container, so it stays short and flat. */
|
|
77
76
|
const MAX_DIR_LENGTH = 64;
|
|
78
77
|
|
|
78
|
+
/**
|
|
79
|
+
* A `dir` on the stage manifest: a plain path segment. Since issue #796 it names one stage (`package-stage.mjs`, name, version and a unique
|
|
80
|
+
* suffix) and may be longer than the package's own directory name, which `MAX_DIR_LENGTH` still bounds. A receipt from before is still valid.
|
|
81
|
+
*/
|
|
82
|
+
const STAGED_TREE_RE = /^[a-z0-9](?:[a-z0-9_.-]{0,142}[a-z0-9])?$/i;
|
|
83
|
+
|
|
79
84
|
function isNonEmptyString(value) {
|
|
80
85
|
return typeof value === "string" && value.trim() !== "";
|
|
81
86
|
}
|
|
@@ -266,7 +271,7 @@ export function readStageManifest({ globalPiDir, readFile = readFileSync, fileEx
|
|
|
266
271
|
if (entry === null || typeof entry !== "object") return null;
|
|
267
272
|
const { name, version, dir } = entry;
|
|
268
273
|
if (!isNonEmptyString(name) || !isNonEmptyString(version) || !isNonEmptyString(dir)) return null;
|
|
269
|
-
if (!
|
|
274
|
+
if (!STAGED_TREE_RE.test(dir)) return null;
|
|
270
275
|
packages.push({ name, version, dir, from: entry.from === FROM_HOST ? FROM_HOST : FROM_DECLARED });
|
|
271
276
|
}
|
|
272
277
|
return { stagedAt: typeof parsed.stagedAt === "string" ? parsed.stagedAt : null, packages };
|
|
@@ -281,12 +286,14 @@ export function readStageManifest({ globalPiDir, readFile = readFileSync, fileEx
|
|
|
281
286
|
* readStageManifest's policy, because the consumers are advisory (doctor's per-trigger flow lines,
|
|
282
287
|
* and issue #188's topology) and a half-staged tree must degrade to "nothing visible", not a crash.
|
|
283
288
|
*
|
|
284
|
-
* The semantics mirror pi's collectPackageResources at the
|
|
285
|
-
* that agrees with pi by hand is how doctor comes to report a tier pi then
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
289
|
+
* The semantics mirror pi's collectPackageResources at the pin EXACTLY, and worker/test/packages.test.mjs holds them to
|
|
290
|
+
* pi's own package manager, because an enumerator that agrees with pi by hand is how doctor comes to report a tier pi then
|
|
291
|
+
* ignores:
|
|
292
|
+
* - a `pi` object that lists a resource (a field that is an array of strings, which is all readPiManifest keeps) means
|
|
293
|
+
* its `skills` entries are the ONLY sources -- one listing other resources but no `skills` contributes NO skills and
|
|
294
|
+
* gets NO convention fallback;
|
|
295
|
+
* - no `pi` key, or since 1.5.0 a `pi` object holding only metadata such as `changelogPath`, falls through to the
|
|
296
|
+
* convention `skills/` dir (RESOURCE_DIRS);
|
|
290
297
|
* - a manifest entry may be a SKILL.md file, a skill dir, or a dir of skill dirs -- pi walks
|
|
291
298
|
* recursively, so this walks the same shape (bounded, it is host-advisory);
|
|
292
299
|
* - a glob (`*`/`?`) or override (`!`/`+`/`-` prefix) entry makes the whole package UNENUMERABLE
|
|
@@ -307,10 +314,11 @@ export function readStagedSkills({ globalPiDir, readFile = readFileSync, fileExi
|
|
|
307
314
|
const root = join(globalPiDir, PACKAGES_SUBDIR, pkg.dir);
|
|
308
315
|
let sources;
|
|
309
316
|
try {
|
|
310
|
-
const parsed = JSON.parse(readFile(join(root, "package.json"), "utf8"));
|
|
311
|
-
const pi = parsed !== null && typeof parsed === "object" && parsed.pi !== null && typeof parsed.pi === "object" ? parsed.pi : null;
|
|
312
|
-
|
|
313
|
-
|
|
317
|
+
const parsed = JSON.parse(readFile(join(root, "package.json"), "utf8").replace(/^\uFEFF/, ""));
|
|
318
|
+
const pi = parsed !== null && typeof parsed === "object" && parsed.pi !== null && typeof parsed.pi === "object" && !Array.isArray(parsed.pi) ? parsed.pi : null;
|
|
319
|
+
const listed = (field) => Array.isArray(pi?.[field]) && pi[field].every((e) => typeof e === "string");
|
|
320
|
+
if (RESOURCE_DIRS.some(listed)) {
|
|
321
|
+
const entries = listed("skills") ? pi.skills : [];
|
|
314
322
|
if (entries.some((e) => e.startsWith("!") || e.startsWith("+") || e.startsWith("-") || e.includes("*") || e.includes("?"))) {
|
|
315
323
|
out.unenumerable.push(pkg.name);
|
|
316
324
|
continue;
|
package/src/parked.mjs
CHANGED
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
* widens WHICH comments start a job, never WHO may: the comment trigger's author gate still runs first.
|
|
12
12
|
*/
|
|
13
13
|
export const PARKED_LABEL = "pi-awaiting-input";
|
|
14
|
+
export const APPROVAL_LABEL = "pi-awaiting-approval";
|
|
14
15
|
|
|
15
16
|
/** Whether a subset's `[{ name }]` label list (or Azure's tag list, the same shape) carries the marker. */
|
|
16
17
|
export function hasParkedLabel(labels) {
|
|
17
|
-
return Array.isArray(labels) && labels.some((label) => label?.name === PARKED_LABEL);
|
|
18
|
+
return Array.isArray(labels) && labels.some((label) => (label?.name === PARKED_LABEL || label?.name === APPROVAL_LABEL));
|
|
18
19
|
}
|
package/src/pause-windows.mjs
CHANGED
|
@@ -17,9 +17,106 @@ import { existsSync as fsExistsSync, readFileSync as fsReadFileSync } from "node
|
|
|
17
17
|
import { isAbsolute } from "node:path";
|
|
18
18
|
import { configError } from "./config.mjs";
|
|
19
19
|
import { FORGE_KINDS, isForgeKind } from "./forges.mjs";
|
|
20
|
+
import { heldDocument } from "./held-document.mjs";
|
|
21
|
+
import { findDuplicateKey } from "./json-duplicates.mjs";
|
|
22
|
+
import { neededVersion, underDeclared, valueNeededVersion, valueUnderDeclared, versionProblem } from "./store-version.mjs";
|
|
20
23
|
|
|
21
|
-
|
|
22
|
-
|
|
24
|
+
/**
|
|
25
|
+
* The highest pause windows file version this build reads (issue #761, `DES-STORE-VERSION-WHEN-NEEDED`): a top-level
|
|
26
|
+
* `version`, absent meaning 1, written only when a window uses a field that needs more, so every file of today's
|
|
27
|
+
* fields stays byte-identical.
|
|
28
|
+
*/
|
|
29
|
+
export const PAUSE_WINDOWS_VERSION = 1;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The window fields `normalizeWindow` reads, in its order. The console's whole-list action checks a submitted window
|
|
33
|
+
* against this list, and the version table's keys must come from it; a test holds it to the normalizer's own reads.
|
|
34
|
+
*/
|
|
35
|
+
export const PAUSE_WINDOW_FIELDS = Object.freeze(["scope", "from", "to", "tz", "days", "dateFrom", "dateTo"]);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Each window field whose ABSENCE an older build would misread (a field that narrows when a window pauses, so a build
|
|
39
|
+
* that drops it pauses at the wrong time), with the version that introduced it. Empty until a field needs one.
|
|
40
|
+
*/
|
|
41
|
+
export const PAUSE_WINDOW_FIELD_VERSIONS = Object.freeze({});
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Each EXISTING window field whose range a later release widens, as `field -> (value) => version` (the shared rule,
|
|
45
|
+
* `valueNeededVersion`): a new `scope` form, say, or a new `days` form. An older build would refuse the whole file as
|
|
46
|
+
* broken, keep its last good windows and run the jobs the new file pauses; a stamp makes it defer them instead.
|
|
47
|
+
* Empty until a value needs one.
|
|
48
|
+
*/
|
|
49
|
+
export const PAUSE_WINDOW_VALUE_VERSIONS = Object.freeze({});
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The lowest version a list of RAW windows needs, from each window's own keys and their values: 1 while nothing needs
|
|
53
|
+
* more. The highest window wins, whatever its place in the list.
|
|
54
|
+
*/
|
|
55
|
+
export function pauseWindowsVersionFor(windows, { table = PAUSE_WINDOW_FIELD_VERSIONS, valueTable = PAUSE_WINDOW_VALUE_VERSIONS } = {}) {
|
|
56
|
+
let needed = 1;
|
|
57
|
+
for (const w of Array.isArray(windows) ? windows : []) {
|
|
58
|
+
if (w === null || typeof w !== "object") continue;
|
|
59
|
+
const needs = Math.max(neededVersion(Object.keys(w), table), valueNeededVersion(w, valueTable));
|
|
60
|
+
if (needs > needed) needed = needs;
|
|
61
|
+
}
|
|
62
|
+
return needed;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Whether a writer must refuse the parsed document `doc`: `{ newer, message }` when its raw `version` is newer than
|
|
67
|
+
* this build reads (`newer: true`) or is not an integer of 1 or more (`newer: false`), else null. The RAW version
|
|
68
|
+
* only, never the full parser, so a list with a bad window in it can still be replaced whole; a writer refuses the newer case (it would lose what a newer release wrote) and replaces a broken `version`.
|
|
69
|
+
*/
|
|
70
|
+
export function pauseWindowsVersionRefusal(doc, { supported = PAUSE_WINDOWS_VERSION } = {}) {
|
|
71
|
+
if (doc === null || typeof doc !== "object" || Array.isArray(doc) || !Object.hasOwn(doc, "version")) return null;
|
|
72
|
+
return versionProblem(doc.version, { supported, what: "pause-windows file" });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* What a write of this file starts from, `{ doc, windows }`, or `{ problem }` with the sentence saying why it could not keep
|
|
77
|
+
* what the file holds, and `doc` when it parsed, for the caller's newer-release answer, which comes first. One function for
|
|
78
|
+
* `writePauseWindows` and the console's whole-list action, so a preview and its write cannot read a file two ways. Only a file
|
|
79
|
+
* that holds nothing starts from no windows: missing (`null`), whitespace, or a byte-order mark alone. A file with one bad
|
|
80
|
+
* window is not such a file, since its raw list is still there to replace. A key written twice is refused too: `JSON.parse`
|
|
81
|
+
* keeps the last, so a write would drop the windows a person reading the file can see.
|
|
82
|
+
*/
|
|
83
|
+
export function heldPauseWindows(text, path) {
|
|
84
|
+
if ((text ?? "").trim() === "") return { doc: null, windows: [] };
|
|
85
|
+
const { value, problem } = heldDocument(text, "windows");
|
|
86
|
+
const refused = problem ?? (findDuplicateKey(text) ? "has a key written twice" : null);
|
|
87
|
+
if (refused) return { doc: value ?? null, problem: `the pause-windows file ${path} ${refused}, so its windows cannot be kept` };
|
|
88
|
+
return { doc: value, windows: Array.isArray(value.windows) ? value.windows : [] };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The pause windows file text for `windows`, written over the parsed document `doc` the write started from (issue
|
|
93
|
+
* #761): 2-space JSON and a trailing newline. Every top-level key of `doc` other than `windows` and `version` is
|
|
94
|
+
* carried in its original place (the file is shared, and a writer from an older release must not delete a block a
|
|
95
|
+
* newer one added), `windows` keeps its position (first when `doc` had none), and
|
|
96
|
+
* `version` leads only when the windows need more than 1. A `doc` whose only key is `windows`, or no `doc`, gives
|
|
97
|
+
* exactly the bytes this file was always written with, `JSON.stringify({ windows }, null, 2)` plus the newline.
|
|
98
|
+
* Built with `Object.defineProperty`, so a carried key named `__proto__` stays data.
|
|
99
|
+
*/
|
|
100
|
+
export function pauseWindowsText(doc, windows, { table = PAUSE_WINDOW_FIELD_VERSIONS, valueTable = PAUSE_WINDOW_VALUE_VERSIONS } = {}) {
|
|
101
|
+
const rest = doc !== null && typeof doc === "object" && !Array.isArray(doc) ? doc : {};
|
|
102
|
+
const out = {};
|
|
103
|
+
const put = (key, value) => Object.defineProperty(out, key, { value, enumerable: true, writable: true, configurable: true });
|
|
104
|
+
const version = pauseWindowsVersionFor(windows, { table, valueTable });
|
|
105
|
+
if (version > 1) put("version", version);
|
|
106
|
+
if (!Object.hasOwn(rest, "windows")) put("windows", windows);
|
|
107
|
+
for (const key of Object.keys(rest)) {
|
|
108
|
+
if (key === "version") continue;
|
|
109
|
+
put(key, key === "windows" ? windows : rest[key]);
|
|
110
|
+
}
|
|
111
|
+
return `${JSON.stringify(out, null, 2)}\n`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The weekdays a window's `days` may name, Sunday-first to match JS getUTCDay() and the Intl weekday index used in
|
|
116
|
+
* zonedParts(). A `days` entry is read by its first three letters, lowercased. EXPORTED for the version bolt (issue
|
|
117
|
+
* #761): a form added here is one an older build refuses, so it needs a `PAUSE_WINDOW_VALUE_VERSIONS.days` rule.
|
|
118
|
+
*/
|
|
119
|
+
export const DAYS = Object.freeze(["sun", "mon", "tue", "wed", "thu", "fri", "sat"]);
|
|
23
120
|
const DAY_SET = new Set(DAYS);
|
|
24
121
|
|
|
25
122
|
function isNonEmptyString(value) {
|
|
@@ -60,19 +157,38 @@ function validateTz(tz, at, path) {
|
|
|
60
157
|
* Parse, validate, and normalize the pause-windows file text. Returns an array of normalized windows
|
|
61
158
|
* (unknown fields dropped; `fromMin`/`toMin` precomputed). Throws `configError` (fail-loud) on any malformed
|
|
62
159
|
* entry. `path` is for messages only.
|
|
160
|
+
*
|
|
161
|
+
* The file's `version` is judged FIRST, before the windows (issue #761): a newer file's windows may use a shape this
|
|
162
|
+
* build would call malformed, and the true message is "upgrade". A newer file throws a configError with
|
|
163
|
+
* `.newer === true` (the worker defers jobs rather than misread a pause); a broken `version`, or a window using a field
|
|
164
|
+
* or a value that needs a higher version than the file declares, throws the ordinary configError. `supported`, `table`
|
|
165
|
+
* and `valueTable` are injectable for tests.
|
|
63
166
|
*/
|
|
64
|
-
export function parsePauseWindows(text, path) {
|
|
167
|
+
export function parsePauseWindows(text, path, { supported = PAUSE_WINDOWS_VERSION, table = PAUSE_WINDOW_FIELD_VERSIONS, valueTable = PAUSE_WINDOW_VALUE_VERSIONS } = {}) {
|
|
65
168
|
let parsed;
|
|
66
169
|
try {
|
|
67
170
|
parsed = JSON.parse(text);
|
|
68
171
|
} catch (error) {
|
|
69
172
|
throw configError(`pause-windows file is not valid JSON: ${path} (${error.message})`);
|
|
70
173
|
}
|
|
174
|
+
const problem = pauseWindowsVersionRefusal(parsed, { supported });
|
|
175
|
+
if (problem) {
|
|
176
|
+
const error = configError(`${problem.message}: ${path}`);
|
|
177
|
+
if (problem.newer) error.newer = true;
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
71
180
|
const windows = parsed?.windows;
|
|
72
181
|
if (!Array.isArray(windows)) {
|
|
73
182
|
throw configError(`pause-windows file must have a "windows" array: ${path}`);
|
|
74
183
|
}
|
|
75
|
-
|
|
184
|
+
const normalized = windows.map((w, index) => normalizeWindow(w, index, path));
|
|
185
|
+
windows.forEach((w, index) => {
|
|
186
|
+
const short = underDeclared(Object.keys(w), table, parsed.version);
|
|
187
|
+
if (short) throw configError(`pause window at index ${index}: ${short.field} needs pause-windows file version ${short.needs}, and the file declares ${parsed.version ?? 1}: ${path}`);
|
|
188
|
+
const shortValue = valueUnderDeclared(w, valueTable, parsed.version);
|
|
189
|
+
if (shortValue) throw configError(`pause window at index ${index}: ${shortValue.field} holds a value that needs pause-windows file version ${shortValue.needs}, and the file declares ${parsed.version ?? 1}: ${path}`);
|
|
190
|
+
});
|
|
191
|
+
return normalized;
|
|
76
192
|
}
|
|
77
193
|
|
|
78
194
|
function normalizeWindow(w, index, path) {
|
|
@@ -142,11 +258,12 @@ export function loadPauseWindows(config, { readFileSync = fsReadFileSync, exists
|
|
|
142
258
|
const WEEKDAY_INDEX = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
|
|
143
259
|
|
|
144
260
|
/** The wall-clock parts of instant `ms` in `tz`: `{ y, mo, d, hh, mi, dow }` (dow 0=Sun..6=Sat). */
|
|
145
|
-
function zonedParts(tz, ms) {
|
|
146
|
-
const fmt = new Intl.DateTimeFormat("en-US", {
|
|
261
|
+
function zonedParts(tz, ms, formatters = null) {
|
|
262
|
+
const fmt = formatters?.get(tz) ?? new Intl.DateTimeFormat("en-US", {
|
|
147
263
|
timeZone: tz, hour12: false,
|
|
148
264
|
year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", weekday: "short",
|
|
149
265
|
});
|
|
266
|
+
if (formatters && !formatters.has(tz)) formatters.set(tz, fmt);
|
|
150
267
|
const p = {};
|
|
151
268
|
for (const part of fmt.formatToParts(new Date(ms))) p[part.type] = part.value;
|
|
152
269
|
let hh = Number(p.hour);
|
|
@@ -159,17 +276,17 @@ function zonedParts(tz, ms) {
|
|
|
159
276
|
* guess back in `tz`, diff to recover the zone offset, subtract. Correct except the ~1h DST-transition seam,
|
|
160
277
|
* which is immaterial for a pause boundary.
|
|
161
278
|
*/
|
|
162
|
-
function wallToMs(tz, y, mo, d, hh, mi) {
|
|
279
|
+
function wallToMs(tz, y, mo, d, hh, mi, formatters = null) {
|
|
163
280
|
const guess = Date.UTC(y, mo - 1, d, hh, mi);
|
|
164
|
-
const shown = zonedParts(tz, guess);
|
|
281
|
+
const shown = zonedParts(tz, guess, formatters);
|
|
165
282
|
const shownAsUtc = Date.UTC(shown.y, shown.mo - 1, shown.d, shown.hh, shown.mi);
|
|
166
283
|
return guess - (shownAsUtc - guess);
|
|
167
284
|
}
|
|
168
285
|
|
|
169
286
|
/** The date `delta` days from `parts`' date, computed at local noon to dodge DST edges. Returns zoned parts. */
|
|
170
|
-
function shiftDay(tz, parts, delta) {
|
|
171
|
-
const noon = wallToMs(tz, parts.y, parts.mo, parts.d, 12, 0);
|
|
172
|
-
return zonedParts(tz, noon + delta * 86400000);
|
|
287
|
+
function shiftDay(tz, parts, delta, formatters = null) {
|
|
288
|
+
const noon = wallToMs(tz, parts.y, parts.mo, parts.d, 12, 0, formatters);
|
|
289
|
+
return zonedParts(tz, noon + delta * 86400000, formatters);
|
|
173
290
|
}
|
|
174
291
|
|
|
175
292
|
function ymd(parts) {
|
|
@@ -186,11 +303,13 @@ function qualifies(w, parts) {
|
|
|
186
303
|
}
|
|
187
304
|
|
|
188
305
|
/** The end epoch ms of the window occurrence active at `nowMs`, or null if `now` is not inside `w`. Exported
|
|
189
|
-
* so the admin panel can mark a single window "currently paused · resumes in …" without a synthetic job.
|
|
190
|
-
|
|
191
|
-
|
|
306
|
+
* so the admin panel can mark a single window "currently paused · resumes in …" without a synthetic job.
|
|
307
|
+
* An optional caller-owned formatter cache lets a bounded calendar projection reuse clocks; the worker default
|
|
308
|
+
* still constructs its clock per call, and both paths evaluate exactly the same wall-time rules. */
|
|
309
|
+
export function windowEndAt(w, nowMs, formatters = null) {
|
|
310
|
+
const p = zonedParts(w.tz, nowMs, formatters);
|
|
192
311
|
const nowMin = p.hh * 60 + p.mi;
|
|
193
|
-
const endAt = (dateParts) => wallToMs(w.tz, dateParts.y, dateParts.mo, dateParts.d, Math.floor(w.toMin / 60), w.toMin % 60);
|
|
312
|
+
const endAt = (dateParts) => wallToMs(w.tz, dateParts.y, dateParts.mo, dateParts.d, Math.floor(w.toMin / 60), w.toMin % 60, formatters);
|
|
194
313
|
|
|
195
314
|
if (w.fromMin < w.toMin) {
|
|
196
315
|
// Same-day window: [from, to) today, starting today.
|
|
@@ -199,8 +318,8 @@ export function windowEndAt(w, nowMs) {
|
|
|
199
318
|
}
|
|
200
319
|
// Overnight window (from > to): started tonight (>= from, today qualifies) -> ends tomorrow at `to`,
|
|
201
320
|
// or started last night (< to, yesterday qualified) -> ends today at `to`.
|
|
202
|
-
if (nowMin >= w.fromMin && qualifies(w, p)) return endAt(shiftDay(w.tz, p, 1));
|
|
203
|
-
if (nowMin < w.toMin && qualifies(w, shiftDay(w.tz, p, -1))) return endAt(p);
|
|
321
|
+
if (nowMin >= w.fromMin && qualifies(w, p)) return endAt(shiftDay(w.tz, p, 1, formatters));
|
|
322
|
+
if (nowMin < w.toMin && qualifies(w, shiftDay(w.tz, p, -1, formatters))) return endAt(p);
|
|
204
323
|
return null;
|
|
205
324
|
}
|
|
206
325
|
|
package/src/podman-stack.mjs
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
import { execFileSync } from "node:child_process";
|
|
26
26
|
import { connect as netConnect } from "node:net";
|
|
27
27
|
import { dirname, join } from "node:path";
|
|
28
|
+
import { openshellSetupOf, venuesOf } from "./backends.mjs";
|
|
28
29
|
import { DEFAULT_EGRESS_PROXY, egressProxyName } from "./egress.mjs";
|
|
29
30
|
import { MODEL_ENDPOINTS_INCLUDE_NAME } from "./model-endpoints.mjs";
|
|
30
31
|
import { VALKEY_PASSWORD_KEY, valkeyEnvFileText } from "./valkey-auth.mjs";
|
|
@@ -593,8 +594,9 @@ export async function decideValkey({ venues, url, installed, probeTcp, lookup, e
|
|
|
593
594
|
export async function resolveWorkerValkey({ url, venues, platform, env, envText, envPath, probeTcp, lookup, fs, euid, user, ownerName, interfaces, subuids, waitMs = 20_000, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = () => Date.now(), configError: refuse }) {
|
|
594
595
|
// Gate round 3: judged on EVERY venue on Linux, not only podman's. Another account's listener is refused everywhere
|
|
595
596
|
// (item 7: a local-venue worker silently adopted another account's rootless Valkey); root's (docker-proxy) is refused
|
|
596
|
-
// only where the podman venue is this deployment's and `local` is not
|
|
597
|
-
|
|
597
|
+
// only where the podman venue is this deployment's and `local` is not, or (issue #707, review round 1 of #715) where an
|
|
598
|
+
// openshell venue on Podman is the queue's engine (`openshellSetupOf`), whose Quadlet Valkey is this account's too.
|
|
599
|
+
const rootRefused = (venues.podmanUsed && !venues.localUsed) || openshellSetupOf(venues, env).valkeyEngine === "podman";
|
|
598
600
|
const same = { url, servername: null, pinned: null, notes: [], rootRefused };
|
|
599
601
|
if (platform !== "linux") return same;
|
|
600
602
|
const notes = [];
|
|
@@ -956,7 +958,12 @@ export function foreignContainerRefusal(found, { forceHint }) {
|
|
|
956
958
|
* docker-shaped worker for a podman deployment, and the opposite guess a stack for a docker one. So does a line systemd
|
|
957
959
|
* splits differently from this reader (`envFileSystemdHazard`, issue #447), for the whole file.
|
|
958
960
|
*/
|
|
959
|
-
|
|
961
|
+
// Issue #707: and the openshell venues' two keys, read from the same place for the same reason. `up` and `service install`
|
|
962
|
+
// decide from PI_OPENSHELL_ENGINE which engine's store gets the job image and where Valkey runs (`openshellSetupOf`), so
|
|
963
|
+
// a shell value the service never sees would stand the stack up on the wrong engine.
|
|
964
|
+
const PODMAN_VENUE_KEYS = Object.freeze(["PI_BACKENDS", "PI_EGRESS", "PI_EGRESS_PROXY"]);
|
|
965
|
+
const OPENSHELL_VENUE_KEYS = Object.freeze(["PI_OPENSHELL_ENGINE", "PI_OPENSHELL_GATEWAY"]);
|
|
966
|
+
export const STACK_KEYS = Object.freeze([...PODMAN_VENUE_KEYS, ...OPENSHELL_VENUE_KEYS]);
|
|
960
967
|
|
|
961
968
|
/** The longest venue-key value `readStackKeys` accepts, in bytes (issue #447 gate round 2; systemd's own limit is ~128 KiB). */
|
|
962
969
|
export const STACK_VALUE_MAX = 4096;
|
|
@@ -967,7 +974,26 @@ function shapedRefusal(path, hazard, why) {
|
|
|
967
974
|
return `${path} line ${hazard.line} has ${shape.what}, and ${why}, so what the service reads for it is unknown: ${shape.fix}`;
|
|
968
975
|
}
|
|
969
976
|
|
|
970
|
-
|
|
977
|
+
/**
|
|
978
|
+
* The venue keys of a deployment's `.env` (`STACK_KEYS`), read by `readVenueKeys` below in two passes since review round 2
|
|
979
|
+
* of #715. The three the podman venue decides from are read and refused as ever. The openshell venues' two are read the
|
|
980
|
+
* same way but REFUSED only where an openshell venue is blessed (the file's PI_BACKENDS, or `openshellBlessed` from the
|
|
981
|
+
* caller, which knows this shell's): a stray `export PI_OPENSHELL_ENGINE=...` or a continued line in a `local`-only
|
|
982
|
+
* deployment names a key nothing there reads, and stopping `up` or `service install` on it is a refusal with no reason.
|
|
983
|
+
*/
|
|
984
|
+
export function readStackKeys(content, { loader = "systemd", path = ".env", assumeSpelled = false, openshellBlessed = false } = {}) {
|
|
985
|
+
const base = readVenueKeys(content, { loader, path, assumeSpelled, keys: PODMAN_VENUE_KEYS, about: "whether this deployment runs the podman venue" });
|
|
986
|
+
if (base.error) return base;
|
|
987
|
+
let blessed = openshellBlessed === true;
|
|
988
|
+
try {
|
|
989
|
+
blessed ||= venuesOf({ PI_BACKENDS: base.keys.PI_BACKENDS }).openshellUsed;
|
|
990
|
+
} catch {}
|
|
991
|
+
const openshell = readVenueKeys(content, { loader, path, assumeSpelled, keys: OPENSHELL_VENUE_KEYS, about: "which engine and gateway the openshell venue uses (PI_OPENSHELL_ENGINE, PI_OPENSHELL_GATEWAY)" });
|
|
992
|
+
if (openshell.error) return blessed ? openshell : base;
|
|
993
|
+
return { keys: { ...base.keys, ...openshell.keys } };
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
function readVenueKeys(content, { loader, path, assumeSpelled, keys: KEYS, about }) {
|
|
971
997
|
// The file's BYTES where the caller has them (issue #447, gate round 1): systemd refuses to LOAD a file with a NUL
|
|
972
998
|
// or with invalid UTF-8 in a key or value, and the unit then fails with every key unset, which no reading of the
|
|
973
999
|
// decoded text can see. Refused whatever the file assigns, because the service does not start on it at all.
|
|
@@ -979,9 +1005,9 @@ export function readStackKeys(content, { loader = "systemd", path = ".env", assu
|
|
|
979
1005
|
const lines = String(text ?? "").split("\n").map((l) => (l.endsWith("\r") ? l.slice(0, -1) : l));
|
|
980
1006
|
// A line TOUCHES a key when the key is its leading word, `export ` allowed: that is where a loader could read an
|
|
981
1007
|
// assignment of it. A key name inside another key's value (`PI_ENV_SETUP=/opt/PI_BACKENDS.sh`) touches nothing.
|
|
982
|
-
const touches = new RegExp(`^[ \\t]*(?:export[ \\t]+)?(${
|
|
1008
|
+
const touches = new RegExp(`^[ \\t]*(?:export[ \\t]+)?(${KEYS.join("|")})(?![A-Za-z0-9_])`);
|
|
983
1009
|
// `export K=v` is an assignment only to a loader that SOURCES the file (the macOS wrapper); systemd ignores it.
|
|
984
|
-
const exact = new RegExp(`^[ \\t]*${loader === "shell" ? "(?:export[ \\t]+)?" : ""}(${
|
|
1010
|
+
const exact = new RegExp(`^[ \\t]*${loader === "shell" ? "(?:export[ \\t]+)?" : ""}(${KEYS.join("|")})=`);
|
|
985
1011
|
// A `#` line is a comment to every loader. A `;` line (a comment to systemd's EnvironmentFile=) needs no rule of its
|
|
986
1012
|
// own: its leading word is `;`, never a key, so it touches nothing and is not refused (the round 2 nit).
|
|
987
1013
|
const comment = /^[ \t]*#/;
|
|
@@ -1004,13 +1030,13 @@ export function readStackKeys(content, { loader = "systemd", path = ".env", assu
|
|
|
1004
1030
|
// value, and to a shell part of the joined line (`X=a\` + `#;PI_EGRESS=0` sets PI_EGRESS, gate round 2).
|
|
1005
1031
|
const afterLoneCr = (l) => (l.includes("\r") ? l.slice(l.indexOf("\r") + 1) : "");
|
|
1006
1032
|
const inValue = loader === "cmd" ? [] : envFileValueLines(text, { loader });
|
|
1007
|
-
const spelledOutside = (commentLine) => (assumeSpelled ?
|
|
1033
|
+
const spelledOutside = (commentLine) => (assumeSpelled ? KEYS[0] : KEYS.find((k) => lines.some((l, i) => (commentLine.test(l) && !inValue[i] ? afterLoneCr(l) : l).includes(k))));
|
|
1008
1034
|
if (loader === "systemd") {
|
|
1009
1035
|
const h = envFileSystemdHazard(text);
|
|
1010
1036
|
const spelled = h === null ? undefined : spelledOutside(/^[ \t]*[#;]/);
|
|
1011
1037
|
if (h !== null && spelled !== undefined) {
|
|
1012
1038
|
const shape = SYSTEMD_HAZARD_SHAPES[h.shape];
|
|
1013
|
-
return { error: `${path} line ${h.line} has ${shape.what}. The service's systemd would read the lines of this file differently from this command, so which venue keys (${
|
|
1039
|
+
return { error: `${path} line ${h.line} has ${shape.what}. The service's systemd would read the lines of this file differently from this command, so which venue keys (${KEYS.join(", ")}) it sets is unknown: ${shape.fix}` };
|
|
1014
1040
|
}
|
|
1015
1041
|
}
|
|
1016
1042
|
let touched = null;
|
|
@@ -1021,7 +1047,7 @@ export function readStackKeys(content, { loader = "systemd", path = ".env", assu
|
|
|
1021
1047
|
if (!m) continue;
|
|
1022
1048
|
touched ??= { key: m[1], line: i + 1 };
|
|
1023
1049
|
if (!exact.test(line)) {
|
|
1024
|
-
return { error: `${path} line ${i + 1} assigns ${m[1]} in a form other than a plain ${m[1]}=value line, which the loaders do not agree on (systemd sets \`${m[1]} =x\` and ignores \`export ${m[1]}=x\`; the shells do the opposite).
|
|
1050
|
+
return { error: `${path} line ${i + 1} assigns ${m[1]} in a form other than a plain ${m[1]}=value line, which the loaders do not agree on (systemd sets \`${m[1]} =x\` and ignores \`export ${m[1]}=x\`; the shells do the opposite). ${about[0].toUpperCase()}${about.slice(1)} is therefore unknown: write it as a plain ${m[1]}=value line` };
|
|
1025
1051
|
}
|
|
1026
1052
|
}
|
|
1027
1053
|
if (touched) {
|
|
@@ -1060,12 +1086,12 @@ export function readStackKeys(content, { loader = "systemd", path = ".env", assu
|
|
|
1060
1086
|
return { error: `${path} line ${hazard.line} is one this command cannot read (an open quote, a continuation, a line that runs, or a second assignment on one line), and ${why}, so what the service reads for it is unknown. Fix that line first` };
|
|
1061
1087
|
}
|
|
1062
1088
|
}
|
|
1063
|
-
const found = readEnvAssignments(text,
|
|
1089
|
+
const found = readEnvAssignments(text, KEYS, { loader });
|
|
1064
1090
|
const keys = {};
|
|
1065
|
-
for (const key of
|
|
1091
|
+
for (const key of KEYS) {
|
|
1066
1092
|
const read = found[key];
|
|
1067
1093
|
if (!read) continue;
|
|
1068
|
-
if (!read.plain) return { error: `${path} line ${read.line} assigns ${key} in a form this command cannot read the way the service's loader will ($, quotes, spaces or a backslash in the value), so
|
|
1094
|
+
if (!read.plain) return { error: `${path} line ${read.line} assigns ${key} in a form this command cannot read the way the service's loader will ($, quotes, spaces or a backslash in the value), so ${about} is unknown. Write it as a plain ${key}=value line` };
|
|
1069
1095
|
// THE PROJECT'S OWN CAP, not systemd's (gate round 3 corrected the wording): systemd 259 passes a value up to about
|
|
1070
1096
|
// 128 KiB, and past that the whole environment is `envFileLoadHazard`'s `exec-too-large`. A venue key names a list,
|
|
1071
1097
|
// a switch or a container, so 4096 bytes is far more than one needs, and a longer one is a mistake worth naming.
|
|
@@ -1291,9 +1317,9 @@ export function describeRollBack(rolled, { partial = false } = {}) {
|
|
|
1291
1317
|
* also take the worker down with it, since the worker's own queue connection retries and says why, and a proxy that
|
|
1292
1318
|
* is down refuses jobs pre-spend by itself. `After=` so a boot starts the queue before the worker reaches for it.
|
|
1293
1319
|
*/
|
|
1294
|
-
export function workerUnitDeps(units) {
|
|
1320
|
+
export function workerUnitDeps(units, { forWhat = "the podman venue (issue #430)" } = {}) {
|
|
1295
1321
|
if (units.length === 0) return "";
|
|
1296
|
-
return `# Added by \`pi-dispatch service install\` for
|
|
1322
|
+
return `# Added by \`pi-dispatch service install\` for ${forWhat}: the Quadlet units it installed.\nWants=${units.join(" ")}\nAfter=${units.join(" ")}\n`;
|
|
1297
1323
|
}
|
|
1298
1324
|
|
|
1299
1325
|
/**
|
|
@@ -16,7 +16,9 @@
|
|
|
16
16
|
* settles in), so the numbers are complete on every host. A counter holds what was spent AND what running jobs still
|
|
17
17
|
* hold: that is the number the next job is admitted against, so it is the one a manager should plan with. Run counts
|
|
18
18
|
* come from the local run history merged with the Valkey run mirror; without a mirror (no `PI_WORKER_NAME`) they are
|
|
19
|
-
* this host's alone, and `fleet.runsComplete` says so.
|
|
19
|
+
* this host's alone, and `fleet.runsComplete` says so. A run record from a NEWER pi-dispatch (issue #761, run-history.mjs
|
|
20
|
+
* `recordReadable`) is not counted, from either source, and makes `fleet.runsComplete` false the same way: a count that
|
|
21
|
+
* reads a record past its stable core could count a field that release gave another meaning.
|
|
20
22
|
*/
|
|
21
23
|
|
|
22
24
|
import * as nodeFs from "node:fs";
|
|
@@ -26,6 +28,7 @@ import { dayKey, monthKey, weekKey } from "./budget.mjs";
|
|
|
26
28
|
import { OTHER, PLAN_FIELDS, PLAN_RULES, envelopeEntries, scopeRef } from "./priorities.mjs";
|
|
27
29
|
import { InfraRetry } from "./processor.mjs";
|
|
28
30
|
import { isProjectId } from "./project-id.mjs";
|
|
31
|
+
import { recordReadable } from "./run-history.mjs";
|
|
29
32
|
import { RUNS_INDEX_MAX, mergeRuns, readMirroredRuns } from "./run-mirror.mjs";
|
|
30
33
|
import { scopeDollarKeyPrefix } from "./scoped-limits.mjs";
|
|
31
34
|
|
|
@@ -220,7 +223,9 @@ export async function buildPortfolioSnapshot({ envelope, digest, projects = [],
|
|
|
220
223
|
/**
|
|
221
224
|
* The run records of this host's history (`PI_LOGS_DIR/*.json`) that ended at or after `sinceMs`: `{ records, complete }`.
|
|
222
225
|
* A file older than the window by its mtime is not opened (a record is written when its run ends). Never throws: a
|
|
223
|
-
* missing directory is no runs, and any other fault reads as `complete: false`.
|
|
226
|
+
* missing directory is no runs, and any other fault reads as `complete: false`. A record from a newer pi-dispatch is
|
|
227
|
+
* left out and counted in `newerSkipped` (present only when there were any), judged against the window by its core
|
|
228
|
+
* times, the rule `countRuns` reads.
|
|
224
229
|
*/
|
|
225
230
|
export function readLocalRuns({ logsDir, sinceMs, fs = nodeFs }) {
|
|
226
231
|
let names;
|
|
@@ -231,19 +236,22 @@ export function readLocalRuns({ logsDir, sinceMs, fs = nodeFs }) {
|
|
|
231
236
|
}
|
|
232
237
|
const records = [];
|
|
233
238
|
let complete = true;
|
|
239
|
+
let newerSkipped = 0;
|
|
234
240
|
for (const name of names) {
|
|
235
241
|
if (!name.endsWith(".json")) continue;
|
|
236
242
|
try {
|
|
237
243
|
const st = fs.statSync(join(logsDir, name));
|
|
238
244
|
if (!st.isFile() || st.mtimeMs < sinceMs) continue;
|
|
239
245
|
const rec = JSON.parse(fs.readFileSync(join(logsDir, name), "utf8"));
|
|
240
|
-
if (rec
|
|
246
|
+
if (!rec || typeof rec !== "object" || typeof rec.jobId !== "string") continue;
|
|
247
|
+
if (recordReadable(rec)) records.push(rec);
|
|
248
|
+
else if (Date.parse(rec.endedAt ?? rec.startedAt ?? "") >= sinceMs) newerSkipped++;
|
|
241
249
|
} catch (error) {
|
|
242
250
|
// A record reaped between the listing and the read is simply gone; anything else leaves the count short.
|
|
243
251
|
if (error?.code !== "ENOENT") complete = false;
|
|
244
252
|
}
|
|
245
253
|
}
|
|
246
|
-
return { records, complete };
|
|
254
|
+
return { records, complete, ...(newerSkipped > 0 ? { newerSkipped } : {}) };
|
|
247
255
|
}
|
|
248
256
|
|
|
249
257
|
/**
|
|
@@ -257,8 +265,13 @@ export function readLocalRuns({ logsDir, sinceMs, fs = nodeFs }) {
|
|
|
257
265
|
* anything yet, so it is retried rather than run on a snapshot with holes. The run mirror is a view and never throws; a
|
|
258
266
|
* mirror that could not be read makes `runsComplete` false. A snapshot over 64 KiB is refused as
|
|
259
267
|
* `portfolio-snapshot-oversize`, before any spend, and the log line names the project count.
|
|
268
|
+
*
|
|
269
|
+
* `indexMax` is this host's run index cap (`PI_RUN_MIRROR_MAX_RUNS`, issue #761), the most the mirror read asks for.
|
|
270
|
+
* Every writer trims the shared index to its own cap, this host included, so a read of this many is the whole index
|
|
271
|
+
* unless a peer with a larger cap has written since this host last did; a read cut short says `truncated`, which makes
|
|
272
|
+
* `runsComplete` false rather than leaving a short count unflagged.
|
|
260
273
|
*/
|
|
261
|
-
export function makePortfolioSnapshot({ checkPortfolioFlag, governing, projects = () => [], limits = () => null, allocation, redis, logsDir, mirror = false, readMirror = readMirroredRuns, fs = nodeFs, now = () => new Date(), log = () => {} }) {
|
|
274
|
+
export function makePortfolioSnapshot({ checkPortfolioFlag, governing, projects = () => [], limits = () => null, allocation, redis, logsDir, mirror = false, readMirror = readMirroredRuns, indexMax = RUNS_INDEX_MAX, fs = nodeFs, now = () => new Date(), log = () => {} }) {
|
|
262
275
|
return async function portfolioSnapshot(job) {
|
|
263
276
|
const flagged = await Promise.resolve()
|
|
264
277
|
.then(() => checkPortfolioFlag(job))
|
|
@@ -282,11 +295,17 @@ export function makePortfolioSnapshot({ checkPortfolioFlag, governing, projects
|
|
|
282
295
|
const local = readLocalRuns({ logsDir, sinceMs, fs });
|
|
283
296
|
let mirrored = [];
|
|
284
297
|
let complete = false;
|
|
298
|
+
let newerSkipped = local.newerSkipped ?? 0;
|
|
285
299
|
if (mirror) {
|
|
286
|
-
const read = await readMirror(redis, { limit:
|
|
287
|
-
|
|
300
|
+
const read = await readMirror(redis, { limit: indexMax, sinceMs, now: () => at.getTime() });
|
|
301
|
+
for (const r of Array.isArray(read?.runs) ? read.runs : []) {
|
|
302
|
+
if (recordReadable(r)) mirrored.push(r);
|
|
303
|
+
else if (r !== null && typeof r === "object" && !Array.isArray(r)) newerSkipped++;
|
|
304
|
+
}
|
|
288
305
|
complete = local.complete && (read.degraded === "ok" || read.degraded === "off");
|
|
289
306
|
}
|
|
307
|
+
// A newer release's record left out of the counts leaves them short, which is what `runsComplete` says.
|
|
308
|
+
if (newerSkipped > 0) complete = false;
|
|
290
309
|
const records = mergeRuns(local.records, mirrored, { limit: Number.POSITIVE_INFINITY });
|
|
291
310
|
snapshot = await buildPortfolioSnapshot({ envelope: g.envelope, digest: g.digest, projects: projects(), limits: limits(), allocation: { state, lastAttempt: last }, redis, runs: { records, complete }, now: at });
|
|
292
311
|
} catch (error) {
|