@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,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Display history, never a notification queue (INT-HOOK-RUNS-KEY, #775). Deleting hook:* changes no hook or job.
|
|
3
|
+
* Each completion appends one bounded, versioned record; the key expires seven days after the last append.
|
|
4
|
+
* The small memory drain absorbs simultaneous completions without making a hook wait for Valkey. It never retries:
|
|
5
|
+
* a failed append is lost and named. An operation that timed out still owns the drain until it ACTUALLY settles,
|
|
6
|
+
* because ioredis queues commands while offline and a timeout alone cannot bound that queue.
|
|
7
|
+
*/
|
|
8
|
+
import { publishedJobId, LIVE_JOB_ID_RE } from "./live-jobs.mjs";
|
|
9
|
+
import { WORKER_NAME_RE } from "./worker-name.mjs";
|
|
10
|
+
import { RECORD_REASON_RE } from "./record-reason.mjs";
|
|
11
|
+
|
|
12
|
+
export const HOOK_HOSTS = "hook:hosts";
|
|
13
|
+
export const hookRunsKey = (name) => `hook:h:${name}`;
|
|
14
|
+
export const HOOK_RUN_VERSION = 1;
|
|
15
|
+
export const HOOK_RUNS_MAX = 20;
|
|
16
|
+
export const HOOK_RUN_BYTES = 1024;
|
|
17
|
+
export const HOOK_HOSTS_MAX = 64;
|
|
18
|
+
export const HOOK_PENDING_MAX = 64;
|
|
19
|
+
export const HOOK_RUNS_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
20
|
+
/** The portable union, not this reader's platform: a Linux worker may be read from macOS or Windows. */
|
|
21
|
+
export const HOOK_SIGNALS = Object.freeze(["SIGABRT", "SIGALRM", "SIGBUS", "SIGCHLD", "SIGCLD", "SIGCONT", "SIGEMT", "SIGFPE", "SIGHUP", "SIGILL", "SIGINFO", "SIGINT", "SIGIO", "SIGIOT", "SIGKILL", "SIGLOST", "SIGPIPE", "SIGPOLL", "SIGPROF", "SIGPWR", "SIGQUIT", "SIGSEGV", "SIGSTKFLT", "SIGSTOP", "SIGSYS", "SIGTERM", "SIGTRAP", "SIGTSTP", "SIGTTIN", "SIGTTOU", "SIGUNUSED", "SIGURG", "SIGUSR1", "SIGUSR2", "SIGVTALRM", "SIGWINCH", "SIGXCPU", "SIGXFSZ", "SIGBREAK"]);
|
|
22
|
+
export const HOOK_DETAILS = Object.freeze(["exit", "timeout", "spawn", "unresolvable", "argv-unusable", "signal-unknown", ...HOOK_SIGNALS.map((signal) => `signal-${signal}`)]);
|
|
23
|
+
const instant = (value) => Number.isSafeInteger(value) && value >= 0;
|
|
24
|
+
// Windows reports the full unsigned process status (including NTSTATUS), not POSIX's low eight bits.
|
|
25
|
+
const codeOf = (value) => Number.isInteger(value) && value >= 0 && value <= 0xffffffff ? value : null;
|
|
26
|
+
const outcomeOf = (value) => value === "failed" || value === "policy" ? value : null;
|
|
27
|
+
const reasonOf = (value) => typeof value === "string" && RECORD_REASON_RE.test(value) ? value : "infra";
|
|
28
|
+
|
|
29
|
+
export function buildHookRun(fields, at) {
|
|
30
|
+
return { v: HOOK_RUN_VERSION, at, jobId: publishedJobId(fields?.jobId), outcome: outcomeOf(fields?.outcome), reason: reasonOf(fields?.reason), detail: HOOK_DETAILS.includes(fields?.detail) ? fields.detail : "signal-unknown", code: codeOf(fields?.code) };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Another host's text is projected again, including records passed directly to the model boundary. */
|
|
34
|
+
export function parseHookRun(raw) {
|
|
35
|
+
if (typeof raw !== "string" || Buffer.byteLength(raw) > HOOK_RUN_BYTES) return null;
|
|
36
|
+
let value;
|
|
37
|
+
try { value = JSON.parse(raw); } catch { return null; }
|
|
38
|
+
if (!value || value.v !== HOOK_RUN_VERSION || !instant(value.at) || !(value.jobId === null || typeof value.jobId === "string" && LIVE_JOB_ID_RE.test(value.jobId)) || !(value.outcome === null || outcomeOf(value.outcome)) || typeof value.reason !== "string" || !RECORD_REASON_RE.test(value.reason) || !HOOK_DETAILS.includes(value.detail) || !(value.code === null || codeOf(value.code) === value.code)) return null;
|
|
39
|
+
return { v: HOOK_RUN_VERSION, at: value.at, jobId: value.jobId, outcome: value.outcome, reason: value.reason, detail: value.detail, code: value.code };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function bounded(promise, timeoutMs = 2000) {
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
const timer = setTimeout(() => reject(Error("timeout")), timeoutMs);
|
|
45
|
+
Promise.resolve(promise).then((value) => { clearTimeout(timer); resolve(value); }, (error) => { clearTimeout(timer); reject(error); });
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
const validName = (name) => typeof name === "string" && WORKER_NAME_RE.test(name);
|
|
49
|
+
|
|
50
|
+
/** No message from a client, record, executable or environment reaches this diagnostic. */
|
|
51
|
+
export function makeHookRunsPublisher({ redis, name, now = Date.now, log = () => {}, timeoutMs = 2000 }) {
|
|
52
|
+
const pending = [];
|
|
53
|
+
let draining = false;
|
|
54
|
+
let closed = false;
|
|
55
|
+
let pruneAt = null;
|
|
56
|
+
let pruneCursor = "0";
|
|
57
|
+
const note = (reason) => { try { log("hook_runs_publish_failed", { reason }); } catch {} };
|
|
58
|
+
const prune = async (at) => {
|
|
59
|
+
if (closed || pruneAt !== null && at - pruneAt < 15_000) return;
|
|
60
|
+
pruneAt = at;
|
|
61
|
+
const found = await redis.sscan(HOOK_HOSTS, pruneCursor, "COUNT", HOOK_HOSTS_MAX);
|
|
62
|
+
if (!Array.isArray(found) || typeof found[0] !== "string" || !Array.isArray(found[1])) return;
|
|
63
|
+
pruneCursor = found[0];
|
|
64
|
+
const names = found[1].slice(0, HOOK_HOSTS_MAX).filter((member) => validName(member) && member !== name);
|
|
65
|
+
if (closed || !names.length) return;
|
|
66
|
+
const pipe = redis.pipeline();
|
|
67
|
+
// Checking and removing together cannot erase an index entry another host has just republished.
|
|
68
|
+
for (const member of names) pipe.eval("if redis.call('EXISTS', KEYS[1]) == 0 then return redis.call('SREM', KEYS[2], ARGV[1]) end return 0", 2, hookRunsKey(member), HOOK_HOSTS, member);
|
|
69
|
+
const result = await pipe.exec();
|
|
70
|
+
if (!Array.isArray(result) || result.length !== names.length || result.some((row) => !Array.isArray(row) || row[0])) throw Error("unavailable");
|
|
71
|
+
};
|
|
72
|
+
const drain = async () => {
|
|
73
|
+
draining = true;
|
|
74
|
+
while (!closed && pending.length) {
|
|
75
|
+
const record = pending.shift();
|
|
76
|
+
let said = false;
|
|
77
|
+
const fail = () => { if (!said && !closed) { said = true; note("unavailable"); } };
|
|
78
|
+
const timer = setTimeout(fail, timeoutMs);
|
|
79
|
+
timer.unref?.();
|
|
80
|
+
try {
|
|
81
|
+
const key = hookRunsKey(name);
|
|
82
|
+
const tx = redis.multi().lpush(key, JSON.stringify(record)).ltrim(key, 0, HOOK_RUNS_MAX - 1).pexpire(key, HOOK_RUNS_TTL_MS).sadd(HOOK_HOSTS, name);
|
|
83
|
+
const answer = await tx.exec();
|
|
84
|
+
if (!Array.isArray(answer) || answer.length !== 4 || answer.some((row) => !Array.isArray(row) || row[0])) fail();
|
|
85
|
+
else await prune(record.at);
|
|
86
|
+
} catch { fail(); }
|
|
87
|
+
finally { clearTimeout(timer); }
|
|
88
|
+
}
|
|
89
|
+
draining = false;
|
|
90
|
+
};
|
|
91
|
+
return {
|
|
92
|
+
publish(fields) {
|
|
93
|
+
if (closed) return;
|
|
94
|
+
if (!validName(name)) { note("invalid-host"); return; }
|
|
95
|
+
if (pending.length + (draining ? 1 : 0) >= HOOK_PENDING_MAX) { note("full"); return; }
|
|
96
|
+
let at;
|
|
97
|
+
try { at = now(); } catch { note("invalid-time"); return; }
|
|
98
|
+
if (!instant(at)) { note("invalid-time"); return; }
|
|
99
|
+
pending.push(buildHookRun(fields, at));
|
|
100
|
+
if (!draining) void drain();
|
|
101
|
+
},
|
|
102
|
+
close() { closed = true; pending.length = 0; },
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Bounded discovery: live names first, then at most four SSCAN pages, no SMEMBERS. COUNT is a hint, so even a large
|
|
108
|
+
* response is cut before issuing row commands. SCARD reports an incomplete index walk without calling it empty.
|
|
109
|
+
* A lost index still reads known live hosts; an unreadable index makes that answer partial, never complete.
|
|
110
|
+
*/
|
|
111
|
+
async function hostNames(redis, names, timeoutMs, stopped) {
|
|
112
|
+
const live = [...new Set(names.filter(validName))].sort();
|
|
113
|
+
const wanted = new Set(live.slice(0, HOOK_HOSTS_MAX));
|
|
114
|
+
const indexed = new Set();
|
|
115
|
+
const members = new Set();
|
|
116
|
+
let total = 0;
|
|
117
|
+
let partial = live.length > HOOK_HOSTS_MAX;
|
|
118
|
+
let indexFailed = false;
|
|
119
|
+
let cursor = "0";
|
|
120
|
+
try {
|
|
121
|
+
total = await bounded(redis.scard(HOOK_HOSTS), timeoutMs);
|
|
122
|
+
if (!Number.isSafeInteger(total) || total < 0) throw Error("invalid");
|
|
123
|
+
if (total > 0) for (let page = 0; page < 4 && !stopped(); page++) {
|
|
124
|
+
const result = await bounded(redis.sscan(HOOK_HOSTS, cursor, "COUNT", HOOK_HOSTS_MAX), timeoutMs);
|
|
125
|
+
if (!Array.isArray(result) || typeof result[0] !== "string" || !Array.isArray(result[1])) throw Error("invalid");
|
|
126
|
+
cursor = result[0];
|
|
127
|
+
for (const name of result[1].slice(0, HOOK_HOSTS_MAX * 4)) {
|
|
128
|
+
members.add(name);
|
|
129
|
+
if (validName(name)) { indexed.add(name); if (wanted.size < HOOK_HOSTS_MAX) wanted.add(name); }
|
|
130
|
+
}
|
|
131
|
+
if (cursor === "0" || wanted.size === HOOK_HOSTS_MAX) break;
|
|
132
|
+
}
|
|
133
|
+
partial ||= cursor !== "0" || total !== members.size || indexed.size !== members.size;
|
|
134
|
+
} catch { partial = true; indexFailed = true; }
|
|
135
|
+
// With an incomplete walk the unseen members may overlap the omitted live names. Count only the union we SAW,
|
|
136
|
+
// explicitly a floor; adding the two remainders would count a large fleet's same hosts twice.
|
|
137
|
+
const more = Math.max(0, new Set([...live, ...indexed]).size - wanted.size);
|
|
138
|
+
const moreUnknown = indexFailed || cursor !== "0" || total !== members.size;
|
|
139
|
+
return { names: [...wanted], more, moreUnknown, partial: partial || more > 0 };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Readers write nothing. Expired index members are pruned by the next publisher, not by a console request. */
|
|
143
|
+
export async function readHookRuns(redis, { names = [], timeoutMs = 2000 } = {}) {
|
|
144
|
+
let stopped = false;
|
|
145
|
+
// Register the WHOLE-read timer before issuing any command. Per-command bounds alone can turn four slow
|
|
146
|
+
// discovery pages and one row read into ten seconds; late settlement must never start follow-up commands.
|
|
147
|
+
const work = Promise.resolve().then(() => readHookRunsInner(redis, names, timeoutMs, () => stopped));
|
|
148
|
+
try { return await bounded(work, timeoutMs); }
|
|
149
|
+
catch { return { hosts: [], more: 0, moreUnknown: true, partial: true }; }
|
|
150
|
+
finally { stopped = true; }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function readHookRunsInner(redis, names, timeoutMs, stopped) {
|
|
154
|
+
const selected = await hostNames(redis, names, timeoutMs, stopped);
|
|
155
|
+
if (stopped()) return { hosts: [], more: selected.more, moreUnknown: true, partial: true };
|
|
156
|
+
const hosts = await Promise.all(selected.names.map(async (name) => {
|
|
157
|
+
try {
|
|
158
|
+
const key = hookRunsKey(name);
|
|
159
|
+
const [raw, ttl] = await Promise.all([bounded(redis.lrange(key, 0, HOOK_RUNS_MAX), timeoutMs), bounded(redis.pttl(key), timeoutMs)]);
|
|
160
|
+
if (!Array.isArray(raw) || raw.length > HOOK_RUNS_MAX || !Number.isSafeInteger(ttl)) throw Error("invalid");
|
|
161
|
+
if (raw.length === 0 && ttl === -2) return { name, runs: [], absent: true };
|
|
162
|
+
if (ttl <= 0 || ttl > HOOK_RUNS_TTL_MS || raw.length === 0) throw Error("invalid");
|
|
163
|
+
const runs = raw.map(parseHookRun);
|
|
164
|
+
if (runs.some((record) => record === null)) throw Error("invalid");
|
|
165
|
+
return { name, runs };
|
|
166
|
+
} catch { return { name, runs: [], unreadable: true }; }
|
|
167
|
+
}));
|
|
168
|
+
return { hosts: hosts.sort((a, b) => a.name.localeCompare(b.name)), more: selected.more, moreUnknown: selected.moreUnknown, partial: selected.partial || hosts.some((host) => host.unreadable) };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Public display/model DTO. Recomputing counts prevents a polluted DTO inventing a successful hook history. */
|
|
172
|
+
export function hookRunsView(value) {
|
|
173
|
+
const hosts = [];
|
|
174
|
+
const input = Array.isArray(value?.hosts) ? value.hosts : [];
|
|
175
|
+
const omitted = Math.max(0, input.length - HOOK_HOSTS_MAX);
|
|
176
|
+
let partial = value?.partial !== false || omitted > 0;
|
|
177
|
+
const seen = new Set();
|
|
178
|
+
for (const host of input.slice(0, HOOK_HOSTS_MAX)) {
|
|
179
|
+
if (!validName(host?.name) || seen.has(host.name)) { partial = true; continue; }
|
|
180
|
+
seen.add(host.name);
|
|
181
|
+
const raw = Array.isArray(host.runs) ? host.runs : [];
|
|
182
|
+
const runs = raw.slice(0, HOOK_RUNS_MAX).map((run) => { try { return parseHookRun(JSON.stringify(run)); } catch { return null; } });
|
|
183
|
+
const unreadable = host.unreadable === true || !Array.isArray(host.runs) || raw.length > HOOK_RUNS_MAX || runs.some((run) => !run);
|
|
184
|
+
const counts = unreadable ? null : Object.fromEntries([...new Set(runs.map((run) => run.detail))].map((detail) => [detail, runs.filter((run) => run.detail === detail).length]));
|
|
185
|
+
hosts.push({ name: host.name, unreadable, absent: !unreadable && (host.absent === true || runs.length === 0), runs: unreadable ? [] : runs, counts });
|
|
186
|
+
}
|
|
187
|
+
return { hosts, more: (Number.isSafeInteger(value?.more) && value.more >= 0 ? value.more : 0) + omitted, moreUnknown: value?.moreUnknown === true, partial: partial || hosts.some((host) => host.unreadable) };
|
|
188
|
+
}
|
package/src/host-budget.mjs
CHANGED
|
@@ -45,8 +45,8 @@ export const HOST_BUDGET_KEYS = Object.freeze({
|
|
|
45
45
|
});
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
|
-
* How often a job the budget deferred asks again. Its own value, like every re-check cadence,
|
|
49
|
-
*
|
|
48
|
+
* How often a job the budget deferred asks again. Its own value, like every re-check cadence, so the wake instants of the
|
|
49
|
+
* different deferrals stay apart (the reason itself is recorded, `defer-reasons.mjs`): distinct from the scope re-check
|
|
50
50
|
* (5 s), the endpoint re-check (7 s), the wait throttle floor (11 s) and the supersede re-ask (15 s). A test keeps them
|
|
51
51
|
* apart. Short, because a hold keeps the room meanwhile: the wait is the latency of a freed slot, not a queue position.
|
|
52
52
|
*/
|
|
@@ -306,7 +306,7 @@ export function rankHolds(waiters, ledger, minJobsOf = () => 0) {
|
|
|
306
306
|
|
|
307
307
|
/**
|
|
308
308
|
* THE ADMISSION RULE (PURE): may `ask` (`{ id, project, memMiB, cpuCenti }`) start now? `{ ok: true }`, or `{ ok: false,
|
|
309
|
-
* why }` with `why` `share` (its project would hold more than its `hostShare` of the budget) or `
|
|
309
|
+
* why }` with `why` `share` (its project would hold more than its `hostShare` of the budget) or `memory`, `cpu`, `count` (what runs, plus
|
|
310
310
|
* `ask`, plus every hold ranked above it, does not fit in memory, in CPU or in the job COUNT). `holds` is `rankHolds`'
|
|
311
311
|
* order; a job that holds no hold has every hold above it. The share is judged first, so a job its own project's share
|
|
312
312
|
* stops is not reported as waiting for the budget (and so does not hold room it could not use).
|
|
@@ -328,7 +328,10 @@ export function admit({ budget, ledger, holds, ask, shareOf = () => null }) {
|
|
|
328
328
|
const counted = [...ledger, ask, ...(rank === -1 ? holds : holds.slice(0, rank))];
|
|
329
329
|
const need = sumOf(counted);
|
|
330
330
|
const count = Number.isSafeInteger(budget.count) ? budget.count : null;
|
|
331
|
-
|
|
331
|
+
// A stable order explains the first blocked dimension without changing admission or ranked holds.
|
|
332
|
+
if (!fits(need.memMiB, budget.memMiB)) return { ok: false, why: "memory" };
|
|
333
|
+
if (!fits(need.cpuCenti, budget.cpuCenti)) return { ok: false, why: "cpu" };
|
|
334
|
+
if (!fits(counted.length, count)) return { ok: false, why: "count" };
|
|
332
335
|
return { ok: true };
|
|
333
336
|
}
|
|
334
337
|
|
|
@@ -643,7 +646,7 @@ export function makeHostBudget({ settings, jobDefault = { memMiB: 4096, cpuCenti
|
|
|
643
646
|
/**
|
|
644
647
|
* THE GATE, synchronous: `{ admitted: true }` with the hold taken under `ticket`, or `{ admitted: false, why }`:
|
|
645
648
|
* `unseeded` (the boot listing of the job's `venue` is not read yet) and `running-here` (the ledger already holds
|
|
646
|
-
* this id, or an orphan carries the container `name` this pickup will use) make no waiter;
|
|
649
|
+
* this id, or an orphan carries the container `name` this pickup will use) make no waiter; dimension refusals and `share`
|
|
647
650
|
* keep (or make) the job a waiter. `getState` is the job's own, kept for `verify`.
|
|
648
651
|
*/
|
|
649
652
|
gate({ id, ticket = null, project = null, size, venue = null, name = null, getState = null, limits = scopedLimits() }) {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/** Shared host-clone primitives. The credential helper lives outside every agent-reachable tree (#771). */
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join, win32 } from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { GIT_SAFE_CONFIG, storeGitEnv } from "./git-hardening.mjs";
|
|
8
|
+
|
|
9
|
+
const exec = promisify(execFile);
|
|
10
|
+
export const HOST_CLONE_FLAGS = Object.freeze([...GIT_SAFE_CONFIG, "-c", "protocol.ext.allow=never", "-c", "credential.helper=", "--no-pager"]);
|
|
11
|
+
export const hardenedCloneArgs = (args) => [...HOST_CLONE_FLAGS, ...args];
|
|
12
|
+
|
|
13
|
+
export function createAskpassHelper() {
|
|
14
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-askpass-"));
|
|
15
|
+
const path = join(dir, "askpass.sh");
|
|
16
|
+
try {
|
|
17
|
+
writeFileSync(path, "#!/bin/sh\nprintf '%s' \"$GIT_ASKPASS_TOKEN\"\n", { mode: 0o700 });
|
|
18
|
+
chmodSync(path, 0o700);
|
|
19
|
+
return { dir, path };
|
|
20
|
+
} catch (error) {
|
|
21
|
+
rmSync(dir, { recursive: true, force: true });
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function defaultHostGit(cwd, args, { env, signal, timeout } = {}) {
|
|
27
|
+
const { stdout } = await exec("git", args, { cwd, encoding: "utf8", maxBuffer: 16 * 1024 * 1024, env, ...(timeout ? { timeout } : {}), ...(signal ? { signal } : {}) });
|
|
28
|
+
return stdout;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The determinate missing-ref class, shared with the job preparer's existing refusal. */
|
|
32
|
+
export function isCloneRefGone(error) {
|
|
33
|
+
const text = `${error?.stderr ?? ""}\n${error?.message ?? ""}`.toLowerCase();
|
|
34
|
+
return ["couldn't find remote ref", "not our ref", "unadvertised object", "did not send all necessary objects", "reference is not a tree"].some((part) => text.includes(part));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** storeGitEnv's trust switches are shared; Windows keeps absolute drive/UNC tool paths with its own separator. */
|
|
38
|
+
export function hostCloneEnvironment({ home, path, platform = process.platform }) {
|
|
39
|
+
const clean = storeGitEnv({ home, path });
|
|
40
|
+
delete clean.GIT_DIR;
|
|
41
|
+
if (platform === "win32") clean.PATH = String(path ?? "").split(";").filter((entry) => win32.isAbsolute(entry)).join(";");
|
|
42
|
+
return clean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A credential-free checkout after the one authenticated fetch. No materialisation, prompt, session or agent inputs.
|
|
47
|
+
* The empty helper home and allowlisted environment also stop a repository's attributes selecting a host filter.
|
|
48
|
+
*/
|
|
49
|
+
export async function cloneHostWorkspace({ workspace, remote, ref, token, env = process.env, git = defaultHostGit, askpass = createAskpassHelper, platform = process.platform, signal } = {}) {
|
|
50
|
+
const url = new URL(remote);
|
|
51
|
+
if (!["https:", "http:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new Error("sandbox clone requires a credential-free configured http(s) remote");
|
|
52
|
+
const helper = askpass();
|
|
53
|
+
try {
|
|
54
|
+
const home = join(helper.dir, "home");
|
|
55
|
+
mkdirSync(home, { mode: 0o700 });
|
|
56
|
+
mkdirSync(workspace, { recursive: true, mode: 0o700 });
|
|
57
|
+
// env-internal PATH: use only absolute host tool directories, as the checkpoint-store Git does.
|
|
58
|
+
const clean = hostCloneEnvironment({ home, path: env.PATH, platform });
|
|
59
|
+
// env-internal GIT_SSL_CAINFO: an operator's private CA trust input, never repository content or a container env.
|
|
60
|
+
if (typeof env.GIT_SSL_CAINFO === "string") clean.GIT_SSL_CAINFO = env.GIT_SSL_CAINFO;
|
|
61
|
+
const run = (args, net = false) => git(workspace, hardenedCloneArgs(args), { timeout: 120_000, env: net ? { ...clean, GIT_ASKPASS: helper.path, GIT_ASKPASS_TOKEN: token } : clean, ...(signal ? { signal } : {}) });
|
|
62
|
+
await run(["init"]);
|
|
63
|
+
await run(["remote", "add", "origin", remote]);
|
|
64
|
+
await run(["fetch", "--depth=1", "--no-tags", "--no-recurse-submodules", "origin", ref], true);
|
|
65
|
+
await run(["checkout", "--detach", "FETCH_HEAD"]);
|
|
66
|
+
const sha = String(await run(["rev-parse", "HEAD"])).trim();
|
|
67
|
+
if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(sha)) throw new Error("sandbox clone returned no commit id");
|
|
68
|
+
return { workspace, sha };
|
|
69
|
+
} finally {
|
|
70
|
+
rmSync(helper.dir, { recursive: true, force: true });
|
|
71
|
+
}
|
|
72
|
+
}
|