@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,693 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The durable inbox of a running job (issue #646, INT-STEER-INBOX-CONTRACT, DES-STEER-TARGET-POINTER-IS-LIVENESS-NOT-INDEX).
|
|
3
|
+
*
|
|
4
|
+
* One module for every side that touches it: the worker's relay (registers the run, writes queued messages into the
|
|
5
|
+
* read-only `/inbox` mount, marks them), the CLI and the console (push an operator's message, withdraw one, list them
|
|
6
|
+
* without their text), and the receiver (PR 2: find a target's live runs and push a comment into each). Exported as
|
|
7
|
+
* `@edgehero/pi-dispatch/steer-inbox`.
|
|
8
|
+
*
|
|
9
|
+
* KEYS (runId is `job.data.recoveryOf ?? job.id`, recovery-queue.mjs `runIdOf`, spelled through `sanitizeJobId`):
|
|
10
|
+
* run:steer:target:<sha256(kind\0repo\0targetType\0number) hex32> ZSET runId -> expiresAtMs, PEXPIRE 60 s
|
|
11
|
+
* run:steer:open:<runId> STRING host name, PX 30 s: the ONE authority on whether a run takes messages
|
|
12
|
+
* run:inbox:<runId> LIST of entry JSON, append only
|
|
13
|
+
* run:inbox:state:<runId> HASH id -> queued|written|delivered|withdrawn|late, plus `_bytes` and `_count`,
|
|
14
|
+
* and `m:<id>` -> "<seq> <mode> <source> <at> <bytes>" per message (issue #646 part 3)
|
|
15
|
+
* run:inbox:fallback:<runId> HASH id -> the job the receiver would have enqueued (forge source, PR 2)
|
|
16
|
+
* run:inbox:closed:<runId> STRING, set by the final close
|
|
17
|
+
*
|
|
18
|
+
* EVERY STATE CHANGE IS ONE LUA SCRIPT. Push, withdraw, close and the relay's mark are each a compare and set against
|
|
19
|
+
* the state hash, run atomically by Valkey. The load-bearing one is the pair push and close: close sets `closed`,
|
|
20
|
+
* drops the open key and the target member, turns every queued or written message into `late`, and returns the list,
|
|
21
|
+
* all in one script, while push refuses on `closed` or a missing open key in its own. So a message is either in
|
|
22
|
+
* close's reply (and the settle step answers it) or refused at push (and its sender is told): there is no interleaving
|
|
23
|
+
* in which it is accepted and then silently never seen. A client-side read then write would have exactly that gap.
|
|
24
|
+
*
|
|
25
|
+
* NEVER THROWS. Every export returns a value or `{ error }`. A validation refusal is `{ error: "refused", reason }`,
|
|
26
|
+
* whose reason is fit for an operator; a Valkey fault is `{ error: <code> }` and names no key or value. Callers must
|
|
27
|
+
* fail loudly on either, never treat it as an empty inbox.
|
|
28
|
+
*
|
|
29
|
+
* CAPS. Two are per run and enforced inside the push script, so concurrent pushers cannot race past them: 16 messages
|
|
30
|
+
* and 64 KiB of text (UTF-8 bytes). Two are per message and checked in JavaScript before Valkey is touched: 8 KiB of
|
|
31
|
+
* text after the scrub, and 16 KiB for the framed inbox file (STEER_FILE_MAX_BYTES). Over a cap is refused, never cut:
|
|
32
|
+
* a truncated message is one the person did not send. The run counters count every message ever accepted, withdrawn
|
|
33
|
+
* ones too, so withdraw and push again cannot flood a run.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { createHash, randomBytes as nodeRandomBytes } from "node:crypto";
|
|
37
|
+
import { ANSWER_TEXT_MAX_BYTES, boundAnswerText, scrubAnswerText } from "./console-answer.mjs";
|
|
38
|
+
import { TOKEN_NAME_RE } from "./console-tokens.mjs";
|
|
39
|
+
import { sanitizeJobId } from "./run-history.mjs";
|
|
40
|
+
// The author charsets come from the framing, so what the inbox accepts is exactly what the framing can attribute.
|
|
41
|
+
import { ASSOCIATION_RE, FORGE_USER_RE, frameSteerMessage, STEER_FILE_MAX_BYTES, STEER_ID_RE, STEER_REF_WORST, steerFileJson } from "./steer-framing.mjs";
|
|
42
|
+
|
|
43
|
+
export const STEER_MAX_MESSAGES = 16;
|
|
44
|
+
/** The console answer's own bound: one rule for a person's text typed at the deployment, whatever it is for. */
|
|
45
|
+
export const STEER_TEXT_MAX_BYTES = ANSWER_TEXT_MAX_BYTES;
|
|
46
|
+
export const STEER_TOTAL_MAX_BYTES = 64 * 1024;
|
|
47
|
+
/** The inbox keys live an hour, refreshed by the relay while the run lives, so a crashed worker leaves no residue. */
|
|
48
|
+
export const STEER_INBOX_TTL_MS = 60 * 60 * 1000;
|
|
49
|
+
/** The open key's life: the relay refreshes it every 2 s, so 30 s of silence means the run is gone. */
|
|
50
|
+
export const STEER_OPEN_TTL_MS = 30 * 1000;
|
|
51
|
+
/** The target pointer's own expiry, twice a member's score horizon, so no live member outlives the ZSET it sits in. */
|
|
52
|
+
export const STEER_TARGET_TTL_MS = 60 * 1000;
|
|
53
|
+
export { STEER_ID_RE };
|
|
54
|
+
export const STEER_MODES = Object.freeze(["steer", "followUp"]);
|
|
55
|
+
export const STEER_SOURCES = Object.freeze(["forge", "console", "cli", "panel"]);
|
|
56
|
+
export const STEER_STATES = Object.freeze(["queued", "written", "delivered", "withdrawn", "late"]);
|
|
57
|
+
|
|
58
|
+
const DELIVERY_ID_RE = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
59
|
+
const HOST_MAX = 255;
|
|
60
|
+
|
|
61
|
+
const sha32 = (s) => createHash("sha256").update(s).digest("hex").slice(0, 32);
|
|
62
|
+
const nowOf = (now) => (typeof now === "function" ? now() : now);
|
|
63
|
+
const isMs = (n) => Number.isSafeInteger(n) && n >= 0;
|
|
64
|
+
const validRunId = (runId) => (typeof runId === "string" && runId !== "") || (Number.isSafeInteger(runId) && runId >= 0);
|
|
65
|
+
const codeOf = (error) => (typeof error?.code === "string" && error.code !== "" ? error.code : "valkey");
|
|
66
|
+
const refused = (reason) => ({ error: "refused", reason });
|
|
67
|
+
|
|
68
|
+
/** The target pointer key for one forge target, or null when any part is missing or not plain. */
|
|
69
|
+
export function steerTargetKey({ kind, repo, targetType, number } = {}) {
|
|
70
|
+
if (typeof kind !== "string" || kind === "" || typeof repo !== "string" || repo === "" || typeof targetType !== "string" || targetType === "") return null;
|
|
71
|
+
const n = typeof number === "string" && /^[1-9][0-9]{0,15}$/.test(number) ? Number(number) : number;
|
|
72
|
+
if (!Number.isSafeInteger(n) || n < 1) return null;
|
|
73
|
+
return `run:steer:target:${sha32(`${kind}\0${repo}\0${targetType}\0${n}`)}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function steerOpenKey(runId) {
|
|
77
|
+
return `run:steer:open:${sanitizeJobId(runId)}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function inboxKeys(runId) {
|
|
81
|
+
const id = sanitizeJobId(runId);
|
|
82
|
+
return { list: `run:inbox:${id}`, state: `run:inbox:state:${id}`, fallback: `run:inbox:fallback:${id}`, closed: `run:inbox:closed:${id}` };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A forge message's id: the delivery GUID's digest, so a redelivered webhook is the same message and pushes `dup`. */
|
|
86
|
+
export function forgeMessageId(deliveryId) {
|
|
87
|
+
return typeof deliveryId === "string" && deliveryId !== "" ? sha32(`forge\0${deliveryId}`) : null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** An operator message's id: 16 random bytes. `randomBytes` is the seam. */
|
|
91
|
+
export function operatorMessageId(randomBytes = nodeRandomBytes) {
|
|
92
|
+
return Buffer.from(randomBytes(16)).toString("hex");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Characters a model reads and a person reviewing the text does not see, removed on the steer path on top of the
|
|
97
|
+
* console answer's scrub: the Unicode tag block (U+E0000 to U+E007F, which spells hidden ASCII a model decodes), the
|
|
98
|
+
* zero width space, non-joiner and joiner (U+200B to U+200D), the word joiner and the invisible operators (U+2060 to
|
|
99
|
+
* U+2064), the Mongolian vowel separator (U+180E) and the byte order mark (U+FEFF). The cost is that an emoji built
|
|
100
|
+
* with a zero width joiner arrives as its separate emoji; the meaning survives, hidden text does not.
|
|
101
|
+
*
|
|
102
|
+
* Kept on this path and not added to `scrubAnswerText`, whose output a parked answer's trail comment quotes on the
|
|
103
|
+
* forge (where U+200B is the mention neutraliser itself); widening that function is a change to a shipped feature.
|
|
104
|
+
*/
|
|
105
|
+
const STEER_INVISIBLE_RE = /[\u{E0000}-\u{E007F}\u200b-\u200d\u2060-\u2064\u180e\ufeff]/gu;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Backtick look-alikes folded to the ASCII backtick (U+FF40 fullwidth grave accent, U+02CB modifier letter grave,
|
|
109
|
+
* U+1FEF Greek varia), so `fenceBlock` counts them in the run it must out-length and a row of them cannot read to the
|
|
110
|
+
* model as a closing fence.
|
|
111
|
+
*
|
|
112
|
+
* WHY NOT NFKC. NFKC would fold these too, but it rewrites far more than look-alikes: it turns a CJK writer's fullwidth
|
|
113
|
+
* punctuation into ASCII, ligatures and superscripts into letters and digits, and can lengthen text past the byte cap
|
|
114
|
+
* it was measured under (one code point, U+FDFA, becomes eighteen). The framing promises the message verbatim; the
|
|
115
|
+
* fence is defense in depth (placement as a user turn is the boundary), so the one look-alike class that touches the
|
|
116
|
+
* fence is folded, and nothing else is rewritten.
|
|
117
|
+
*/
|
|
118
|
+
const BACKTICK_LOOKALIKE_RE = /[\uff40\u02cb\u1fef]/g;
|
|
119
|
+
|
|
120
|
+
/** The steer scrub: the console answer's rule, then the invisible characters removed and the look-alikes folded. */
|
|
121
|
+
export function scrubSteerText(raw) {
|
|
122
|
+
return scrubAnswerText(raw).replace(STEER_INVISIBLE_RE, "").replace(BACKTICK_LOOKALIKE_RE, "`");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Whether a string holds an unpaired surrogate (malformed UTF-16, which UTF-8 cannot carry faithfully). */
|
|
126
|
+
const malformed = (s) => (typeof s.isWellFormed === "function" ? !s.isWellFormed() : /\p{Surrogate}/u.test(s));
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Bound a steer text: `{ text }` scrubbed (`scrubSteerText`) and within STEER_TEXT_MAX_BYTES, else `{ refusal }`. The
|
|
130
|
+
* size and emptiness decision is `boundAnswerText`'s, run on the steer scrub's output (its own scrub is idempotent on
|
|
131
|
+
* it), so the two features cannot disagree on what fits; the words are this feature's, since an answer's refusal says
|
|
132
|
+
* "answer". An unpaired surrogate is refused rather than replaced: the text is malformed, and a U+FFFD the person never
|
|
133
|
+
* typed is a message they did not send. No refusal ever quotes the text.
|
|
134
|
+
*/
|
|
135
|
+
export function boundSteerText(raw) {
|
|
136
|
+
if (typeof raw === "string" && malformed(raw)) return { refusal: "the message is malformed text (it holds an unpaired UTF-16 surrogate); nothing was sent" };
|
|
137
|
+
const text = scrubSteerText(raw);
|
|
138
|
+
const bounded = boundAnswerText(text);
|
|
139
|
+
if (!bounded.refusal) return bounded;
|
|
140
|
+
if (text.trim() === "") return { refusal: "the message is empty (after control and invisible characters are removed); nothing was sent" };
|
|
141
|
+
return { refusal: `the message is ${Buffer.byteLength(text, "utf8")} bytes, above the ${STEER_TEXT_MAX_BYTES}-byte limit; shorten it. Nothing was sent` };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The entry as the inbox stores it, or `{ refusal }`. Only the contract's fields are kept; the source and the author
|
|
146
|
+
* must agree (a forge source has a forge author, every other source an operator one), and the text is bounded and
|
|
147
|
+
* scrubbed here, so whatever reaches Valkey is what the relay frames. A message whose inbox file would exceed
|
|
148
|
+
* STEER_FILE_MAX_BYTES (a long backtick run lengthens the fence; quotes and backslashes double in JSON) is refused now,
|
|
149
|
+
* because once accepted it could never be written.
|
|
150
|
+
*/
|
|
151
|
+
export function normalizeSteerEntry(entry, now = Date.now) {
|
|
152
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return { refusal: "the message is not an object" };
|
|
153
|
+
if (typeof entry.id !== "string" || !STEER_ID_RE.test(entry.id)) return { refusal: "the message id is not 32 lowercase hex characters" };
|
|
154
|
+
if (!STEER_MODES.includes(entry.mode)) return { refusal: `the mode is not one of ${STEER_MODES.join(", ")}` };
|
|
155
|
+
if (!STEER_SOURCES.includes(entry.source)) return { refusal: `the source is not one of ${STEER_SOURCES.join(", ")}` };
|
|
156
|
+
const a = entry.author;
|
|
157
|
+
let author;
|
|
158
|
+
if (entry.source === "forge") {
|
|
159
|
+
const id = typeof a?.id === "number" ? String(a.id) : a?.id;
|
|
160
|
+
if (a?.kind !== "forge" || typeof id !== "string" || !FORGE_USER_RE.test(id) || typeof a.association !== "string" || !ASSOCIATION_RE.test(a.association)) {
|
|
161
|
+
return { refusal: "a forge message needs a forge author with a forge user id and an association" };
|
|
162
|
+
}
|
|
163
|
+
// A numeric id stays a number (the record and the framing read it so); Azure DevOps's GUID or `azid-` digest stays
|
|
164
|
+
// the string it is. `Number()` on those was NaN, and the framing then refused the entry under the size message.
|
|
165
|
+
author = { kind: "forge", id: /^[0-9]+$/.test(id) ? Number(id) : id, association: a.association };
|
|
166
|
+
} else {
|
|
167
|
+
if (a?.kind !== "operator" || typeof a.by !== "string" || !TOKEN_NAME_RE.test(a.by)) return { refusal: "an operator message needs an operator author named by a token-name word" };
|
|
168
|
+
author = { kind: "operator", by: a.by };
|
|
169
|
+
}
|
|
170
|
+
const bounded = boundSteerText(entry.text);
|
|
171
|
+
if (bounded.refusal) return { refusal: bounded.refusal };
|
|
172
|
+
const at = entry.at === undefined ? nowOf(now) : entry.at;
|
|
173
|
+
if (!isMs(at)) return { refusal: "the message time is not a millisecond timestamp" };
|
|
174
|
+
const out = { v: 1, id: entry.id, mode: entry.mode, source: entry.source, author, text: bounded.text, at };
|
|
175
|
+
if (entry.deliveryId !== undefined) {
|
|
176
|
+
if (typeof entry.deliveryId !== "string" || !DELIVERY_ID_RE.test(entry.deliveryId)) return { refusal: "the delivery id is not a plain delivery GUID" };
|
|
177
|
+
out.deliveryId = entry.deliveryId;
|
|
178
|
+
}
|
|
179
|
+
if (entry.hop !== undefined) {
|
|
180
|
+
if (!Number.isSafeInteger(entry.hop) || entry.hop < 0 || entry.hop > 8) return { refusal: "the hop is not a small non-negative integer" };
|
|
181
|
+
out.hop = entry.hop;
|
|
182
|
+
}
|
|
183
|
+
if (frameSteerMessage({ entry: out, ref: STEER_REF_WORST, forge: "x".repeat(16) }) === null) return { refusal: "the message cannot be framed for the run (an author or reference outside its charset). Nothing was sent" };
|
|
184
|
+
if (steerFileJson({ entry: out, ref: STEER_REF_WORST, forge: "x".repeat(16) }) === null) {
|
|
185
|
+
return { refusal: `the message, framed for the run, would be above the ${STEER_FILE_MAX_BYTES}-byte inbox file limit (long runs of backticks, quotes and backslashes count extra); shorten it. Nothing was sent` };
|
|
186
|
+
}
|
|
187
|
+
return { entry: out };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// dup FIRST, before closed and gone: a redelivered webhook GUID of a message the run already holds is that message,
|
|
191
|
+
// whatever became of the run since (close answered it as late, or it was delivered). Saying `closed` instead would make
|
|
192
|
+
// the receiver enqueue its fallback job a second time for one comment.
|
|
193
|
+
// closed BEFORE gone: close also deletes the open key, and "the run has finished" is the truer of the two answers.
|
|
194
|
+
// KEYS: open, closed, list, state. ARGV: id, entry JSON, text bytes, max count, max bytes, ttl ms, meta.
|
|
195
|
+
// The meta field (`m:<id>`, "<seq> <meta>") is what `listInboxPublic` reads instead of the list, so a surface that lists
|
|
196
|
+
// the inbox every second never moves the text (part 3, review F3); `seq` is the accepted order, the list's own.
|
|
197
|
+
const PUSH_SCRIPT = `if redis.call('HEXISTS', KEYS[4], ARGV[1]) == 1 then return 'dup' end
|
|
198
|
+
if redis.call('EXISTS', KEYS[2]) == 1 then return 'closed' end
|
|
199
|
+
if redis.call('EXISTS', KEYS[1]) == 0 then return 'gone' end
|
|
200
|
+
local count = tonumber(redis.call('HGET', KEYS[4], '_count') or '0') or 0
|
|
201
|
+
local bytes = tonumber(redis.call('HGET', KEYS[4], '_bytes') or '0') or 0
|
|
202
|
+
local add = tonumber(ARGV[3])
|
|
203
|
+
if count + 1 > tonumber(ARGV[4]) or bytes + add > tonumber(ARGV[5]) then return 'full' end
|
|
204
|
+
redis.call('RPUSH', KEYS[3], ARGV[2])
|
|
205
|
+
redis.call('HSET', KEYS[4], ARGV[1], 'queued', '_count', count + 1, '_bytes', bytes + add, 'm:' .. ARGV[1], (count + 1) .. ' ' .. ARGV[7])
|
|
206
|
+
redis.call('PEXPIRE', KEYS[3], ARGV[6])
|
|
207
|
+
redis.call('PEXPIRE', KEYS[4], ARGV[6])
|
|
208
|
+
return 'ok'`;
|
|
209
|
+
|
|
210
|
+
// queued or written -> withdrawn; anything else is left as it is. Returns the state it found (nil when unknown).
|
|
211
|
+
// KEYS: state. ARGV: id.
|
|
212
|
+
const WITHDRAW_SCRIPT = `local cur = redis.call('HGET', KEYS[1], ARGV[1])
|
|
213
|
+
if not cur then return false end
|
|
214
|
+
if cur == 'queued' or cur == 'written' then redis.call('HSET', KEYS[1], ARGV[1], 'withdrawn') end
|
|
215
|
+
return cur`;
|
|
216
|
+
|
|
217
|
+
// The final close. KEYS: closed, open, list, state[, target]. ARGV: target member (the raw runId), ttl ms. The third
|
|
218
|
+
// element says whether THIS call made the closing transition (1) or found the inbox already closed (0): a run whose
|
|
219
|
+
// final outcome is settled twice (the processor's settle, then BullMQ's failed listener for the same thrown failure)
|
|
220
|
+
// gets the same late entries back both times, and only the first may answer them.
|
|
221
|
+
const CLOSE_SCRIPT = `local first = 1 - redis.call('EXISTS', KEYS[1])
|
|
222
|
+
redis.call('SET', KEYS[1], '1', 'PX', ARGV[2])
|
|
223
|
+
redis.call('DEL', KEYS[2])
|
|
224
|
+
if #KEYS >= 5 then redis.call('ZREM', KEYS[5], ARGV[1]) end
|
|
225
|
+
local st = redis.call('HGETALL', KEYS[4])
|
|
226
|
+
for i = 1, #st, 2 do
|
|
227
|
+
local v = st[i + 1]
|
|
228
|
+
if string.sub(st[i], 1, 1) ~= '_' and (v == 'queued' or v == 'written') then redis.call('HSET', KEYS[4], st[i], 'late') end
|
|
229
|
+
end
|
|
230
|
+
redis.call('PEXPIRE', KEYS[3], ARGV[2])
|
|
231
|
+
redis.call('PEXPIRE', KEYS[4], ARGV[2])
|
|
232
|
+
return {redis.call('LRANGE', KEYS[3], 0, -1), redis.call('HGETALL', KEYS[4]), first}`;
|
|
233
|
+
|
|
234
|
+
// Compare and set. KEYS: state. ARGV: id, to, from...
|
|
235
|
+
const MARK_SCRIPT = `local cur = redis.call('HGET', KEYS[1], ARGV[1])
|
|
236
|
+
if not cur then return {0, false} end
|
|
237
|
+
for i = 3, #ARGV do
|
|
238
|
+
if cur == ARGV[i] then
|
|
239
|
+
redis.call('HSET', KEYS[1], ARGV[1], ARGV[2])
|
|
240
|
+
return {1, cur}
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
return {0, cur}`;
|
|
244
|
+
|
|
245
|
+
/** A flat HGETALL reply (or ioredis's object form) as `{ state, count, bytes }`: ids only in `state`. */
|
|
246
|
+
function stateOf(reply) {
|
|
247
|
+
const pairs = Array.isArray(reply) ? reply.flatMap((v, i, all) => (i % 2 === 0 ? [[v, all[i + 1]]] : [])) : Object.entries(reply ?? {});
|
|
248
|
+
const state = {};
|
|
249
|
+
let count = 0;
|
|
250
|
+
let bytes = 0;
|
|
251
|
+
for (const [k, v] of pairs) {
|
|
252
|
+
if (k === "_count") count = Number(v) || 0;
|
|
253
|
+
else if (k === "_bytes") bytes = Number(v) || 0;
|
|
254
|
+
else if (STEER_ID_RE.test(k) && STEER_STATES.includes(v)) state[k] = v;
|
|
255
|
+
}
|
|
256
|
+
return { state, count, bytes };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* A normalized entry's public metadata as the state hash keeps it (`m:<id>`, after the script's seq): "<mode> <source>
|
|
261
|
+
* <at> <bytes>". Every part is from a closed vocabulary or an integer, so the field holds nothing a sender chose.
|
|
262
|
+
*/
|
|
263
|
+
function metaOf(entry) {
|
|
264
|
+
return `${entry.mode} ${entry.source} ${entry.at} ${Buffer.byteLength(entry.text, "utf8")}`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** The `m:<id>` fields of a state hash reply, `id -> { seq, mode, source, at, bytes }`; a malformed one is left out. */
|
|
268
|
+
function metaFieldsOf(reply) {
|
|
269
|
+
const pairs = Array.isArray(reply) ? reply.flatMap((v, i, all) => (i % 2 === 0 ? [[v, all[i + 1]]] : [])) : Object.entries(reply ?? {});
|
|
270
|
+
const out = {};
|
|
271
|
+
for (const [k, v] of pairs) {
|
|
272
|
+
if (typeof k !== "string" || !k.startsWith("m:") || typeof v !== "string") continue;
|
|
273
|
+
const id = k.slice(2);
|
|
274
|
+
const m = /^([0-9]{1,6}) ([A-Za-z]{1,16}) ([a-z]{1,16}) ([0-9]{1,16}) ([0-9]{1,9})$/.exec(v);
|
|
275
|
+
if (!STEER_ID_RE.test(id) || m === null) continue;
|
|
276
|
+
out[id] = { seq: Number(m[1]), mode: m[2], source: m[3], at: Number(m[4]), bytes: Number(m[5]) };
|
|
277
|
+
}
|
|
278
|
+
return out;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** LRANGE reply to entries; a line that does not parse as an entry object is dropped, never surfaced as text. */
|
|
282
|
+
function entriesOf(list) {
|
|
283
|
+
const out = [];
|
|
284
|
+
for (const line of Array.isArray(list) ? list : []) {
|
|
285
|
+
try {
|
|
286
|
+
const e = JSON.parse(line);
|
|
287
|
+
if (e && typeof e === "object" && typeof e.id === "string" && STEER_ID_RE.test(e.id)) out.push(e);
|
|
288
|
+
} catch {}
|
|
289
|
+
}
|
|
290
|
+
return out;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Push one message into a run's inbox: "ok", "dup", "closed", "gone" or "full", else `{ error }`. The text is bounded
|
|
295
|
+
* and the entry validated before Valkey is touched (a free refusal goes first). `now` stamps `at` when the entry has
|
|
296
|
+
* none.
|
|
297
|
+
*/
|
|
298
|
+
export async function pushSteer({ redis, runId, entry, now = Date.now } = {}) {
|
|
299
|
+
if (!validRunId(runId)) return refused("no run id");
|
|
300
|
+
const norm = normalizeSteerEntry(entry, now);
|
|
301
|
+
if (norm.refusal) return refused(norm.refusal);
|
|
302
|
+
const k = inboxKeys(runId);
|
|
303
|
+
const bytes = Buffer.byteLength(norm.entry.text, "utf8");
|
|
304
|
+
try {
|
|
305
|
+
const r = await redis.eval(PUSH_SCRIPT, 4, steerOpenKey(runId), k.closed, k.list, k.state, norm.entry.id, JSON.stringify(norm.entry), String(bytes), String(STEER_MAX_MESSAGES), String(STEER_TOTAL_MAX_BYTES), String(STEER_INBOX_TTL_MS), metaOf(norm.entry));
|
|
306
|
+
return ["ok", "dup", "closed", "gone", "full"].includes(r) ? r : { error: "bad-reply" };
|
|
307
|
+
} catch (error) {
|
|
308
|
+
return { error: codeOf(error) };
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// The late branch's carry (issue #646, branch A): messages written into the inbox of a run that has NOT STARTED yet, so
|
|
313
|
+
// no open-key check (there is no live relay to set one), but every other rule of a push: refused when closed (that run
|
|
314
|
+
// already ended, and a reset at its start would delete them anyway), a message already present is skipped, and the
|
|
315
|
+
// run's two caps hold for the batch as a whole, all or nothing, decided before anything is written.
|
|
316
|
+
// KEYS: closed, list, state. ARGV: ttl ms, max count, max bytes, then (id, entry JSON, text bytes, meta) per message.
|
|
317
|
+
const PRESEED_SCRIPT = `if redis.call('EXISTS', KEYS[1]) == 1 then return {'closed', 0} end
|
|
318
|
+
local count = tonumber(redis.call('HGET', KEYS[3], '_count') or '0') or 0
|
|
319
|
+
local bytes = tonumber(redis.call('HGET', KEYS[3], '_bytes') or '0') or 0
|
|
320
|
+
local add, addBytes = 0, 0
|
|
321
|
+
for i = 4, #ARGV, 4 do
|
|
322
|
+
if redis.call('HEXISTS', KEYS[3], ARGV[i]) == 0 then
|
|
323
|
+
add = add + 1
|
|
324
|
+
addBytes = addBytes + tonumber(ARGV[i + 2])
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
if count + add > tonumber(ARGV[2]) or bytes + addBytes > tonumber(ARGV[3]) then return {'full', 0} end
|
|
328
|
+
local seq = count
|
|
329
|
+
for i = 4, #ARGV, 4 do
|
|
330
|
+
if redis.call('HEXISTS', KEYS[3], ARGV[i]) == 0 then
|
|
331
|
+
seq = seq + 1
|
|
332
|
+
redis.call('RPUSH', KEYS[2], ARGV[i + 1])
|
|
333
|
+
redis.call('HSET', KEYS[3], ARGV[i], 'queued', 'm:' .. ARGV[i], seq .. ' ' .. ARGV[i + 3])
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
if add > 0 then redis.call('HSET', KEYS[3], '_count', count + add, '_bytes', bytes + addBytes) end
|
|
337
|
+
redis.call('PEXPIRE', KEYS[2], ARGV[1])
|
|
338
|
+
redis.call('PEXPIRE', KEYS[3], ARGV[1])
|
|
339
|
+
return {'ok', add}`;
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* How long a carried message waits for its run to start: a day, not the inbox's hour, because the run is a queued job
|
|
343
|
+
* that may wait behind a full host or a pause window. The relay's first refresh puts the hour back once it is live.
|
|
344
|
+
*/
|
|
345
|
+
export const STEER_PRESEED_TTL_MS = 24 * 60 * 60 * 1000;
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Pre-seed a run that has not started with carried messages (DES-STEER-LATE-MESSAGE-RESUME-OR-REPLY, branch A): the
|
|
349
|
+
* late messages after the first, written `queued` into the inbox of the job the first one started, so its relay writes
|
|
350
|
+
* them at its loop start as steers. Every entry must carry `hop >= 1`: a carried message is never a first one, and a
|
|
351
|
+
* carried message that is late again is answered, never carried a second time (that is what ends the chain). The
|
|
352
|
+
* entries are validated exactly as a push validates them. `{ seeded: n }` (n new; an id already there is skipped),
|
|
353
|
+
* `{ error: "closed" | "full" }`, a refusal, or `{ error: <code> }`. Worker only: no surface takes a message for a run
|
|
354
|
+
* that is not live through this.
|
|
355
|
+
*/
|
|
356
|
+
export async function preseedInbox({ redis, runId, entries, now = Date.now } = {}) {
|
|
357
|
+
if (!validRunId(runId)) return refused("no run id");
|
|
358
|
+
if (!Array.isArray(entries) || entries.length === 0 || entries.length > STEER_MAX_MESSAGES) return refused(`the carried messages are not a list of 1 to ${STEER_MAX_MESSAGES}`);
|
|
359
|
+
const argv = [];
|
|
360
|
+
const seen = new Set();
|
|
361
|
+
for (const raw of entries) {
|
|
362
|
+
if (!Number.isSafeInteger(raw?.hop) || raw.hop < 1) return refused("a carried message needs a hop of at least 1");
|
|
363
|
+
const norm = normalizeSteerEntry(raw, now);
|
|
364
|
+
if (norm.refusal) return refused(norm.refusal);
|
|
365
|
+
if (seen.has(norm.entry.id)) continue;
|
|
366
|
+
seen.add(norm.entry.id);
|
|
367
|
+
argv.push(norm.entry.id, JSON.stringify(norm.entry), String(Buffer.byteLength(norm.entry.text, "utf8")), metaOf(norm.entry));
|
|
368
|
+
}
|
|
369
|
+
const k = inboxKeys(runId);
|
|
370
|
+
try {
|
|
371
|
+
const r = await redis.eval(PRESEED_SCRIPT, 3, k.closed, k.list, k.state, String(STEER_PRESEED_TTL_MS), String(STEER_MAX_MESSAGES), String(STEER_TOTAL_MAX_BYTES), ...argv);
|
|
372
|
+
if (!Array.isArray(r) || r.length !== 2) return { error: "bad-reply" };
|
|
373
|
+
if (r[0] === "ok") return { seeded: Number(r[1]) || 0 };
|
|
374
|
+
return r[0] === "closed" || r[0] === "full" ? { error: r[0] } : { error: "bad-reply" };
|
|
375
|
+
} catch (error) {
|
|
376
|
+
return { error: codeOf(error) };
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Withdraw one message: `{ prior }`, the state it was in. `queued` is a guaranteed withdrawal (the relay never wrote
|
|
382
|
+
* it); `written` is best effort (the file was in the run's inbox, and the runner may have read it before the relay
|
|
383
|
+
* unlinks it). Any other prior is left unchanged. `prior: null` means the run's inbox has no such message.
|
|
384
|
+
*/
|
|
385
|
+
export async function withdrawSteer({ redis, runId, id } = {}) {
|
|
386
|
+
if (!validRunId(runId)) return refused("no run id");
|
|
387
|
+
if (typeof id !== "string" || !STEER_ID_RE.test(id)) return refused("the message id is not 32 lowercase hex characters");
|
|
388
|
+
try {
|
|
389
|
+
const r = await redis.eval(WITHDRAW_SCRIPT, 1, inboxKeys(runId).state, id);
|
|
390
|
+
if (r === null || r === undefined) return { prior: null };
|
|
391
|
+
return STEER_STATES.includes(r) ? { prior: r } : { error: "bad-reply" };
|
|
392
|
+
} catch (error) {
|
|
393
|
+
return { error: codeOf(error) };
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* The final close (settle, at a FINAL outcome only): `{ entries, state, count, bytes, first }` with every message the
|
|
399
|
+
* run ever accepted, the formerly queued or written ones now `late`. After it every push is refused. `first` is true
|
|
400
|
+
* only for the call that closed it; a second close returns the same entries with `first: false`, and its caller must
|
|
401
|
+
* not answer them again. `targetKey` is the run's pointer, null for a run without one.
|
|
402
|
+
*/
|
|
403
|
+
export async function closeInbox({ redis, runId, targetKey = null } = {}) {
|
|
404
|
+
if (!validRunId(runId)) return refused("no run id");
|
|
405
|
+
if (targetKey !== null && (typeof targetKey !== "string" || !targetKey.startsWith("run:steer:target:"))) return refused("the target key is not a steer target key");
|
|
406
|
+
const k = inboxKeys(runId);
|
|
407
|
+
const keys = [k.closed, steerOpenKey(runId), k.list, k.state, ...(targetKey ? [targetKey] : [])];
|
|
408
|
+
try {
|
|
409
|
+
const r = await redis.eval(CLOSE_SCRIPT, keys.length, ...keys, String(runId), String(STEER_INBOX_TTL_MS));
|
|
410
|
+
if (!Array.isArray(r) || r.length !== 3) return { error: "bad-reply" };
|
|
411
|
+
return { entries: entriesOf(r[0]), ...stateOf(r[1]), first: Number(r[2]) === 1 };
|
|
412
|
+
} catch (error) {
|
|
413
|
+
return { error: codeOf(error) };
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Everything in the inbox, text included: `{ entries, state, count, bytes, closed }`. For the worker's relay only;
|
|
419
|
+
* a surface that shows the inbox to a person uses `listInboxPublic`.
|
|
420
|
+
*/
|
|
421
|
+
export async function readInbox({ redis, runId } = {}) {
|
|
422
|
+
if (!validRunId(runId)) return refused("no run id");
|
|
423
|
+
const k = inboxKeys(runId);
|
|
424
|
+
try {
|
|
425
|
+
const res = await redis.multi().lrange(k.list, 0, -1).hgetall(k.state).exists(k.closed).exec();
|
|
426
|
+
if (!Array.isArray(res) || res.length !== 3) return { error: "bad-reply" };
|
|
427
|
+
const failed = res.find(([err]) => err);
|
|
428
|
+
if (failed) return { error: codeOf(failed[0]) };
|
|
429
|
+
return { entries: entriesOf(res[0][1]), ...stateOf(res[1][1]), closed: Number(res[2][1]) === 1 };
|
|
430
|
+
} catch (error) {
|
|
431
|
+
return { error: codeOf(error) };
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* The inbox as a person may see it: `{ entries: [{ id, mode, source, state, at, bytes }], closed }`, NEVER the text
|
|
437
|
+
* (the console state API and the CLI show it; INT-CONSOLE-STATE-API carries no attacker-chosen string).
|
|
438
|
+
*
|
|
439
|
+
* METADATA ONLY (part 3, review F3): one HGETALL of the state hash and an EXISTS, read from the `m:<id>` fields the push
|
|
440
|
+
* and the pre-seed write, in their `seq` order, so the console's snapshot, which lists up to 16 inboxes every second,
|
|
441
|
+
* never moves up to 64 KiB of text per run to count bytes. A message held without its field (pushed by a worker from
|
|
442
|
+
* before part 3, during a rolling upgrade) makes this one read fall back to the whole list, as it used to.
|
|
443
|
+
*/
|
|
444
|
+
export async function listInboxPublic({ redis, runId } = {}) {
|
|
445
|
+
if (!validRunId(runId)) return refused("no run id");
|
|
446
|
+
const k = inboxKeys(runId);
|
|
447
|
+
let head;
|
|
448
|
+
try {
|
|
449
|
+
const res = await redis.multi().hgetall(k.state).exists(k.closed).exec();
|
|
450
|
+
if (!Array.isArray(res) || res.length !== 2) return { error: "bad-reply" };
|
|
451
|
+
const failed = res.find(([err]) => err);
|
|
452
|
+
if (failed) return { error: codeOf(failed[0]) };
|
|
453
|
+
head = { ...stateOf(res[0][1]), meta: metaFieldsOf(res[0][1]), closed: Number(res[1][1]) === 1 };
|
|
454
|
+
} catch (error) {
|
|
455
|
+
return { error: codeOf(error) };
|
|
456
|
+
}
|
|
457
|
+
const ids = Object.keys(head.state);
|
|
458
|
+
if (ids.every((id) => head.meta[id])) {
|
|
459
|
+
const entries = ids
|
|
460
|
+
.map((id) => ({ id, ...head.meta[id] }))
|
|
461
|
+
.sort((a, b) => a.seq - b.seq)
|
|
462
|
+
.map((m) => ({ id: m.id, mode: STEER_MODES.includes(m.mode) ? m.mode : null, source: STEER_SOURCES.includes(m.source) ? m.source : null, state: head.state[m.id], at: isMs(m.at) ? m.at : null, bytes: m.bytes }));
|
|
463
|
+
return { entries, closed: head.closed };
|
|
464
|
+
}
|
|
465
|
+
const r = await readInbox({ redis, runId });
|
|
466
|
+
if (r.error) return r;
|
|
467
|
+
const entries = r.entries.map((e) => ({
|
|
468
|
+
id: e.id,
|
|
469
|
+
mode: STEER_MODES.includes(e.mode) ? e.mode : null,
|
|
470
|
+
source: STEER_SOURCES.includes(e.source) ? e.source : null,
|
|
471
|
+
state: r.state[e.id] ?? null,
|
|
472
|
+
at: isMs(e.at) ? e.at : null,
|
|
473
|
+
bytes: typeof e.text === "string" ? Buffer.byteLength(e.text, "utf8") : 0,
|
|
474
|
+
}));
|
|
475
|
+
return { entries, closed: r.closed };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** One message's state: a STEER_STATES word, null when unknown, or `{ error }`. The CLI's poll. */
|
|
479
|
+
export async function messageState({ redis, runId, id } = {}) {
|
|
480
|
+
if (!validRunId(runId)) return refused("no run id");
|
|
481
|
+
if (typeof id !== "string" || !STEER_ID_RE.test(id)) return refused("the message id is not 32 lowercase hex characters");
|
|
482
|
+
try {
|
|
483
|
+
const v = await redis.hget(inboxKeys(runId).state, id);
|
|
484
|
+
if (v === null || v === undefined) return null;
|
|
485
|
+
return STEER_STATES.includes(v) ? v : { error: "bad-reply" };
|
|
486
|
+
} catch (error) {
|
|
487
|
+
return { error: codeOf(error) };
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* The relay's compare and set: `{ ok, prior }`. `ok` is true only when the message was in one of `from` and is now
|
|
493
|
+
* `to`; otherwise nothing changed and `prior` says what it is (null when unknown). Used for queued -> written,
|
|
494
|
+
* written|queued -> delivered, delivered -> written when a retry re-baselines, withdrawn -> delivered when a verified
|
|
495
|
+
* ack shows the agent received a message withdrawn too late, and any state -> late. The script allows any listed
|
|
496
|
+
* transition; which ones are legitimate is the caller's rule, not this function's.
|
|
497
|
+
*/
|
|
498
|
+
export async function markState({ redis, runId, id, from, to } = {}) {
|
|
499
|
+
if (!validRunId(runId)) return refused("no run id");
|
|
500
|
+
if (typeof id !== "string" || !STEER_ID_RE.test(id)) return refused("the message id is not 32 lowercase hex characters");
|
|
501
|
+
if (!STEER_STATES.includes(to)) return refused("the target state is not a steer state");
|
|
502
|
+
if (!Array.isArray(from) || from.length === 0 || !from.every((s) => STEER_STATES.includes(s))) return refused("the from states are not steer states");
|
|
503
|
+
try {
|
|
504
|
+
const r = await redis.eval(MARK_SCRIPT, 1, inboxKeys(runId).state, id, to, ...from);
|
|
505
|
+
if (!Array.isArray(r)) return { error: "bad-reply" };
|
|
506
|
+
const prior = r[1] === null || r[1] === undefined || r[1] === false ? null : r[1];
|
|
507
|
+
return { ok: Number(r[0]) === 1, prior };
|
|
508
|
+
} catch (error) {
|
|
509
|
+
return { error: codeOf(error) };
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Register a run as live, or refresh it (the relay calls this every 2 s): one MULTI that sets the open key (PX 30 s),
|
|
515
|
+
* scores the run in its target's ZSET at `now + 30 s` (PEXPIRE 60 s, and drops members already expired so a target
|
|
516
|
+
* steered by many runs over its life does not grow), and refreshes the inbox keys' hour. `targetKey` null for a run
|
|
517
|
+
* no forge comment can reach (local, cron): the operator surfaces still find it by its open key. `{ ok: true }` or
|
|
518
|
+
* `{ error }`.
|
|
519
|
+
*/
|
|
520
|
+
export async function registerLive({ redis, runId, targetKey = null, host, now = Date.now } = {}) {
|
|
521
|
+
if (!validRunId(runId)) return refused("no run id");
|
|
522
|
+
if (targetKey !== null && (typeof targetKey !== "string" || !targetKey.startsWith("run:steer:target:"))) return refused("the target key is not a steer target key");
|
|
523
|
+
if (typeof host !== "string" || host === "" || host.length > HOST_MAX) return refused("the host name is missing or too long");
|
|
524
|
+
const t = nowOf(now);
|
|
525
|
+
if (!isMs(t)) return refused("now is not a millisecond timestamp");
|
|
526
|
+
const k = inboxKeys(runId);
|
|
527
|
+
try {
|
|
528
|
+
const m = redis.multi().set(steerOpenKey(runId), host, "PX", STEER_OPEN_TTL_MS);
|
|
529
|
+
if (targetKey) m.zremrangebyscore(targetKey, "-inf", t).zadd(targetKey, t + STEER_OPEN_TTL_MS, String(runId)).pexpire(targetKey, STEER_TARGET_TTL_MS);
|
|
530
|
+
m.pexpire(k.list, STEER_INBOX_TTL_MS).pexpire(k.state, STEER_INBOX_TTL_MS).pexpire(k.fallback, STEER_INBOX_TTL_MS);
|
|
531
|
+
const res = await m.exec();
|
|
532
|
+
if (!Array.isArray(res)) return { error: "bad-reply" };
|
|
533
|
+
const failed = res.find(([err]) => err);
|
|
534
|
+
if (failed) return { error: codeOf(failed[0]) };
|
|
535
|
+
return { ok: true };
|
|
536
|
+
} catch (error) {
|
|
537
|
+
return { error: codeOf(error) };
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** The relay's 2 s refresh: the same writes as `registerLive`, named apart so a log line says which. */
|
|
542
|
+
export function refreshLive(args) {
|
|
543
|
+
return registerLive(args);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Drop the run from its target and delete the open key, now. Not a close (the inbox stays open), and no longer the
|
|
548
|
+
* relay's exit, which holds the keys instead (`holdLive`, review round 1, F3); kept for a caller that must make a run
|
|
549
|
+
* unreachable at once.
|
|
550
|
+
*/
|
|
551
|
+
export async function unregisterLive({ redis, runId, targetKey = null } = {}) {
|
|
552
|
+
if (!validRunId(runId)) return refused("no run id");
|
|
553
|
+
if (targetKey !== null && (typeof targetKey !== "string" || !targetKey.startsWith("run:steer:target:"))) return refused("the target key is not a steer target key");
|
|
554
|
+
try {
|
|
555
|
+
const m = redis.multi().del(steerOpenKey(runId));
|
|
556
|
+
if (targetKey) m.zrem(targetKey, String(runId));
|
|
557
|
+
const res = await m.exec();
|
|
558
|
+
if (!Array.isArray(res)) return { error: "bad-reply" };
|
|
559
|
+
const failed = res.find(([err]) => err);
|
|
560
|
+
if (failed) return { error: codeOf(failed[0]) };
|
|
561
|
+
return { ok: true };
|
|
562
|
+
} catch (error) {
|
|
563
|
+
return { error: codeOf(error) };
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* How long a run that stopped (its container exited) still takes messages: 15 minutes, longer than the 10-minute
|
|
569
|
+
* semantic window a forge job holds (queue.mjs SEMANTIC_WINDOW_MS). While that window can still swallow a comment's
|
|
570
|
+
* enqueue, the run must still be reachable as a steer, or the comment is lost in the gap between the container's exit
|
|
571
|
+
* and the settle, or between two attempts (an InfraRetry's backoff, a #645 recovery waiting for its pickup). Past the
|
|
572
|
+
* window a comment that finds no run is enqueued normally and nothing swallows it, so a longer hold buys nothing.
|
|
573
|
+
*/
|
|
574
|
+
export const STEER_HOLD_TTL_MS = 15 * 60 * 1000;
|
|
575
|
+
|
|
576
|
+
// The relay's exit. KEYS: closed, open[, target]. ARGV: host, now ms, hold ms, target member. Nothing when the inbox is
|
|
577
|
+
// already closed (a settle that ran first owns the keys). The target's TTL is only ever lengthened here.
|
|
578
|
+
const HOLD_SCRIPT = `if redis.call('EXISTS', KEYS[1]) == 1 then return 0 end
|
|
579
|
+
local hold = tonumber(ARGV[3])
|
|
580
|
+
redis.call('SET', KEYS[2], ARGV[1], 'PX', hold)
|
|
581
|
+
if #KEYS >= 3 then
|
|
582
|
+
redis.call('ZADD', KEYS[3], tonumber(ARGV[2]) + hold, ARGV[4])
|
|
583
|
+
if redis.call('PTTL', KEYS[3]) < hold then redis.call('PEXPIRE', KEYS[3], hold) end
|
|
584
|
+
end
|
|
585
|
+
return 1`;
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* The relay's exit (review round 1, F3): NOT an unregister. The open key and the target member stay, for
|
|
589
|
+
* STEER_HOLD_TTL_MS, so a comment or an operator message in the gap before the settle (or before the next attempt's
|
|
590
|
+
* relay re-registers) still lands in the inbox: the next attempt writes it, or the settle closes it as `late`, which is
|
|
591
|
+
* answered or resumed. Never lost, and the record counts it. The settle's close removes both; a run that never settles
|
|
592
|
+
* (it cannot: a stall-kill settles in the failed listener) loses them at the TTL. `{ held: boolean }` or `{ error }`.
|
|
593
|
+
*/
|
|
594
|
+
export async function holdLive({ redis, runId, targetKey = null, host, now = Date.now, holdMs = STEER_HOLD_TTL_MS } = {}) {
|
|
595
|
+
if (!validRunId(runId)) return refused("no run id");
|
|
596
|
+
if (targetKey !== null && (typeof targetKey !== "string" || !targetKey.startsWith("run:steer:target:"))) return refused("the target key is not a steer target key");
|
|
597
|
+
if (typeof host !== "string" || host === "" || host.length > HOST_MAX) return refused("the host name is missing or too long");
|
|
598
|
+
const t = nowOf(now);
|
|
599
|
+
if (!isMs(t)) return refused("now is not a millisecond timestamp");
|
|
600
|
+
if (!Number.isSafeInteger(holdMs) || holdMs < 1) return refused("the hold is not a positive number of milliseconds");
|
|
601
|
+
const keys = [inboxKeys(runId).closed, steerOpenKey(runId), ...(targetKey ? [targetKey] : [])];
|
|
602
|
+
try {
|
|
603
|
+
const r = await redis.eval(HOLD_SCRIPT, keys.length, ...keys, host, String(t), String(holdMs), String(runId));
|
|
604
|
+
return { held: Number(r) === 1 };
|
|
605
|
+
} catch (error) {
|
|
606
|
+
return { error: codeOf(error) };
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* The runs live on a target: at most `limit` runIds whose score (expiresAtMs) is after `now`, else `{ error }`. A
|
|
612
|
+
* member whose relay stopped refreshing is ignored the moment its score passes, whatever the ZSET's own TTL says.
|
|
613
|
+
*/
|
|
614
|
+
export async function liveRunsForTarget({ redis, targetKey, now = Date.now, limit = 4 } = {}) {
|
|
615
|
+
if (typeof targetKey !== "string" || !targetKey.startsWith("run:steer:target:")) return refused("the target key is not a steer target key");
|
|
616
|
+
const t = nowOf(now);
|
|
617
|
+
if (!isMs(t)) return refused("now is not a millisecond timestamp");
|
|
618
|
+
const n = Number.isSafeInteger(limit) && limit > 0 ? Math.min(limit, 64) : 4;
|
|
619
|
+
try {
|
|
620
|
+
const r = await redis.zrangebyscore(targetKey, `(${t}`, "+inf", "LIMIT", 0, n);
|
|
621
|
+
return Array.isArray(r) ? r.filter((m) => typeof m === "string" && m !== "") : { error: "bad-reply" };
|
|
622
|
+
} catch (error) {
|
|
623
|
+
return { error: codeOf(error) };
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** Whether a run takes messages now: true or false, or `{ error }`. */
|
|
628
|
+
export async function isOpen({ redis, runId } = {}) {
|
|
629
|
+
if (!validRunId(runId)) return refused("no run id");
|
|
630
|
+
try {
|
|
631
|
+
return Number(await redis.exists(steerOpenKey(runId))) === 1;
|
|
632
|
+
} catch (error) {
|
|
633
|
+
return { error: codeOf(error) };
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* The most fallback jobs one run holds (review round 1, F8): twice the message cap, because a fallback is stored BEFORE
|
|
639
|
+
* its push (receiver/src/steer.mjs), so a push the run then refuses (`full`, `gone`) still leaves one, and the hash must
|
|
640
|
+
* not grow with every comment a busy target gets. A store beyond it is refused, and the receiver falls through to an
|
|
641
|
+
* ordinary enqueue.
|
|
642
|
+
*/
|
|
643
|
+
export const STEER_MAX_FALLBACKS = 32;
|
|
644
|
+
|
|
645
|
+
// KEYS: fallback. ARGV: id, job JSON, max, ttl ms. A message already held is overwritten (a redelivery), never counted.
|
|
646
|
+
const FALLBACK_SCRIPT = `if redis.call('HEXISTS', KEYS[1], ARGV[1]) == 0 and redis.call('HLEN', KEYS[1]) >= tonumber(ARGV[3]) then return 'full' end
|
|
647
|
+
redis.call('HSET', KEYS[1], ARGV[1], ARGV[2])
|
|
648
|
+
redis.call('PEXPIRE', KEYS[1], ARGV[4])
|
|
649
|
+
return 'ok'`;
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Store the job a forge message would have started had no run been live (PR 2's late branch A enqueues it). The job is
|
|
653
|
+
* stored as JSON under the message id with the inbox's hour, at most STEER_MAX_FALLBACKS per run. `{ ok: true }`,
|
|
654
|
+
* `{ error: "full" }` or `{ error }`.
|
|
655
|
+
*/
|
|
656
|
+
export async function storeFallback({ redis, runId, id, job } = {}) {
|
|
657
|
+
if (!validRunId(runId)) return refused("no run id");
|
|
658
|
+
if (typeof id !== "string" || !STEER_ID_RE.test(id)) return refused("the message id is not 32 lowercase hex characters");
|
|
659
|
+
let body;
|
|
660
|
+
try {
|
|
661
|
+
body = JSON.stringify(job);
|
|
662
|
+
} catch {
|
|
663
|
+
return refused("the fallback job does not serialise");
|
|
664
|
+
}
|
|
665
|
+
if (typeof body !== "string" || !job || typeof job !== "object") return refused("the fallback job is not an object");
|
|
666
|
+
const k = inboxKeys(runId).fallback;
|
|
667
|
+
try {
|
|
668
|
+
const r = await redis.eval(FALLBACK_SCRIPT, 1, k, id, body, String(STEER_MAX_FALLBACKS), String(STEER_INBOX_TTL_MS));
|
|
669
|
+
if (r === "ok") return { ok: true };
|
|
670
|
+
return r === "full" ? { error: "full" } : { error: "bad-reply" };
|
|
671
|
+
} catch (error) {
|
|
672
|
+
return { error: codeOf(error) };
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/** Every stored fallback job, `{ jobs: { id: job } }` (a value that does not parse is dropped), or `{ error }`. */
|
|
677
|
+
export async function readFallbacks({ redis, runId } = {}) {
|
|
678
|
+
if (!validRunId(runId)) return refused("no run id");
|
|
679
|
+
try {
|
|
680
|
+
const r = await redis.hgetall(inboxKeys(runId).fallback);
|
|
681
|
+
const jobs = {};
|
|
682
|
+
for (const [id, v] of Object.entries(r ?? {})) {
|
|
683
|
+
if (!STEER_ID_RE.test(id)) continue;
|
|
684
|
+
try {
|
|
685
|
+
const job = JSON.parse(v);
|
|
686
|
+
if (job && typeof job === "object") jobs[id] = job;
|
|
687
|
+
} catch {}
|
|
688
|
+
}
|
|
689
|
+
return { jobs };
|
|
690
|
+
} catch (error) {
|
|
691
|
+
return { error: codeOf(error) };
|
|
692
|
+
}
|
|
693
|
+
}
|