@edgehero/pi-dispatch 4.3.0 → 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 +94 -4
- 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 +199 -30
- 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 +1663 -198
- package/src/dollar-budget.mjs +212 -3
- package/src/endpoint-status.mjs +100 -0
- package/src/env-allowlist.mjs +103 -9
- package/src/env-file.mjs +6 -4
- package/src/file-expect.mjs +47 -0
- package/src/forgejo-auth.mjs +4 -0
- package/src/forgejo-host.mjs +127 -31
- package/src/forgejo-prompt.mjs +5 -4
- package/src/forges.mjs +73 -15
- 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 +740 -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 +1554 -167
- 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 +296 -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 +782 -40
- 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,557 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a host's jobs can load (issue #800, `INT-DOCTOR-RESULTS-KEY`'s `doctor:loads:<name>`).
|
|
3
|
+
*
|
|
4
|
+
* Each host has its own overlay and its own staged packages, so a fresh sandbox, a project that runs on three machines and
|
|
5
|
+
* a trigger's page all want to know what THAT host would hand a job. Nothing said so: doctor's record keeps a label only
|
|
6
|
+
* for a warn or a fail (`doctor-results.mjs`), so a healthy host's extension names, skills and MCP servers were published
|
|
7
|
+
* nowhere. One STRING per host, written by doctor after its own record, in one `SET ... PX`, for doctor's reasons.
|
|
8
|
+
*
|
|
9
|
+
* THE SAME TEST AS doctor:*: delete every key while the fleet runs and every host behaves exactly as before. Nothing reads
|
|
10
|
+
* this to decide anything. And a reader that finds none says unknown, never that a host has nothing: a host on an older
|
|
11
|
+
* release publishes none, and "the same" is the one thing that must never be read off its silence (`hostsAgree`).
|
|
12
|
+
*
|
|
13
|
+
* THE CONTENT RULE. Read by every console viewer, on another machine, so: names and closed words only, each held to its
|
|
14
|
+
* charset, on the way in AND on the way out, since another host wrote what a reader parses. An overlay extension's first
|
|
15
|
+
* path segment and whether it is a file or a folder; a skill's folder name; a server's name, transport, where it is
|
|
16
|
+
* (`mcpServerWhere`: a url's host and port, or a command's base name, never a header, an argument or a value), exposure,
|
|
17
|
+
* whether it is on, and the variable NAMES it references; a package's name and version; a trigger's position, flow name and
|
|
18
|
+
* the tier it resolved in. No path, no value and no tool name: what a server or an extension offers is known only once it
|
|
19
|
+
* runs, and the run's own record says that. These are the same facts the viewer's Connections page shows; a model's
|
|
20
|
+
* projection (`modelConnections`) carries counts in place of `where` and the key names, and one of these rows gets the
|
|
21
|
+
* same treatment when a tool reads it.
|
|
22
|
+
*
|
|
23
|
+
* The image and pi's version are not here: `host:h:<name>` already publishes them (INT-HOST-REGISTRY-CONTRACT), from the
|
|
24
|
+
* beat, and a second copy here would only be able to disagree with it.
|
|
25
|
+
*
|
|
26
|
+
* APPROXIMATE, and in the safe direction, as doctor's flow lines are (`DES-FLOW-RESOLUTION-TWO-ADVISORY-LAYERS`). This reads
|
|
27
|
+
* folders on the host and runs nothing: a frontmatter `name:` rename is invisible, and what a run really loaded is its
|
|
28
|
+
* own record's `loaded`. Where a read cannot say, it says so (`unread`), never an empty list that reads as none.
|
|
29
|
+
*/
|
|
30
|
+
import * as nodeFs from "node:fs";
|
|
31
|
+
import { join, resolve, sep } from "node:path";
|
|
32
|
+
import { DOCTOR_HOSTS, DOCTOR_HOSTS_MAX, DOCTOR_RESULTS_TTL_MS } from "./doctor-results.mjs";
|
|
33
|
+
import { SKILL_NAME_RE } from "./flow-gate.mjs";
|
|
34
|
+
import { ABSENT_CODES, MCP_EXPOSURES, MCP_SERVER_NAME_RE, mcpServerWhere } from "./mcp-config.mjs";
|
|
35
|
+
import { parsePackageCanary } from "./package-canary-record.mjs";
|
|
36
|
+
import { isPackageName } from "./package-names.mjs";
|
|
37
|
+
import { WORKER_NAME_RE } from "./worker-name.mjs";
|
|
38
|
+
|
|
39
|
+
/** One host's record, beside `doctor:h:<name>` and under the same `doctor:hosts` index. */
|
|
40
|
+
export const hostLoadsKey = (name) => `doctor:loads:${name}`;
|
|
41
|
+
/** A reader refuses any other, so a later shape reads as unknown, never as something misread. */
|
|
42
|
+
export const HOST_LOADS_VERSION = 1;
|
|
43
|
+
/** Each list's cap. Past it the rest is counted in `more`, never dropped silently. */
|
|
44
|
+
export const HOST_LOADS_CAPS = Object.freeze({ extensions: 32, skills: 64, mcp: 32, keys: 4, packages: 64, flows: 128 });
|
|
45
|
+
/** The whole record, serialized. The caps alone allow more, so the fit below drops rows until it holds. */
|
|
46
|
+
export const HOST_LOADS_MAX_BYTES = 64 * 1024;
|
|
47
|
+
/** The order rows go when the whole record is over its bound: the most numerous and the longest first, a server last. */
|
|
48
|
+
const FIT_ORDER = Object.freeze(["flows", "packages", "skills", "extensions", "mcp"]);
|
|
49
|
+
/** The parts a read can fail to say, each then reading as unknown. */
|
|
50
|
+
export const HOST_LOADS_PARTS = Object.freeze(["extensions", "skills", "mcp", "packages"]);
|
|
51
|
+
/** What `more` can count: each list, and the keys a server's row left out. Each has a cap, so the caps name them. */
|
|
52
|
+
const HOST_LOADS_MORE = Object.freeze(Object.keys(HOST_LOADS_CAPS));
|
|
53
|
+
/** The record's five lists, every one of which a record must hold: a list it does not have is not an empty one. */
|
|
54
|
+
const HOST_LOADS_LISTS = Object.freeze(HOST_LOADS_MORE.filter((part) => part !== "keys"));
|
|
55
|
+
export const LOADS_EXTENSION_KINDS = Object.freeze(["file", "dir"]);
|
|
56
|
+
export const LOADS_TRANSPORTS = Object.freeze(["http", "stdio"]);
|
|
57
|
+
/** Where a flow resolved, the run record's words, with `unknown` for a tier this host cannot see. */
|
|
58
|
+
export const LOADS_FLOW_TIERS = Object.freeze(["repository", "trigger", "overlay", "package", "none", "unknown"]);
|
|
59
|
+
export const LOADS_REPO_STATES = Object.freeze(["present", "absent", "unknown"]);
|
|
60
|
+
/** How many of a host's own doctor intervals a record may age before it reads as old (`DOCTOR_STALE_INTERVALS`, pinned). */
|
|
61
|
+
export const HOST_LOADS_OLD_INTERVALS = 2;
|
|
62
|
+
|
|
63
|
+
const HOUR_MS = 3_600_000;
|
|
64
|
+
/** How long any one command may take, doctor-results.mjs's bound and for its reason. */
|
|
65
|
+
const OP_TIMEOUT_MS = 2_000;
|
|
66
|
+
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
67
|
+
const count = (value) => (Number.isSafeInteger(value) && value >= 0 ? value : null);
|
|
68
|
+
|
|
69
|
+
const EXTENSION_NAME_RE = /^[A-Za-z0-9._-]{1,48}$/;
|
|
70
|
+
/** A variable a server references: `mcp-config.mjs`'s `${NAME}`, bounded here. */
|
|
71
|
+
const KEY_NAME_RE = /^[A-Z_][A-Z0-9_]{0,63}$/;
|
|
72
|
+
/** A url's host and port, or a command's base name. No separator, so never a path; no space, so never prose. */
|
|
73
|
+
const WHERE_RE = /^[A-Za-z0-9._:[\]-]{1,255}$/;
|
|
74
|
+
const INDEX_MAX = 1_000_000;
|
|
75
|
+
|
|
76
|
+
/** A first path segment: the shape a run's record holds, less the two a folder listing never returns and a path could. */
|
|
77
|
+
const extensionOf = (row) => (isObject(row) && typeof row.name === "string" && EXTENSION_NAME_RE.test(row.name) && row.name !== "." && row.name !== ".." && LOADS_EXTENSION_KINDS.includes(row.kind) ? { name: row.name, kind: row.kind } : null);
|
|
78
|
+
const skillOf = (name) => (typeof name === "string" && SKILL_NAME_RE.test(name) ? name : null);
|
|
79
|
+
const flowOf = (row) => {
|
|
80
|
+
if (!isObject(row) || !Number.isSafeInteger(row.trigger) || row.trigger < 0 || row.trigger > INDEX_MAX) return null;
|
|
81
|
+
if (!LOADS_FLOW_TIERS.includes(row.tier) || !LOADS_REPO_STATES.includes(row.repo)) return null;
|
|
82
|
+
const flow = row.flow === null ? null : skillOf(row.flow);
|
|
83
|
+
if (flow === null && row.flow !== null) return null;
|
|
84
|
+
const pkg = row.package === null ? null : isPackageName(row.package) ? row.package : undefined;
|
|
85
|
+
if (pkg === undefined || (pkg !== null && row.tier !== "package")) return null;
|
|
86
|
+
return { trigger: row.trigger, flow, tier: row.tier, package: pkg, repo: row.repo };
|
|
87
|
+
};
|
|
88
|
+
/** One server's row, or null, with how many of its keys its cap left out. */
|
|
89
|
+
const serverOf = (row) => {
|
|
90
|
+
if (!isObject(row) || typeof row.name !== "string" || !MCP_SERVER_NAME_RE.test(row.name)) return null;
|
|
91
|
+
if (!LOADS_TRANSPORTS.includes(row.transport) || !MCP_EXPOSURES.includes(row.exposure) || typeof row.enabled !== "boolean" || !Array.isArray(row.keys)) return null;
|
|
92
|
+
const where = typeof row.where === "string" && WHERE_RE.test(row.where) ? row.where : null;
|
|
93
|
+
const keys = row.keys.filter((key) => typeof key === "string" && KEY_NAME_RE.test(key));
|
|
94
|
+
return { row: { name: row.name, transport: row.transport, where, exposure: row.exposure, enabled: row.enabled, keys: keys.slice(0, HOST_LOADS_CAPS.keys) }, leftOut: row.keys.length - Math.min(keys.length, HOST_LOADS_CAPS.keys) };
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** A list kept through `of` and held to `cap`, with how many it did not keep: what failed its charset, and what is past the cap. */
|
|
98
|
+
function within(list, of, cap) {
|
|
99
|
+
const kept = [];
|
|
100
|
+
for (const entry of list) {
|
|
101
|
+
const value = of(entry);
|
|
102
|
+
if (value !== null && kept.length < cap) kept.push(value);
|
|
103
|
+
}
|
|
104
|
+
return { kept, rest: list.length - kept.length };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The servers, whose rows each carry their own count of keys left out. */
|
|
108
|
+
function serversWithin(list, cap) {
|
|
109
|
+
const kept = [];
|
|
110
|
+
let rest = 0;
|
|
111
|
+
let keys = 0;
|
|
112
|
+
for (const entry of list) {
|
|
113
|
+
const server = serverOf(entry);
|
|
114
|
+
if (server !== null && kept.length < cap) {
|
|
115
|
+
kept.push(server.row);
|
|
116
|
+
keys += server.leftOut;
|
|
117
|
+
} else {
|
|
118
|
+
rest += 1;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return { kept, rest, keys };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Only the counts that are not zero: a record with nothing left out says `{}`. */
|
|
125
|
+
function moreOf(counts) {
|
|
126
|
+
const out = {};
|
|
127
|
+
for (const [part, value] of Object.entries(counts)) if (value > 0) out[part] = value;
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The server rows of an overlay's parsed `mcp.json` (`readOverlayMcp`'s `servers`), as Connections reads them: a refused
|
|
133
|
+
* entry is never offered to a job, so it is not what a job can load, and doctor names it. `redact` takes this run's secrets
|
|
134
|
+
* out of `where`: a host is never a secret, except when someone put one there.
|
|
135
|
+
*/
|
|
136
|
+
export function mcpRowsOf(servers, { redact = (text) => text } = {}) {
|
|
137
|
+
return Object.entries(servers ?? {}).map(([name, server]) => {
|
|
138
|
+
const where = mcpServerWhere(server);
|
|
139
|
+
return { name, transport: server.transport === "http" ? "http" : "stdio", where: redact(where) === where ? where : null, exposure: typeof server.exposure === "string" ? server.exposure : "codemode", enabled: server.enabled !== false, keys: Array.isArray(server.refs) ? [...server.refs].sort() : [] };
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The record one doctor run publishes, from the parts it read. Pure. `at`, `every` and `by` are the doctor record's own,
|
|
145
|
+
* since this is the same run. Bounded twice: each list by its cap, then the whole by `HOST_LOADS_MAX_BYTES`, since the
|
|
146
|
+
* longest name of each kind at every cap is over it. Everything dropped, at either bound, is counted in `more`.
|
|
147
|
+
*/
|
|
148
|
+
export function buildHostLoads({ at, every = null, by = null, overlayExtensionsAllowed = true, extensions = [], skills = [], mcp = [], packages = [], flows = [], unread = [] }) {
|
|
149
|
+
const parts = {
|
|
150
|
+
extensions: within(extensions, extensionOf, HOST_LOADS_CAPS.extensions),
|
|
151
|
+
skills: within(skills, skillOf, HOST_LOADS_CAPS.skills),
|
|
152
|
+
packages: within(packages, parsePackageCanary, HOST_LOADS_CAPS.packages),
|
|
153
|
+
flows: within(flows, flowOf, HOST_LOADS_CAPS.flows),
|
|
154
|
+
};
|
|
155
|
+
const servers = serversWithin(mcp, HOST_LOADS_CAPS.mcp);
|
|
156
|
+
const more = { extensions: parts.extensions.rest, skills: parts.skills.rest, mcp: servers.rest, keys: servers.keys, packages: parts.packages.rest, flows: parts.flows.rest };
|
|
157
|
+
const record = {
|
|
158
|
+
v: HOST_LOADS_VERSION,
|
|
159
|
+
at,
|
|
160
|
+
every: count(every),
|
|
161
|
+
by: { uid: count(by?.uid), worker: by?.worker === true },
|
|
162
|
+
overlayExtensionsAllowed: overlayExtensionsAllowed === true,
|
|
163
|
+
extensions: parts.extensions.kept,
|
|
164
|
+
skills: parts.skills.kept,
|
|
165
|
+
mcp: servers.kept,
|
|
166
|
+
packages: parts.packages.kept,
|
|
167
|
+
flows: parts.flows.kept,
|
|
168
|
+
...(unread.length > 0 ? { unread: HOST_LOADS_PARTS.filter((part) => unread.includes(part)) } : {}),
|
|
169
|
+
more: moreOf(more),
|
|
170
|
+
};
|
|
171
|
+
// The whole bound, from the tail of the part that can spare it, so the record that goes out always parses.
|
|
172
|
+
const size = () => Buffer.byteLength(JSON.stringify(record), "utf8");
|
|
173
|
+
for (const part of FIT_ORDER) {
|
|
174
|
+
while (size() > HOST_LOADS_MAX_BYTES && record[part].length > 0) {
|
|
175
|
+
record[part].pop();
|
|
176
|
+
more[part] += 1;
|
|
177
|
+
record.more = moreOf(more);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return record;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* A record read back through an ALLOWLIST, or null where it does not read: text past `HOST_LOADS_MAX_BYTES`, which no builder
|
|
185
|
+
* writes; JSON that does not parse; any `v` but this one, newer included; and a record with no shape. Another host wrote it,
|
|
186
|
+
* so a row that fails its charset is dropped and COUNTED in `more`: a list shorter than what that host said can never read
|
|
187
|
+
* as the same as another's. A list that is not there, or a `more` that cannot be read, is not that: nothing can be counted.
|
|
188
|
+
* REJECTED: reading such a part as empty or off, as this did, which let `{"v":1,"at":1000}` parse as a real host
|
|
189
|
+
* with nothing on it, agreeing with one. Null reads as unknown, and unknown is the rule (`hostsAgree`).
|
|
190
|
+
*/
|
|
191
|
+
export function parseHostLoads(text) {
|
|
192
|
+
if (typeof text !== "string" || Buffer.byteLength(text, "utf8") > HOST_LOADS_MAX_BYTES) return null;
|
|
193
|
+
let doc;
|
|
194
|
+
try {
|
|
195
|
+
doc = JSON.parse(text);
|
|
196
|
+
} catch {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
if (!isObject(doc) || doc.v !== HOST_LOADS_VERSION || !Number.isSafeInteger(doc.at) || doc.at < 0) return null;
|
|
200
|
+
if (!HOST_LOADS_LISTS.every((part) => Array.isArray(doc[part])) || typeof doc.overlayExtensionsAllowed !== "boolean" || !isObject(doc.by)) return null;
|
|
201
|
+
// `{ worker: "yes" }` read as false, which says an operator ran it, and `{ uid: "root" }` as no uid known. The builder writes
|
|
202
|
+
// both keys always, a uid it does not know as null, so anything else is not this record's shape.
|
|
203
|
+
if ((doc.by.uid !== null && count(doc.by.uid) === null) || typeof doc.by.worker !== "boolean") return null;
|
|
204
|
+
// A word that is not there, or not a count, would read as nothing left out or as a host with no interval, which never ages.
|
|
205
|
+
if (doc.every !== null && count(doc.every) === null) return null;
|
|
206
|
+
// A key with no word here is refused too. Dropping it read `{ widgets: 3 }` as nothing left out, and `hostsAgree`, whose rule is
|
|
207
|
+
// nothing in `more`, then said true of a host that says it left something out.
|
|
208
|
+
if (!isObject(doc.more) || !Object.entries(doc.more).every(([part, value]) => HOST_LOADS_MORE.includes(part) && count(value) !== null)) return null;
|
|
209
|
+
// Absent is none and the builder's own way of saying so. A part this reader has no word for is one it cannot say was read.
|
|
210
|
+
if (doc.unread !== undefined && (!Array.isArray(doc.unread) || !doc.unread.every((part) => HOST_LOADS_PARTS.includes(part)))) return null;
|
|
211
|
+
const parts = {
|
|
212
|
+
extensions: within(doc.extensions, extensionOf, HOST_LOADS_CAPS.extensions),
|
|
213
|
+
skills: within(doc.skills, skillOf, HOST_LOADS_CAPS.skills),
|
|
214
|
+
packages: within(doc.packages, parsePackageCanary, HOST_LOADS_CAPS.packages),
|
|
215
|
+
flows: within(doc.flows, flowOf, HOST_LOADS_CAPS.flows),
|
|
216
|
+
};
|
|
217
|
+
const servers = serversWithin(doc.mcp, HOST_LOADS_CAPS.mcp);
|
|
218
|
+
const said = (part) => doc.more[part] ?? 0;
|
|
219
|
+
const unread = HOST_LOADS_PARTS.filter((part) => doc.unread?.includes(part));
|
|
220
|
+
return {
|
|
221
|
+
at: doc.at,
|
|
222
|
+
every: count(doc.every),
|
|
223
|
+
by: { uid: count(doc.by?.uid), worker: doc.by?.worker === true },
|
|
224
|
+
overlayExtensionsAllowed: doc.overlayExtensionsAllowed,
|
|
225
|
+
extensions: parts.extensions.kept,
|
|
226
|
+
skills: parts.skills.kept,
|
|
227
|
+
mcp: servers.kept,
|
|
228
|
+
packages: parts.packages.kept,
|
|
229
|
+
flows: parts.flows.kept,
|
|
230
|
+
unread,
|
|
231
|
+
more: moreOf({ extensions: said("extensions") + parts.extensions.rest, skills: said("skills") + parts.skills.rest, mcp: said("mcp") + servers.rest, keys: said("keys") + servers.keys, packages: said("packages") + parts.packages.rest, flows: said("flows") + parts.flows.rest }),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Reject rather than wait forever (`host-registry`'s `bounded`, and for its reason). */
|
|
236
|
+
function bounded(promise, ms) {
|
|
237
|
+
return new Promise((done, fail) => {
|
|
238
|
+
const timer = setTimeout(() => fail(new Error("timeout")), ms);
|
|
239
|
+
Promise.resolve(promise).then(
|
|
240
|
+
(value) => (clearTimeout(timer), done(value)),
|
|
241
|
+
(error) => (clearTimeout(timer), fail(error)),
|
|
242
|
+
);
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Write one host's record: one `SET ... PX`, the value and its expiry in one command, the reason `publishDoctorRecord` gives.
|
|
248
|
+
* No index write: this comes after the doctor record, which has just put this host under `doctor:hosts`. Throws on a fault;
|
|
249
|
+
* the caller is best effort.
|
|
250
|
+
*/
|
|
251
|
+
export async function publishHostLoads(redis, name, record, { ttlMs = DOCTOR_RESULTS_TTL_MS, timeoutMs = OP_TIMEOUT_MS } = {}) {
|
|
252
|
+
if (typeof name !== "string" || !WORKER_NAME_RE.test(name)) throw new Error("not a worker name");
|
|
253
|
+
await bounded(redis.set(hostLoadsKey(name), JSON.stringify(record), "PX", ttlMs), timeoutMs);
|
|
254
|
+
return record;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Whether a parsed row is `current` or `old`: older than `HOST_LOADS_OLD_INTERVALS` of its own host's interval. A host whose
|
|
259
|
+
* interval is 0 (its worker runs no doctor) or unknown is never called old, doctor-view.mjs's rule: only an operator's run
|
|
260
|
+
* publishes there, and its age is shown.
|
|
261
|
+
*/
|
|
262
|
+
export function loadsFreshness(row, { now = Date.now() } = {}) {
|
|
263
|
+
const ageMs = Math.max(0, now - row.at);
|
|
264
|
+
return count(row.every) > 0 && ageMs > HOST_LOADS_OLD_INTERVALS * row.every * HOUR_MS ? "old" : "current";
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Every host's record: the live hosts a caller names first, then the index's other members (`readDoctorResults`'s rule, so
|
|
269
|
+
* expired members can never push a live host out of the read), worker names only, at most `DOCTOR_HOSTS_MAX`, in one MGET.
|
|
270
|
+
* Promises to write nothing: doctor's publish prunes the index it shares.
|
|
271
|
+
*
|
|
272
|
+
* A row is `{ name, state, ageMs, ...record }`, `state` being `current` or `old`, or `{ name, state: "unknown", why }` where
|
|
273
|
+
* nothing can be said, `why` being `absent` (never published, an older host, or expired past its seven days) or `unreadable`
|
|
274
|
+
* (does not parse, or a newer `v`). `{ unreachable }` and `{ hosts: [] }` are different answers, as for the doctor rows.
|
|
275
|
+
*/
|
|
276
|
+
export async function readHostLoads(redis, { names = [], now = () => Date.now(), timeoutMs = OP_TIMEOUT_MS } = {}) {
|
|
277
|
+
let indexed;
|
|
278
|
+
try {
|
|
279
|
+
indexed = await bounded(redis.smembers(DOCTOR_HOSTS), timeoutMs);
|
|
280
|
+
if (indexed !== null && indexed !== undefined && !Array.isArray(indexed)) throw new Error("smembers did not return a list");
|
|
281
|
+
} catch (error) {
|
|
282
|
+
return { unreachable: error?.message ?? "host loads unreadable" };
|
|
283
|
+
}
|
|
284
|
+
const live = [...new Set(names.filter((name) => typeof name === "string" && WORKER_NAME_RE.test(name)))].sort();
|
|
285
|
+
const rest = [...new Set(indexed ?? [])].filter((name) => typeof name === "string" && WORKER_NAME_RE.test(name) && !live.includes(name)).sort();
|
|
286
|
+
const all = [...live, ...rest];
|
|
287
|
+
const wanted = all.slice(0, DOCTOR_HOSTS_MAX);
|
|
288
|
+
if (wanted.length === 0) return { hosts: [], more: 0 };
|
|
289
|
+
let values;
|
|
290
|
+
try {
|
|
291
|
+
values = await bounded(redis.mget(...wanted.map(hostLoadsKey)), timeoutMs);
|
|
292
|
+
if (!Array.isArray(values)) throw new Error("mget did not return a list");
|
|
293
|
+
} catch (error) {
|
|
294
|
+
return { unreachable: error?.message ?? "host loads unreadable" };
|
|
295
|
+
}
|
|
296
|
+
const hosts = wanted.map((name, index) => {
|
|
297
|
+
const value = values[index];
|
|
298
|
+
if (value === null || value === undefined) return { name, state: "unknown", why: "absent" };
|
|
299
|
+
const record = typeof value === "string" ? parseHostLoads(value) : null;
|
|
300
|
+
if (record === null) return { name, state: "unknown", why: "unreadable" };
|
|
301
|
+
const at = now();
|
|
302
|
+
return { name, state: loadsFreshness(record, { now: at }), ageMs: Math.max(0, at - record.at), ...record };
|
|
303
|
+
});
|
|
304
|
+
hosts.sort((a, b) => a.name.localeCompare(b.name));
|
|
305
|
+
return { hosts, more: all.length - wanted.length };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** What two hosts must share to read as the same, each list in an order the file system did not choose. Package order is pi's load order, so it stays. */
|
|
309
|
+
function loadsFingerprint(row) {
|
|
310
|
+
const sorted = (list, key) => [...list].sort((a, b) => key(a).localeCompare(key(b)));
|
|
311
|
+
return JSON.stringify({
|
|
312
|
+
overlayExtensionsAllowed: row.overlayExtensionsAllowed,
|
|
313
|
+
extensions: sorted(row.extensions, (entry) => entry.name),
|
|
314
|
+
skills: sorted(row.skills, (name) => name),
|
|
315
|
+
mcp: sorted(row.mcp, (entry) => entry.name),
|
|
316
|
+
packages: row.packages,
|
|
317
|
+
flows: sorted(row.flows, (entry) => String(entry.trigger).padStart(10, "0")),
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Whether every host's jobs can load the same things: true, false, or null when that cannot be said. NEVER true unless every
|
|
323
|
+
* host named has a current record that lists everything. An unknown or old host, a part a host could not read and a list
|
|
324
|
+
* past its cap are each a reason to say nothing, because each is what "the same" would be guessing at: a host on an older
|
|
325
|
+
* release publishes no record and may run anything. So is a server whose `where` reads null: it failed its charset or held a
|
|
326
|
+
* secret, and two different places read the same that way. Null also for no hosts at all. A difference between two known
|
|
327
|
+
* hosts still reads as null beside an unknown third: this is a question about all of them.
|
|
328
|
+
*/
|
|
329
|
+
export function hostsAgree(rows) {
|
|
330
|
+
if (!Array.isArray(rows) || rows.length === 0) return null;
|
|
331
|
+
if (!rows.every((row) => row?.state === "current" && Array.isArray(row.unread) && row.unread.length === 0 && isObject(row.more) && Object.keys(row.more).length === 0 && HOST_LOADS_LISTS.every((part) => Array.isArray(row[part])) && row.mcp.every((server) => server.where !== null))) return null;
|
|
332
|
+
const [first, ...others] = rows.map(loadsFingerprint);
|
|
333
|
+
return others.every((other) => other === first);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Where a trigger's flow resolves on this host, in the loader's own precedence order (repository, trigger, overlay, staged
|
|
338
|
+
* packages), first hit wins. The one reading behind doctor's check line and the published row, so the two cannot disagree
|
|
339
|
+
* (`DES-FLOW-RESOLUTION-TWO-ADVISORY-LAYERS`). Keeps doctor's existential probe: an unknown tier does not stop the walk.
|
|
340
|
+
*
|
|
341
|
+
* tier the first tier that holds it; else `none` when every tier was checked, or `unknown` when one could not be.
|
|
342
|
+
* A forge's repository is not on a host, so a forge trigger found nowhere else is `unknown`, never `none`. A tier
|
|
343
|
+
* that could not be read is its caller's to say, through `flowProbes`: `fileExists` here has one answer
|
|
344
|
+
* package the staged package's name, for `package` alone
|
|
345
|
+
* repo what the repository tier answered: `present`, `absent` or `unknown`
|
|
346
|
+
* resolved, checked, unknown the words of doctor's own line, which name folders and so are never published
|
|
347
|
+
*
|
|
348
|
+
* `repoFlowAtHead(folder, flow)` answers `present`, `absent` or anything else for not known. `staged` is `readStagedSkills`.
|
|
349
|
+
*/
|
|
350
|
+
export async function flowTierOf({ flow, folder = null, skillsDir = null, packages = true, excludeExtensions = [] }, { repoFlowAtHead, fileExists, overlay = null, staged = { skills: [], unenumerable: [] } }) {
|
|
351
|
+
let tier = null;
|
|
352
|
+
let pkg = null;
|
|
353
|
+
let resolved = null;
|
|
354
|
+
let repo = "unknown";
|
|
355
|
+
const checked = [];
|
|
356
|
+
const unknown = [];
|
|
357
|
+
if (folder) {
|
|
358
|
+
const state = await repoFlowAtHead(folder, flow);
|
|
359
|
+
if (state === "present") {
|
|
360
|
+
repo = "present";
|
|
361
|
+
tier = "repository";
|
|
362
|
+
resolved = `repo .pi/skills at HEAD of ${folder}`;
|
|
363
|
+
} else if (state === "absent") {
|
|
364
|
+
repo = "absent";
|
|
365
|
+
checked.push("repo .pi/skills at HEAD");
|
|
366
|
+
} else {
|
|
367
|
+
unknown.push(`repo (${folder} is not readable as a git repo here)`);
|
|
368
|
+
}
|
|
369
|
+
} else {
|
|
370
|
+
unknown.push("repo (a forge clone, not on this host)");
|
|
371
|
+
}
|
|
372
|
+
if (!resolved && skillsDir) {
|
|
373
|
+
if (fileExists(join(skillsDir, flow, "SKILL.md"))) {
|
|
374
|
+
tier = "trigger";
|
|
375
|
+
resolved = `injected run.skillsDir ${skillsDir}`;
|
|
376
|
+
} else {
|
|
377
|
+
checked.push("injected run.skillsDir");
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (!resolved && overlay) {
|
|
381
|
+
if (fileExists(join(overlay, "skills", flow, "SKILL.md"))) {
|
|
382
|
+
tier = "overlay";
|
|
383
|
+
resolved = "the overlay skills/";
|
|
384
|
+
} else {
|
|
385
|
+
checked.push("overlay skills/");
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
if (!resolved) {
|
|
389
|
+
if (!packages) {
|
|
390
|
+
checked.push("staged packages (withheld: run.packages false)");
|
|
391
|
+
} else {
|
|
392
|
+
// A package this trigger leaves out by name (issue #800) brings it no skill, so such a package is no answer here.
|
|
393
|
+
const hit = staged.skills.find((skill) => skill.name === flow && !excludeExtensions.includes(skill.package));
|
|
394
|
+
// And no such package is a doubt either: what its globs might hold, the job does not load.
|
|
395
|
+
const unenumerable = staged.unenumerable.filter((name) => !excludeExtensions.includes(name));
|
|
396
|
+
if (hit) {
|
|
397
|
+
tier = "package";
|
|
398
|
+
pkg = hit.package;
|
|
399
|
+
resolved = `staged package ${hit.package}`;
|
|
400
|
+
} else if (unenumerable.length > 0) {
|
|
401
|
+
unknown.push(`staged package(s) ${unenumerable.join(", ")} (manifest patterns, not enumerable here)`);
|
|
402
|
+
} else {
|
|
403
|
+
checked.push("staged packages");
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return { tier: tier ?? (unknown.length > 0 ? "unknown" : "none"), package: pkg, repo, resolved, checked, unknown };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* The flow tiers' readers, each answering exactly as before so doctor's own lines do not move, and saying whether any met a
|
|
412
|
+
* failure that is not absence. `readStagedSkills` swallows such a failure ("no readable package.json: pi would skip it too") and
|
|
413
|
+
* `existsSync` answers false on `EACCES`, so a package, a skills folder or a `run.skillsDir` this uid cannot enter read as not
|
|
414
|
+
* holding the flow, and the row as `none`, with `unread` empty. `none` says every tier was checked, and that one was not.
|
|
415
|
+
* REJECTED: answering differently, or throwing, on such a failure: doctor's line says "visible here", which is still true. A
|
|
416
|
+
* `fileExists` seam keeps the answer; the `stat` beside it only notes why it might be false, since `existsSync` cannot say.
|
|
417
|
+
*/
|
|
418
|
+
export function flowProbes({ fileExists, fs = nodeFs }) {
|
|
419
|
+
let blind = false;
|
|
420
|
+
const noting = (read) => (...args) => {
|
|
421
|
+
try {
|
|
422
|
+
return read(...args);
|
|
423
|
+
} catch (error) {
|
|
424
|
+
if (!ABSENT_CODES.has(error?.code)) blind = true;
|
|
425
|
+
throw error;
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
const stat = noting((path) => fs.statSync(path));
|
|
429
|
+
return {
|
|
430
|
+
fileExists: (path) => {
|
|
431
|
+
try {
|
|
432
|
+
stat(path);
|
|
433
|
+
} catch {
|
|
434
|
+
// noted; the answer stays the seam's
|
|
435
|
+
}
|
|
436
|
+
return fileExists(path);
|
|
437
|
+
},
|
|
438
|
+
readFile: noting((path) => fs.readFileSync(path, "utf8")),
|
|
439
|
+
readDir: noting((path, options) => fs.readdirSync(path, options)),
|
|
440
|
+
blind: () => blind,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** The ignore files a job's discovery honours (image/runner/src/loader.mjs `EXTENSION_IGNORE_FILES`, pinned). */
|
|
445
|
+
export const LOADS_IGNORE_FILES = Object.freeze([".gitignore", ".ignore", ".fdignore"]);
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Whether a path is there, by the one answer that says so: only `ABSENT_CODES` mean it is not, and anything else THROWS.
|
|
449
|
+
* REJECTED: `existsSync`, which answers false on EACCES too, so an overlay at mode 000 or an operator's doctor run under
|
|
450
|
+
* another uid read as a host with nothing on it, over the worker's good record, with `unread` empty.
|
|
451
|
+
*/
|
|
452
|
+
export function pathIsThere(path, fs = nodeFs) {
|
|
453
|
+
try {
|
|
454
|
+
fs.statSync(path);
|
|
455
|
+
return true;
|
|
456
|
+
} catch (error) {
|
|
457
|
+
if (ABSENT_CODES.has(error?.code)) return false;
|
|
458
|
+
throw error;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** A file's text, or null where it is absent. Anything else throws, as `pathIsThere` does and for its reason. */
|
|
463
|
+
function readIfThere(path, fs) {
|
|
464
|
+
try {
|
|
465
|
+
return fs.readFileSync(path, "utf8");
|
|
466
|
+
} catch (error) {
|
|
467
|
+
if (ABSENT_CODES.has(error?.code)) return null;
|
|
468
|
+
throw error;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Whether a file holds an ignore pattern at all, the job's own test: an empty one or one of comments changes nothing. */
|
|
473
|
+
function holdsPatterns(path, fs) {
|
|
474
|
+
const text = readIfThere(path, fs);
|
|
475
|
+
return text !== null && text.split(/\r?\n/).some((line) => {
|
|
476
|
+
const trimmed = line.trim();
|
|
477
|
+
return trimmed !== "" && (!trimmed.startsWith("#") || trimmed.startsWith("\\#"));
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* A directory's own entries by the job's first step (`directoryExtensionEntries`): a manifest's `pi.extensions` that exist, a
|
|
483
|
+
* directory among them being skipped as the job skips it, else `index.ts`, else `index.js`, else null. A manifest that does
|
|
484
|
+
* not parse is the job's "declares nothing"; one that cannot be read throws, since what it declares is then not known.
|
|
485
|
+
*/
|
|
486
|
+
function ownEntries(dir, fs) {
|
|
487
|
+
const text = readIfThere(join(dir, "package.json"), fs);
|
|
488
|
+
if (text !== null) {
|
|
489
|
+
let declared = [];
|
|
490
|
+
try {
|
|
491
|
+
const listed = JSON.parse(text.replace(/^/, ""))?.pi?.extensions;
|
|
492
|
+
declared = Array.isArray(listed) ? listed.filter((entry) => typeof entry === "string") : [];
|
|
493
|
+
} catch {
|
|
494
|
+
declared = [];
|
|
495
|
+
}
|
|
496
|
+
const found = declared.map((entry) => resolve(dir, entry)).filter((entry) => pathIsThere(entry, fs));
|
|
497
|
+
if (found.length > 0) return found.filter((entry) => !fs.statSync(entry).isDirectory());
|
|
498
|
+
}
|
|
499
|
+
for (const index of ["index.ts", "index.js"]) if (pathIsThere(join(dir, index), fs)) return [join(dir, index)];
|
|
500
|
+
return null;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* The overlay's `extensions/` as `{ name, kind }` rows, by the rule a job discovers them by (image/runner/src/loader.mjs
|
|
505
|
+
* `discoverExtensionEntries`): one level, skipping dot entries and `node_modules`, a `*.ts` or `*.js` file, or a folder with
|
|
506
|
+
* its own entries; a folder that is itself a package is that package. Each row's name is a first path segment, the name a
|
|
507
|
+
* run's record uses. `null`, for "this read cannot say", when an ignore file holds a pattern: the job honours it with pi's
|
|
508
|
+
* own matcher, which the host does not have, and listing what it ignores would name an extension no job loads. Throws on
|
|
509
|
+
* a read that fails for any reason but absence, a folder of one extension included: leaving that one out would be a list
|
|
510
|
+
* that looks whole. No folder is no extensions.
|
|
511
|
+
*/
|
|
512
|
+
export function readOverlayExtensionRows(overlay, { fs = nodeFs } = {}) {
|
|
513
|
+
if (typeof overlay !== "string" || overlay === "") return [];
|
|
514
|
+
const dir = join(overlay, "extensions");
|
|
515
|
+
if (!pathIsThere(dir, fs)) return [];
|
|
516
|
+
const own = ownEntries(dir, fs);
|
|
517
|
+
if (own !== null) {
|
|
518
|
+
// An entry a manifest points outside the folder is no first segment of it, so it has no name here.
|
|
519
|
+
const base = `${resolve(dir)}${sep}`;
|
|
520
|
+
const segments = [...new Set(own.filter((entry) => resolve(entry).startsWith(base)).map((entry) => resolve(entry).slice(base.length).split(sep)[0]))];
|
|
521
|
+
return segments.map((name) => ({ name, kind: fs.statSync(join(dir, name)).isDirectory() ? "dir" : "file" }));
|
|
522
|
+
}
|
|
523
|
+
if (LOADS_IGNORE_FILES.some((name) => holdsPatterns(join(dir, name), fs))) return null;
|
|
524
|
+
const rows = [];
|
|
525
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
526
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
527
|
+
const path = join(dir, entry.name);
|
|
528
|
+
let isDir = entry.isDirectory();
|
|
529
|
+
let isFile = entry.isFile();
|
|
530
|
+
if (entry.isSymbolicLink()) {
|
|
531
|
+
if (!pathIsThere(path, fs)) continue;
|
|
532
|
+
const stats = fs.statSync(path);
|
|
533
|
+
isDir = stats.isDirectory();
|
|
534
|
+
isFile = stats.isFile();
|
|
535
|
+
}
|
|
536
|
+
if (isFile && (entry.name.endsWith(".ts") || entry.name.endsWith(".js"))) rows.push({ name: entry.name, kind: "file" });
|
|
537
|
+
else if (isDir && (ownEntries(path, fs) ?? []).length > 0) rows.push({ name: entry.name, kind: "dir" });
|
|
538
|
+
}
|
|
539
|
+
return rows.sort((a, b) => a.name.localeCompare(b.name));
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* The overlay's `skills/` by folder name, each holding a `SKILL.md`: the layout doctor's flow line reads, so the two agree.
|
|
544
|
+
* pi names a skill `frontmatter.name` or else its folder, so a rename reads under the folder's name here (that line's own
|
|
545
|
+
* residual). No folder is no skills; any other failed read throws, a skill's own folder included.
|
|
546
|
+
*/
|
|
547
|
+
export function readOverlaySkillNames(overlay, { fs = nodeFs } = {}) {
|
|
548
|
+
if (typeof overlay !== "string" || overlay === "") return [];
|
|
549
|
+
const dir = join(overlay, "skills");
|
|
550
|
+
if (!pathIsThere(dir, fs)) return [];
|
|
551
|
+
const names = [];
|
|
552
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
553
|
+
if (entry.name.startsWith(".")) continue;
|
|
554
|
+
if (pathIsThere(join(dir, entry.name, "SKILL.md"), fs)) names.push(entry.name);
|
|
555
|
+
}
|
|
556
|
+
return names.sort((a, b) => a.localeCompare(b));
|
|
557
|
+
}
|