@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,230 @@
|
|
|
1
|
+
/** Display-only receiver observations (#775). No request makes a storage command or carries request text here. */
|
|
2
|
+
import { DEFER_TIME_MAX_MS } from "./defer-reasons.mjs";
|
|
3
|
+
import { FORGE_KINDS } from "./forges.mjs";
|
|
4
|
+
import { WORKER_NAME_RE } from "./worker-name.mjs";
|
|
5
|
+
|
|
6
|
+
/** The forges a record counts: the forge table's own list (issue #824), so a forge added to FORGES is observed,
|
|
7
|
+
* published and read with no edit here. */
|
|
8
|
+
export const RECEIVER_FORGES = FORGE_KINDS;
|
|
9
|
+
/** The most `forges` entries a record may name. A fixed bound, never this build's forge count: a newer receiver that
|
|
10
|
+
* serves a forge this build does not know names more forges than this build has, and its record must still be read. */
|
|
11
|
+
export const RECEIVER_FORGES_MAX = 64;
|
|
12
|
+
export const RECEIVER_OUTCOMES = Object.freeze(["queued", "deduplicated", "held", "steered", "dropped", "unauthorized", "not-configured", "lookup-failed", "enqueue-failed", "malformed", "error"]);
|
|
13
|
+
export const RECEIVER_TAKEN = Object.freeze(RECEIVER_OUTCOMES.slice(0, 4));
|
|
14
|
+
export const RECEIVER_REFUSED = Object.freeze(RECEIVER_OUTCOMES.slice(5));
|
|
15
|
+
export const RECEIVER_BEAT_MS = 15_000;
|
|
16
|
+
export const RECEIVER_TTL_MS = 90_000;
|
|
17
|
+
export const RECEIVER_HOSTS_MAX = 64;
|
|
18
|
+
export const RECEIVER_MAX_BYTES = 64 * 1024;
|
|
19
|
+
export const RECEIVER_LIVE = "receiver:live";
|
|
20
|
+
export const receiverKey = (name) => `receiver:h:${name}`;
|
|
21
|
+
const HOUR = 3_600_000;
|
|
22
|
+
const integer = (n) => Number.isSafeInteger(n) && n >= 0;
|
|
23
|
+
const instant = (n) => integer(n) && n <= DEFER_TIME_MAX_MS;
|
|
24
|
+
const nameOk = (s) => typeof s === "string" && WORKER_NAME_RE.test(s);
|
|
25
|
+
const hourOf = (at) => Math.floor(at / HOUR) * HOUR;
|
|
26
|
+
const plainObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
27
|
+
/** A forge's counts in a record that leaves it out: never observed, and zero in each of 24 hours, the newest the hour
|
|
28
|
+
* that holds the record's `at`, or hour 23 for an `at` under 23 hours past the epoch, so no hour falls before the epoch
|
|
29
|
+
* and a rebuilt record parses again (the console parses it twice). */
|
|
30
|
+
const unobserved = (at) => {
|
|
31
|
+
const newest = Math.max(hourOf(at), 23 * HOUR);
|
|
32
|
+
return Object.fromEntries(RECEIVER_OUTCOMES.map((word) => [word, { lastAt: null, hours: Array.from({ length: 24 }, (_, i) => [newest - (23 - i) * HOUR, 0]) }]));
|
|
33
|
+
};
|
|
34
|
+
const safeLog = (log, reason) => { try { log("receiver_status_unavailable", { reason }); } catch {} };
|
|
35
|
+
|
|
36
|
+
/** The address is classified and discarded. Both IPv4 and IPv6 socket answers occur for localhost. */
|
|
37
|
+
export function receiverBind(address) {
|
|
38
|
+
if (typeof address !== "string") return null;
|
|
39
|
+
const value = address.toLowerCase();
|
|
40
|
+
if (["0.0.0.0", "::", "::0"].includes(value)) return "all";
|
|
41
|
+
if (value === "::1" || /^(?:::ffff:)?127\.(?:\d{1,3}\.){2}\d{1,3}$/.test(value)) return "loopback";
|
|
42
|
+
return "address";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function receiverOutcome(status, result) {
|
|
46
|
+
if (status === 202) return RECEIVER_TAKEN.includes(result) ? result : null;
|
|
47
|
+
if (status === 204) return "dropped";
|
|
48
|
+
if (status === 401) return "unauthorized";
|
|
49
|
+
if (status === 404) return "not-configured";
|
|
50
|
+
if ([400, 405, 413, 415].includes(status)) return "malformed";
|
|
51
|
+
if (status === 503) return result === "enqueue-failed" ? "enqueue-failed" : result === "lookup-failed" ? "lookup-failed" : null;
|
|
52
|
+
return status === 500 ? "error" : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** At most 24 hours and 44 last instants. Clock reversal never resurrects an expired bucket. */
|
|
56
|
+
export function makeReceiverObservations({ now = Date.now } = {}) {
|
|
57
|
+
let newest = hourOf(now());
|
|
58
|
+
const words = Object.fromEntries(RECEIVER_FORGES.map((forge) => [forge, Object.fromEntries(RECEIVER_OUTCOMES.map((word) => [word, { lastAt: null, hours: new Map() }]))]));
|
|
59
|
+
const trim = () => {
|
|
60
|
+
newest = Math.max(newest, hourOf(now()));
|
|
61
|
+
for (const forge of Object.values(words)) for (const value of Object.values(forge)) for (const hour of value.hours.keys()) if (hour < newest - 23 * HOUR) value.hours.delete(hour);
|
|
62
|
+
};
|
|
63
|
+
return {
|
|
64
|
+
observe(forge, status, result) {
|
|
65
|
+
const word = receiverOutcome(status, result);
|
|
66
|
+
if (!RECEIVER_FORGES.includes(forge) || word === null) return;
|
|
67
|
+
trim();
|
|
68
|
+
const at = now();
|
|
69
|
+
const value = words[forge][word];
|
|
70
|
+
value.lastAt = Math.max(value.lastAt ?? 0, at);
|
|
71
|
+
const hour = hourOf(at);
|
|
72
|
+
if (hour >= newest - 23 * HOUR) value.hours.set(hour, Math.min(Number.MAX_SAFE_INTEGER, (value.hours.get(hour) ?? 0) + 1));
|
|
73
|
+
},
|
|
74
|
+
snapshot() {
|
|
75
|
+
trim();
|
|
76
|
+
return Object.fromEntries(RECEIVER_FORGES.map((forge) => [forge, Object.fromEntries(RECEIVER_OUTCOMES.map((word) => [word, { lastAt: words[forge][word].lastAt, hours: Array.from({ length: 24 }, (_, i) => { const at = newest - (23 - i) * HOUR; return [at, words[forge][word].hours.get(at) ?? 0]; }) }]))]));
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Strict rebuilding keeps a planted extension field out of the console and the agent's context.
|
|
83
|
+
*
|
|
84
|
+
* Tolerant across releases both ways, on its forges only (issue #824). A NEWER record may name a forge this build does
|
|
85
|
+
* not know: it is dropped from `forges` and its `deliveries` are never read, so the forges this build knows still
|
|
86
|
+
* show. An OLDER record may leave out a forge this build knows: the release that wrote it did not know that forge,
|
|
87
|
+
* so it served none, and the forge reads as zero counts never observed. A forge the record names as served but whose
|
|
88
|
+
* deliveries it leaves out is no record any release writes, and is refused, as is a malformed entry of a known forge.
|
|
89
|
+
* So `v` stays 1 when a forge is added: another `v` still means a shape this build cannot read.
|
|
90
|
+
*/
|
|
91
|
+
export function parseReceiverStatus(raw) {
|
|
92
|
+
if (typeof raw !== "string" || Buffer.byteLength(raw) > RECEIVER_MAX_BYTES) return null;
|
|
93
|
+
let d;
|
|
94
|
+
try { d = JSON.parse(raw); } catch { return null; }
|
|
95
|
+
if (!d || d.v !== 1 || !nameOk(d.name) || typeof d.version !== "string" || !/^[0-9A-Za-z.+-]{1,32}$/.test(d.version) || !instant(d.startedAt) || !instant(d.at) || !Number.isInteger(d.port) || d.port < 1 || d.port > 65535 || !["all", "loopback", "address"].includes(d.bind) || !Array.isArray(d.forges) || d.forges.length > RECEIVER_FORGES_MAX || !plainObject(d.deliveries)) return null;
|
|
96
|
+
const forges = [...new Set(d.forges.filter((f) => RECEIVER_FORGES.includes(f)))];
|
|
97
|
+
const deliveries = {};
|
|
98
|
+
for (const forge of RECEIVER_FORGES) {
|
|
99
|
+
if (!Object.hasOwn(d.deliveries, forge)) {
|
|
100
|
+
if (forges.includes(forge)) return null;
|
|
101
|
+
deliveries[forge] = unobserved(d.at);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
deliveries[forge] = {};
|
|
105
|
+
for (const word of RECEIVER_OUTCOMES) {
|
|
106
|
+
const v = d.deliveries[forge]?.[word];
|
|
107
|
+
if (!v || !(v.lastAt === null || instant(v.lastAt)) || !Array.isArray(v.hours) || v.hours.length !== 24 || v.hours.some((r, i) => !Array.isArray(r) || r.length !== 2 || !instant(r[0]) || r[0] % HOUR !== 0 || !integer(r[1]) || i > 0 && r[0] !== v.hours[i - 1][0] + HOUR)) return null;
|
|
108
|
+
deliveries[forge][word] = { lastAt: v.lastAt, hours: v.hours.map(([at, n]) => [at, n]) };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return { v: 1, name: d.name, version: d.version, startedAt: d.startedAt, at: d.at, port: d.port, bind: d.bind, forges, deliveries };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function bounded(p, timeoutMs, onTimeout = () => {}) {
|
|
115
|
+
return new Promise((resolve, reject) => {
|
|
116
|
+
const timer = setTimeout(() => { onTimeout(); reject(new Error("timeout")); }, timeoutMs);
|
|
117
|
+
Promise.resolve(p).then((v) => { clearTimeout(timer); resolve(v); }, (e) => { clearTimeout(timer); reject(e); });
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** A timeout diagnoses the write but never frees its flight: ioredis may still have it queued offline. */
|
|
122
|
+
export function makeReceiverPublisher({ client, name, version, port, bind, forges, observations, now = Date.now, log = () => {}, timeoutMs = 2000, intervalMs = RECEIVER_BEAT_MS, setIntervalFn = setInterval, clearIntervalFn = clearInterval }) {
|
|
123
|
+
let closed = false;
|
|
124
|
+
let busy = false;
|
|
125
|
+
let timer = null;
|
|
126
|
+
let pruneCursor = "0";
|
|
127
|
+
const startedAt = now();
|
|
128
|
+
const valid = nameOk(name) && Number.isInteger(port) && port > 0 && port <= 65535 && ["all", "loopback", "address"].includes(bind);
|
|
129
|
+
if (!valid) safeLog(log, "identity-or-socket");
|
|
130
|
+
const beat = () => {
|
|
131
|
+
if (closed || busy || !valid) return;
|
|
132
|
+
busy = true;
|
|
133
|
+
let reported = false;
|
|
134
|
+
const fail = (reason) => { if (!reported) { reported = true; safeLog(log, reason); } };
|
|
135
|
+
const timeout = setTimeout(() => fail("timeout"), timeoutMs);
|
|
136
|
+
timeout.unref?.();
|
|
137
|
+
const work = async () => {
|
|
138
|
+
const redis = await client();
|
|
139
|
+
if (closed) return;
|
|
140
|
+
const record = { v: 1, name, version, startedAt, at: now(), port, bind, forges, deliveries: observations.snapshot() };
|
|
141
|
+
if (!parseReceiverStatus(JSON.stringify(record))) throw new Error("record");
|
|
142
|
+
if (await redis.set(receiverKey(name), JSON.stringify(record), "PX", RECEIVER_TTL_MS) !== "OK") throw new Error("set");
|
|
143
|
+
if (closed) return;
|
|
144
|
+
if (!integer(await redis.sadd(RECEIVER_LIVE, name))) throw new Error("index");
|
|
145
|
+
if (closed) return;
|
|
146
|
+
const indexed = await redis.sscan(RECEIVER_LIVE, pruneCursor, "COUNT", 64);
|
|
147
|
+
if (!Array.isArray(indexed) || typeof indexed[0] !== "string" || !/^\d{1,20}$/.test(indexed[0]) || !Array.isArray(indexed[1])) throw new Error("index");
|
|
148
|
+
pruneCursor = indexed[0];
|
|
149
|
+
const look = indexed[1].slice(0, 256).filter((n) => nameOk(n) && n !== name);
|
|
150
|
+
if (look.length && !closed) {
|
|
151
|
+
// A separate GET/SREM can erase a peer that republished between them. Check and prune atomically.
|
|
152
|
+
const removed = await redis.eval("local n = 0; for i = 2, #KEYS do if redis.call('EXISTS', KEYS[i]) == 0 then n = n + redis.call('SREM', KEYS[1], ARGV[i - 1]) end end; return n", look.length + 1, RECEIVER_LIVE, ...look.map(receiverKey), ...look);
|
|
153
|
+
if (!integer(removed)) throw new Error("index");
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
Promise.resolve().then(work).catch(() => fail("write")).finally(() => { clearTimeout(timeout); busy = false; });
|
|
157
|
+
};
|
|
158
|
+
return {
|
|
159
|
+
start() { if (closed || timer || !valid) return; beat(); timer = setIntervalFn(beat, intervalMs); timer.unref?.(); },
|
|
160
|
+
beat,
|
|
161
|
+
close() { closed = true; if (timer) clearIntervalFn(timer); timer = null; },
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Read-only, live names first. Incomplete reads cannot prove no receiver or a zero delivery count. */
|
|
166
|
+
export async function readReceiverStatus(redis, { names = [], now = Date.now, timeoutMs = 2000, clock = () => performance.now() } = {}) {
|
|
167
|
+
const deadline = clock() + timeoutMs;
|
|
168
|
+
// Spent for good once a bound fires. `left <= 0` alone is not enough: the bound's timer and this clock are different
|
|
169
|
+
// clocks, and a timer can fire a fraction of a millisecond before the deadline it was set from, so the next check would
|
|
170
|
+
// still see time left and start the row reads this deadline already ruled out.
|
|
171
|
+
let spent = false;
|
|
172
|
+
const within = (fn) => {
|
|
173
|
+
const left = deadline - clock();
|
|
174
|
+
if (spent || left <= 0) return Promise.reject(new Error("timeout"));
|
|
175
|
+
try { return bounded(fn(), Math.max(1, Math.ceil(left)), () => { spent = true; }); } catch { return Promise.reject(new Error("unavailable")); }
|
|
176
|
+
};
|
|
177
|
+
let indexed;
|
|
178
|
+
let complete = true;
|
|
179
|
+
indexed = [];
|
|
180
|
+
try {
|
|
181
|
+
let cursor = "0";
|
|
182
|
+
for (let page = 0; page < 4; page++) {
|
|
183
|
+
const answer = await within(() => redis.sscan(RECEIVER_LIVE, cursor, "COUNT", 64));
|
|
184
|
+
if (!Array.isArray(answer) || typeof answer[0] !== "string" || !/^\d{1,20}$/.test(answer[0]) || !Array.isArray(answer[1])) throw new Error("index");
|
|
185
|
+
cursor = answer[0];
|
|
186
|
+
const room = 256 - indexed.length;
|
|
187
|
+
indexed.push(...answer[1].slice(0, room));
|
|
188
|
+
if (answer[1].length > room) complete = false;
|
|
189
|
+
if (cursor === "0") break;
|
|
190
|
+
if (page === 3 || indexed.length >= 256) { complete = false; break; }
|
|
191
|
+
}
|
|
192
|
+
} catch { complete = false; }
|
|
193
|
+
const live = [...new Set(names.filter(nameOk))].sort();
|
|
194
|
+
const rest = [...new Set(indexed.filter(nameOk))].filter((n) => !live.includes(n)).sort();
|
|
195
|
+
if (indexed.some((n) => !nameOk(n))) complete = false;
|
|
196
|
+
const all = [...live, ...rest];
|
|
197
|
+
const wanted = all.slice(0, RECEIVER_HOSTS_MAX);
|
|
198
|
+
const more = Math.max(0, all.length - wanted.length);
|
|
199
|
+
const moreUnknown = !complete;
|
|
200
|
+
if (more) complete = false;
|
|
201
|
+
if (!wanted.length) return { hosts: [], complete, more, moreUnknown };
|
|
202
|
+
let values;
|
|
203
|
+
let ttls;
|
|
204
|
+
try {
|
|
205
|
+
[values, ttls] = await within(() => Promise.all([redis.mget(...wanted.map(receiverKey)), Promise.all(wanted.map((name) => Promise.resolve().then(() => redis.pttl(receiverKey(name))).catch(() => null)))]));
|
|
206
|
+
if (!Array.isArray(values) || values.length !== wanted.length) throw new Error("records");
|
|
207
|
+
} catch { return { hosts: wanted.map((name) => ({ name, unreadable: true })), complete: false, more, moreUnknown }; }
|
|
208
|
+
const hosts = [];
|
|
209
|
+
for (const [i, name] of wanted.entries()) {
|
|
210
|
+
if (values[i] === null || ttls[i] === -2) continue;
|
|
211
|
+
const record = parseReceiverStatus(values[i]);
|
|
212
|
+
if (!record || record.name !== name || !Number.isInteger(ttls[i]) || ttls[i] < 0 || ttls[i] > RECEIVER_TTL_MS) { hosts.push({ name, unreadable: true }); complete = false; continue; }
|
|
213
|
+
hosts.push({ ...record, ageMs: RECEIVER_TTL_MS - ttls[i] });
|
|
214
|
+
}
|
|
215
|
+
return { hosts, complete, more, moreUnknown };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Current counts are folded on the reader's clock; a dead process never freezes an old busy hour in view. */
|
|
219
|
+
export function receiverForgeFacts(record, forge, now) {
|
|
220
|
+
if (!record?.deliveries?.[forge]) return null;
|
|
221
|
+
const start = hourOf(now) - 23 * HOUR;
|
|
222
|
+
const end = hourOf(now);
|
|
223
|
+
const counts = {};
|
|
224
|
+
const last = (words) => {
|
|
225
|
+
const times = words.map((word) => record.deliveries[forge][word].lastAt).filter((at) => at !== null);
|
|
226
|
+
return times.length ? Math.max(...times) : null;
|
|
227
|
+
};
|
|
228
|
+
for (const word of RECEIVER_OUTCOMES) counts[word] = Math.min(Number.MAX_SAFE_INTEGER, record.deliveries[forge][word].hours.reduce((sum, [at, n]) => sum + (at >= start && at <= end ? n : 0), 0));
|
|
229
|
+
return { counts, lastTaken: last(RECEIVER_TAKEN), lastRefused: last(RECEIVER_REFUSED) };
|
|
230
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The run record's `reason` field (INT-RUN-HISTORY-FILE-CONTRACT): the token charset every reader holds it to, the
|
|
3
|
+
* tokens for the failures that carried only a message, and the door every record goes through on its way in. A LEAF:
|
|
4
|
+
* it imports nothing, so the admin console imports it (`@edgehero/pi-dispatch/record-reason`) without pulling in the
|
|
5
|
+
* worker.
|
|
6
|
+
*
|
|
7
|
+
* A record's reason is a token, never free text: a thrown error's message can carry a forge's API path, a repository
|
|
8
|
+
* name or a library's own words, and the record is read by every console role. So a result's reason that is no token
|
|
9
|
+
* is recorded as no reason, and an error's as `worker-error`, the failure the worker has no reason of its own for.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** A record reason: lowercase letters, digits and dashes, 1 to 64 characters, starting with a letter or digit. */
|
|
13
|
+
export const RECORD_REASON_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
14
|
+
|
|
15
|
+
/** The runner's infrastructure exit (exit code 1): the container ended on a fault the runner counts as infrastructure. */
|
|
16
|
+
export const CONTAINER_INFRA_EXIT = "container-infra-exit";
|
|
17
|
+
/** An exit code the runner never uses: the worker cannot tell what happened. */
|
|
18
|
+
export const CONTAINER_EXIT_UNKNOWN = "container-exit-unknown";
|
|
19
|
+
/** A failure the worker has no reason for: an error with no token (a forge or Valkey call that failed, a worker fault). */
|
|
20
|
+
export const WORKER_ERROR = "worker-error";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The record's session reasons that say the conversation was NOT saved, and the ones that cannot say. A session is
|
|
24
|
+
* written only by the store's promotion, on the completed branch, so every promote refusal and the `disabled` of a job
|
|
25
|
+
* that keeps none says "not saved"; but `absent`, `not-a-regular-file` and `too-large` are written by the resolve step
|
|
26
|
+
* and by the promote step alike, so from the record alone they do not tell a saved session from one that was not.
|
|
27
|
+
* session-reasons.test.mjs derives both lists from the store's refusals.
|
|
28
|
+
*/
|
|
29
|
+
export const SESSION_NOT_SAVED = Object.freeze(["disabled", "key-not-a-directory", "locked", "promote-failed", "transcript-diverted"]);
|
|
30
|
+
export const SESSION_SAVE_AMBIGUOUS = Object.freeze(["absent", "not-a-regular-file", "too-large"]);
|
|
31
|
+
|
|
32
|
+
/** The door: a result's reason when it is a token, else null; an error's when it is a token, else `WORKER_ERROR`. */
|
|
33
|
+
export function recordReasonOf({ result = null, error = null } = {}) {
|
|
34
|
+
if (result) return typeof result.reason === "string" && RECORD_REASON_RE.test(result.reason) ? result.reason : null;
|
|
35
|
+
const r = error?.reason;
|
|
36
|
+
return typeof r === "string" && RECORD_REASON_RE.test(r) ? r : WORKER_ERROR;
|
|
37
|
+
}
|