@edgehero/pi-dispatch 4.3.1 → 5.1.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.
Files changed (260) hide show
  1. package/.env.example +84 -3
  2. package/README.md +21 -7
  3. package/deploy/console.service +52 -0
  4. package/deploy/docker-compose.yml +3 -1
  5. package/deploy/pi-dispatch-valkey.container +4 -2
  6. package/deploy/sandbox-gateway.service +23 -0
  7. package/deploy/worker-env-wrapper.cmd +6 -0
  8. package/deploy/worker-env-wrapper.sh +10 -1
  9. package/package.json +118 -19
  10. package/src/agent-snapshot.mjs +49 -0
  11. package/src/answerer-runtime.mjs +194 -0
  12. package/src/answerer.mjs +178 -0
  13. package/src/approval-policy.mjs +76 -0
  14. package/src/approval-runtime.mjs +188 -0
  15. package/src/approval-session.mjs +137 -0
  16. package/src/approval-state.mjs +170 -0
  17. package/src/auth-cli.mjs +86 -0
  18. package/src/auth-connections.mjs +145 -0
  19. package/src/auth-policy.mjs +32 -0
  20. package/src/auth-store.mjs +97 -0
  21. package/src/azure-host.mjs +66 -4
  22. package/src/azure-prompt.mjs +5 -4
  23. package/src/backend-conformance.mjs +34 -2
  24. package/src/backend-local.mjs +103 -34
  25. package/src/backend-openshell.mjs +1540 -0
  26. package/src/backend-podman.mjs +63 -2
  27. package/src/backend-registry.mjs +61 -4
  28. package/src/backends.mjs +395 -6
  29. package/src/bullmq-internals.mjs +208 -0
  30. package/src/canary-cli.mjs +28 -0
  31. package/src/canary-lock.mjs +41 -0
  32. package/src/canary-runtime.mjs +237 -0
  33. package/src/canary.mjs +53 -0
  34. package/src/cancel-cli.mjs +47 -34
  35. package/src/cancel-state.mjs +213 -15
  36. package/src/capabilities.mjs +36 -1
  37. package/src/capacity-cli.mjs +28 -8
  38. package/src/capacity-records.mjs +75 -16
  39. package/src/capacity.mjs +65 -0
  40. package/src/chat-cli.mjs +288 -0
  41. package/src/chat-fork.mjs +547 -0
  42. package/src/chat-mode.mjs +156 -0
  43. package/src/chat-view.mjs +720 -0
  44. package/src/checkpoint-paths.mjs +113 -0
  45. package/src/cli.mjs +136 -41
  46. package/src/comment-tables.mjs +322 -0
  47. package/src/community-auth-adapters.mjs +87 -0
  48. package/src/community-auth-helper.mjs +76 -0
  49. package/src/community-auth-metadata.mjs +6 -0
  50. package/src/community-auth-output-pins.mjs +38 -0
  51. package/src/community-auth-patches.mjs +168 -0
  52. package/src/community-auth-pins.mjs +38 -0
  53. package/src/config.mjs +190 -29
  54. package/src/connection.mjs +83 -7
  55. package/src/console-answer.mjs +113 -0
  56. package/src/console-audit.mjs +604 -0
  57. package/src/console-bootstrap.mjs +325 -0
  58. package/src/console-client.mjs +848 -0
  59. package/src/console-devices.mjs +372 -0
  60. package/src/console-install.mjs +239 -0
  61. package/src/console-open.mjs +99 -0
  62. package/src/console-probe.mjs +106 -0
  63. package/src/console-tokens.mjs +459 -0
  64. package/src/container-names.mjs +51 -0
  65. package/src/container-spec.mjs +20 -1
  66. package/src/cpu-reserve.mjs +4 -1
  67. package/src/defer-reasons.mjs +200 -0
  68. package/src/defer-write.mjs +208 -0
  69. package/src/deployment-venue.mjs +23 -6
  70. package/src/docker-run.mjs +11 -0
  71. package/src/doctor-bundles.mjs +130 -0
  72. package/src/doctor-model-proposals.mjs +109 -0
  73. package/src/doctor-openshell.mjs +238 -0
  74. package/src/doctor-results.mjs +602 -0
  75. package/src/doctor-spawner.mjs +215 -0
  76. package/src/doctor.mjs +1657 -196
  77. package/src/dollar-budget.mjs +212 -3
  78. package/src/endpoint-status.mjs +100 -0
  79. package/src/env-allowlist.mjs +96 -6
  80. package/src/env-file.mjs +6 -4
  81. package/src/envelope.mjs +5 -3
  82. package/src/file-expect.mjs +47 -0
  83. package/src/forgejo-host.mjs +42 -5
  84. package/src/forgejo-prompt.mjs +5 -4
  85. package/src/forges.mjs +33 -3
  86. package/src/fork-job.mjs +58 -0
  87. package/src/fork-policy.mjs +23 -0
  88. package/src/fork-store.mjs +338 -0
  89. package/src/get-token.mjs +1 -1
  90. package/src/git-dirty.mjs +119 -14
  91. package/src/git-hardening.mjs +85 -0
  92. package/src/github-host.mjs +31 -6
  93. package/src/github-prompt.mjs +7 -16
  94. package/src/gitlab-host.mjs +42 -3
  95. package/src/gitlab-prompt.mjs +5 -4
  96. package/src/held-document.mjs +23 -0
  97. package/src/held-replies.mjs +325 -0
  98. package/src/held-settle.mjs +266 -0
  99. package/src/hook-runs.mjs +188 -0
  100. package/src/host-budget.mjs +8 -5
  101. package/src/host-clone.mjs +72 -0
  102. package/src/host-loads.mjs +557 -0
  103. package/src/host-registry.mjs +66 -6
  104. package/src/host-telemetry.mjs +92 -0
  105. package/src/http-body.mjs +78 -0
  106. package/src/image-preflight.mjs +156 -13
  107. package/src/import-pi.mjs +60 -172
  108. package/src/inbox-relay.mjs +482 -0
  109. package/src/inbox-settle.mjs +196 -0
  110. package/src/index.mjs +734 -129
  111. package/src/init.mjs +35 -5
  112. package/src/job-data-version.mjs +75 -0
  113. package/src/job-id.mjs +30 -15
  114. package/src/job-layout.mjs +22 -0
  115. package/src/job-size.mjs +16 -0
  116. package/src/live-jobs.mjs +14 -6
  117. package/src/live-probes.mjs +6 -2
  118. package/src/live-transcript.mjs +872 -0
  119. package/src/loaded-record.mjs +609 -0
  120. package/src/mcp-config.mjs +4 -2
  121. package/src/mcp-preflight.mjs +36 -5
  122. package/src/model-attempts.mjs +84 -0
  123. package/src/model-catalog.mjs +83 -0
  124. package/src/model-cooldowns.mjs +22 -0
  125. package/src/model-failover-protocol.mjs +11 -0
  126. package/src/model-failover.mjs +134 -0
  127. package/src/model-observation.mjs +50 -0
  128. package/src/model-proposal-protocol.mjs +21 -0
  129. package/src/model-proposals.mjs +75 -0
  130. package/src/model-ref.mjs +67 -10
  131. package/src/model-roles-file.mjs +24 -0
  132. package/src/model-roles.mjs +73 -0
  133. package/src/model-status.mjs +34 -0
  134. package/src/models-json.mjs +12 -2
  135. package/src/money.mjs +1 -1
  136. package/src/oauth-openai.mjs +121 -0
  137. package/src/older-state.mjs +249 -0
  138. package/src/on-failure.mjs +17 -13
  139. package/src/open-browser.mjs +31 -2
  140. package/src/opener-cli.mjs +135 -0
  141. package/src/opener-protocol.mjs +27 -0
  142. package/src/opener-terminal.mjs +128 -0
  143. package/src/openshell-cli.mjs +155 -0
  144. package/src/openshell-policy.mjs +163 -0
  145. package/src/outbox.mjs +14 -0
  146. package/src/output-cap.mjs +1 -0
  147. package/src/package-canary-record.mjs +26 -0
  148. package/src/package-canary.mjs +135 -0
  149. package/src/package-names.mjs +9 -0
  150. package/src/package-stage.mjs +418 -0
  151. package/src/packages.mjs +24 -16
  152. package/src/parked.mjs +2 -1
  153. package/src/pause-windows.mjs +136 -17
  154. package/src/podman-stack.mjs +40 -14
  155. package/src/portfolio-snapshot.mjs +26 -7
  156. package/src/prepare-github.mjs +41 -77
  157. package/src/prepare-local.mjs +32 -2
  158. package/src/prepare.mjs +45 -11
  159. package/src/private-store-boundary.mjs +70 -0
  160. package/src/processor.mjs +1550 -173
  161. package/src/projects.mjs +23 -0
  162. package/src/prompt-fence.mjs +18 -0
  163. package/src/provider-key.mjs +7 -0
  164. package/src/provider-steering.mjs +1 -0
  165. package/src/question-context.mjs +35 -0
  166. package/src/question-group-runtime.mjs +91 -0
  167. package/src/question-groups.mjs +171 -0
  168. package/src/queue.mjs +264 -55
  169. package/src/reason-catalog.mjs +448 -0
  170. package/src/receiver-status.mjs +230 -0
  171. package/src/record-reason.mjs +37 -0
  172. package/src/recovery-queue.mjs +566 -0
  173. package/src/redact-secrets.mjs +219 -0
  174. package/src/reserve-estimate.mjs +101 -0
  175. package/src/reserved-env.mjs +36 -1
  176. package/src/retention.mjs +67 -0
  177. package/src/run-budget-ledger.mjs +283 -0
  178. package/src/run-checkpoint.mjs +627 -0
  179. package/src/run-container.mjs +749 -33
  180. package/src/run-history.mjs +791 -45
  181. package/src/run-mirror.mjs +75 -7
  182. package/src/run-progress.mjs +751 -0
  183. package/src/runtime-observations.mjs +3 -1
  184. package/src/runtime-settings.mjs +272 -37
  185. package/src/sandbox-cli.mjs +78 -2
  186. package/src/sandbox-deployment.mjs +51 -0
  187. package/src/sandbox-gateway-protocol.mjs +378 -0
  188. package/src/sandbox-hosts.mjs +100 -0
  189. package/src/sandbox-inventory.mjs +83 -0
  190. package/src/sandbox-policy.mjs +225 -0
  191. package/src/sandbox-prepare-requests.mjs +145 -0
  192. package/src/sandbox-prepare.mjs +285 -0
  193. package/src/sandbox-refs.mjs +70 -0
  194. package/src/sandbox-session-monitor.mjs +4 -0
  195. package/src/sandbox-session-watch.mjs +124 -0
  196. package/src/sandbox-sessions.mjs +161 -0
  197. package/src/sandbox-store.mjs +63 -6
  198. package/src/sandbox-workspace-guard.mjs +39 -0
  199. package/src/sandbox.mjs +27 -200
  200. package/src/scheduler-stall-guard.mjs +14 -8
  201. package/src/schedules.mjs +19 -6
  202. package/src/secret-bundles.mjs +245 -0
  203. package/src/secret-profiles.mjs +103 -3
  204. package/src/secrets.mjs +19 -9
  205. package/src/service-env.mjs +38 -5
  206. package/src/service.mjs +638 -106
  207. package/src/session-key.mjs +60 -0
  208. package/src/session-store.mjs +168 -10
  209. package/src/setup-contract.mjs +40 -0
  210. package/src/setup-egress.mjs +113 -0
  211. package/src/setup-host-entry.mjs +36 -0
  212. package/src/setup-host.mjs +242 -0
  213. package/src/setup-lock.mjs +41 -0
  214. package/src/size-records.mjs +15 -3
  215. package/src/start.mjs +1353 -135
  216. package/src/status-comment.mjs +415 -0
  217. package/src/stderr-transcript.mjs +318 -0
  218. package/src/steer-ack.mjs +122 -0
  219. package/src/steer-cli.mjs +213 -0
  220. package/src/steer-framing.mjs +185 -0
  221. package/src/steer-inbox.mjs +693 -0
  222. package/src/store-version.mjs +139 -0
  223. package/src/subscription-config.mjs +56 -0
  224. package/src/subscription-limits.mjs +3 -0
  225. package/src/subscription-quota.mjs +259 -0
  226. package/src/subscription-runtime.mjs +123 -0
  227. package/src/subscription-status.mjs +42 -0
  228. package/src/subscription-wait.mjs +11 -0
  229. package/src/subscriptions.mjs +7 -1
  230. package/src/thinking-level.mjs +19 -0
  231. package/src/touch-outbox.mjs +61 -0
  232. package/src/touch-state.mjs +133 -0
  233. package/src/triggers-file.mjs +299 -39
  234. package/src/triggers.mjs +780 -38
  235. package/src/trust-paths.mjs +171 -0
  236. package/src/up.mjs +402 -53
  237. package/src/updates-cli.mjs +61 -0
  238. package/src/updates-look.mjs +127 -0
  239. package/src/updates-reconcile.mjs +182 -0
  240. package/src/updates-subscription-adapters.mjs +8 -0
  241. package/src/updates.mjs +252 -0
  242. package/src/valkey-auth.mjs +32 -9
  243. package/src/valkey-endpoint.mjs +14 -4
  244. package/src/wait-for.mjs +1 -1
  245. package/src/wait-state.mjs +62 -3
  246. package/src/watch-board.mjs +38 -0
  247. package/src/watch-closer.mjs +1 -1
  248. package/src/watch-contract.mjs +102 -0
  249. package/src/watch-decision.mjs +78 -0
  250. package/src/watch-drainer.mjs +14 -0
  251. package/src/watch-issue-adapter.mjs +44 -0
  252. package/src/watch-issue.mjs +111 -0
  253. package/src/watch-record.mjs +10 -0
  254. package/src/watch-runtime.mjs +167 -0
  255. package/src/watch-store.mjs +183 -0
  256. package/src/watch-trace.mjs +34 -0
  257. package/src/workspace-checkpoint.mjs +1750 -0
  258. package/src/zero-touch-fixes.mjs +99 -0
  259. package/src/zero-touch.mjs +225 -0
  260. package/src/repeat-slot.mjs +0 -13
