@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
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One exact version of one staged package, for one job (issue #796, `REQ-PACKAGE-UPDATES`, `DES-PACKAGE-UPDATES-PREPARED-NOT-APPLIED`).
|
|
3
|
+
*
|
|
4
|
+
* The candidate REPLACES the staged copy in this job's `PI_PACKAGES`: loaded beside it, which of the two wins would be pi's business and the run would prove nothing. It is
|
|
5
|
+
* installed under this job's own directory and never in the overlay, which every job mounts whole, so a job running beside this one cannot see it.
|
|
6
|
+
*
|
|
7
|
+
* THE SPENDING ORDER, stated exactly because `CONST-BUDGET-BEFORE-TOKENS` is about this. Every free determinate refusal, including that the exact version exists, runs before the
|
|
8
|
+
* token mint (`check`). The install needs the job directory, which on a job with a forge token comes after the mint, so it runs before the budget reservation and not before the
|
|
9
|
+
* mint (`install`). No money is spent before the candidate is proven installable. A token minted first simply expires, as on any refusal after the mint.
|
|
10
|
+
*/
|
|
11
|
+
import * as nodeFs from "node:fs";
|
|
12
|
+
import { execFile } from "node:child_process";
|
|
13
|
+
import { basename, join } from "node:path";
|
|
14
|
+
import { promisify } from "node:util";
|
|
15
|
+
import { CONTAINER_PACKAGES_ROOT, readStageManifest, stagedDirName } from "./packages.mjs";
|
|
16
|
+
import { parsePackageCanary } from "./package-canary-record.mjs";
|
|
17
|
+
import { PackageStageError, SERVICE_NPM_TIMEOUT_MS, npmExecOptions, npmFailureKind, serviceNpmEnv, stagePackageTree, stagedStem, treeDigest } from "./package-stage.mjs";
|
|
18
|
+
import { isSubscriptionAdapter } from "./updates-reconcile.mjs";
|
|
19
|
+
import { changelogExcerpt } from "./updates.mjs";
|
|
20
|
+
|
|
21
|
+
export { CANARY_LOADS, CANARY_RECORD_LOADS, parsePackageCanary, recordedPackageCanary } from "./package-canary-record.mjs";
|
|
22
|
+
export const PACKAGE_CANARY_SUBDIR = "canary";
|
|
23
|
+
export const PACKAGE_CANARY_REASONS = Object.freeze(["package-canary-invalid", "package-canary-packages-off", "package-canary-excluded", "package-canary-resume", "package-canary-not-staged", "package-canary-adapter", "package-canary-version-missing", "package-canary-refused"]);
|
|
24
|
+
|
|
25
|
+
/** The most of a changelog read, so a huge one costs nothing. What is kept of it is `changelogExcerpt`'s. */
|
|
26
|
+
const CHANGELOG_READ_MAX = 64 * 1024;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Whether this path in `PI_PACKAGES` is a stage of the package `replaces` is a stage of. Compared on what stays the same between stages, because an Adopt may have moved the
|
|
30
|
+
* manifest on since the free check. A candidate loaded beside the staged copy it was meant to replace is the one thing this must never allow.
|
|
31
|
+
*/
|
|
32
|
+
function sameStagedPackage(path, replaces) {
|
|
33
|
+
return typeof path === "string" && stagedStem(basename(path)) === stagedStem(replaces);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The job's package list with the candidate in place of the staged copy. In its place, not appended: package order is pi's load order. */
|
|
37
|
+
export function withCanaryPath(paths, { replaces, tree }, jobRoot) {
|
|
38
|
+
const candidate = `${jobRoot}/${PACKAGE_CANARY_SUBDIR}/${tree}`;
|
|
39
|
+
let placed = false;
|
|
40
|
+
const out = [];
|
|
41
|
+
for (const path of paths) {
|
|
42
|
+
if (!sameStagedPackage(path, replaces)) out.push(path);
|
|
43
|
+
else if (!placed) {
|
|
44
|
+
out.push(candidate);
|
|
45
|
+
placed = true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (!placed) out.push(candidate);
|
|
49
|
+
return { paths: out, candidate };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The first lines of a package's changelog, as pi finds it (`pi.changelogPath`, else CHANGELOG.md), kept inside the package and bounded. */
|
|
53
|
+
export function readChangelog(root, fs = nodeFs) {
|
|
54
|
+
try {
|
|
55
|
+
const pkg = JSON.parse(fs.readFileSync(join(root, "package.json"), "utf8"));
|
|
56
|
+
const declared = typeof pkg?.pi?.changelogPath === "string" ? pkg.pi.changelogPath : "CHANGELOG.md";
|
|
57
|
+
if (declared.startsWith("/") || declared.split(/[\\/]/).includes("..")) return null;
|
|
58
|
+
const path = join(root, declared);
|
|
59
|
+
// Where it really is, not where it says: a link anywhere along the path, not only its last part, could reach any file on this host, and this text goes to every viewer.
|
|
60
|
+
const real = fs.realpathSync(path);
|
|
61
|
+
if (!real.startsWith(`${fs.realpathSync(root)}/`) && !real.startsWith(`${fs.realpathSync(root)}\\`)) return null;
|
|
62
|
+
const stat = fs.lstatSync(path);
|
|
63
|
+
if (!stat.isFile()) return null;
|
|
64
|
+
const handle = fs.openSync(path, "r");
|
|
65
|
+
try {
|
|
66
|
+
const buffer = Buffer.alloc(Math.min(stat.size, CHANGELOG_READ_MAX));
|
|
67
|
+
const read = fs.readSync(handle, buffer, 0, buffer.length, 0);
|
|
68
|
+
return changelogExcerpt(buffer.subarray(0, read).toString("utf8"));
|
|
69
|
+
} finally {
|
|
70
|
+
fs.closeSync(handle);
|
|
71
|
+
}
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The runtime the processor asks, built once by the worker. `check` runs before the mint and `install` before the reservation. Both return `{ refused: reason }` for a determinate
|
|
79
|
+
* answer, and throw `PackageStageError("infra")` for a registry that could not be asked, which the processor turns into a retry (`CONST-RETRY-INFRA-ONLY`).
|
|
80
|
+
*/
|
|
81
|
+
export function makePackageCanaryRuntime({ globalPiDir, env = process.env, exec = promisify(execFile), fs = nodeFs, platform = process.platform, publishFacts = async () => {}, host = null, now = Date.now, log = () => {} }) {
|
|
82
|
+
const npmBin = platform === "win32" ? "npm.cmd" : "npm";
|
|
83
|
+
const npmEnv = serviceNpmEnv(env);
|
|
84
|
+
return {
|
|
85
|
+
async check(job) {
|
|
86
|
+
const canary = parsePackageCanary(job.packageCanary);
|
|
87
|
+
if (!canary) return { refused: "package-canary-invalid" };
|
|
88
|
+
// A trigger that withholds staged packages has no staged copy to replace, and a run of it would prove nothing about the job this package goes into.
|
|
89
|
+
if (job.packages === false) return { refused: "package-canary-packages-off" };
|
|
90
|
+
// Nor one that leaves this package out (issue #800): `withCanaryPath` would put the candidate back in a job its trigger keeps it out of, for a run
|
|
91
|
+
// that says nothing about the jobs this package goes into. Free, here, before the mint, the clone and the reservation.
|
|
92
|
+
if (Array.isArray(job.excludeExtensions) && job.excludeExtensions.includes(canary.name)) return { refused: "package-canary-excluded" };
|
|
93
|
+
// A resuming trigger's next tick continues the transcript this job writes, with the candidate's work in it, so its effects would outlive the one job a person agreed to.
|
|
94
|
+
if (job.resume === true) return { refused: "package-canary-resume" };
|
|
95
|
+
if (isSubscriptionAdapter(canary.name)) return { refused: "package-canary-adapter" };
|
|
96
|
+
const manifest = globalPiDir ? readStageManifest({ globalPiDir, readFile: (p, e) => fs.readFileSync(p, e), fileExists: (p) => fs.existsSync(p) }) : null;
|
|
97
|
+
const staged = manifest?.packages.find((p) => p.name === canary.name) ?? null;
|
|
98
|
+
if (!staged) return { refused: "package-canary-not-staged" };
|
|
99
|
+
try {
|
|
100
|
+
// Metadata only: no tarball, and nothing the package wrote runs. npm prints nothing, or says E404, for a version that is not there.
|
|
101
|
+
const { stdout } = await exec(npmBin, ["view", `${canary.name}@${canary.version}`, "version", "--json", "--no-audit", "--no-fund", "--loglevel=error"], { ...npmExecOptions(platform, undefined, { timeoutMs: 30_000, env: npmEnv }) });
|
|
102
|
+
if (String(stdout ?? "").trim() === "") return { refused: "package-canary-version-missing" };
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if (npmFailureKind(error) === "not-found") return { refused: "package-canary-version-missing" };
|
|
105
|
+
throw new PackageStageError("infra", "the registry could not be asked");
|
|
106
|
+
}
|
|
107
|
+
return { ok: true, canary, replaces: staged.dir };
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
async install(job, jobDir, { replaces }) {
|
|
111
|
+
const canary = parsePackageCanary(job.packageCanary);
|
|
112
|
+
if (!canary) return { refused: "package-canary-invalid" };
|
|
113
|
+
const packagesRoot = join(jobDir, PACKAGE_CANARY_SUBDIR);
|
|
114
|
+
fs.mkdirSync(packagesRoot, { recursive: true });
|
|
115
|
+
let tree;
|
|
116
|
+
try {
|
|
117
|
+
({ dir: tree } = await stagePackageTree({ fs, exec, entry: { ...canary, dir: stagedDirName(canary.name) }, packagesRoot, npmBin, platform, timeoutMs: SERVICE_NPM_TIMEOUT_MS, env: npmEnv }));
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (error instanceof PackageStageError && error.kind !== "infra") return { refused: error.kind === "not-found" ? "package-canary-version-missing" : "package-canary-refused" };
|
|
120
|
+
throw error instanceof PackageStageError ? error : new PackageStageError("infra", "the candidate could not be installed");
|
|
121
|
+
}
|
|
122
|
+
const root = join(packagesRoot, tree);
|
|
123
|
+
try {
|
|
124
|
+
// The excerpt is a third party's text, so it goes beside the host's facts and never on the run record. Display only: a failure costs the page a line.
|
|
125
|
+
await publishFacts({ ...canary, host, digest: await treeDigest(root, { fs }), excerpt: readChangelog(root, fs), at: now() });
|
|
126
|
+
} catch {
|
|
127
|
+
log("package_canary_facts_unpublished", {});
|
|
128
|
+
}
|
|
129
|
+
return { replaces, tree };
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Where a staged package sits in a job on the plain layout. Exported for a test to name the path it replaces. */
|
|
135
|
+
export const stagedContainerPath = (dir) => `${CONTAINER_PACKAGES_ROOT}/${dir}`;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a package's name and exact version may look like. Their own module because the run record validates them too, and `run-history.mjs` must not reach `packages.mjs`,
|
|
3
|
+
* whose import of `import-pi.mjs` reaches `config.mjs`: the admin loads the record module inside pi (issue #499's review).
|
|
4
|
+
*/
|
|
5
|
+
export const EXACT_VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
6
|
+
/** An npm package name: lowercase, optionally `@scope/`-prefixed. */
|
|
7
|
+
export const NPM_NAME_RE = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
|
|
8
|
+
/** A package's name, bounded as npm bounds it, for a reader that holds no version beside it. A name with its version is `parsePackageCanary`'s. */
|
|
9
|
+
export const isPackageName = (value) => typeof value === "string" && value.length <= 214 && NPM_NAME_RE.test(value);
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Staging one third-party pi package (issue #796, `DES-PACKAGE-UPDATES-PREPARED-NOT-APPLIED`).
|
|
3
|
+
*
|
|
4
|
+
* The assertions here are what `import-pi --with-packages` always ran. They moved out because a person's Adopt and a canary need
|
|
5
|
+
* them too, and neither can run `import-pi`: it reads the account's paths and working directory, rewrites models, MCP and extensions,
|
|
6
|
+
* and reinstalls every package. The service account usually is not the operator's.
|
|
7
|
+
*
|
|
8
|
+
* WHY A DIRECTORY PER STAGE. `rename` cannot replace a non-empty directory, and swapping one in place would change the tree under a
|
|
9
|
+
* running job's bind mount, so an atomic swap was never available. Each stage gets a directory of its own, nothing already staged is
|
|
10
|
+
* touched, and the manifest (temp and rename) is the single commit point. `start.mjs` reads that manifest at each job start, so a job
|
|
11
|
+
* sees the old set or the new set and never half of either. An old tree is removed only after a delay longer than any job.
|
|
12
|
+
*/
|
|
13
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
14
|
+
import { join, relative } from "node:path";
|
|
15
|
+
import { PACKAGES_SUBDIR, RESOURCE_DIRS, STAGE_MANIFEST, stagedDirName } from "./packages.mjs";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* What kind of failure this is, because `CONST-RETRY-INFRA-ONLY` depends on it. `not-found` and `policy` are determinate: the same
|
|
19
|
+
* request can never succeed, so they are refusals. `infra` (a registry that cannot be reached, a timeout) can, so it is retried.
|
|
20
|
+
*/
|
|
21
|
+
export class PackageStageError extends Error {
|
|
22
|
+
constructor(kind, message) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = "PackageStageError";
|
|
25
|
+
this.kind = kind;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** How long one `npm install` may take in a service. An operator at the keyboard of `import-pi` has Ctrl-C, a worker's child does not. */
|
|
30
|
+
export const SERVICE_NPM_TIMEOUT_MS = 180_000;
|
|
31
|
+
|
|
32
|
+
/** How long a tree the manifest stopped naming is kept: longer than any job, so a running job never loses a file it imports lazily. */
|
|
33
|
+
export const STAGED_TREE_KEEP_MS = 7 * 24 * 60 * 60 * 1000;
|
|
34
|
+
|
|
35
|
+
/** The longest a stage may hold the lock. A holder older than this crashed: the longest stage is a few npm timeouts. */
|
|
36
|
+
export const STAGE_LOCK_STALE_MS = 30 * 60 * 1000;
|
|
37
|
+
|
|
38
|
+
const LOCK_DIR = ".stage.lock";
|
|
39
|
+
const RETIRED_FILE = ".retired.json";
|
|
40
|
+
const STAGING_PREFIX = ".staging-";
|
|
41
|
+
/** Each package's way back: what Undo restores. Not a stage, so it is never a retired tree. */
|
|
42
|
+
export const UNDO_DIR = ".undo";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The directory of one stage: readable, unique and a plain path segment. The name stays in it because doctor and the admin-name check
|
|
46
|
+
* read it (`ADMIN_RE` is deliberately unanchored). A build's `+` is not allowed in a segment, and the version on the manifest stays exact.
|
|
47
|
+
*/
|
|
48
|
+
export function stagedTreeName(base, version, unique = randomBytes(4).toString("hex")) {
|
|
49
|
+
const head = `${base}--${String(version).replace(/[^0-9A-Za-z.-]/g, "_")}`;
|
|
50
|
+
return `${head.slice(0, 118)}--${unique}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The base a stage's directory was named from: what stays the same between stages of one package. Read from the right, because a package's own name may hold `--` (npm allows it), and
|
|
55
|
+
* a directory from before per-stage names is its own base.
|
|
56
|
+
*/
|
|
57
|
+
export function stagedStem(dir) {
|
|
58
|
+
return /^(.+)--\d+\.\d+\.\d+[^/]*--[0-9a-f]{8}$/.exec(dir)?.[1] ?? dir;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The first string anywhere in a `pi` manifest that leaves the package dir, or null when all are contained. */
|
|
62
|
+
function findEscapingEntry(value) {
|
|
63
|
+
if (typeof value === "string") {
|
|
64
|
+
return /^[\\/]/.test(value) || value.split(/[\\/]/).includes("..") ? value : null;
|
|
65
|
+
}
|
|
66
|
+
if (Array.isArray(value) || (value !== null && typeof value === "object")) {
|
|
67
|
+
for (const child of Object.values(value)) {
|
|
68
|
+
const hit = findEscapingEntry(child);
|
|
69
|
+
if (hit) return hit;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The exec options for one `npm install`: always `cwd: <staging>` (that IS the install target now that
|
|
77
|
+
* `--prefix` is gone), plus `shell: true` on win32 and nowhere else.
|
|
78
|
+
*
|
|
79
|
+
* WHY shell:true is REQUIRED on win32: npm ships there as `npm.cmd`, and since Node 18.20.2 / 20.12.2
|
|
80
|
+
* (CVE-2024-27980) spawning a `.cmd`/`.bat` WITHOUT a shell throws EINVAL outright. This package floors at
|
|
81
|
+
* Node >=22.19, so every Node it can run on has that behaviour -- without this, `--with-packages` fails on
|
|
82
|
+
* every Windows host with a misleading "spawn npm.cmd EINVAL" and the branch above is dead on arrival.
|
|
83
|
+
*
|
|
84
|
+
* WHY shell:true is SAFE HERE SPECIFICALLY, which it would NOT be in general: with `--prefix` replaced by
|
|
85
|
+
* `cwd`, argv holds no filesystem path at all -- only literal flags this file spells out, plus the single
|
|
86
|
+
* `name@version` token, and BOTH halves of that token were validated before anything was created (packages.mjs
|
|
87
|
+
* rejects any name failing NPM_NAME_RE and any version failing EXACT_VERSION_RE, neither of which admits a
|
|
88
|
+
* space, quote, or cmd metacharacter). So no operator-supplied string that could survive as shell syntax ever
|
|
89
|
+
* reaches the command line. Re-introducing a path -- or loosening either regex -- breaks that argument, so
|
|
90
|
+
* this option must be revisited together with them.
|
|
91
|
+
*/
|
|
92
|
+
export function npmExecOptions(platform, staging, { timeoutMs, env } = {}) {
|
|
93
|
+
return { ...(staging ? { cwd: staging } : {}), ...(platform === "win32" ? { shell: true } : {}), ...(timeoutMs ? { timeout: timeoutMs } : {}), ...(env ? { env } : {}) };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* What a service's npm gets of the worker's environment. It holds every token the deployment has, and npm has no reason to see them. Reaching a registry takes these
|
|
98
|
+
* and nothing else, and a private registry's credentials live in the service account's `.npmrc`, which npm reads for itself. The registry itself is kept, and
|
|
99
|
+
* `PI_UPDATES_REGISTRY` is honoured here as it is by the look: dropping it would resolve a private name from the public registry, which is dependency confusion. `import-pi` keeps
|
|
100
|
+
* the operator's whole environment: they run it at their own keyboard.
|
|
101
|
+
*/
|
|
102
|
+
export function serviceNpmEnv(env = process.env) {
|
|
103
|
+
const kept = {};
|
|
104
|
+
for (const [key, value] of Object.entries(env)) {
|
|
105
|
+
if (/^(PATH|HOME|USERPROFILE|APPDATA|LOCALAPPDATA|SYSTEMROOT|TMPDIR|TEMP|TMP|NODE_EXTRA_CA_CERTS|SSL_CERT_FILE|HTTPS?_PROXY|https?_proxy|NO_PROXY|no_proxy|NPM_CONFIG_REGISTRY|npm_config_registry)$/.test(key)) kept[key] = value;
|
|
106
|
+
}
|
|
107
|
+
if (env.PI_UPDATES_REGISTRY) kept.npm_config_registry = env.PI_UPDATES_REGISTRY;
|
|
108
|
+
return kept;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** npm said this exact version does not exist, as opposed to could not be asked. `E404` and `ETARGET` are its only two ways of saying so. */
|
|
112
|
+
const npmSaysNotFound = (detail) => /\b(?:E404|ETARGET)\b/.test(detail);
|
|
113
|
+
/** npm said this package can never install as asked, whenever it is asked. Retrying these would repeat the attempt every couple of minutes for ever. */
|
|
114
|
+
const npmSaysNever = (detail) => /\b(?:EUNSUPPORTEDPROTOCOL|ERESOLVE|EINTEGRITY|EINVALIDPACKAGENAME|EINVALIDTAGNAME|ENOVERSIONS)\b/.test(detail);
|
|
115
|
+
|
|
116
|
+
/** A failed npm call's kind. Anything npm did not name is infrastructure, so that a registry outage is never a verdict on a package. */
|
|
117
|
+
export function npmFailureKind(error) {
|
|
118
|
+
const detail = String(error?.stderr ?? error?.message ?? "");
|
|
119
|
+
return npmSaysNotFound(detail) ? "not-found" : npmSaysNever(detail) ? "policy" : "infra";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Whether a resolved tree holds anything that is not a registry tarball with a hash, as a sentence, else null. THIS IS WHAT MAKES A SERVICE'S INSTALL SAFE, and `--ignore-scripts`
|
|
124
|
+
* is not: npm runs a git dependency's `prepare` to build it, whatever that flag says, as this account and outside any container, and a tarball URL has npm fetch wherever the package
|
|
125
|
+
* said. A `file:` dependency links a path on this host into the tree. So a service resolves first with `--package-lock-only`, which fetches metadata and runs nothing, refuses a tree
|
|
126
|
+
* holding any of these, and only then installs exactly that tree. `.github/scripts/pi-bump.mjs` holds pi's own tree to the same rule.
|
|
127
|
+
*/
|
|
128
|
+
export function lockfileProblem(lock, origins) {
|
|
129
|
+
const packages = lock?.packages;
|
|
130
|
+
if (packages === null || typeof packages !== "object") return "npm resolved no tree";
|
|
131
|
+
for (const [path, entry] of Object.entries(packages)) {
|
|
132
|
+
if (path === "" || entry?.inBundle === true) continue; // the root, and what a checked tarball carries inside itself
|
|
133
|
+
const name = path.split("node_modules/").pop();
|
|
134
|
+
if (entry?.link === true) return `${name} is a link to a path on this host`;
|
|
135
|
+
let origin = null;
|
|
136
|
+
try { origin = new URL(entry?.resolved).origin; } catch { /* a git+ssh URL or none at all has no origin we accept */ }
|
|
137
|
+
if (!origin || !origins.includes(origin)) return `${name} does not come from a configured registry`;
|
|
138
|
+
if (typeof entry?.integrity !== "string" || !/^sha(?:1|256|384|512)-/.test(entry.integrity)) return `${name} has no integrity hash`;
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The origins this account's npm resolves from: its registry and any scoped ones. Read from npm itself, so that an `.npmrc` means what it means to npm. */
|
|
144
|
+
async function registryOrigins({ exec, npmBin, options }) {
|
|
145
|
+
const { stdout } = await exec(npmBin, ["config", "list", "--json"], options);
|
|
146
|
+
const config = JSON.parse(String(stdout));
|
|
147
|
+
const origins = [];
|
|
148
|
+
for (const [key, value] of Object.entries(config)) {
|
|
149
|
+
if (key !== "registry" && !/^@[^:]+:registry$/.test(key)) continue;
|
|
150
|
+
try { origins.push(new URL(value).origin); } catch { /* not a URL: npm could not use it either */ }
|
|
151
|
+
}
|
|
152
|
+
return origins;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Install and ASSERT one package inside its private staging dir, returning the path to assert-clean source.
|
|
157
|
+
* Throws on any failure; the caller decides whether that is fatal (a declared pin) or a drop (a discovered
|
|
158
|
+
* one). Every assertion here is about one property: the staged dir must be SELF-CONTAINED, because at job
|
|
159
|
+
* time it is resolved from a read-only mount with no network and no install step.
|
|
160
|
+
*/
|
|
161
|
+
export async function preparePackage({ fs, exec, out = () => {}, entry, staging, npmBin, platform, warnings = [], timeoutMs, env, resolveFirst = false }) {
|
|
162
|
+
fs.mkdirSync(staging, { recursive: true });
|
|
163
|
+
// A private root package.json pins npm's idea of "the project" to the staging dir, so it cannot
|
|
164
|
+
// walk up and install into (or read config from) the operator's own checkout.
|
|
165
|
+
fs.writeFileSync(join(staging, "package.json"), `${JSON.stringify({ name: "pi-dispatch-staging", private: true, ...(entry.name === "pi-anthropic-oauth" ? { overrides: { "@anthropic-ai/sdk": "0.124.0" } } : {}) })}\n`);
|
|
166
|
+
|
|
167
|
+
// ARRAY argv, never a shell string: the name and version come from a config file and must never be
|
|
168
|
+
// able to become shell syntax on the operator's host. The install target is the exec's `cwd`, NOT a
|
|
169
|
+
// `--prefix <staging>` pair -- npm installs into the cwd's node_modules by default, and dropping the
|
|
170
|
+
// flag removes the only filesystem PATH from argv. What is left is nothing but literal flags and one
|
|
171
|
+
// `name@version` token already validated against NPM_NAME_RE + EXACT_VERSION_RE; that is the property
|
|
172
|
+
// npmExecOptions relies on below.
|
|
173
|
+
//
|
|
174
|
+
// --ignore-scripts is load-bearing: without it the lifecycle scripts of this package AND of every
|
|
175
|
+
// transitive dependency would run as the operator, on the operator's host, at stage time.
|
|
176
|
+
// --omit=peer because pi aliases its own packages for extensions at load time, so a staged peer
|
|
177
|
+
// copy is ignored dead weight -- and a floating pi version at that (CONST-PI-VERSION-PINNED).
|
|
178
|
+
// --install-strategy=nested asks npm to keep every dependency inside the package dir; step 4 below
|
|
179
|
+
// ASSERTS the result rather than trusting the flag, whose name and default have moved across npm
|
|
180
|
+
// versions.
|
|
181
|
+
const flags = ["--omit=dev", "--omit=peer", "--omit=optional", "--ignore-scripts", "--install-strategy=nested", "--no-audit", "--no-fund", "--loglevel=error"];
|
|
182
|
+
const spec = `${entry.name}@${entry.version}`;
|
|
183
|
+
out(` staging ${spec} -> packages/${entry.dir}\n`);
|
|
184
|
+
const options = npmExecOptions(platform, staging, { timeoutMs, env });
|
|
185
|
+
try {
|
|
186
|
+
if (resolveFirst) {
|
|
187
|
+
await exec(npmBin, ["install", spec, "--package-lock-only", ...flags], options);
|
|
188
|
+
const problem = lockfileProblem(JSON.parse(fs.readFileSync(join(staging, "package-lock.json"), "utf8")), await registryOrigins({ exec, npmBin, options }));
|
|
189
|
+
if (problem) throw new PackageStageError("policy", `${spec} cannot be staged by a service: ${problem}. Nothing was installed.`);
|
|
190
|
+
await exec(npmBin, ["ci", ...flags], options);
|
|
191
|
+
} else {
|
|
192
|
+
await exec(npmBin, ["install", spec, ...flags], options);
|
|
193
|
+
}
|
|
194
|
+
} catch (error) {
|
|
195
|
+
if (error instanceof PackageStageError) throw error;
|
|
196
|
+
const detail = String(error?.stderr ?? error?.message ?? "").trim();
|
|
197
|
+
throw new PackageStageError(npmFailureKind(error), `npm install failed for ${spec}: ${detail}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const source = join(staging, "node_modules", entry.name);
|
|
201
|
+
let pkg;
|
|
202
|
+
try {
|
|
203
|
+
pkg = JSON.parse(fs.readFileSync(join(source, "package.json"), "utf8"));
|
|
204
|
+
} catch {
|
|
205
|
+
throw new PackageStageError("policy", `${entry.name}@${entry.version}: npm reported success but there is no readable package.json at ${join(source, "package.json")}`);
|
|
206
|
+
}
|
|
207
|
+
if (pkg.version !== entry.version) {
|
|
208
|
+
throw new PackageStageError("policy", `${entry.name}: npm staged version ${JSON.stringify(pkg.version)}, not the pinned ${JSON.stringify(entry.version)} (CONST-PI-VERSION-PINNED)`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Dependency completeness -- catches hoisting whatever npm's flag defaults do this month. A
|
|
212
|
+
// hoisted dependency would only surface as an import failure inside a job, hours later.
|
|
213
|
+
for (const dep of Object.keys(pkg.dependencies ?? {})) {
|
|
214
|
+
if (!fs.existsSync(join(source, "node_modules", dep))) {
|
|
215
|
+
throw new PackageStageError("policy", `${entry.name}: dependency "${dep}" is not inside the package dir -- npm hoisted it out, so the staged copy could not import it at run time (no network at job time)`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// A package that contributes no pi resources loads as a silent no-op; staging exists to turn that
|
|
220
|
+
// run-time nothing into a stage-time error the operator can act on.
|
|
221
|
+
const manifest = pkg.pi !== null && typeof pkg.pi === "object" ? pkg.pi : null;
|
|
222
|
+
const hasResourceDir = RESOURCE_DIRS.some((name) => fs.existsSync(join(source, name)));
|
|
223
|
+
if (!manifest && !hasResourceDir) {
|
|
224
|
+
throw new PackageStageError("policy", `${entry.name} is not a pi package -- no "pi" manifest in package.json and none of ${RESOURCE_DIRS.join("/")}; it would load as a silent no-op`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Containment: manifest entries are resolved relative to the package dir at job time, so one that
|
|
228
|
+
// climbs out of it would reach the rest of the read-only overlay.
|
|
229
|
+
const escaping = manifest && findEscapingEntry(manifest);
|
|
230
|
+
if (escaping) {
|
|
231
|
+
throw new PackageStageError("policy", `${entry.name}: pi manifest entry ${JSON.stringify(escaping)} leaves the package dir (no ".." segment, no leading "/")`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Warn, do not refuse: --ignore-scripts means a build/postinstall step did NOT run and an optional
|
|
235
|
+
// dependency was NOT fetched, so such a package is staged INCOMPLETE and may fail at run time.
|
|
236
|
+
const scriptKeys = ["install", "preinstall", "postinstall"].filter((key) => typeof pkg.scripts?.[key] === "string");
|
|
237
|
+
const hasOptional = Object.keys(pkg.optionalDependencies ?? {}).length > 0;
|
|
238
|
+
if (scriptKeys.length > 0 || hasOptional) {
|
|
239
|
+
const declares = [...scriptKeys.map((key) => `scripts.${key}`), ...(hasOptional ? ["optionalDependencies"] : [])].join(", ");
|
|
240
|
+
warnings.push({ dir: entry.dir, reason: `${entry.name} declares ${declares} -- staged with --ignore-scripts, so it is INCOMPLETE and may fail at run time` });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (["pi-anthropic-oauth@0.3.6", "@gotgenes/pi-anthropic-auth@3.4.3"].includes(`${entry.name}@${entry.version}`)) {
|
|
244
|
+
// Read-only host-pi consumers, including the scratch canary, have no lifecycle dependencies installed.
|
|
245
|
+
const { prepareCommunityAuthAdapter } = await import("./community-auth-patches.mjs");
|
|
246
|
+
const receipt = prepareCommunityAuthAdapter({ adapter: `${entry.name}@${entry.version}`, root: source, fs });
|
|
247
|
+
out(` reviewed subscription adapter ${receipt.adapter}: ${receipt.digest}\n`);
|
|
248
|
+
}
|
|
249
|
+
return source;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** A staging directory's unique name. A fixed `.staging-<i>` would delete a peer's work on a shared home. */
|
|
253
|
+
export const newStagingDir = (packagesRoot, unique = randomBytes(6).toString("hex")) => join(packagesRoot, `${STAGING_PREFIX}${unique}`);
|
|
254
|
+
|
|
255
|
+
/** The most files a digest reads. A tree past it has no digest, which the page reads as nothing to compare: no third party's package may hold the worker's loop by size. */
|
|
256
|
+
const DIGEST_FILES_MAX = 200_000;
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* A digest of a resolved tree, so that the page can say when the canaried tree and the adopted tree differ: a package has no lockfile,
|
|
260
|
+
* so its transitive dependencies float. A symlink is hashed by its target and never followed (node_modules/.bin holds them). It gives way every few hundred files, because a canary's
|
|
261
|
+
* install runs in the worker that renews paid job locks (`doctor-spawner.mjs` on why a blocked loop fails them). `null` when the tree is past its bound.
|
|
262
|
+
*/
|
|
263
|
+
export async function treeDigest(root, { fs }) {
|
|
264
|
+
const lines = [];
|
|
265
|
+
let seen = 0;
|
|
266
|
+
const walk = async (path) => {
|
|
267
|
+
if (++seen > DIGEST_FILES_MAX) throw new RangeError("tree too large");
|
|
268
|
+
if (seen % 256 === 0) await new Promise((resolve) => setImmediate(resolve));
|
|
269
|
+
const stat = fs.lstatSync(path);
|
|
270
|
+
const rel = relative(root, path).split("\\").join("/");
|
|
271
|
+
if (stat.isSymbolicLink()) lines.push(`${rel}\0link\0${fs.readlinkSync(path)}\0`);
|
|
272
|
+
else if (stat.isDirectory()) for (const name of fs.readdirSync(path)) await walk(join(path, name));
|
|
273
|
+
else if (stat.isFile()) lines.push(`${rel}\0file\0${createHash("sha256").update(fs.readFileSync(path)).digest("hex")}\0`);
|
|
274
|
+
};
|
|
275
|
+
try {
|
|
276
|
+
await walk(root);
|
|
277
|
+
} catch (error) {
|
|
278
|
+
if (error instanceof RangeError) return null;
|
|
279
|
+
throw error;
|
|
280
|
+
}
|
|
281
|
+
return createHash("sha256").update(lines.sort().join("")).digest("hex");
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Install one package and move it to a new directory of its own, leaving what is staged alone. Nothing refers to the result until a caller
|
|
286
|
+
* writes the manifest. Returns `{ dir, warnings }`, and removes its staging directory whichever way it ends.
|
|
287
|
+
*/
|
|
288
|
+
export async function stagePackageTree({ fs, exec, out, entry, packagesRoot, npmBin, platform, timeoutMs, env, unique }) {
|
|
289
|
+
const staging = newStagingDir(packagesRoot);
|
|
290
|
+
const warnings = [];
|
|
291
|
+
try {
|
|
292
|
+
const source = await preparePackage({ fs, exec, out, entry, staging, npmBin, platform, warnings, timeoutMs, env, resolveFirst: true });
|
|
293
|
+
const dir = stagedTreeName(entry.dir ?? stagedDirName(entry.name), entry.version, unique);
|
|
294
|
+
// renameSync, never copyTree: copyTree's symlink guard uses statSync, which FOLLOWS links, so it
|
|
295
|
+
// would copy the target of every node_modules/.bin symlink instead of skipping it.
|
|
296
|
+
fs.renameSync(source, join(packagesRoot, dir));
|
|
297
|
+
return { dir, warnings };
|
|
298
|
+
} finally {
|
|
299
|
+
// Off the loop where the fs offers it: a staging tree can hold every file of a package's dependencies.
|
|
300
|
+
await (fs.promises?.rm ? fs.promises.rm(staging, { recursive: true, force: true }) : fs.rmSync(staging, { recursive: true, force: true }));
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Write the manifest, the commit point. Temp and rename, on the same filesystem: a reader sees the old file or the new one, never half. */
|
|
305
|
+
export function writeStageManifest({ fs, packagesRoot, manifest, unique = randomBytes(6).toString("hex") }) {
|
|
306
|
+
const path = join(packagesRoot, STAGE_MANIFEST);
|
|
307
|
+
const temp = `${path}.${unique}.tmp`;
|
|
308
|
+
fs.writeFileSync(temp, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
309
|
+
try {
|
|
310
|
+
fs.renameSync(temp, path);
|
|
311
|
+
} catch (error) {
|
|
312
|
+
fs.rmSync(temp, { force: true });
|
|
313
|
+
throw error;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Note the trees the manifest stopped naming, and remove those noted longer ago than the delay. The delay is measured from when a tree left
|
|
319
|
+
* the manifest, not from when it was installed, and only under the stage lock. A tree that is named again (Undo) is simply no longer retired.
|
|
320
|
+
* Failures are ignored by design: a tree kept too long costs disk, and the manifest has already committed.
|
|
321
|
+
*/
|
|
322
|
+
export function pruneStagedTrees({ fs, packagesRoot, manifest, now = Date.now, keepMs = STAGED_TREE_KEEP_MS }) {
|
|
323
|
+
const named = new Set((manifest?.packages ?? []).map((p) => p.dir));
|
|
324
|
+
const path = join(packagesRoot, RETIRED_FILE);
|
|
325
|
+
let retired = {};
|
|
326
|
+
try {
|
|
327
|
+
const parsed = JSON.parse(fs.readFileSync(path, "utf8"));
|
|
328
|
+
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) retired = parsed;
|
|
329
|
+
} catch { /* none yet, or unreadable: every tree starts its delay again, which only keeps it longer */ }
|
|
330
|
+
const kept = {};
|
|
331
|
+
const at = now();
|
|
332
|
+
let names = [];
|
|
333
|
+
try { names = fs.readdirSync(packagesRoot); } catch { return; }
|
|
334
|
+
for (const name of names) {
|
|
335
|
+
if (name === STAGE_MANIFEST || name === RETIRED_FILE || name === LOCK_DIR || name === UNDO_DIR || name.endsWith(".tmp") || named.has(name)) continue;
|
|
336
|
+
// A staging directory carries no version and no record: it is a peer's until it is older than any stage could be.
|
|
337
|
+
const since = name.startsWith(STAGING_PREFIX) ? modifiedAt(fs, join(packagesRoot, name)) ?? at : Number.isFinite(retired[name]) ? retired[name] : at;
|
|
338
|
+
const limit = name.startsWith(STAGING_PREFIX) ? STAGE_LOCK_STALE_MS : keepMs;
|
|
339
|
+
if (at - since >= limit) {
|
|
340
|
+
try { fs.rmSync(join(packagesRoot, name), { recursive: true, force: true }); continue; } catch { /* kept for the next stage */ }
|
|
341
|
+
}
|
|
342
|
+
if (!name.startsWith(STAGING_PREFIX)) kept[name] = since;
|
|
343
|
+
}
|
|
344
|
+
try { writeRetired(fs, path, kept); } catch { /* the delay restarts next time */ }
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function modifiedAt(fs, path) {
|
|
348
|
+
try { return fs.statSync(path).mtimeMs; } catch { return null; }
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function writeRetired(fs, path, value) {
|
|
352
|
+
const temp = `${path}.${randomBytes(6).toString("hex")}.tmp`;
|
|
353
|
+
fs.writeFileSync(temp, `${JSON.stringify(value)}\n`);
|
|
354
|
+
fs.renameSync(temp, path);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* One stage at a time on one overlay, also across hosts on a shared home (`docs/multi-host.md`). `mkdir` is atomic on NFS too, which `open(O_EXCL)` was not on older clients. The holder
|
|
359
|
+
* renews its time while it works, so a slow install is not taken for a crash, and everything it does to the lock it does under its own token: release removes only its own lock, and
|
|
360
|
+
* `stillHeld` lets it stop before the commit if it slept past the limit and a peer took over. Taking a stale lock is by rename, so that two hosts cannot both remove it, and the rename
|
|
361
|
+
* is checked afterwards, since a peer may have taken the lock between the read that judged it stale and the rename: that lock is put back.
|
|
362
|
+
* Returns the callback's result; throws `PackageStageError("infra")` when a live peer holds it, since trying again later can succeed.
|
|
363
|
+
*/
|
|
364
|
+
export async function withStageLock({ fs, packagesRoot, now = Date.now, staleMs = STAGE_LOCK_STALE_MS, renewMs = staleMs / 3, holder = "" }, callback) {
|
|
365
|
+
const lock = join(packagesRoot, LOCK_DIR);
|
|
366
|
+
const holderFile = (dir) => join(dir, "holder.json");
|
|
367
|
+
const token = randomBytes(8).toString("hex");
|
|
368
|
+
const stamp = () => {
|
|
369
|
+
const temp = `${holderFile(lock)}.${token}.tmp`;
|
|
370
|
+
fs.writeFileSync(temp, `${JSON.stringify({ at: now(), holder, token })}\n`);
|
|
371
|
+
fs.renameSync(temp, holderFile(lock));
|
|
372
|
+
};
|
|
373
|
+
const readHolder = (dir) => {
|
|
374
|
+
try { return JSON.parse(fs.readFileSync(holderFile(dir), "utf8")); } catch { return null; }
|
|
375
|
+
};
|
|
376
|
+
const acquire = () => {
|
|
377
|
+
fs.mkdirSync(lock);
|
|
378
|
+
try { stamp(); } catch { /* an empty lock still holds, until its age says otherwise */ }
|
|
379
|
+
};
|
|
380
|
+
try {
|
|
381
|
+
acquire();
|
|
382
|
+
} catch (error) {
|
|
383
|
+
if (error?.code !== "EEXIST") throw error;
|
|
384
|
+
const seen = readHolder(lock);
|
|
385
|
+
const at = seen?.at ?? modifiedAt(fs, lock);
|
|
386
|
+
if (!Number.isFinite(at) || now() - at < staleMs) throw new PackageStageError("infra", `another stage holds ${LOCK_DIR} on this overlay`);
|
|
387
|
+
const stale = `${lock}.${randomBytes(6).toString("hex")}.tmp`;
|
|
388
|
+
try {
|
|
389
|
+
fs.renameSync(lock, stale);
|
|
390
|
+
} catch {
|
|
391
|
+
throw new PackageStageError("infra", `another stage took ${LOCK_DIR} on this overlay`);
|
|
392
|
+
}
|
|
393
|
+
if (readHolder(stale)?.token !== seen?.token) {
|
|
394
|
+
try { fs.renameSync(stale, lock); } catch { /* its holder's release finds nothing, and its commit check says so */ }
|
|
395
|
+
throw new PackageStageError("infra", `another stage took ${LOCK_DIR} on this overlay`);
|
|
396
|
+
}
|
|
397
|
+
fs.rmSync(stale, { recursive: true, force: true });
|
|
398
|
+
try {
|
|
399
|
+
acquire();
|
|
400
|
+
} catch {
|
|
401
|
+
throw new PackageStageError("infra", `another stage took ${LOCK_DIR} on this overlay`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
const stillHeld = () => readHolder(lock)?.token === token;
|
|
405
|
+
const renewing = setInterval(() => {
|
|
406
|
+
try { if (stillHeld()) stamp(); } catch { /* the next tick, or its age says so */ }
|
|
407
|
+
}, renewMs);
|
|
408
|
+
renewing.unref?.();
|
|
409
|
+
try {
|
|
410
|
+
return await callback({ stillHeld });
|
|
411
|
+
} finally {
|
|
412
|
+
clearInterval(renewing);
|
|
413
|
+
if (stillHeld()) fs.rmSync(lock, { recursive: true, force: true });
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Where a deployment's overlay keeps its staged packages. */
|
|
418
|
+
export const packagesRootOf = (globalPiDir) => join(globalPiDir, PACKAGES_SUBDIR);
|