@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,602 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Doctor's results, per host, in Valkey (issue #712, `INT-DOCTOR-RESULTS-KEY`, `DES-DOCTOR-RESULTS-PUBLISHED`).
|
|
3
|
+
*
|
|
4
|
+
* doctor:hosts SET of host names that have published: an index the READER prunes, as `host:live` is
|
|
5
|
+
* doctor:h:<name> STRING, one JSON record of that host's last doctor run, kept 7 days from its last publish
|
|
6
|
+
*
|
|
7
|
+
* Every `pi-dispatch doctor` run publishes (unless `--no-publish`), and the worker runs one itself at boot and once a
|
|
8
|
+
* day (`doctor-spawner.mjs`). The console's Fleet page and the `/dispatch` panel read the records back, so a sentence
|
|
9
|
+
* that used to say "run pi-dispatch doctor for the reason" can quote the reason, and a host nobody is looking at still
|
|
10
|
+
* says what is wrong with it.
|
|
11
|
+
*
|
|
12
|
+
* A STRING HOLDING ONE JSON RECORD, NOT A HASH, and that is the choice the registry did not make. A doctor run is one
|
|
13
|
+
* snapshot that replaces the last one whole: `SET ... PX` writes the value and its expiry in ONE command, so there is no
|
|
14
|
+
* moment where the record exists without its TTL (the window `host-registry.write` has to argue about between its HSET
|
|
15
|
+
* and its PEXPIRE), and no field left over from a previous run that published more checks. A HASH earns its keep for a
|
|
16
|
+
* row whose fields are re-written on a beat; this record is written once a day and read whole.
|
|
17
|
+
*
|
|
18
|
+
* THE SAME TEST AS THE REGISTRY: delete every `doctor:*` key while the fleet runs, and every host behaves exactly as
|
|
19
|
+
* before. Nothing reads these keys to decide anything: not the worker, not the receiver, and not doctor itself. They
|
|
20
|
+
* are a display, and a reader that finds none says "no doctor results", never "healthy".
|
|
21
|
+
*
|
|
22
|
+
* THE CONTENT RULE, stricter than a terminal's. Doctor's own lines are written for the operator at the keyboard of
|
|
23
|
+
* that host, and they name paths and print configured values; a published record is read by every console viewer, on
|
|
24
|
+
* another machine. So what is published is:
|
|
25
|
+
* - each check's `id` (a stable name, `assignCheckIds`) and `level` (ok, warn or fail);
|
|
26
|
+
* - for a warn, a fail, or a validated typed model proposal, its label and its fix sentence, each REDACTED (`redactText`): an absolute path becomes
|
|
27
|
+
* `<path>`, a URL keeps its scheme and host only (never userinfo, path or query), every value of a secret-named
|
|
28
|
+
* variable in doctor's environment and every known token shape becomes `[redacted]`, control characters become
|
|
29
|
+
* spaces, and the result is capped at `DOCTOR_LABEL_MAX`;
|
|
30
|
+
* - integers, booleans, the worker version and the host name.
|
|
31
|
+
* An ok check publishes its id and level only, except a validated typed model proposal (#773), whose redacted label/fix and closed metadata are retained under the same detail cap: the console shows what is wrong, and an ok line's label is
|
|
32
|
+
* where doctor prints the most configuration ("Secret resolver profiles declared: a -> ..."), for nobody's benefit.
|
|
33
|
+
*
|
|
34
|
+
* NOT DEFENDED, and said: a secret held under a variable whose NAME does not look like one, and that has no known
|
|
35
|
+
* token shape, is not recognised as a secret. Doctor's own lines do not print such values (it prints set or unset for
|
|
36
|
+
* the secret service keys, and URLs through `urlShown`), so this is the second line of defence, not the first.
|
|
37
|
+
*/
|
|
38
|
+
import { modelProposalOf } from "./model-proposal-protocol.mjs";
|
|
39
|
+
import { DOCTOR_INTERVAL_HOURS, DOCTOR_INTERVAL_MAX_HOURS } from "./doctor-spawner.mjs";
|
|
40
|
+
import { makeRedactor } from "./redact-secrets.mjs";
|
|
41
|
+
import { WORKER_NAME_RE } from "./worker-name.mjs";
|
|
42
|
+
|
|
43
|
+
/** The index. A SET cannot expire its members, so the reader prunes, as `readLiveHosts` does for `host:live`. */
|
|
44
|
+
export const DOCTOR_HOSTS = "doctor:hosts";
|
|
45
|
+
|
|
46
|
+
/** One host's record. */
|
|
47
|
+
export const doctorKey = (name) => `doctor:h:${name}`;
|
|
48
|
+
|
|
49
|
+
/** How long a record outlives its last publish: seven days, the user's decision. Refreshed by every publish. */
|
|
50
|
+
export const DOCTOR_RESULTS_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
51
|
+
|
|
52
|
+
/** The record's shape version. A reader refuses any other, so a later shape is "unreadable", never misread. */
|
|
53
|
+
export const DOCTOR_RECORD_VERSION = 1;
|
|
54
|
+
|
|
55
|
+
/** The longest published label or fix, after redaction (`scrubCredentials`'s cap, for the same reason). */
|
|
56
|
+
export const DOCTOR_LABEL_MAX = 300;
|
|
57
|
+
|
|
58
|
+
/** At most this many checks are listed; the rest are counted in `more`. A doctor run prints well under two hundred. */
|
|
59
|
+
export const DOCTOR_CHECKS_MAX = 400;
|
|
60
|
+
|
|
61
|
+
/** At most this many carry words (warn/fail checks and typed model proposals, in doctor's order). */
|
|
62
|
+
export const DOCTOR_DETAILED_MAX = 80;
|
|
63
|
+
|
|
64
|
+
/** At most this many hosts are read back, so a polluted index cannot make one panel tick an unbounded MGET. */
|
|
65
|
+
export const DOCTOR_HOSTS_MAX = 64;
|
|
66
|
+
|
|
67
|
+
/** How long any one command may take, `host-registry`'s bound and for its reason (`maxRetriesPerRequest: null`). */
|
|
68
|
+
const DOCTOR_OP_TIMEOUT_MS = 2_000;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The sections a check's derived id is named by, in doctor's order. A check takes the section that was current when
|
|
72
|
+
* it was added (`collectChecks` marks each boundary), and its derived id is `<section>.<n>`, its place in that section
|
|
73
|
+
* on that run. Pinned by a test, so a section is never renamed or dropped in passing.
|
|
74
|
+
*
|
|
75
|
+
* A derived id is stable for one host's configuration and NOT across hosts: a check doctor skips on one host moves the
|
|
76
|
+
* numbers after it in that section. It is a key for a list, not a contract. What a reader matches on is a NAMED id
|
|
77
|
+
* (`NAMED_CHECK_IDS`), which a check carries by itself.
|
|
78
|
+
*/
|
|
79
|
+
export const DOCTOR_SECTIONS = Object.freeze([
|
|
80
|
+
"node",
|
|
81
|
+
"env",
|
|
82
|
+
"image",
|
|
83
|
+
"docker",
|
|
84
|
+
"triggers",
|
|
85
|
+
"dollars",
|
|
86
|
+
"venues",
|
|
87
|
+
"images",
|
|
88
|
+
"egress",
|
|
89
|
+
"backends",
|
|
90
|
+
"sizing",
|
|
91
|
+
"job-user",
|
|
92
|
+
"podman",
|
|
93
|
+
"openshell",
|
|
94
|
+
"selinux",
|
|
95
|
+
"forges",
|
|
96
|
+
"github",
|
|
97
|
+
"valkey",
|
|
98
|
+
"fleet",
|
|
99
|
+
"capacity",
|
|
100
|
+
"provider",
|
|
101
|
+
"overlay",
|
|
102
|
+
"waits",
|
|
103
|
+
"models",
|
|
104
|
+
"secrets",
|
|
105
|
+
"sessions",
|
|
106
|
+
"files",
|
|
107
|
+
"limits",
|
|
108
|
+
"envelope",
|
|
109
|
+
"console",
|
|
110
|
+
"dirs",
|
|
111
|
+
"state",
|
|
112
|
+
"live",
|
|
113
|
+
]);
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The ids a reader matches on, each carried by its check in doctor.mjs (`id:`), pinned by a test against the source.
|
|
117
|
+
* `<file>.file` is the SERVICE's verdict on a boot file (does the file the service reads load), `<file>.file-shell`
|
|
118
|
+
* this shell's; the console quotes the first beside its own "does not load" sentence.
|
|
119
|
+
*
|
|
120
|
+
* Issue #737, for the console's first-run steps and its hosts:
|
|
121
|
+
* - `egress.proxy`: whether the egress proxy runs, on each venue that has one. Docker's line carries it, or, for a
|
|
122
|
+
* running proxy that is not this deployment's, the line saying so, which doctor prints in its place; Podman's line
|
|
123
|
+
* carries it too. A host on both venues publishes `egress.proxy` and `egress.proxy-2` (`assignCheckIds`), and a
|
|
124
|
+
* reader judges the worst of the two.
|
|
125
|
+
* - `provider.key`: whether the provider's key is found where the worker looks.
|
|
126
|
+
* Issue #761, for an upgrade: `settings.unknown-keys`, the overlay keys this build does not know (names only, a warning),
|
|
127
|
+
* and `fleet.store-versions`, a peer that cannot read a store this host's files need a version above 1 for (a warning,
|
|
128
|
+
* one per store and kind of peer, so a run may publish `-2` and on). And for the upgrade path: `fleet.versions`, the
|
|
129
|
+
* pi-dispatch versions the live workers report and this doctor's own against this host's worker row (a warning when
|
|
130
|
+
* they differ or cannot all be read, an ok line when peers agree, absent on a single host that matches);
|
|
131
|
+
* `dollars.window-reset`, a deployment dollar window counter below what this host's own runs settled into it (a
|
|
132
|
+
* warning, absent otherwise); and `state.older`,
|
|
133
|
+
* a store an older release left in a shape this build no longer writes (a warning per finding, so a run may publish
|
|
134
|
+
* `state.older-2` and on). Each is written at one site.
|
|
135
|
+
* No id names a forge's credentials, because no one check holds them: the `github` section alone prints about twenty
|
|
136
|
+
* lines across its three auth sources, and the other forges print theirs under `forges`. A reader judges those by the
|
|
137
|
+
* section part of their derived ids, which `DOCTOR_SECTIONS` pins.
|
|
138
|
+
*/
|
|
139
|
+
export const NAMED_CHECK_IDS = Object.freeze([
|
|
140
|
+
"triggers.file",
|
|
141
|
+
"triggers.file-missing",
|
|
142
|
+
"pause-windows.file",
|
|
143
|
+
"pause-windows.file-shell",
|
|
144
|
+
"scoped-limits.file",
|
|
145
|
+
"scoped-limits.file-shell",
|
|
146
|
+
"projects.file",
|
|
147
|
+
"projects.file-shell",
|
|
148
|
+
"model-endpoints.file",
|
|
149
|
+
"model-endpoints.file-shell",
|
|
150
|
+
"envelope.file",
|
|
151
|
+
"envelope.file-shell",
|
|
152
|
+
"settings.overlay",
|
|
153
|
+
"settings.overlay-dollars",
|
|
154
|
+
"settings.unknown-keys",
|
|
155
|
+
"fleet.store-versions",
|
|
156
|
+
"fleet.versions",
|
|
157
|
+
"dollars.window-reset",
|
|
158
|
+
"state.older",
|
|
159
|
+
"egress.proxy",
|
|
160
|
+
"provider.key",
|
|
161
|
+
"watch.sources",
|
|
162
|
+
]);
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The named ids doctor.mjs writes at more than one site, with how many. Every other named id is written once, which a
|
|
166
|
+
* test holds against the source. `egress.proxy` is written twice because the proxy is a different container on each
|
|
167
|
+
* venue (docker's, and the one under this account's rootless Podman), each judged by its own line, and the question a
|
|
168
|
+
* reader asks of either is the same one. Docker's stand-in line takes the id from its check (`proxyCheck.id`) rather
|
|
169
|
+
* than writing it a third time.
|
|
170
|
+
*/
|
|
171
|
+
export const REPEATED_CHECK_IDS = Object.freeze({ "egress.proxy": 2 });
|
|
172
|
+
|
|
173
|
+
/** An id as published: a section, a dot, a name or a number, with a `-<n>` tail when a run repeated one. */
|
|
174
|
+
export const CHECK_ID_RE = /^[a-z][a-z0-9-]{0,31}\.[a-z0-9][a-z0-9-]{0,39}$/;
|
|
175
|
+
|
|
176
|
+
/** The three levels, in doctor's own tiers (`render`): ok first, so a warning that passes is still ok. */
|
|
177
|
+
export const DOCTOR_LEVELS = Object.freeze(["ok", "warn", "fail"]);
|
|
178
|
+
|
|
179
|
+
/** A check's level, by `render`'s rule: `ok` is read first, then `warn`. */
|
|
180
|
+
export function checkLevel(check) {
|
|
181
|
+
if (check?.ok) return "ok";
|
|
182
|
+
return check?.warn ? "warn" : "fail";
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Each check's id: its own `id` where it names one, else `<section>.<n>`. A repeat (two checks naming one id, or a
|
|
187
|
+
* check object added twice) gets `-2`, `-3` and so on, so no two published entries share an id; a test holds each of
|
|
188
|
+
* the source's named ids to one site, `REPEATED_CHECK_IDS` excepted. `sectionOf(check)` is the section it was added in.
|
|
189
|
+
*/
|
|
190
|
+
export function assignCheckIds(checks, sectionOf = () => "env") {
|
|
191
|
+
const counters = new Map();
|
|
192
|
+
const seen = new Map();
|
|
193
|
+
return checks.map((check) => {
|
|
194
|
+
let base = typeof check?.id === "string" && CHECK_ID_RE.test(check.id) ? check.id : null;
|
|
195
|
+
if (base === null) {
|
|
196
|
+
const section = DOCTOR_SECTIONS.includes(sectionOf(check)) ? sectionOf(check) : "env";
|
|
197
|
+
const n = (counters.get(section) ?? 0) + 1;
|
|
198
|
+
counters.set(section, n);
|
|
199
|
+
base = `${section}.${n}`;
|
|
200
|
+
}
|
|
201
|
+
const times = (seen.get(base) ?? 0) + 1;
|
|
202
|
+
seen.set(base, times);
|
|
203
|
+
return times === 1 ? base : `${base}-${times}`;
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** A variable whose value is a secret by its name. The tail list keeps a path, a file, a mode or a pointer readable. */
|
|
208
|
+
const SECRET_NAME = /(TOKEN|SECRET|PASS|PRIVATE|API_KEY|ACCESS_KEY|_KEY$|^KEY$|_PAT$|^GITHUB_PAT|CRED|AUTH|COOKIE|SESSION|SIGNATURE)/i;
|
|
209
|
+
const NOT_A_SECRET = /(_PATH|_FILE|_DIR|_DIRS|_VAR|_SOURCE|_MODE|_HEADER|_URL|_ID|_TTL_DAYS|_MAX_AGE_DAYS|_MAX_CONTEXT_PCT|_MAX_RESUME_CHAIN|_MINUTES|_HOURS|_MAX_BYTES|_CAP|_TOKENS|_TIMEOUT_MS|_ROOTS|_PROFILES|_BUNDLES)$/i;
|
|
210
|
+
/**
|
|
211
|
+
* A value that is a number or a short switch is a knob, never a credential (`PI_DAILY_TOKEN_CAP=40` matched the name
|
|
212
|
+
* rule): taken as a secret, its digits would be redacted wherever they occur in a label, every count and size with them.
|
|
213
|
+
*/
|
|
214
|
+
const KNOB_VALUE = /^(?:\d+(?:\.\d+)?|true|false|on|off|yes|no)$/i;
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Every value in these environments that must never be published: each secret-named variable's value, the value of the
|
|
218
|
+
* variable `GITHUB_PAT_VAR` names, and the password inside any URL-shaped value. Strings only; `makeRedactor` drops
|
|
219
|
+
* anything under its eight-character floor (a port, a flag, a word).
|
|
220
|
+
*/
|
|
221
|
+
export function secretValuesIn(...envs) {
|
|
222
|
+
const values = [];
|
|
223
|
+
for (const env of envs) {
|
|
224
|
+
for (const [key, value] of Object.entries(env ?? {})) {
|
|
225
|
+
if (typeof value !== "string" || value === "") continue;
|
|
226
|
+
if (SECRET_NAME.test(key) && !NOT_A_SECRET.test(key) && !KNOB_VALUE.test(value.trim())) values.push(value);
|
|
227
|
+
const m = /^[A-Za-z][A-Za-z0-9+.-]*:\/\/([^/@\s]+)@/.exec(value);
|
|
228
|
+
if (m && m[1].includes(":")) {
|
|
229
|
+
const password = m[1].slice(m[1].indexOf(":") + 1);
|
|
230
|
+
if (password !== "") values.push(password);
|
|
231
|
+
try {
|
|
232
|
+
const decoded = decodeURIComponent(password);
|
|
233
|
+
if (decoded !== password) values.push(decoded);
|
|
234
|
+
} catch {
|
|
235
|
+
// a malformed escape is kept as written, which is the raw form above
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const patVar = env?.GITHUB_PAT_VAR;
|
|
240
|
+
if (typeof patVar === "string" && typeof env?.[patVar] === "string" && env[patVar] !== "") values.push(env[patVar]);
|
|
241
|
+
}
|
|
242
|
+
return values;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* C0, DEL, C1, the bidi embeddings, overrides and isolates, the zero-width characters, the Arabic letter mark, the line
|
|
247
|
+
* and paragraph separators and the byte-order mark: every character a terminal or a page would act on rather than show.
|
|
248
|
+
* Written as escapes, so the class is readable and no invisible character sits in this source.
|
|
249
|
+
*/
|
|
250
|
+
const CONTROLS = /[\u0000-\u001f\u007f-\u009f\u061c\u2028\u2029\u202a-\u202e\u2066-\u2069]/g;
|
|
251
|
+
/**
|
|
252
|
+
* Characters that render as nothing (zero-width spaces and joiners, the marks, the word joiner and invisible operators,
|
|
253
|
+
* the byte-order mark, the soft hyphen, the Mongolian vowel separator). DELETED rather than turned into spaces, and
|
|
254
|
+
* before any other rule: one inside a token or a path (`ghp_<U+200B>...`, `/<U+200B>Users`) would otherwise split it
|
|
255
|
+
* into pieces that no secret, shape or path rule recognises while a reader still sees the whole word.
|
|
256
|
+
*/
|
|
257
|
+
const INVISIBLE = /[\u00ad\u180e\u200b-\u200f\u2060-\u2064\ufeff]/g;
|
|
258
|
+
/** A URL: a scheme, `//`, then a run up to whitespace or a delimiter. Its host part ends at the first `/`, `?` or `#`. */
|
|
259
|
+
const URL_RUN = /\b([a-z][a-z0-9+.-]*):\/\/([^\s"'`<>()]*)/gi;
|
|
260
|
+
/**
|
|
261
|
+
* JSON.parse's own message QUOTES the input around the bad token (measured, node 22 and 23):
|
|
262
|
+
* `Unexpected token 'm', ..."prompt": mysecretpr"... is not valid JSON`. A file a loader refuses is the operator's,
|
|
263
|
+
* and its text may hold a value, so the quoted run (and the token, which is one character of it) is cut to
|
|
264
|
+
* `<content>`. The position forms ("in JSON at position 7 (line 1 column 8)") quote nothing and are kept.
|
|
265
|
+
*/
|
|
266
|
+
const JSON_SNIPPET = /Unexpected token '(?:[^'\\]|\\.){0,8}', (?:\.\.\.)?\\?"[\s\S]*?\\?"(?:\.\.\.)? is not valid JSON/g;
|
|
267
|
+
/**
|
|
268
|
+
* The same quote without the token lead, anchored where a message starts (after `: ` or `(`), so a quoted value EARLIER
|
|
269
|
+
* in the label (`envelope path "/a/b" must be ...`) is not swallowed from its leftmost quote.
|
|
270
|
+
*/
|
|
271
|
+
const JSON_SNIPPET_BARE = /(?<=(?:^|: |\())(?:\.\.\.)?"[\s\S]*?"(?:\.\.\.)? is not valid JSON/g;
|
|
272
|
+
/** The characters that end a path that is not quoted. */
|
|
273
|
+
const PATH_END = /[\s"'`<>()[\]{},;|]/;
|
|
274
|
+
/** A query parameter that carries a credential, in a URL or a bare `host/path?key=...`: its value is cut. */
|
|
275
|
+
const SECRET_QUERY = /([?&#;][A-Za-z0-9_.-]*(?:token|secret|key|password|passwd|pwd|sig|auth|credential)[A-Za-z0-9_.-]*=)[^&#\s"'`<>]+/gi;
|
|
276
|
+
/**
|
|
277
|
+
* Credential forms outside a URL: `password=...` and `password: ...` (an assignment or a YAML line a parser quoted),
|
|
278
|
+
* `--password <v>` and `--token=<v>` (a command a fix line names), `Basic <base64>` without its `Authorization` lead,
|
|
279
|
+
* and a `*-api-key:`/`*-token:` header. Each keeps its name and loses its value.
|
|
280
|
+
*/
|
|
281
|
+
const SECRET_ASSIGN = /((?<![\w-])(?:[A-Za-z0-9]+_)*(?:password|passwd|pwd|api[_-]?key|access[_-]?token|auth[_-]?token)\s*[=:]\s*["']?|(?<![\w-])secret\s*[=:]\s*["']?)[^\s"',;&]+/gi;
|
|
282
|
+
const SECRET_FLAG = /((?<![\w-])--(?:password|passwd|token|secret|api-key|auth-token)(?:=|\s+))[^\s"',;&]+/gi;
|
|
283
|
+
const BASIC_CREDENTIAL = /(\bbasic\s+)[A-Za-z0-9+/=]{8,}/gi;
|
|
284
|
+
const SECRET_HEADER = /(\b[\w-]*(?:api-key|-token|-secret)\s*:\s*)[^\s"',;]+/gi;
|
|
285
|
+
/** `user:password@host` with no scheme (an ssh or scp spelling): the userinfo is cut, the host kept. */
|
|
286
|
+
const BARE_USERINFO = /(?<![\w/:.@-])[A-Za-z0-9._-]+:[^\s@:/"'`<>]+@(?=[A-Za-z0-9])/g;
|
|
287
|
+
/** A character that, before a `/`, makes it part of a word, a ratio, a zone or a URL rather than the start of a path. */
|
|
288
|
+
const PATH_GLUE = /[A-Za-z0-9_.\-/\\~%+@]/;
|
|
289
|
+
/** Punctuation that ends a sentence rather than a path: kept after `<path>` instead of being swallowed by it. */
|
|
290
|
+
const TRAILING = /[.:,;!?]+$/;
|
|
291
|
+
const QUOTES = new Set(['"', "'", "`"]);
|
|
292
|
+
|
|
293
|
+
/** One URL as published: its scheme and host, never its userinfo, path, query or fragment. */
|
|
294
|
+
function urlShownForPublish(scheme, rest) {
|
|
295
|
+
const end = rest.search(/[/?#]/);
|
|
296
|
+
const authority = end < 0 ? rest : rest.slice(0, end);
|
|
297
|
+
const host = authority.slice(authority.lastIndexOf("@") + 1);
|
|
298
|
+
const tail = end < 0 ? "" : rest.slice(end);
|
|
299
|
+
const trail = (tail.match(TRAILING) ?? [""])[0];
|
|
300
|
+
if (host === "") return `${scheme}://${tail.replace(TRAILING, "") ? "<path>" : ""}${trail}`;
|
|
301
|
+
return `${scheme}://${host}${trail}`;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Whether a path starts at `i`, and how long its start marker is (POSIX `/x`, `~/`, `C:\`, `C:/`, `\\server`). */
|
|
305
|
+
function pathStartAt(text, i) {
|
|
306
|
+
const prev = i > 0 ? text[i - 1] : "";
|
|
307
|
+
if (prev !== "" && PATH_GLUE.test(prev)) return false;
|
|
308
|
+
const c = text[i];
|
|
309
|
+
const next = text[i + 1] ?? "";
|
|
310
|
+
if (c === "/") return next !== "" && next !== "/" && !/\s/.test(next);
|
|
311
|
+
if (c === "~") return next === "/";
|
|
312
|
+
if (/[A-Za-z]/.test(c) && next === ":" && /[\\/]/.test(text[i + 2] ?? "")) return !(text[i + 2] === "/" && text[i + 3] === "/");
|
|
313
|
+
if (c === "\\") return next === "\\" && /[^\s\\]/.test(text[i + 2] ?? "");
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Every absolute path in `text` as `<path>`. A scanner rather than one expression, because the shapes that leaked
|
|
319
|
+
* through a lead-character expression were the ones a person writes: `path:/etc/x`, `name|/x`, an em dash before one,
|
|
320
|
+
* `user@host:~/dev`, and a path with a space in it (`/Users/rob/My Projects/pi/.env`, `C:\Program Files\x`).
|
|
321
|
+
* - A path STARTS at a `/` (not doubled, not followed by a space), a `~/`, a drive letter or a UNC `\\`, wherever the
|
|
322
|
+
* character before it is not part of a word, a ratio or a URL (`PATH_GLUE`): so `Europe/Amsterdam`, `2/4`,
|
|
323
|
+
* `acme/web` and `ghcr.io/x/y` stay, and `:` or any other punctuation before it does not protect it.
|
|
324
|
+
* - A path that opens right after a quote runs to that quote's close, spaces and all.
|
|
325
|
+
* - Otherwise it runs to whitespace or a delimiter, and then over a space into the next word as long as that word
|
|
326
|
+
* holds a separator too (`My Projects/pi/.env`): a word with no separator after a space is prose. The residual,
|
|
327
|
+
* said rather than hidden: the last segment of a spaced path that holds no separator (`/a/My Projects`) is kept.
|
|
328
|
+
*/
|
|
329
|
+
function cutPaths(text) {
|
|
330
|
+
let out = "";
|
|
331
|
+
let i = 0;
|
|
332
|
+
while (i < text.length) {
|
|
333
|
+
if (!pathStartAt(text, i)) {
|
|
334
|
+
out += text[i];
|
|
335
|
+
i += 1;
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
const quote = i > 0 && QUOTES.has(text[i - 1]) ? text[i - 1] : null;
|
|
339
|
+
let j = i + 1;
|
|
340
|
+
if (quote) {
|
|
341
|
+
const close = text.indexOf(quote, j);
|
|
342
|
+
j = close < 0 ? text.length : close;
|
|
343
|
+
} else {
|
|
344
|
+
while (j < text.length && !PATH_END.test(text[j])) j += 1;
|
|
345
|
+
for (;;) {
|
|
346
|
+
const gap = text.slice(j).match(/^[ \u00a0]([^\s"'`<>()[\]{},;|]+)/);
|
|
347
|
+
if (!gap || !/[\\/]/.test(gap[1])) break;
|
|
348
|
+
j += gap[0].length;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
const path = text.slice(i, j);
|
|
352
|
+
const trail = quote ? "" : (path.match(TRAILING) ?? [""])[0];
|
|
353
|
+
out += `<path>${trail}`;
|
|
354
|
+
i = j;
|
|
355
|
+
}
|
|
356
|
+
return out;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Token shapes beyond `redact-secrets.mjs`'s, for text a console viewer reads: a JWT (three base64url parts, the first
|
|
361
|
+
* `eyJ`), a Google API key (`AIza` and 35), a Hugging Face token (`hf_`) and an npm token (`npm_`).
|
|
362
|
+
*/
|
|
363
|
+
export const PUBLISH_TOKEN_SHAPES = Object.freeze([
|
|
364
|
+
/(?<![A-Za-z0-9_-])eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g,
|
|
365
|
+
/(?<![A-Za-z0-9_])AIza[0-9A-Za-z_-]{28,}/g,
|
|
366
|
+
/(?<![A-Za-z0-9_])hf_[A-Za-z0-9]{20,}/g,
|
|
367
|
+
/(?<![A-Za-z0-9_])npm_[A-Za-z0-9]{20,}/g,
|
|
368
|
+
]);
|
|
369
|
+
|
|
370
|
+
/** The redactor a publish uses: this run's secret values, `redact-secrets.mjs`'s shapes and the shapes above. */
|
|
371
|
+
export function publishRedactor(secrets = []) {
|
|
372
|
+
return makeRedactor({ values: secrets, extraPatterns: [...PUBLISH_TOKEN_SHAPES] });
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* `text` as it may be published. The ORDER is the design: controls first (a separator inside a value would split what
|
|
377
|
+
* the rules below match), then secrets (a token inside a URL or a path is taken whole before either rule cuts around
|
|
378
|
+
* it), then JSON.parse's quoted snippet, then URLs (so a URL's own path is not mistaken for a filesystem path), then
|
|
379
|
+
* paths, then the cap, which comes LAST so a cut can never leave the prefix of something the rules above would take.
|
|
380
|
+
*/
|
|
381
|
+
export function redactText(text, { redact = publishRedactor() } = {}) {
|
|
382
|
+
if (typeof text !== "string" || text === "") return "";
|
|
383
|
+
let out = text.replace(INVISIBLE, "").replace(CONTROLS, " ");
|
|
384
|
+
out = redact(out);
|
|
385
|
+
out = out.replace(SECRET_QUERY, "$1[redacted]");
|
|
386
|
+
out = out.replace(SECRET_FLAG, "$1[redacted]");
|
|
387
|
+
out = out.replace(SECRET_ASSIGN, "$1[redacted]");
|
|
388
|
+
out = out.replace(BASIC_CREDENTIAL, "$1[redacted]");
|
|
389
|
+
out = out.replace(SECRET_HEADER, "$1[redacted]");
|
|
390
|
+
out = out.replace(BARE_USERINFO, "[redacted]@");
|
|
391
|
+
out = out.replace(JSON_SNIPPET, "<content> is not valid JSON");
|
|
392
|
+
out = out.replace(JSON_SNIPPET_BARE, "<content> is not valid JSON");
|
|
393
|
+
out = out.replace(URL_RUN, (_, scheme, rest) => urlShownForPublish(scheme.toLowerCase(), rest));
|
|
394
|
+
out = cutPaths(out);
|
|
395
|
+
out = out.replace(/\s+/g, " ").trim();
|
|
396
|
+
return out.length > DOCTOR_LABEL_MAX ? `${out.slice(0, DOCTOR_LABEL_MAX - 1)}\u2026` : out;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** A safe integer at least 0, or null. */
|
|
400
|
+
const count = (v) => (Number.isSafeInteger(v) && v >= 0 ? v : null);
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* The record one run publishes. `checks` are doctor's own objects in its order and `ids` their ids
|
|
404
|
+
* (`assignCheckIds`); `secrets` the values `secretValuesIn` found; `every` the worker's doctor interval in hours as the
|
|
405
|
+
* service reads it (null when it does not parse), which is what a reader judges a record's age against.
|
|
406
|
+
*/
|
|
407
|
+
export function buildDoctorRecord({ checks, ids, at, version, openshell = false, every = null, secrets = [], by = null }) {
|
|
408
|
+
const redact = publishRedactor(secrets);
|
|
409
|
+
const counts = { ok: 0, warn: 0, fail: 0 };
|
|
410
|
+
const listed = [];
|
|
411
|
+
let detailed = 0;
|
|
412
|
+
checks.forEach((check, i) => {
|
|
413
|
+
const level = checkLevel(check);
|
|
414
|
+
counts[level] += 1;
|
|
415
|
+
if (listed.length >= DOCTOR_CHECKS_MAX) return;
|
|
416
|
+
const entry = { id: ids[i], level };
|
|
417
|
+
const proposal = modelProposalOf(check?.proposal);
|
|
418
|
+
if (proposal) entry.proposal = proposal;
|
|
419
|
+
if ((level !== "ok" || proposal) && detailed < DOCTOR_DETAILED_MAX) {
|
|
420
|
+
detailed += 1;
|
|
421
|
+
entry.label = redactText(String(check?.label ?? ""), { redact });
|
|
422
|
+
const fix = typeof check?.fix === "string" ? redactText(check.fix, { redact }) : "";
|
|
423
|
+
if (fix !== "") entry.fix = fix;
|
|
424
|
+
}
|
|
425
|
+
listed.push(entry);
|
|
426
|
+
});
|
|
427
|
+
return {
|
|
428
|
+
v: DOCTOR_RECORD_VERSION,
|
|
429
|
+
at,
|
|
430
|
+
version: typeof version === "string" && /^[0-9A-Za-z.+-]{1,32}$/.test(version) ? version : "",
|
|
431
|
+
openshell: openshell === true,
|
|
432
|
+
every: count(every),
|
|
433
|
+
by: byOf(by),
|
|
434
|
+
counts,
|
|
435
|
+
checks: listed,
|
|
436
|
+
more: checks.length - listed.length,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Who ran it: `{ uid, worker }`, the effective uid (null where the platform has none) and whether the worker's own spawn
|
|
442
|
+
* ran it (`--by-worker`). An operator's run, under sudo or another account, publishes over the worker's record like any
|
|
443
|
+
* run; this is what lets a reader SAY so rather than show a root's view of the host as the worker's.
|
|
444
|
+
*/
|
|
445
|
+
function byOf(by) {
|
|
446
|
+
return { uid: count(by?.uid), worker: by?.worker === true };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** The worker's doctor interval as the service reads it (`config.mjs`'s rule), or null when it does not parse. */
|
|
450
|
+
export function doctorIntervalHours(env) {
|
|
451
|
+
const raw = env?.PI_DOCTOR_INTERVAL_HOURS;
|
|
452
|
+
if (raw === undefined || raw === "") return DOCTOR_INTERVAL_HOURS;
|
|
453
|
+
const n = Number.parseInt(raw, 10);
|
|
454
|
+
return Number.isInteger(n) && n >= 0 && n <= DOCTOR_INTERVAL_MAX_HOURS && String(n) === String(raw).trim() ? n : null;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** Reject rather than wait forever (`host-registry`'s `bounded`, and for its reason). Not unref'd, for that reason too. */
|
|
458
|
+
function bounded(promise, ms) {
|
|
459
|
+
return new Promise((resolve, reject) => {
|
|
460
|
+
const t = setTimeout(() => reject(new Error("timeout")), ms);
|
|
461
|
+
Promise.resolve(promise).then(
|
|
462
|
+
(v) => (clearTimeout(t), resolve(v)),
|
|
463
|
+
(e) => (clearTimeout(t), reject(e)),
|
|
464
|
+
);
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Write one host's record and index it. Throws on a fault; the CALLER is best effort (doctor says one line and keeps
|
|
470
|
+
* its exit code). The name must be a worker name, which is what lets it be a key segment unescaped.
|
|
471
|
+
*/
|
|
472
|
+
export async function publishDoctorRecord(redis, name, record, { ttlMs = DOCTOR_RESULTS_TTL_MS, timeoutMs = DOCTOR_OP_TIMEOUT_MS } = {}) {
|
|
473
|
+
if (typeof name !== "string" || !WORKER_NAME_RE.test(name)) throw new Error("not a worker name");
|
|
474
|
+
// An operator's run over the worker's: the worker's last run is named in the new record, so a reader can say whose
|
|
475
|
+
// view it shows (review round 1). Read best effort: a GET that fails costs the note, never the publish.
|
|
476
|
+
let written = record;
|
|
477
|
+
if (record?.by?.worker !== true) {
|
|
478
|
+
const before = await bounded(redis.get(doctorKey(name)), timeoutMs).then(
|
|
479
|
+
(v) => (typeof v === "string" ? parseDoctorRecord(v) : null),
|
|
480
|
+
() => null,
|
|
481
|
+
);
|
|
482
|
+
const worker = before?.by?.worker ? { uid: before.by.uid, at: before.at } : (before?.workerRun ?? null);
|
|
483
|
+
if (worker && worker.uid !== record?.by?.uid) written = { ...record, workerRun: worker };
|
|
484
|
+
}
|
|
485
|
+
// The value and its expiry in one command: see the header for why this is a STRING.
|
|
486
|
+
await bounded(redis.set(doctorKey(name), JSON.stringify(written), "PX", ttlMs), timeoutMs);
|
|
487
|
+
await bounded(redis.sadd(DOCTOR_HOSTS, name), timeoutMs);
|
|
488
|
+
// The writer prunes too (review round 1): a reader that promises no write (the console) never does, so on a
|
|
489
|
+
// deployment watched only from the console, members whose records expired would pile up and crowd the read's cap.
|
|
490
|
+
// At most PRUNE_MAX members are looked at, in one MGET, and a fault here is not the publish's.
|
|
491
|
+
try {
|
|
492
|
+
const members = (await bounded(redis.smembers(DOCTOR_HOSTS), timeoutMs)) ?? [];
|
|
493
|
+
const look = (Array.isArray(members) ? members : []).filter((m) => typeof m === "string" && m !== name).slice(0, PRUNE_MAX);
|
|
494
|
+
if (look.length > 0) {
|
|
495
|
+
const values = await bounded(redis.mget(...look.map(doctorKey)), timeoutMs);
|
|
496
|
+
for (const [i, m] of look.entries()) if (Array.isArray(values) && (values[i] === null || values[i] === undefined)) await bounded(redis.srem(DOCTOR_HOSTS, m), timeoutMs);
|
|
497
|
+
}
|
|
498
|
+
} catch {
|
|
499
|
+
// the reader's own prune and the next publish try again
|
|
500
|
+
}
|
|
501
|
+
return written;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** How many index members one publish looks at to prune. */
|
|
505
|
+
const PRUNE_MAX = 256;
|
|
506
|
+
|
|
507
|
+
/** A string field back through the content rule: control characters out and the cap kept, or null. */
|
|
508
|
+
function textBack(v) {
|
|
509
|
+
if (typeof v !== "string") return null;
|
|
510
|
+
const out = v.replace(INVISIBLE, "").replace(CONTROLS, " ");
|
|
511
|
+
return out.length > DOCTOR_LABEL_MAX ? `${out.slice(0, DOCTOR_LABEL_MAX - 1)}…` : out;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* A record read back through an ALLOWLIST, or null where it does not read. Another host wrote it, so nothing in it is
|
|
516
|
+
* trusted: every field is re-checked against the shape this module writes and anything else is dropped.
|
|
517
|
+
*/
|
|
518
|
+
export function parseDoctorRecord(text) {
|
|
519
|
+
let doc;
|
|
520
|
+
try {
|
|
521
|
+
doc = JSON.parse(text);
|
|
522
|
+
} catch {
|
|
523
|
+
return null;
|
|
524
|
+
}
|
|
525
|
+
if (!doc || typeof doc !== "object" || doc.v !== DOCTOR_RECORD_VERSION || !Number.isSafeInteger(doc.at) || doc.at < 0) return null;
|
|
526
|
+
const counts = { ok: count(doc.counts?.ok) ?? 0, warn: count(doc.counts?.warn) ?? 0, fail: count(doc.counts?.fail) ?? 0 };
|
|
527
|
+
const checks = [];
|
|
528
|
+
for (const c of Array.isArray(doc.checks) ? doc.checks.slice(0, DOCTOR_CHECKS_MAX) : []) {
|
|
529
|
+
if (!c || typeof c.id !== "string" || !CHECK_ID_RE.test(c.id) || !DOCTOR_LEVELS.includes(c.level)) continue;
|
|
530
|
+
const entry = { id: c.id, level: c.level };
|
|
531
|
+
const proposal = modelProposalOf(c.proposal);
|
|
532
|
+
if (proposal) entry.proposal = proposal;
|
|
533
|
+
if (c.level !== "ok" || proposal) {
|
|
534
|
+
const label = textBack(c.label);
|
|
535
|
+
const fix = textBack(c.fix);
|
|
536
|
+
if (label) entry.label = label;
|
|
537
|
+
if (fix) entry.fix = fix;
|
|
538
|
+
}
|
|
539
|
+
checks.push(entry);
|
|
540
|
+
}
|
|
541
|
+
return {
|
|
542
|
+
at: doc.at,
|
|
543
|
+
version: typeof doc.version === "string" && /^[0-9A-Za-z.+-]{0,32}$/.test(doc.version) ? doc.version : "",
|
|
544
|
+
openshell: doc.openshell === true,
|
|
545
|
+
every: count(doc.every),
|
|
546
|
+
by: byOf(doc.by),
|
|
547
|
+
workerRun: doc.workerRun && Number.isSafeInteger(doc.workerRun.at) ? { uid: count(doc.workerRun.uid), at: doc.workerRun.at } : null,
|
|
548
|
+
counts,
|
|
549
|
+
checks,
|
|
550
|
+
more: count(doc.more) ?? 0,
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Every published record: the index's names plus `names` (the live hosts a caller already read, so a host whose index
|
|
556
|
+
* entry was lost is still found), worker names only, at most `DOCTOR_HOSTS_MAX`, read in ONE MGET.
|
|
557
|
+
*
|
|
558
|
+
* `{ unreachable }` and `{ hosts: [] }` are different answers, `readLiveHosts`'s rule: "nobody published" is not "I
|
|
559
|
+
* could not find out". A member whose record has expired is pruned when `prune` (a reader that promises to write
|
|
560
|
+
* nothing, the console, passes false). Each row is `{ name, ageMs, ...record }` or `{ name, unreadable: true }`.
|
|
561
|
+
*/
|
|
562
|
+
export async function readDoctorResults(redis, { names = [], prune = true, now = () => Date.now(), timeoutMs = DOCTOR_OP_TIMEOUT_MS } = {}) {
|
|
563
|
+
let indexed;
|
|
564
|
+
try {
|
|
565
|
+
indexed = await bounded(redis.smembers(DOCTOR_HOSTS), timeoutMs);
|
|
566
|
+
if (indexed !== null && indexed !== undefined && !Array.isArray(indexed)) throw new Error("smembers did not return a list");
|
|
567
|
+
} catch (err) {
|
|
568
|
+
return { unreachable: err?.message ?? "doctor results unreadable" };
|
|
569
|
+
}
|
|
570
|
+
const inIndex = new Set((indexed ?? []).filter((n) => typeof n === "string"));
|
|
571
|
+
// The LIVE names first, then the index (review round 1): with the cap applied to one sorted list, seventy expired
|
|
572
|
+
// index members that sort before a live host pushed it past the cap, and the panel then said it never published.
|
|
573
|
+
const live = [...new Set(names.filter((n) => typeof n === "string" && WORKER_NAME_RE.test(n)))].sort();
|
|
574
|
+
const rest = [...inIndex].filter((n) => WORKER_NAME_RE.test(n) && !live.includes(n)).sort();
|
|
575
|
+
const all = [...live, ...rest];
|
|
576
|
+
const wanted = all.slice(0, DOCTOR_HOSTS_MAX);
|
|
577
|
+
if (wanted.length === 0) return { hosts: [], more: 0 };
|
|
578
|
+
let values;
|
|
579
|
+
try {
|
|
580
|
+
values = await bounded(redis.mget(...wanted.map(doctorKey)), timeoutMs);
|
|
581
|
+
if (!Array.isArray(values)) throw new Error("mget did not return a list");
|
|
582
|
+
} catch (err) {
|
|
583
|
+
return { unreachable: err?.message ?? "doctor results unreadable" };
|
|
584
|
+
}
|
|
585
|
+
const hosts = [];
|
|
586
|
+
for (const [i, name] of wanted.entries()) {
|
|
587
|
+
const value = values[i];
|
|
588
|
+
if (value === null || value === undefined) {
|
|
589
|
+
if (prune && inIndex.has(name)) await bounded(redis.srem(DOCTOR_HOSTS, name), timeoutMs).catch(() => {});
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
const record = typeof value === "string" ? parseDoctorRecord(value) : null;
|
|
593
|
+
if (record === null) {
|
|
594
|
+
hosts.push({ name, unreadable: true });
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
// A record whose clock is AHEAD of ours reads as 0, the registry's posture on skew.
|
|
598
|
+
hosts.push({ name, ageMs: Math.max(0, now() - record.at), ...record });
|
|
599
|
+
}
|
|
600
|
+
hosts.sort((a, b) => a.name.localeCompare(b.name));
|
|
601
|
+
return { hosts, more: all.length - wanted.length };
|
|
602
|
+
}
|