@@ -0,0 +1,61 @@
1
+ /**
2
+ * `pi-dispatch updates look|reconcile`: what the worker runs as a child (issue #796). A person may run them too, to see them work on a host, and they do the same thing.
3
+ * Exit 0 ran, 1 could not reach what it needed, 2 was asked for something else. Names only on stdout.
4
+ */
5
+ import { execFile } from "node:child_process";
6
+ import { promisify } from "node:util";
7
+ import { piVersionOf, resolvePiBinary } from "./chat-fork.mjs";
8
+ import { piAiVersion } from "./pricing.mjs";
9
+ import { workerName } from "./config.mjs";
10
+ import { boundedJson, buildLook, hostPiFacts } from "./updates-look.mjs";
11
+ import { reconcileUpdates } from "./updates-reconcile.mjs";
12
+ import { publishLook } from "./updates.mjs";
13
+
14
+ const defaultExec = promisify(execFile);
15
+
16
+ /** Doctor's own one-shot client: connected before the first command, bounded, and always dropped. */
17
+ async function defaultWithClient(url, fn) {
18
+ const { withDoctorClient } = await import("./doctor.mjs");
19
+ return await withDoctorClient(url, fn);
20
+ }
21
+
22
+ export async function runUpdatesCli(argv, { env = process.env, cwd = process.cwd(), out = (s) => process.stdout.write(s), err = (s) => process.stderr.write(s), withClient = defaultWithClient, exec = defaultExec, look = null, fetchJson = boundedJson } = {}) {
23
+ const [what, ...rest] = argv;
24
+ if (!["look", "reconcile"].includes(what) || rest.length > 0) {
25
+ err("usage: pi-dispatch updates look|reconcile\n");
26
+ return 2;
27
+ }
28
+ if (!env.VALKEY_URL) {
29
+ err("pi-dispatch updates: VALKEY_URL is not set\n");
30
+ return 1;
31
+ }
32
+ if (what === "look" && env.PI_UPDATES_LOOK !== "1") {
33
+ err("pi-dispatch updates look: the look is off until PI_UPDATES_LOOK=1\n");
34
+ return 2;
35
+ }
36
+ const host = workerName(env);
37
+ try {
38
+ return await withClient(env.VALKEY_URL, async (redis) => {
39
+ if (what === "look") {
40
+ const proxyUrl = env.PI_UPDATES_PROXY || null;
41
+ const tested = piAiVersion();
42
+ const record = await (look ?? buildLook)({
43
+ env, host, pinned: tested, get: (url) => fetchJson(url, { proxyUrl }),
44
+ hostPi: async () => hostPiFacts({ tested, resolvePi: () => resolvePiBinary({ env }), versionOf: (path) => piVersionOf(path, { env }) }),
45
+ });
46
+ await publishLook(redis, record);
47
+ out(`updates look: published for ${host}\n`);
48
+ return 0;
49
+ }
50
+ const done = await reconcileUpdates({ redis, host, env, cwd, exec, log: (event, fields) => out(`${JSON.stringify({ event, ...fields })}\n`) });
51
+ if (done.unreachable) {
52
+ err("pi-dispatch updates reconcile: Valkey did not answer\n");
53
+ return 1;
54
+ }
55
+ return 0;
56
+ });
57
+ } catch (error) {
58
+ err(`pi-dispatch updates ${what}: ${error?.code ?? "failed"}\n`);
59
+ return 1;
60
+ }
61
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The daily look (issue #796, `REQ-PACKAGE-UPDATES`): which pi and which staged extensions are newer, and whether this repository's checks on the
3
+ * newest pi hold. Free, and it changes nothing. It runs in a child the worker starts (`doctor-spawner.mjs`'s reason: it must stay off the loop that
4
+ * renews paid job locks), and never in a job. Off until `PI_UPDATES_LOOK=1`.
5
+ *
6
+ * Bounded as doctor's model proposals are: five seconds, 1 MiB, no redirects, and through `PI_UPDATES_PROXY` when one is declared, with no direct
7
+ * fallback. A failed read is **unknown, never passing**.
8
+ */
9
+ import * as nodeFs from "node:fs";
10
+ import { dirname, join } from "node:path";
11
+ import { ProxyAgent, fetch as undiciFetch } from "undici";
12
+ import { readStageManifest } from "./packages.mjs";
13
+ import { PINNED_PI_NEEDLES } from "./host-pi.mjs";
14
+ import { compareVersions, parseLookRecord, UPDATES_LOOK_EVERY_HOURS } from "./updates.mjs";
15
+
16
+ /** Public repository, public API, no token: this reads what anyone can read. */
17
+ export const BUMP_CHECKS_REPO = "edgehero/pi-dispatch";
18
+ export const BUMP_BRANCH = "chore/pi-bump";
19
+ export const PI_PACKAGE = "@earendil-works/pi-coding-agent";
20
+ const DEFAULT_REGISTRY = "https://registry.npmjs.org";
21
+ const MAX_BYTES = 1024 * 1024;
22
+
23
+ /**
24
+ * The members of pi's extension API the admin extension needs, by the names `USED_API` declares in `admin/src/index.ts`. A dependency points from admin
25
+ * to the worker, so this is a copy, bolted to that list by `admin/test/updates-view.test.mjs`. A look starts pi only to ask its version and loads no extension, so
26
+ * it can only say whether the host's pi still defines them: the real probe is the extension's own at load.
27
+ */
28
+ export const ADMIN_USED_API = Object.freeze(["registerCommand", "registerTool", "sendMessage", "on"]);
29
+
30
+ /** Refusals here are thrown and turn into `unknown` at the caller: nothing below this line decides a state. */
31
+ export async function boundedJson(url, { proxyUrl = null, request = undiciFetch, timeoutMs = 5000, maxBytes = MAX_BYTES, headers = {}, ProxyAgentClass = ProxyAgent } = {}) {
32
+ const dispatcher = proxyUrl ? new ProxyAgentClass(proxyUrl) : undefined;
33
+ try {
34
+ const response = await request(url, { ...(dispatcher ? { dispatcher } : {}), redirect: "error", signal: AbortSignal.timeout(timeoutMs), headers: { accept: "application/json", "user-agent": "pi-dispatch-updates-look", ...headers } });
35
+ if (!response.ok) return { status: response.status, value: null };
36
+ const parts = [];
37
+ let length = 0;
38
+ for await (const part of response.body) {
39
+ length += part.length;
40
+ if (length > maxBytes) throw new Error("response too large");
41
+ parts.push(part);
42
+ }
43
+ return { status: response.status, value: JSON.parse(Buffer.concat(parts).toString("utf8")) };
44
+ } finally {
45
+ await dispatcher?.close();
46
+ }
47
+ }
48
+
49
+ /** The `latest` dist-tag only, so a pre-release is never offered. Reading the version document keeps it small and downloads no tarball. */
50
+ export async function newestVersion(pkg, { get, registry = DEFAULT_REGISTRY }) {
51
+ const { value } = await get(`${registry}/${pkg.startsWith("@") ? pkg.replace("/", "%2f") : pkg}/latest`);
52
+ return typeof value?.version === "string" ? value.version : null;
53
+ }
54
+
55
+ /**
56
+ * The checks on the bump branch, as long as that branch is about this version. A branch still on an older bump would otherwise report that older pi's
57
+ * checks as this one's, which is how a passing word would come to mean nothing.
58
+ */
59
+ export async function bumpChecks(version, { get, api = "https://api.github.com" }) {
60
+ const ref = encodeURIComponent(BUMP_BRANCH);
61
+ const commit = await get(`${api}/repos/${BUMP_CHECKS_REPO}/commits/${ref}`);
62
+ const subject = String(commit.value?.commit?.message ?? "").split("\n")[0];
63
+ if (subject !== `chore(pi): run on pi ${version}`) return "unknown";
64
+ const { value } = await get(`${api}/repos/${BUMP_CHECKS_REPO}/commits/${ref}/check-runs?per_page=100`);
65
+ const runs = value?.check_runs;
66
+ // Fewer listed than exist means an unread run could be failing, which is unknown rather than a verdict on the ones read.
67
+ if (!Array.isArray(runs) || runs.length === 0 || value.total_count !== runs.length) return "unknown";
68
+ if (runs.some((run) => ["failure", "timed_out", "cancelled", "action_required", "startup_failure", "stale"].includes(run?.conclusion))) return "failing";
69
+ if (runs.some((run) => run?.status !== "completed")) return "pending";
70
+ return runs.every((run) => ["success", "neutral", "skipped"].includes(run?.conclusion)) ? "passing" : "unknown";
71
+ }
72
+
73
+ /**
74
+ * This host's own pi against the version pi-dispatch is tested with, and whether what it mirrors still holds there. `resolvePi` and `versionOf` are
75
+ * `chat-fork.mjs`'s, the pi this account would run. `needles` are `PINNED_PI_NEEDLES`; `absent` means there is simply no pi here, which is a normal host.
76
+ */
77
+ export function hostPiFacts({ tested, resolvePi, versionOf, fs = nodeFs, needles = PINNED_PI_NEEDLES, api = ADMIN_USED_API }) {
78
+ const found = resolvePi();
79
+ if (!found?.path) return { version: null, tested, state: "absent", needles: "unknown", extension: "unknown" };
80
+ const version = versionOf(found.path);
81
+ const state = !version || !tested ? "unknown" : compareVersions(version, tested) === 0 ? "same" : compareVersions(version, tested) > 0 ? "newer" : "older";
82
+ const root = piPackageRoot(found.path, fs);
83
+ if (!root) return { version, tested, state, needles: "unknown", extension: "unknown" };
84
+ let needlesHold = "ok", extension = "ok";
85
+ try {
86
+ for (const [file, list] of Object.entries(needles)) {
87
+ const text = fs.readFileSync(join(root, file), "utf8");
88
+ if (!list.every((needle) => text.includes(needle))) needlesHold = "drift";
89
+ }
90
+ } catch { needlesHold = "drift"; }
91
+ try {
92
+ const loader = fs.readFileSync(join(root, "dist/core/extensions/loader.js"), "utf8");
93
+ if (!api.every((member) => loader.includes(`${member}(`))) extension = "drift";
94
+ } catch { extension = "drift"; }
95
+ return { version, tested, state, needles: needlesHold, extension };
96
+ }
97
+
98
+ /** The pi package a `pi` command belongs to, through its symlink. `null` for a wrapper that lives elsewhere: unknown, not a failure. */
99
+ function piPackageRoot(piPath, fs) {
100
+ try {
101
+ let dir = dirname(fs.realpathSync(piPath));
102
+ for (let i = 0; i < 6; i += 1) {
103
+ const pkg = join(dir, "package.json");
104
+ if (fs.existsSync(pkg) && JSON.parse(fs.readFileSync(pkg, "utf8"))?.name === PI_PACKAGE) return dir;
105
+ dir = dirname(dir);
106
+ }
107
+ } catch { /* unreadable: unknown below */ }
108
+ return null;
109
+ }
110
+
111
+ /** Assemble one host's look. Throws nothing: every read that fails is a word on the record. */
112
+ export async function buildLook({ env, host, pinned, now = Date.now, fs = nodeFs, get, hostPi, globalPiDir = env.PI_GLOBAL_PI_DIR }) {
113
+ const registry = env.PI_UPDATES_REGISTRY || DEFAULT_REGISTRY;
114
+ const attempt = async (fn) => { try { return { read: "ok", value: await fn() }; } catch { return { read: "unknown", value: null }; } };
115
+ const pi = await attempt(() => newestVersion(PI_PACKAGE, { get, registry }));
116
+ const newest = pi.read === "ok" && typeof pi.value === "string" ? pi.value : null;
117
+ let checks = null;
118
+ if (newest && pinned && compareVersions(newest, pinned) > 0) checks = (await attempt(() => bumpChecks(newest, { get }))).value ?? "unknown";
119
+ const manifest = globalPiDir ? readStageManifest({ globalPiDir, readFile: (p, e) => fs.readFileSync(p, e), fileExists: (p) => fs.existsSync(p) }) : null;
120
+ const packages = [];
121
+ for (const pkg of manifest?.packages ?? []) {
122
+ const got = await attempt(() => newestVersion(pkg.name, { get, registry }));
123
+ packages.push({ name: pkg.name, version: pkg.version, newest: got.read === "ok" ? got.value : null, read: got.read === "ok" && got.value ? "ok" : "unknown" });
124
+ }
125
+ const record = { v: 1, host, at: now(), everyHours: UPDATES_LOOK_EVERY_HOURS, pi: { pinned, newest, read: newest ? "ok" : "unknown", checks }, hostPi: await hostPi(), packages };
126
+ return parseLookRecord(record);
127
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Each host applying what a person asked for (issue #796, `DES-PACKAGE-UPDATES-PREPARED-NOT-APPLIED`).
3
+ *
4
+ * A desired row per package, and each host moves to the newest generation. Never a copied request: those expire, and a queued Adopt followed by an Undo could
5
+ * replay stale. A host that was offline reads only the newest row when it returns, so an Adopt it never saw and the Undo of it become nothing at all.
6
+ *
7
+ * Determinate outcomes are a state on the host's row. A lock held by a peer or a registry that cannot be reached is no state: the host stays pending and tries again.
8
+ */
9
+ import * as nodeFs from "node:fs";
10
+ import { dirname, join } from "node:path";
11
+ import { isSubscriptionAdapter } from "./updates-subscription-adapters.mjs";
12
+ import { EXACT_VERSION_RE, PACKAGES_SUBDIR, parsePackagesFile, readStageManifest, stagedDirName } from "./packages.mjs";
13
+ import { PackageStageError, SERVICE_NPM_TIMEOUT_MS, UNDO_DIR, pruneStagedTrees, serviceNpmEnv, stagePackageTree, stagedStem, treeDigest, withStageLock, writeStageManifest } from "./package-stage.mjs";
14
+ import { compareVersions, readApplied, readDesired, writeApplied } from "./updates.mjs";
15
+
16
+ export { isSubscriptionAdapter };
17
+
18
+ const writable = (fs, path) => {
19
+ try {
20
+ fs.accessSync(path, nodeFs.constants.W_OK);
21
+ return true;
22
+ } catch {
23
+ return false;
24
+ }
25
+ };
26
+
27
+ /**
28
+ * The file's text with one package's entry set or removed, temp and rename. Other entries and their fields stay as they were, so an Adopt of one package
29
+ * and a later Undo of another do not disturb each other: restoring whole-file text would have undone everything since.
30
+ */
31
+ function writePackagesEntry({ fs, path, name, entry }) {
32
+ const text = fs.existsSync(path) ? fs.readFileSync(path, "utf8") : null;
33
+ const doc = text === null ? { packages: [] } : JSON.parse(text);
34
+ const rows = Array.isArray(doc.packages) ? doc.packages : [];
35
+ const at = rows.findIndex((row) => row?.name === name);
36
+ if (entry === null && at >= 0) rows.splice(at, 1);
37
+ else if (entry !== null && at >= 0) rows[at] = entry;
38
+ else if (entry !== null) rows.push(entry);
39
+ doc.packages = rows;
40
+ const next = `${JSON.stringify(doc, null, 2)}\n`;
41
+ parsePackagesFile(next, path); // what is about to be written must be a file the stager would take
42
+ const temp = `${path}.${process.pid}.tmp`;
43
+ fs.writeFileSync(temp, next);
44
+ fs.renameSync(temp, path);
45
+ }
46
+
47
+ /** The file's current entry for a package, or `null`. Throws when the file does not parse: the caller reads that as unusable. */
48
+ function readPackagesEntry({ fs, path, name }) {
49
+ if (!fs.existsSync(path)) return null;
50
+ const text = fs.readFileSync(path, "utf8");
51
+ parsePackagesFile(text, path);
52
+ const rows = JSON.parse(text).packages ?? [];
53
+ return rows.find((row) => row?.name === name) ?? null;
54
+ }
55
+
56
+ const undoPath = (packagesRoot, name) => join(packagesRoot, UNDO_DIR, `${stagedDirName(name)}.json`);
57
+
58
+ function readUndo(fs, packagesRoot, name) {
59
+ try {
60
+ return JSON.parse(fs.readFileSync(undoPath(packagesRoot, name), "utf8"));
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ function replaceManifestEntry(manifest, name, entry) {
67
+ return { stagedAt: new Date().toISOString(), packages: manifest.packages.map((row) => (row.name === name ? entry : row)) };
68
+ }
69
+
70
+ const npmBinFor = (platform) => (platform === "win32" ? "npm.cmd" : "npm");
71
+
72
+ /** Apply one desired row on this host. Returns the row to publish; throws `PackageStageError("infra")` to stay pending. */
73
+ export async function applyDesired({ desired, env, cwd, fs = nodeFs, exec, now = Date.now, out = () => {}, platform = process.platform, globalPiDir = env.PI_GLOBAL_PI_DIR }) {
74
+ const row = (state, fields = {}) => ({ v: 1, generation: desired.generation, state, at: now(), version: null, reason: null, digest: null, ...fields });
75
+ const refuse = (reason, version = null) => row("cannot-adopt", { reason, version });
76
+ // A host that never took the Adopt being undone has nothing to take back, whichever else is true of it, and that includes having no overlay at all.
77
+ if (!globalPiDir || !fs.existsSync(globalPiDir)) return desired.op === "undo" ? row("no-change") : refuse("no-overlay");
78
+ const packagesRoot = join(globalPiDir, PACKAGES_SUBDIR);
79
+ const manifest = readStageManifest({ globalPiDir, readFile: (p, e) => fs.readFileSync(p, e), fileExists: (p) => fs.existsSync(p) });
80
+ const current = manifest?.packages.find((p) => p.name === desired.name) ?? null;
81
+ const undo = desired.op === "undo" ? readUndo(fs, packagesRoot, desired.name) : null;
82
+ if (desired.op === "undo" && undo?.generation !== desired.undoOf) return row("no-change", { version: current?.version ?? null });
83
+ if (!current) return refuse("not-staged-here");
84
+ if (!writable(fs, packagesRoot)) return refuse("overlay-read-only", current.version);
85
+ if (desired.op === "adopt" && isSubscriptionAdapter(desired.name)) return refuse("adapter", current.version);
86
+ // Never to an older version. A person may have moved past this row with `import-pi` since, and a host may read it again for any number of reasons (a new host, a lost applied key),
87
+ // with nobody looking: "nothing updates by itself" has to hold for a downgrade too. Going back is what Undo is for.
88
+ if (desired.op === "adopt" && compareVersions(current.version, desired.version) >= 0) return row("no-change", { version: current.version });
89
+ if (!EXACT_VERSION_RE.test(desired.version)) return refuse("stage-refused", current.version);
90
+ const packagesFile = env.PI_PACKAGES_FILE ?? join(cwd, "pi-packages.json");
91
+ try {
92
+ readPackagesEntry({ fs, path: packagesFile, name: desired.name });
93
+ // The file is replaced by temp and rename, so what has to be writable is its directory, whether or not the file itself is.
94
+ if (!writable(fs, dirname(packagesFile)) || (fs.existsSync(packagesFile) && !writable(fs, packagesFile))) throw new Error("not writable");
95
+ } catch {
96
+ return refuse("packages-file-unusable", current.version);
97
+ }
98
+
99
+ return await withStageLock({ fs, packagesRoot, now, holder: "updates-reconcile" }, async ({ stillHeld }) => {
100
+ const committing = () => { if (!stillHeld()) throw new PackageStageError("infra", "another stage took over this overlay's lock"); };
101
+ // Read again under the lock: a peer on a shared home may have applied this very row while we waited. It shares this manifest, and doing it twice
102
+ // would lose the record of what to undo.
103
+ const fresh = readStageManifest({ globalPiDir, readFile: (p, e) => fs.readFileSync(p, e), fileExists: (p) => fs.existsSync(p) });
104
+ const here = fresh?.packages.find((p) => p.name === desired.name) ?? null;
105
+ if (!here) return refuse("not-staged-here");
106
+ const stageOptions = { fs, exec, out, packagesRoot, npmBin: npmBinFor(platform), platform, timeoutMs: SERVICE_NPM_TIMEOUT_MS, env: serviceNpmEnv(env) };
107
+ const stageIt = async (entry) => {
108
+ try {
109
+ return { ...(await stagePackageTree({ ...stageOptions, entry })) };
110
+ } catch (error) {
111
+ if (error instanceof PackageStageError && error.kind === "not-found") return { refused: "version-not-found" };
112
+ if (error instanceof PackageStageError && error.kind === "policy") return { refused: "stage-refused" };
113
+ throw error instanceof PackageStageError ? error : new PackageStageError("infra", "the stage could not finish");
114
+ }
115
+ };
116
+
117
+ if (desired.op === "undo") {
118
+ const prior = undo.manifestEntry;
119
+ let dir = prior.dir;
120
+ // The old tree is kept for a week, and flipping back to it is what makes Undo instant. Past that it is staged again, at the exact version.
121
+ if (!fs.existsSync(join(packagesRoot, dir))) {
122
+ const staged = await stageIt({ name: prior.name, version: prior.version, dir: stagedStem(prior.dir) });
123
+ if (staged.refused) return refuse(staged.refused, here.version);
124
+ dir = staged.dir;
125
+ }
126
+ const manifest2 = replaceManifestEntry(fresh, desired.name, { ...prior, dir });
127
+ committing();
128
+ writePackagesEntry({ fs, path: packagesFile, name: desired.name, entry: undo.fileEntry });
129
+ writeStageManifest({ fs, packagesRoot, manifest: manifest2 });
130
+ fs.rmSync(undoPath(packagesRoot, desired.name), { force: true });
131
+ pruneStagedTrees({ fs, packagesRoot, manifest: manifest2, now });
132
+ return row("undone", { version: prior.version, digest: await treeDigest(join(packagesRoot, dir), { fs }) });
133
+ }
134
+
135
+ if (compareVersions(here.version, desired.version) >= 0) return row("no-change", { version: here.version });
136
+ const staged = await stageIt({ name: desired.name, version: desired.version, dir: stagedStem(here.dir) });
137
+ if (staged.refused) return refuse(staged.refused, here.version);
138
+ const removeUnnamed = () => { try { fs.rmSync(join(packagesRoot, staged.dir), { recursive: true, force: true }); } catch { /* the prune's, later */ } };
139
+ try {
140
+ const digest = await treeDigest(join(packagesRoot, staged.dir), { fs });
141
+ // Read under the lock, and kept if a previous attempt at this very generation already recorded it: that attempt may have written the pin before its manifest failed, and reading
142
+ // the file now would record the adopted pin as what came before it, so that Undo restored the wrong thing.
143
+ const earlier = readUndo(fs, packagesRoot, desired.name);
144
+ const priorEntry = earlier?.generation === desired.generation ? earlier.fileEntry : readPackagesEntry({ fs, path: packagesFile, name: desired.name });
145
+ const priorManifest = earlier?.generation === desired.generation ? earlier.manifestEntry : here;
146
+ committing();
147
+ // The undo record first, the pin second and the manifest last, so a crash leaves at worst a pin the next stage honours, and jobs still on the old set.
148
+ fs.mkdirSync(join(packagesRoot, UNDO_DIR), { recursive: true });
149
+ fs.writeFileSync(undoPath(packagesRoot, desired.name), `${JSON.stringify({ generation: desired.generation, manifestEntry: priorManifest, fileEntry: priorEntry })}\n`);
150
+ writePackagesEntry({ fs, path: packagesFile, name: desired.name, entry: { ...(priorEntry ?? { name: desired.name }), version: desired.version } });
151
+ const manifest2 = replaceManifestEntry(fresh, desired.name, { ...here, version: desired.version, dir: staged.dir });
152
+ writeStageManifest({ fs, packagesRoot, manifest: manifest2 });
153
+ pruneStagedTrees({ fs, packagesRoot, manifest: manifest2, now });
154
+ return row("adopted", { version: desired.version, digest });
155
+ } catch (error) {
156
+ // Nothing names this tree yet, and each retry would leave one more.
157
+ removeUnnamed();
158
+ throw error instanceof PackageStageError ? error : new PackageStageError("infra", "the stage could not commit");
159
+ }
160
+ });
161
+ }
162
+
163
+ /** One pass on this host: every row newer than what it applied, oldest first, one failure not stopping the rest. Returns a count of what happened, names only. */
164
+ export async function reconcileUpdates({ redis, host, env, cwd, fs = nodeFs, exec, now = Date.now, log = () => {}, platform = process.platform }) {
165
+ const desired = await readDesired(redis);
166
+ const applied = desired === null ? null : await readApplied(redis, host);
167
+ if (!desired || !applied) return { unreachable: true };
168
+ const done = { applied: 0, pending: 0 };
169
+ for (const row of desired) {
170
+ if ((applied[row.name]?.generation ?? 0) >= row.generation) continue;
171
+ try {
172
+ const result = await applyDesired({ desired: row, env, cwd, fs, exec, now, platform });
173
+ await writeApplied(redis, host, row.name, result);
174
+ done.applied += 1;
175
+ log("updates_applied", { package: row.name, generation: row.generation, state: result.state, reason: result.reason });
176
+ } catch (error) {
177
+ done.pending += 1;
178
+ log("updates_pending", { package: row.name, generation: row.generation, kind: error instanceof PackageStageError ? error.kind : "infra" });
179
+ }
180
+ }
181
+ return done;
182
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Which packages carry a reviewed subscription adapter's identity (`docs/global-pi-overlay.md`), which re-staging would break. Its own module so that the console can ask without loading the stager.
3
+ */
4
+ import { COMMUNITY_AUTH_ADAPTERS } from "./community-auth-metadata.mjs";
5
+
6
+ export function isSubscriptionAdapter(name) {
7
+ return Object.values(COMMUNITY_AUTH_ADAPTERS).some((adapter) => adapter.package === name);
8
+ }
@@ -0,0 +1,252 @@
1
+ /**
2
+ * The Updates page's facts and a person's desired versions, in Valkey (issue #796, `INT-UPDATES-KEYS`, `DES-PACKAGE-UPDATES-PREPARED-NOT-APPLIED`).
3
+ *
4
+ * THE SAME TEST AS DOCTOR'S RESULTS: delete every `updates:*` key while the fleet runs and every job behaves exactly as before, because a job reads
5
+ * only the stage manifest on disk. What is lost is the page's facts and any Adopt no host has applied yet.
6
+ *
7
+ * A look record is rebuilt on read, never passed through. Names and versions must match the same rules the stager takes, and each state is a closed
8
+ * word, so what a registry or a forge answered cannot reach a viewer as anything else.
9
+ */
10
+ import { randomUUID } from "node:crypto";
11
+ import { EXACT_VERSION_RE, NPM_NAME_RE } from "./packages.mjs";
12
+ import { WORKER_NAME_RE } from "./worker-name.mjs";
13
+
14
+ export const UPDATES_HOSTS = "updates:hosts";
15
+ export const UPDATES_GENERATION = "updates:generation";
16
+ export const UPDATES_DESIRED = "updates:desired";
17
+ export const updatesLookKey = (host) => `updates:h:${host}`;
18
+ export const updatesAppliedKey = (host) => `updates:applied:${host}`;
19
+ export const updatesCanaryKey = (name, version) => `updates:canary:${name}@${version}`;
20
+
21
+ export const UPDATES_LOOK_TTL_MS = 7 * 24 * 60 * 60 * 1000;
22
+ export const UPDATES_CANARY_TTL_MS = 30 * 24 * 60 * 60 * 1000;
23
+ /** A host with no row at the newest generation is pending; this long after the request it reads as overdue, never as lost. */
24
+ export const UPDATES_OVERDUE_MS = 24 * 60 * 60 * 1000;
25
+ export const UPDATES_LOOK_EVERY_HOURS = 24;
26
+ export const UPDATES_HOSTS_MAX = 64;
27
+ export const UPDATES_PACKAGES_MAX = 64;
28
+ export const UPDATES_EXCERPT_MAX = 2000;
29
+
30
+ export const READ_STATES = Object.freeze(["ok", "unknown"]);
31
+ export const CHECK_STATES = Object.freeze(["passing", "failing", "pending", "unknown"]);
32
+ export const HOST_PI_STATES = Object.freeze(["same", "newer", "older", "absent", "unknown"]);
33
+ export const PROBE_STATES = Object.freeze(["ok", "drift", "unknown"]);
34
+ export const APPLIED_STATES = Object.freeze(["adopted", "undone", "no-change", "cannot-adopt"]);
35
+ export const CANNOT_ADOPT_REASONS = Object.freeze(["no-overlay", "overlay-read-only", "packages-file-unusable", "adapter", "not-staged-here", "version-not-found", "stage-refused"]);
36
+ export const DESIRED_OPS = Object.freeze(["adopt", "undo"]);
37
+
38
+ const OP_TIMEOUT_MS = 3_000;
39
+ const object = (x) => x !== null && typeof x === "object" && !Array.isArray(x);
40
+ const word = (value, words) => (words.includes(value) ? value : null);
41
+ const name = (value) => (typeof value === "string" && value.length <= 214 && NPM_NAME_RE.test(value) ? value : null);
42
+ const version = (value) => (typeof value === "string" && value.length <= 128 && EXACT_VERSION_RE.test(value) ? value : null);
43
+ const instant = (value) => (Number.isSafeInteger(value) && value > 0 ? value : null);
44
+ const digest = (value) => (typeof value === "string" && /^[a-f0-9]{64}$/.test(value) ? value : null);
45
+ const person = (value) => (typeof value === "string" && /^[A-Za-z0-9._@-]{1,64}$/.test(value) ? value : null);
46
+
47
+ function bounded(promise, timeoutMs = OP_TIMEOUT_MS) {
48
+ return new Promise((resolve, reject) => {
49
+ const timer = setTimeout(() => reject(new Error("valkey did not answer")), timeoutMs);
50
+ timer.unref?.();
51
+ Promise.resolve(promise).then((value) => { clearTimeout(timer); resolve(value); }, (error) => { clearTimeout(timer); reject(error); });
52
+ });
53
+ }
54
+
55
+ /** A version's order, as semver says: a pre-release sorts before its release, its dot-separated parts compared in turn, numbers as numbers and below words. Build metadata does not order. */
56
+ export function compareVersions(a, b) {
57
+ const parts = (v) => {
58
+ const [core, pre = null] = String(v).split("+")[0].split(/-(.*)/s);
59
+ return { nums: core.split(".").map(Number), pre };
60
+ };
61
+ const x = parts(a), y = parts(b);
62
+ for (let i = 0; i < 3; i += 1) {
63
+ if (x.nums[i] !== y.nums[i]) return x.nums[i] < y.nums[i] ? -1 : 1;
64
+ }
65
+ if (x.pre === y.pre) return 0;
66
+ if (x.pre === null) return 1;
67
+ if (y.pre === null) return -1;
68
+ const xs = x.pre.split("."), ys = y.pre.split(".");
69
+ for (let i = 0; i < Math.max(xs.length, ys.length); i += 1) {
70
+ if (xs[i] === undefined) return -1;
71
+ if (ys[i] === undefined) return 1;
72
+ if (xs[i] === ys[i]) continue;
73
+ const xn = /^\d+$/.test(xs[i]), yn = /^\d+$/.test(ys[i]);
74
+ if (xn && yn) return Number(xs[i]) < Number(ys[i]) ? -1 : 1;
75
+ if (xn !== yn) return xn ? -1 : 1;
76
+ return xs[i] < ys[i] ? -1 : 1;
77
+ }
78
+ return 0;
79
+ }
80
+
81
+ /** One look record, rebuilt from anything: `null` unless its frame is whole, and an unknown word degrades to `unknown`, never to a good state. */
82
+ export function parseLookRecord(text) {
83
+ let raw;
84
+ try { raw = typeof text === "string" ? JSON.parse(text) : text; } catch { return null; }
85
+ if (!object(raw) || raw.v !== 1 || !WORKER_NAME_RE.test(raw.host ?? "") || !instant(raw.at) || !Number.isSafeInteger(raw.everyHours) || raw.everyHours < 1 || raw.everyHours > 168) return null;
86
+ const pi = object(raw.pi) ? raw.pi : {};
87
+ const host = object(raw.hostPi) ? raw.hostPi : {};
88
+ const packages = [];
89
+ for (const row of Array.isArray(raw.packages) ? raw.packages.slice(0, UPDATES_PACKAGES_MAX) : []) {
90
+ const pkg = object(row) ? name(row.name) : null;
91
+ const at = object(row) ? version(row.version) : null;
92
+ if (!pkg || !at) continue;
93
+ packages.push({ name: pkg, version: at, newest: version(row.newest), read: word(row.read, READ_STATES) ?? "unknown" });
94
+ }
95
+ return {
96
+ v: 1, host: raw.host, at: raw.at, everyHours: raw.everyHours,
97
+ pi: { pinned: version(pi.pinned), newest: version(pi.newest), read: word(pi.read, READ_STATES) ?? "unknown", checks: pi.checks === null ? null : word(pi.checks, CHECK_STATES) ?? "unknown" },
98
+ hostPi: { version: version(host.version), tested: version(host.tested), state: word(host.state, HOST_PI_STATES) ?? "unknown", needles: word(host.needles, PROBE_STATES) ?? "unknown", extension: word(host.extension, PROBE_STATES) ?? "unknown" },
99
+ packages,
100
+ };
101
+ }
102
+
103
+ export const lookIsStale = (record, now) => now - record.at > 2 * record.everyHours * 60 * 60 * 1000;
104
+
105
+ /** The value and its expiry in one command, for doctor's reason. The look's whole record replaces the last one. */
106
+ export async function publishLook(redis, record) {
107
+ const parsed = parseLookRecord(record);
108
+ if (!parsed) throw new Error("not a look record");
109
+ await bounded(redis.set(updatesLookKey(parsed.host), JSON.stringify(parsed), "PX", UPDATES_LOOK_TTL_MS));
110
+ await bounded(redis.sadd(UPDATES_HOSTS, parsed.host));
111
+ }
112
+
113
+ /** `{ hosts: [{ record, stale }], more }` or `{ unreachable }`. Writes nothing: a viewer's page view prunes no index. */
114
+ export async function readLooks(redis, { now = Date.now } = {}) {
115
+ try {
116
+ const members = (await bounded(redis.smembers(UPDATES_HOSTS))) ?? [];
117
+ const names = [...new Set(members)].filter((host) => typeof host === "string" && WORKER_NAME_RE.test(host)).sort();
118
+ const wanted = names.slice(0, UPDATES_HOSTS_MAX);
119
+ if (wanted.length === 0) return { hosts: [], more: 0 };
120
+ const values = await bounded(redis.mget(...wanted.map(updatesLookKey)));
121
+ const hosts = [];
122
+ for (const value of values) {
123
+ const record = typeof value === "string" ? parseLookRecord(value) : null;
124
+ if (record) hosts.push({ record, stale: lookIsStale(record, now()) });
125
+ }
126
+ return { hosts, more: names.length - wanted.length };
127
+ } catch {
128
+ return { unreachable: true };
129
+ }
130
+ }
131
+
132
+ /** One desired row, rebuilt. `undoOf` is the generation an Undo takes back. */
133
+ export function parseDesired(text) {
134
+ let raw;
135
+ try { raw = typeof text === "string" ? JSON.parse(text) : text; } catch { return null; }
136
+ if (!object(raw) || raw.v !== 1 || !Number.isSafeInteger(raw.generation) || raw.generation < 1) return null;
137
+ const pkg = name(raw.name), at = version(raw.version), op = word(raw.op, DESIRED_OPS), by = person(raw.by), when = instant(raw.at);
138
+ if (!pkg || !at || !op || !by || !when) return null;
139
+ const undoOf = op === "undo" && Number.isSafeInteger(raw.undoOf) && raw.undoOf > 0 ? raw.undoOf : null;
140
+ if (op === "undo" && undoOf === null) return null;
141
+ const from = version(raw.from);
142
+ return { v: 1, generation: raw.generation, name: pkg, version: at, op, by, at: when, ...(undoOf === null ? {} : { undoOf }), ...(from === null ? {} : { from }), request: typeof raw.request === "string" && raw.request.length <= 64 ? raw.request : null };
143
+ }
144
+
145
+ /**
146
+ * The generation and its row in ONE script. INCR then HSET as two commands would let two concurrent requests land in the wrong order, leaving the older one as the row with the
147
+ * newer one's generation lost, and every host would then reconcile to what nobody last asked for. `expectGeneration` makes it a compare and set: what a person's preview was shown has to
148
+ * still be the row, or nothing is written and it answers 0 (a racing Undo would otherwise overwrite an Adopt it never saw).
149
+ */
150
+ const SET_DESIRED = `if ARGV[3] ~= '' then
151
+ local current = redis.call('HGET', KEYS[2], ARGV[1])
152
+ local generation = 0
153
+ if current then generation = cjson.decode(current).generation end
154
+ if string.format('%d', generation) ~= ARGV[3] then return 0 end
155
+ end
156
+ local g = redis.call('INCR', KEYS[1])
157
+ local row = cjson.decode(ARGV[2])
158
+ row.generation = g
159
+ redis.call('HSET', KEYS[2], ARGV[1], cjson.encode(row))
160
+ return g`;
161
+
162
+ /** Record what a person asked for, and return its generation, or 0 when `expectGeneration` no longer holds. */
163
+ export async function setDesired(redis, { name: pkg, version: at, op, by, at: when, undoOf = null, from = null, expectGeneration = null }) {
164
+ const row = { v: 1, generation: 1, name: pkg, version: at, op, by, at: when, ...(undoOf === null ? {} : { undoOf }), ...(from === null ? {} : { from }), request: randomUUID() };
165
+ if (!parseDesired(row)) throw new Error("not a desired version");
166
+ return await bounded(redis.eval(SET_DESIRED, 2, UPDATES_GENERATION, UPDATES_DESIRED, pkg, JSON.stringify(row), expectGeneration === null ? "" : String(expectGeneration)));
167
+ }
168
+
169
+ /** Every desired row, or `null` when Valkey could not answer. An unreadable row is one nobody can act on, so it is left out. */
170
+ export async function readDesired(redis) {
171
+ try {
172
+ const raw = (await bounded(redis.hgetall(UPDATES_DESIRED))) ?? {};
173
+ const rows = [];
174
+ for (const [key, value] of Object.entries(raw)) {
175
+ const row = parseDesired(value);
176
+ if (row && row.name === key) rows.push(row);
177
+ }
178
+ return rows.sort((a, b) => a.generation - b.generation);
179
+ } catch {
180
+ return null;
181
+ }
182
+ }
183
+
184
+ export function parseApplied(text) {
185
+ let raw;
186
+ try { raw = typeof text === "string" ? JSON.parse(text) : text; } catch { return null; }
187
+ if (!object(raw) || raw.v !== 1 || !Number.isSafeInteger(raw.generation) || raw.generation < 1) return null;
188
+ const state = word(raw.state, APPLIED_STATES), when = instant(raw.at);
189
+ if (!state || !when) return null;
190
+ return { v: 1, generation: raw.generation, state, version: version(raw.version), reason: state === "cannot-adopt" ? word(raw.reason, CANNOT_ADOPT_REASONS) : null, digest: digest(raw.digest), at: when };
191
+ }
192
+
193
+ export async function writeApplied(redis, host, pkg, row) {
194
+ const parsed = parseApplied(row);
195
+ if (!parsed || !WORKER_NAME_RE.test(host) || !name(pkg)) throw new Error("not an applied row");
196
+ // No expiry: a row's expiry would have this host read an old desired row as new, with nobody looking. A host holds one small row per package.
197
+ await bounded(redis.hset(updatesAppliedKey(host), pkg, JSON.stringify(parsed)));
198
+ }
199
+
200
+ /** `{ <package>: row }` for one host, or `null` when Valkey could not answer. */
201
+ export async function readApplied(redis, host) {
202
+ try {
203
+ const raw = (await bounded(redis.hgetall(updatesAppliedKey(host)))) ?? {};
204
+ const rows = {};
205
+ for (const [key, value] of Object.entries(raw)) {
206
+ const row = parseApplied(value);
207
+ if (row && name(key)) rows[key] = row;
208
+ }
209
+ return rows;
210
+ } catch {
211
+ return null;
212
+ }
213
+ }
214
+
215
+ /** Whether this host has work: any desired row newer than what it applied. Read every couple of minutes by the worker, so it must stay two reads. */
216
+ export async function hostHasPending(redis, host) {
217
+ const desired = await readDesired(redis);
218
+ const applied = desired === null ? null : await readApplied(redis, host);
219
+ if (!desired || !applied) return false;
220
+ return desired.some((row) => (applied[row.name]?.generation ?? 0) < row.generation);
221
+ }
222
+
223
+ /** Third-party text for a person to read: control characters gone, cut to its bound. Escaped where it is drawn, like everything else. */
224
+ export function changelogExcerpt(text) {
225
+ if (typeof text !== "string") return null;
226
+ const clean = text.replace(/\r\n/g, "\n").replace(/[\u0000-\u0009\u000b-\u001f\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g, " ").trim();
227
+ return clean === "" ? null : clean.slice(0, UPDATES_EXCERPT_MAX);
228
+ }
229
+
230
+ export function parseCanaryFacts(text) {
231
+ let raw;
232
+ try { raw = typeof text === "string" ? JSON.parse(text) : text; } catch { return null; }
233
+ if (!object(raw) || raw.v !== 1 || !WORKER_NAME_RE.test(raw.host ?? "") || !instant(raw.at)) return null;
234
+ return { v: 1, host: raw.host, at: raw.at, digest: digest(raw.digest), excerpt: changelogExcerpt(raw.excerpt) };
235
+ }
236
+
237
+ /** What a canary's install found, published beside the host's facts and never on the run record: the excerpt is a third party's text. */
238
+ export async function publishCanaryFacts(redis, { name: pkg, version: at, host, digest: tree, excerpt, at: when }) {
239
+ if (!name(pkg) || !version(at)) throw new Error("not a canary's package");
240
+ const facts = parseCanaryFacts({ v: 1, host, at: when, digest: tree, excerpt });
241
+ if (!facts) throw new Error("not a canary's facts");
242
+ await bounded(redis.set(updatesCanaryKey(pkg, at), JSON.stringify(facts), "PX", UPDATES_CANARY_TTL_MS));
243
+ }
244
+
245
+ export async function readCanaryFacts(redis, pkg, at) {
246
+ try {
247
+ const value = await bounded(redis.get(updatesCanaryKey(pkg, at)));
248
+ return typeof value === "string" ? parseCanaryFacts(value) : null;
249
+ } catch {
250
+ return null;
251
+ }
252
+ }