@edgehero/pi-dispatch 4.3.1 → 5.1.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 +21 -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 +118 -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 +136 -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 +325 -0
- package/src/console-client.mjs +848 -0
- package/src/console-devices.mjs +372 -0
- package/src/console-install.mjs +239 -0
- package/src/console-open.mjs +99 -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 +1657 -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/envelope.mjs +5 -3
- 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 +4 -2
- 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 +12 -2
- 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 +36 -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 +402 -53
- 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,318 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The worker's half of the transcript records (issue #686, `DES-OPENSHELL-VENUE`, `INT-RUNNER-EXIT-CODE-PROTOCOL`'s
|
|
5
|
+
* transcript records): splits a copying venue's CLI stderr into the runner's signed transcript records and everything
|
|
6
|
+
* else, verifies each record under this attempt's exit key, and hands the verified ones, in order, to the live
|
|
7
|
+
* transcript's stdio source (live-transcript.mjs `makeLiveTranscript({ source: "stdio" })`).
|
|
8
|
+
*
|
|
9
|
+
* The runner's half is image/runner/src/transcript-mirror.mjs, whose header states the format
|
|
10
|
+
* (`\x1epidt <seq> <type> <end> <mac> <base64>\n`, types `b` baseline, `l` line, `r` reset). The MAC is restated here
|
|
11
|
+
* (the worker cannot import the image at run time) and pinned to the runner's by a test, as the steer ack's is. A
|
|
12
|
+
* verified record proves the CHANNEL (the runner's mirror wrote it, in this order), never the content: the session
|
|
13
|
+
* file is the agent's to write, so a line has the trust of a line the file tail reads on a binding venue.
|
|
14
|
+
*
|
|
15
|
+
* WHY A STREAM DEMUX AND NOT A LINE SPLIT. OpenShell's `sandbox exec` forwards stderr in reads of up to 4 KiB that are
|
|
16
|
+
* not line-aligned (measured, 0.1.4), so a record arrives cut anywhere, its marker included.
|
|
17
|
+
*
|
|
18
|
+
* THE PASS-THROUGH RULE. A record begins at the marker (RS, then `pidt `) wherever it appears, at a line start or after
|
|
19
|
+
* a foreign write that left no newline (a progress bar's `\r50%`). A byte that is not part of a record is passed on as
|
|
20
|
+
* soon as it is known not to be one, byte for byte. Only two things are held: the first bytes that still match the
|
|
21
|
+
* marker (at most its 6), and a record while it still fits the record's grammar (digits, one type letter, digits, 64
|
|
22
|
+
* hex, base64). The grammar is checked as the bytes arrive, so a record cut by another writer ends at the FIRST byte
|
|
23
|
+
* that cannot belong to it: the record's bytes before that byte are dropped (counted, never written out), and that
|
|
24
|
+
* byte and everything after it on the line are passed on as ordinary output. What that means, exactly:
|
|
25
|
+
* - a verified record, and a record's bytes before its break, never reach the job log;
|
|
26
|
+
* - a broken record's bytes AFTER its break byte do (a foreign `\r` mid-payload sends the rest of the base64 there);
|
|
27
|
+
* - a foreign write that lands inside a record and whose head fits the field being read (hex inside the MAC, base64
|
|
28
|
+
* inside the payload, digits inside a number) is taken into the record and lost with it, up to the first byte that
|
|
29
|
+
* does not fit; a newline where a record cannot end is taken as the broken record's own;
|
|
30
|
+
* - every other byte reaches the job log and the terminal unchanged.
|
|
31
|
+
*
|
|
32
|
+
* WHAT IS REFUSED, and counted, never published, never logged with its payload: a record that does not parse, one whose
|
|
33
|
+
* MAC does not verify (a forgery from a tool writing to the container's stderr, or a record another process's write
|
|
34
|
+
* broke), and one whose `seq` is not above the last accepted one (a replay). A gap in `seq` is allowed (the runner
|
|
35
|
+
* skips a line past its bound, and a broken record leaves one) and counted; the stdio source shows it as `skipped`.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
export const TRANSCRIPT_RECORD_MARKER = "\x1epidt ";
|
|
39
|
+
const MARKER = Buffer.from(TRANSCRIPT_RECORD_MARKER, "latin1");
|
|
40
|
+
const TRANSCRIPT_DOMAIN = "pi-dispatch-transcript";
|
|
41
|
+
const TYPES = new Set(["b", "l", "r"]);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The most payload bytes one record holds before it is given up on. A genuine record's payload is at most 4/3 of the
|
|
45
|
+
* runner's longest mirrored line (`TRANSCRIPT_LINE_MAX_BYTES`, 1 MiB) as base64, about 1.4 MiB; 8 MiB is far above
|
|
46
|
+
* that, so no genuine record meets it, and it bounds what a forged unterminated "record" can make this process hold.
|
|
47
|
+
* Past it the record is still read to its end (its bytes dropped as they arrive, never held, never passed on) and
|
|
48
|
+
* counted `oversize`.
|
|
49
|
+
*/
|
|
50
|
+
export const TRANSCRIPT_RECORD_MAX_BYTES = 8 * 1024 * 1024;
|
|
51
|
+
|
|
52
|
+
/** The runner's `transcriptRecordMac`, restated. */
|
|
53
|
+
export function transcriptRecordMac(key, runId, seq, type, end, payload) {
|
|
54
|
+
return createHmac("sha256", key).update(`${TRANSCRIPT_DOMAIN}\0${runId}\0${seq}\0${type}\0${end}\0`, "utf8").update(payload).digest("hex");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const NUM_RE = /^(?:0|[1-9][0-9]{0,15})$/;
|
|
58
|
+
const MAC_RE = /^[0-9a-f]{64}$/;
|
|
59
|
+
const B64_RE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* One record's body (the bytes between the marker and the newline) verified: `{ seq, type, end, payload }` or null.
|
|
63
|
+
* Constant-time on the MAC; numbers and base64 canonical, so one record has exactly one spelling. A baseline or a
|
|
64
|
+
* reset carries no payload.
|
|
65
|
+
*/
|
|
66
|
+
export function verifyTranscriptRecord(body, { key, runId }) {
|
|
67
|
+
if (typeof key !== "string" || key === "" || typeof runId !== "string" || runId === "") return null;
|
|
68
|
+
const text = Buffer.isBuffer(body) ? body.toString("latin1") : String(body ?? "");
|
|
69
|
+
const parts = text.split(" ");
|
|
70
|
+
if (parts.length !== 5) return null;
|
|
71
|
+
const [seqText, type, endText, mac, b64] = parts;
|
|
72
|
+
if (!NUM_RE.test(seqText) || seqText === "0" || !TYPES.has(type) || !NUM_RE.test(endText) || !MAC_RE.test(mac) || !B64_RE.test(b64)) return null;
|
|
73
|
+
const seq = Number(seqText);
|
|
74
|
+
const end = Number(endText);
|
|
75
|
+
if (!Number.isSafeInteger(seq) || !Number.isSafeInteger(end)) return null;
|
|
76
|
+
const payload = Buffer.from(b64, "base64");
|
|
77
|
+
// Canonical only: base64 has spellings that differ in their unused trailing bits and decode to the same bytes.
|
|
78
|
+
if (payload.toString("base64") !== b64) return null;
|
|
79
|
+
if (type !== "l" && payload.length > 0) return null;
|
|
80
|
+
if (!timingSafeEqual(Buffer.from(transcriptRecordMac(key, runId, seqText, type, endText, payload), "hex"), Buffer.from(mac, "hex"))) return null;
|
|
81
|
+
return { seq, type, end, payload };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const isDigit = (c) => c >= 0x30 && c <= 0x39;
|
|
85
|
+
const isHex = (c) => isDigit(c) || (c >= 0x61 && c <= 0x66);
|
|
86
|
+
const isB64 = (c) => isDigit(c) || (c >= 0x41 && c <= 0x5a) || (c >= 0x61 && c <= 0x7a) || c === 0x2b || c === 0x2f || c === 0x3d;
|
|
87
|
+
const isType = (c) => c === 0x62 || c === 0x6c || c === 0x72;
|
|
88
|
+
// Each field before the payload: the bytes it admits and its longest length. Field 4, the payload, is base64 to the newline.
|
|
89
|
+
const FIELDS = [
|
|
90
|
+
{ ok: isDigit, max: 16 },
|
|
91
|
+
{ ok: isType, max: 1 },
|
|
92
|
+
{ ok: isDigit, max: 16 },
|
|
93
|
+
{ ok: isHex, max: 64 },
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* `makeStderrTranscript({ key, runId, onLine, onBaseline, onReset, onGap, maxRecordBytes })` ->
|
|
98
|
+
* `{ push(chunk), end(), stats() }`.
|
|
99
|
+
*
|
|
100
|
+
* - `push(chunk)` takes one stderr chunk and returns the bytes to pass on (a Buffer, possibly empty), in order. The
|
|
101
|
+
* verified records it completed are handed out before it returns: `onBaseline(size)`, `onLine(lineBytes, end)`,
|
|
102
|
+
* `onReset(size)`, and `onGap(missing)` first when `seq` jumped. A throw from `push` (none is known) leaves the
|
|
103
|
+
* state as it was before the call, so the caller can `end()` and pass the chunk on itself without a byte twice.
|
|
104
|
+
* - `end()` returns what is still held and is not a record (the head of a line that matched the marker's first
|
|
105
|
+
* bytes), so nothing the CLI wrote is lost at close. An unterminated record is dropped and counted.
|
|
106
|
+
* - `stats()`: `{ records, unverified, replayed, gaps, oversize }`.
|
|
107
|
+
*
|
|
108
|
+
* Every callback is guarded: a throw in one costs that record only, never the demux or the bytes passed on.
|
|
109
|
+
*/
|
|
110
|
+
export function makeStderrTranscript({ key, runId, onLine = () => {}, onBaseline = () => {}, onReset = () => {}, onGap = () => {}, maxRecordBytes = TRANSCRIPT_RECORD_MAX_BYTES } = {}) {
|
|
111
|
+
// `start`: at a line start, `probe` holding the bytes so far that match the marker's head. `pass`: inside a line that
|
|
112
|
+
// is not a record, passed through to its newline. `record`: inside a record, in field `field` (0 to 4).
|
|
113
|
+
let mode = "start";
|
|
114
|
+
let probe = Buffer.alloc(0);
|
|
115
|
+
let parts = [];
|
|
116
|
+
let field = 0;
|
|
117
|
+
let fieldLen = 0;
|
|
118
|
+
let held = 0;
|
|
119
|
+
let discarding = false; // a payload past the bound: read to its end, never held
|
|
120
|
+
let last = 0;
|
|
121
|
+
const counts = { records: 0, unverified: 0, replayed: 0, gaps: 0, oversize: 0 };
|
|
122
|
+
const call = (fn, ...args) => {
|
|
123
|
+
try {
|
|
124
|
+
fn(...args);
|
|
125
|
+
} catch {}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const settle = (body) => {
|
|
129
|
+
const verified = verifyTranscriptRecord(body, { key, runId });
|
|
130
|
+
if (verified === null) {
|
|
131
|
+
counts.unverified += 1;
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (verified.seq <= last) {
|
|
135
|
+
counts.replayed += 1;
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (verified.seq > last + 1) {
|
|
139
|
+
// `seq` 1 is always the baseline: when it is the one lost, no LINE is missing for it.
|
|
140
|
+
const missing = verified.seq - last - 1 - (last === 0 ? 1 : 0);
|
|
141
|
+
if (missing > 0) {
|
|
142
|
+
counts.gaps += missing;
|
|
143
|
+
call(onGap, missing);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
last = verified.seq;
|
|
147
|
+
counts.records += 1;
|
|
148
|
+
if (verified.type === "b") call(onBaseline, verified.end);
|
|
149
|
+
else if (verified.type === "r") call(onReset, verified.end);
|
|
150
|
+
else if (verified.payload.length > 0) call(onLine, verified.payload, verified.end);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const openRecord = () => {
|
|
154
|
+
mode = "record";
|
|
155
|
+
probe = Buffer.alloc(0);
|
|
156
|
+
parts = [];
|
|
157
|
+
field = 0;
|
|
158
|
+
fieldLen = 0;
|
|
159
|
+
held = 0;
|
|
160
|
+
discarding = false;
|
|
161
|
+
};
|
|
162
|
+
/** The record ended before its grammar did: its bytes are dropped and counted, never passed on. */
|
|
163
|
+
const breakRecord = () => {
|
|
164
|
+
counts.unverified += 1;
|
|
165
|
+
parts = [];
|
|
166
|
+
held = 0;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const step = (buf) => {
|
|
170
|
+
const out = [];
|
|
171
|
+
let i = 0;
|
|
172
|
+
try {
|
|
173
|
+
while (i < buf.length) {
|
|
174
|
+
if (mode === "pass") {
|
|
175
|
+
// To the line's end, or to the next RS, whichever is first: a record can follow a foreign write that left
|
|
176
|
+
// no newline (a progress bar's `\r50%`), and must still be read as one. An RS that does not begin the
|
|
177
|
+
// marker (`\x1e{` of an RS-delimited JSON stream) is passed on by the probe below, unchanged.
|
|
178
|
+
const nl = buf.indexOf(0x0a, i);
|
|
179
|
+
const rs = buf.indexOf(0x1e, i);
|
|
180
|
+
if (rs !== -1 && (nl === -1 || rs < nl)) {
|
|
181
|
+
if (rs > i) out.push(buf.subarray(i, rs));
|
|
182
|
+
i = rs;
|
|
183
|
+
mode = "start";
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (nl === -1) {
|
|
187
|
+
out.push(buf.subarray(i));
|
|
188
|
+
i = buf.length;
|
|
189
|
+
} else {
|
|
190
|
+
out.push(buf.subarray(i, nl + 1));
|
|
191
|
+
i = nl + 1;
|
|
192
|
+
mode = "start";
|
|
193
|
+
}
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (mode === "start") {
|
|
197
|
+
const take = buf.subarray(i, i + (MARKER.length - probe.length));
|
|
198
|
+
const candidate = probe.length > 0 ? Buffer.concat([probe, take]) : take;
|
|
199
|
+
if (MARKER.subarray(0, candidate.length).equals(candidate)) {
|
|
200
|
+
i += take.length;
|
|
201
|
+
if (candidate.length === MARKER.length) openRecord();
|
|
202
|
+
else probe = Buffer.from(candidate);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
// Not a record: what was held goes out first, then this line from `i` on, newline included. The marker
|
|
206
|
+
// holds no newline, so the held head never does either, and the line's own newline is found in `pass`.
|
|
207
|
+
// An RS that failed the probe is passed on here, so `pass` never stops at the same byte twice.
|
|
208
|
+
if (probe.length > 0) out.push(probe);
|
|
209
|
+
else if (buf[i] === 0x1e) {
|
|
210
|
+
out.push(buf.subarray(i, i + 1));
|
|
211
|
+
i += 1;
|
|
212
|
+
}
|
|
213
|
+
probe = Buffer.alloc(0);
|
|
214
|
+
mode = "pass";
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
// mode === "record"
|
|
218
|
+
if (field < 4) {
|
|
219
|
+
const c = buf[i];
|
|
220
|
+
const spec = FIELDS[field];
|
|
221
|
+
if (c === 0x20 && fieldLen > 0 && (field !== 3 || fieldLen === 64)) {
|
|
222
|
+
parts.push(buf.subarray(i, i + 1));
|
|
223
|
+
held += 1;
|
|
224
|
+
field += 1;
|
|
225
|
+
fieldLen = 0;
|
|
226
|
+
i += 1;
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (spec.ok(c) && fieldLen < spec.max) {
|
|
230
|
+
parts.push(buf.subarray(i, i + 1));
|
|
231
|
+
held += 1;
|
|
232
|
+
fieldLen += 1;
|
|
233
|
+
i += 1;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
breakRecord();
|
|
237
|
+
if (c === 0x0a) {
|
|
238
|
+
i += 1;
|
|
239
|
+
mode = "start";
|
|
240
|
+
} else {
|
|
241
|
+
mode = "pass";
|
|
242
|
+
}
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
// The payload: base64 up to the newline, scanned in one run.
|
|
246
|
+
let j = i;
|
|
247
|
+
while (j < buf.length && isB64(buf[j])) j += 1;
|
|
248
|
+
if (j > i) {
|
|
249
|
+
if (!discarding && held + (j - i) > maxRecordBytes) {
|
|
250
|
+
discarding = true;
|
|
251
|
+
parts = [];
|
|
252
|
+
held = 0;
|
|
253
|
+
}
|
|
254
|
+
if (!discarding) {
|
|
255
|
+
parts.push(buf.subarray(i, j));
|
|
256
|
+
held += j - i;
|
|
257
|
+
}
|
|
258
|
+
i = j;
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (buf[i] === 0x0a) {
|
|
262
|
+
i += 1;
|
|
263
|
+
mode = "start";
|
|
264
|
+
if (discarding) {
|
|
265
|
+
counts.oversize += 1;
|
|
266
|
+
discarding = false;
|
|
267
|
+
parts = [];
|
|
268
|
+
held = 0;
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
const body = Buffer.concat(parts);
|
|
272
|
+
parts = [];
|
|
273
|
+
held = 0;
|
|
274
|
+
settle(body);
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
// A byte no record holds: the record was broken there. Dropped, and this byte on is ordinary output.
|
|
278
|
+
if (discarding) {
|
|
279
|
+
counts.oversize += 1;
|
|
280
|
+
discarding = false;
|
|
281
|
+
parts = [];
|
|
282
|
+
held = 0;
|
|
283
|
+
} else {
|
|
284
|
+
breakRecord();
|
|
285
|
+
}
|
|
286
|
+
mode = "pass";
|
|
287
|
+
}
|
|
288
|
+
} catch (cause) {
|
|
289
|
+
// None is known. What this chunk already passed on, and the part not yet read, ride the error, so the caller
|
|
290
|
+
// can send exactly those (and `end()`'s held bytes between them): no byte twice, and no completed record's.
|
|
291
|
+
throw Object.assign(new Error("stderr transcript demux fault", { cause }), { passed: Buffer.concat(out), rest: buf.subarray(i) });
|
|
292
|
+
}
|
|
293
|
+
return out.length === 0 ? Buffer.alloc(0) : out.length === 1 ? Buffer.from(out[0]) : Buffer.concat(out);
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
push(chunk) {
|
|
298
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk ?? ""), "utf8");
|
|
299
|
+
// Copies, because `parts` holds views into the chunk: the caller may reuse its buffer.
|
|
300
|
+
return step(Buffer.from(buf));
|
|
301
|
+
},
|
|
302
|
+
end() {
|
|
303
|
+
let out = Buffer.alloc(0);
|
|
304
|
+
if (mode === "start" && probe.length > 0) out = probe;
|
|
305
|
+
if (mode === "record") {
|
|
306
|
+
if (discarding) counts.oversize += 1;
|
|
307
|
+
else counts.unverified += 1;
|
|
308
|
+
}
|
|
309
|
+
mode = "start";
|
|
310
|
+
probe = Buffer.alloc(0);
|
|
311
|
+
parts = [];
|
|
312
|
+
held = 0;
|
|
313
|
+
discarding = false;
|
|
314
|
+
return out;
|
|
315
|
+
},
|
|
316
|
+
stats: () => ({ ...counts }),
|
|
317
|
+
};
|
|
318
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { STEER_ID_RE } from "./steer-framing.mjs";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The worker's half of a steer delivery ack (issue #646, INT-STEER-INBOX-CONTRACT): a third `onParsed` consumer of the
|
|
6
|
+
* live transcript tail (live-transcript.mjs `makeLiveTranscriptOpener`, beside the progress sink and the checkpoint
|
|
7
|
+
* handle), which turns each `pi-dispatch-inbox` custom message the runner delivered into `delivered` in the run's inbox.
|
|
8
|
+
*
|
|
9
|
+
* WHY A MAC AND NOT THE LINE ITSELF. `/session` is mounted read-write into the job, so the agent can append a
|
|
10
|
+
* `custom_message` of our type to its own transcript, naming any message id. A delivery counts only when the line's
|
|
11
|
+
* `details.ack` is `HMAC-SHA256(exitKey, "pi-dispatch-inbox-ack\0" + PI_JOB_ID + "\0" + id)`, compared in constant
|
|
12
|
+
* time, under the exit key this attempt minted (issue #545): handed to the runner on stdin, held in its non-dumpable
|
|
13
|
+
* process, never in the agent's reach. A line that does not verify is ignored and counted (`steer_ack_unverified`).
|
|
14
|
+
* The domain string is the runner's (image/runner/src/inbox.mjs `inboxAck`), and `ackRunId` is exactly the string the
|
|
15
|
+
* worker put in PI_JOB_ID for this container (run-container.mjs passes the container name), so both sides MAC one
|
|
16
|
+
* message by construction rather than by two call sites that happen to agree.
|
|
17
|
+
*
|
|
18
|
+
* PRIOR LINES. A line that ended at or before the tail's BASELINE (the file's size when this attempt began: a resumed
|
|
19
|
+
* or a #645-restored transcript) was written by an EARLIER attempt, whose exit key is gone, so its MAC cannot be
|
|
20
|
+
* checked here. Such a line is never counted as a delivery. Its id only joins the "already in this transcript" set
|
|
21
|
+
* the relay re-baselines against (inbox-relay.mjs): a message the inbox says is `delivered` whose id is NOT in that
|
|
22
|
+
* set goes back to `written`, so a recovery from a checkpoint older than the delivery hands it over again. The
|
|
23
|
+
* residual is deliberate and narrow: the agent can write a prior-looking line only into a transcript a later attempt
|
|
24
|
+
* restores, and all it buys is that a message which WAS delivered (verified, under that attempt's key) stays so.
|
|
25
|
+
*
|
|
26
|
+
* Never throws; the tail swallows a throw anyway, and nothing here may cost the run. Logs carry ids and counts only.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export const INBOX_CUSTOM_TYPE = "pi-dispatch-inbox";
|
|
30
|
+
const ACK_DOMAIN = "pi-dispatch-inbox-ack";
|
|
31
|
+
|
|
32
|
+
/** The ack for one message: the runner's `inboxAck`, restated (the worker cannot import the image). Pinned by a test. */
|
|
33
|
+
export function inboxAck(key, ackRunId, id) {
|
|
34
|
+
return createHmac("sha256", key).update(`${ACK_DOMAIN}\0${ackRunId}\0${id}`, "utf8").digest("hex");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Whether `ack` is the ack for `id` under `key`, in constant time. False on any malformed input, never a throw. */
|
|
38
|
+
export function ackVerifies(key, ackRunId, id, ack) {
|
|
39
|
+
if (typeof key !== "string" || key === "" || typeof ackRunId !== "string" || ackRunId === "") return false;
|
|
40
|
+
if (typeof id !== "string" || !STEER_ID_RE.test(id)) return false;
|
|
41
|
+
if (typeof ack !== "string" || !/^[0-9a-f]{64}$/.test(ack)) return false;
|
|
42
|
+
return timingSafeEqual(Buffer.from(inboxAck(key, ackRunId, id), "hex"), Buffer.from(ack, "hex"));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* `makeSteerAck({ exitKey, ackRunId, relay, jobId, log })` -> `{ consume, onBaseline, reset, drain, stats }`.
|
|
47
|
+
*
|
|
48
|
+
* - `consume(parsed, ts, flags)`: the tail's `onParsed`. A prior line feeds the prior set; any other line of our type
|
|
49
|
+
* is verified and, when it verifies, handed to `relay.delivered(id)` (which marks the inbox and unlinks the file).
|
|
50
|
+
* - `onBaseline()`: the tail's first pass over the staged transcript is done; calls `relay.afterBaseline(prior)`, so
|
|
51
|
+
* the relay re-baselines and only then starts writing files.
|
|
52
|
+
* - `drain()`: waits for every mark in flight (runContainer awaits it after the tail's final read, before it stops the
|
|
53
|
+
* relay, so a last-moment ack is counted before the settle step reads the states). Bounded by the relay's own calls.
|
|
54
|
+
*/
|
|
55
|
+
export function makeSteerAck({ exitKey, ackRunId, relay, jobId = null, log = () => {} } = {}) {
|
|
56
|
+
const prior = new Set();
|
|
57
|
+
const priorVerified = new Set();
|
|
58
|
+
const acked = new Set();
|
|
59
|
+
const inFlight = new Set();
|
|
60
|
+
const counts = { verified: 0, unverified: 0, prior: 0 };
|
|
61
|
+
let baselined = false;
|
|
62
|
+
let unverifiedSaid = false;
|
|
63
|
+
|
|
64
|
+
const hand = (id) => {
|
|
65
|
+
if (typeof relay?.delivered !== "function") return;
|
|
66
|
+
let p;
|
|
67
|
+
try {
|
|
68
|
+
p = Promise.resolve(relay.delivered(id)).catch(() => {});
|
|
69
|
+
} catch {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
inFlight.add(p);
|
|
73
|
+
p.finally(() => inFlight.delete(p));
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
consume(parsed, _ts, flags) {
|
|
78
|
+
if (parsed?.type !== "custom_message" || parsed.customType !== INBOX_CUSTOM_TYPE) return;
|
|
79
|
+
const details = parsed.details !== null && typeof parsed.details === "object" ? parsed.details : {};
|
|
80
|
+
const id = typeof details.id === "string" && STEER_ID_RE.test(details.id) ? details.id : null;
|
|
81
|
+
if (flags?.prior === true) {
|
|
82
|
+
if (id !== null && !prior.has(id)) {
|
|
83
|
+
prior.add(id);
|
|
84
|
+
counts.prior += 1;
|
|
85
|
+
}
|
|
86
|
+
// F5: a prior line whose ack verifies under THIS attempt's key is a delivery the inbox may not have recorded;
|
|
87
|
+
// the relay marks it at the baseline instead of writing it again. NOTE the reach: the exit key is minted per
|
|
88
|
+
// attempt, so a line an EARLIER attempt wrote never verifies here, and such a message is handed over again
|
|
89
|
+
// (a duplicate, framed, never a loss). Counting an unverified prior line instead would let the agent mark a
|
|
90
|
+
// message delivered that it was never sent, by writing a line into a transcript a recovery restores.
|
|
91
|
+
if (id !== null && ackVerifies(exitKey, ackRunId, id, details.ack)) priorVerified.add(id);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (id === null || !ackVerifies(exitKey, ackRunId, id, details.ack)) {
|
|
95
|
+
counts.unverified += 1;
|
|
96
|
+
// Said once per attempt, then counted: a job that writes forged lines in a loop must not flood the log.
|
|
97
|
+
if (!unverifiedSaid) {
|
|
98
|
+
unverifiedSaid = true;
|
|
99
|
+
log("steer_ack_unverified", { jobId, ...(id !== null ? { id } : {}) });
|
|
100
|
+
}
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (acked.has(id)) return;
|
|
104
|
+
acked.add(id);
|
|
105
|
+
counts.verified += 1;
|
|
106
|
+
hand(id);
|
|
107
|
+
},
|
|
108
|
+
onBaseline() {
|
|
109
|
+
if (baselined) return;
|
|
110
|
+
baselined = true;
|
|
111
|
+
try {
|
|
112
|
+
relay?.afterBaseline?.(new Set(prior), new Set(priorVerified));
|
|
113
|
+
} catch {}
|
|
114
|
+
},
|
|
115
|
+
// A rewritten transcript (the tail's reset) keeps what was already verified: a delivery that happened stays one.
|
|
116
|
+
reset() {},
|
|
117
|
+
async drain() {
|
|
118
|
+
while (inFlight.size > 0) await Promise.allSettled([...inFlight]);
|
|
119
|
+
},
|
|
120
|
+
stats: () => ({ ...counts }),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `pi-dispatch steer <jobId> [--later] <text…|->` and `pi-dispatch steer --withdraw <jobId> <msgId>` (issue #646,
|
|
3
|
+
* REQ-STEER-RUNNING-JOB): an operator's message into a running, steer-armed job, and taking one back.
|
|
4
|
+
*
|
|
5
|
+
* Modelled on cancel-cli.mjs, and on its footing: VALKEY_URL only (resolved by cli.mjs with the kill switch's rule),
|
|
6
|
+
* never loadConfig, the job found across every queue this deployment drains. What differs is what the job must BE:
|
|
7
|
+
*
|
|
8
|
+
* - active. A queued job has no run to steer, and a message waiting for it would arrive as the opening of a run
|
|
9
|
+
* the person did not start it for. The message is refused, never parked.
|
|
10
|
+
* - open: `run:steer:open:<runId>` present. Only the worker's relay sets it, and only for a run that is armed
|
|
11
|
+
* (`run.steer`), running, and on a venue that can deliver (bind mounts, an exit key, an image with `steer`). An
|
|
12
|
+
* active job without it is refused with the one sentence that names all three causes, since this side cannot
|
|
13
|
+
* tell them apart, rather than accepting a message no one will ever write into the run.
|
|
14
|
+
*
|
|
15
|
+
* The push is the inbox's own script (steer-inbox.mjs), so a run that closes in the same moment refuses the message
|
|
16
|
+
* (`closed`/`gone`) instead of swallowing it. After `ok` the command polls up to 10 s for the relay to write it
|
|
17
|
+
* (`written`) or the agent to take it (`delivered`) and says which; a message still `queued` at the end of the window
|
|
18
|
+
* was accepted and is said so, with exit 0. A message that turns `late` (the run ended first) is exit 1: it was not seen.
|
|
19
|
+
*
|
|
20
|
+
* NEVER PRINTS THE TEXT. Not on success, not in an error: the id, the mode and the state are enough to find it again,
|
|
21
|
+
* and a terminal scrollback or a CI log is no place for words meant for one run.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import * as inbox from "./steer-inbox.mjs";
|
|
25
|
+
|
|
26
|
+
/** Same budget the kill switch and cancel give the host registry. */
|
|
27
|
+
const FLEET_READ_TIMEOUT_MS = 2_000;
|
|
28
|
+
/** The most stdin may carry before scrubbing: four times the text bound, so control bytes the scrub removes still fit. */
|
|
29
|
+
const STDIN_MAX_BYTES = 32 * 1024;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Read stdin up to `maxBytes`: `{ text }`, or `{ over: true }` the moment it passes the bound (the rest is not read).
|
|
33
|
+
* `stdin` is the seam; it must be an async iterable of Buffers or strings.
|
|
34
|
+
*/
|
|
35
|
+
export async function readStdinBounded(maxBytes = STDIN_MAX_BYTES, stdin = process.stdin) {
|
|
36
|
+
const chunks = [];
|
|
37
|
+
let n = 0;
|
|
38
|
+
for await (const chunk of stdin) {
|
|
39
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), "utf8");
|
|
40
|
+
n += buf.length;
|
|
41
|
+
if (n > maxBytes) {
|
|
42
|
+
stdin.destroy?.();
|
|
43
|
+
return { over: true };
|
|
44
|
+
}
|
|
45
|
+
chunks.push(buf);
|
|
46
|
+
}
|
|
47
|
+
return { text: Buffer.concat(chunks).toString("utf8") };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const USAGE_LINE = "pi-dispatch steer <jobId> [--later] <text…|-> or pi-dispatch steer --withdraw <jobId> <msgId>";
|
|
51
|
+
|
|
52
|
+
/** The line for a message's state after the push and the poll. */
|
|
53
|
+
function stateLine(id, jobId, mode, state) {
|
|
54
|
+
const head = `message ${id} for ${jobId} (${mode === "followUp" ? "follow-up" : "steer"}):`;
|
|
55
|
+
if (state === "delivered") return `${head} delivered. The agent has received it\n`;
|
|
56
|
+
if (state === "written") {
|
|
57
|
+
return mode === "followUp"
|
|
58
|
+
? `${head} written. The run holds it, and the agent reads it once it would otherwise stop\n`
|
|
59
|
+
: `${head} written. The run holds it, and the agent reads it after its current tool calls\n`;
|
|
60
|
+
}
|
|
61
|
+
return `${head} queued. The worker has not written it into the run yet; it is delivered while the run is open, or reported late if the run ends first. See its state with the console\n`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Run the verb. `positionals` are what follows `steer` once cli.mjs has taken `--valkey-url`, `--later` and
|
|
66
|
+
* `--withdraw` out. Returns the exit code. Every collaborator is a seam with the production default, cancel's shape.
|
|
67
|
+
*/
|
|
68
|
+
export async function runSteer({ positionals = [], later = false, withdraw = false } = {}, url, { write = (chunk) => process.stdout.write(chunk), errWrite = (chunk) => process.stderr.write(chunk), redisFn, queueFn, parseConnectionFn, readLiveHostsFn, discoverHostQueuesFn, fleetQueueNamesFn, unionQueueNamesFn, readStdin = readStdinBounded, randomBytes, now = Date.now, ackTimeoutMs = 10_000, pollMs = 250, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), refusalFn } = {}) {
|
|
69
|
+
const fail = (message) => {
|
|
70
|
+
errWrite(`error: ${message}\n`);
|
|
71
|
+
return 1;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// Everything that can be refused for free is refused before Valkey is touched.
|
|
75
|
+
const jobId = positionals[0];
|
|
76
|
+
if (typeof jobId !== "string" || jobId === "") return fail(`a job id is required: ${USAGE_LINE} (ids are in \`pi-dispatch status\`, the panel, and the run log)`);
|
|
77
|
+
let msgId = null;
|
|
78
|
+
let text = null;
|
|
79
|
+
if (withdraw) {
|
|
80
|
+
if (later) return fail("--later and --withdraw do not go together: --withdraw takes a message back, whatever its mode");
|
|
81
|
+
if (positionals.length !== 2) return fail(`--withdraw takes a job id and one message id: ${USAGE_LINE}`);
|
|
82
|
+
msgId = positionals[1];
|
|
83
|
+
if (!inbox.STEER_ID_RE.test(msgId)) return fail("the message id is 32 lowercase hex characters, as `pi-dispatch steer` printed it");
|
|
84
|
+
} else {
|
|
85
|
+
const words = positionals.slice(1);
|
|
86
|
+
if (words.length === 0) return fail(`no message: ${USAGE_LINE} (- reads it from stdin)`);
|
|
87
|
+
let raw;
|
|
88
|
+
if (words.length === 1 && words[0] === "-") {
|
|
89
|
+
const read = await readStdin(STDIN_MAX_BYTES).catch((error) => ({ error }));
|
|
90
|
+
if (read.error) return fail(`could not read the message from stdin: ${read.error?.message ?? read.error}`);
|
|
91
|
+
if (read.over) return fail(`stdin carried more than ${STDIN_MAX_BYTES} bytes; a message is at most ${inbox.STEER_TEXT_MAX_BYTES} bytes. Nothing was sent`);
|
|
92
|
+
raw = read.text;
|
|
93
|
+
} else {
|
|
94
|
+
raw = words.join(" ");
|
|
95
|
+
}
|
|
96
|
+
const bounded = inbox.boundSteerText(raw);
|
|
97
|
+
if (bounded.refusal) return fail(bounded.refusal);
|
|
98
|
+
text = bounded.text;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const { parseConnection, makeRedisClient } = await import("./connection.mjs");
|
|
102
|
+
const { fleetQueueNames, discoverHostQueues, unionQueueNames, makeQueue } = await import("./queue.mjs");
|
|
103
|
+
const { readLiveHosts } = await import("./host-registry.mjs");
|
|
104
|
+
const { runIdOf } = await import("./recovery-queue.mjs");
|
|
105
|
+
const parseConn = parseConnectionFn ?? parseConnection;
|
|
106
|
+
const mkRedis = redisFn ?? makeRedisClient;
|
|
107
|
+
const mkQueue = queueFn ?? makeQueue;
|
|
108
|
+
const liveHosts = readLiveHostsFn ?? readLiveHosts;
|
|
109
|
+
const discover = discoverHostQueuesFn ?? discoverHostQueues;
|
|
110
|
+
const fleetNames = fleetQueueNamesFn ?? fleetQueueNames;
|
|
111
|
+
const unionNames = unionQueueNamesFn ?? unionQueueNames;
|
|
112
|
+
|
|
113
|
+
// cancel's rule (issue #464): a refused Valkey is said as the refusal it is, judged only where this command builds
|
|
114
|
+
// its own clients.
|
|
115
|
+
const refusalOf =
|
|
116
|
+
refusalFn ??
|
|
117
|
+
(redisFn
|
|
118
|
+
? async () => null
|
|
119
|
+
: async (u) => {
|
|
120
|
+
const { judgeValkeyAtStart, defaultValkeyContext } = await import("./connection.mjs");
|
|
121
|
+
try {
|
|
122
|
+
await judgeValkeyAtStart(u, defaultValkeyContext(), { waitMs: 0 });
|
|
123
|
+
return null;
|
|
124
|
+
} catch (error) {
|
|
125
|
+
return error?.valkeyRefused ? error.message : null;
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
const valkeyRefused = await refusalOf(url);
|
|
129
|
+
if (valkeyRefused) return fail(valkeyRefused);
|
|
130
|
+
const probe = mkRedis(url);
|
|
131
|
+
probe.on?.("error", () => {});
|
|
132
|
+
const queues = [];
|
|
133
|
+
try {
|
|
134
|
+
const [fleet, existing] = await Promise.all([
|
|
135
|
+
liveHosts(probe, { timeoutMs: FLEET_READ_TIMEOUT_MS }).catch((error) => ({ unreachable: error?.message ?? String(error) })),
|
|
136
|
+
discover(probe, { timeoutMs: FLEET_READ_TIMEOUT_MS }),
|
|
137
|
+
]);
|
|
138
|
+
const blind = fleet?.unreachable ?? null;
|
|
139
|
+
const names = unionNames(fleetNames(fleet?.hosts), existing);
|
|
140
|
+
let job = null;
|
|
141
|
+
for (const name of names) {
|
|
142
|
+
const q = mkQueue(parseConn(url, { failFast: true }), { name });
|
|
143
|
+
queues.push(q);
|
|
144
|
+
job = await q.getJob(jobId);
|
|
145
|
+
if (job) break;
|
|
146
|
+
}
|
|
147
|
+
if (!job) return fail(`no job ${jobId} in ${names.length} queue(s)${blind ? ` [registry unreadable: ${blind}; a host that is not registered may still hold it]` : ""}`);
|
|
148
|
+
const runId = runIdOf(job);
|
|
149
|
+
|
|
150
|
+
if (withdraw) {
|
|
151
|
+
const res = await inbox.withdrawSteer({ redis: probe, runId, id: msgId });
|
|
152
|
+
if (res.error) return res.error === "refused" ? fail(res.reason) : fail(`could not withdraw message ${msgId}: Valkey said ${res.error}`);
|
|
153
|
+
if (res.prior === "queued") {
|
|
154
|
+
write(`withdrawn message ${msgId} from ${jobId}: it was queued, so the run never sees it (guaranteed)\n`);
|
|
155
|
+
return 0;
|
|
156
|
+
}
|
|
157
|
+
if (res.prior === "written") {
|
|
158
|
+
write(`withdrawn message ${msgId} from ${jobId}, best effort: it was already written into the run's inbox, so the agent may have read it before the worker removed it. Its transcript says whether it did\n`);
|
|
159
|
+
return 0;
|
|
160
|
+
}
|
|
161
|
+
if (res.prior === "withdrawn") {
|
|
162
|
+
write(`message ${msgId} of ${jobId} was already withdrawn; nothing was changed\n`);
|
|
163
|
+
return 0;
|
|
164
|
+
}
|
|
165
|
+
if (res.prior === "delivered") return fail(`message ${msgId} was already delivered: the agent has received it, so it cannot be withdrawn. Nothing was changed`);
|
|
166
|
+
if (res.prior === "late") return fail(`message ${msgId} arrived after the run had ended (late): the agent never saw it, and there is nothing to withdraw`);
|
|
167
|
+
return fail(`no message ${msgId} in the inbox of ${jobId}`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const state = await job.getState().catch(() => "unknown");
|
|
171
|
+
if (state !== "active") return fail(`job ${jobId} is ${state}: only a running job takes messages. Nothing was sent`);
|
|
172
|
+
const open = await inbox.isOpen({ redis: probe, runId });
|
|
173
|
+
if (open !== true) {
|
|
174
|
+
if (open === false) return fail("the run is not accepting messages: it is not steer-armed, not running, or its venue cannot deliver");
|
|
175
|
+
throw Object.assign(new Error(`Valkey said ${open.error}`), { code: open.error });
|
|
176
|
+
}
|
|
177
|
+
const mode = later ? "followUp" : "steer";
|
|
178
|
+
const id = inbox.operatorMessageId(...(randomBytes ? [randomBytes] : []));
|
|
179
|
+
const entry = { v: 1, id, mode, source: "cli", author: { kind: "operator", by: "cli" }, text, at: now() };
|
|
180
|
+
const pushed = await inbox.pushSteer({ redis: probe, runId, entry, now });
|
|
181
|
+
if (pushed?.error === "refused") return fail(pushed.reason);
|
|
182
|
+
if (pushed?.error) throw Object.assign(new Error(`Valkey said ${pushed.error}`), { code: pushed.error });
|
|
183
|
+
if (pushed === "gone") return fail("the run stopped accepting messages before this one arrived (it ended, or its worker stopped refreshing it). Nothing was sent");
|
|
184
|
+
if (pushed === "closed") return fail("the run has finished and its inbox is closed. Nothing was sent");
|
|
185
|
+
if (pushed === "full") return fail(`the run's inbox is full (at most ${inbox.STEER_MAX_MESSAGES} messages and ${inbox.STEER_TOTAL_MAX_BYTES} bytes per run, withdrawn ones included). Nothing was sent`);
|
|
186
|
+
if (pushed !== "ok") return fail(`the inbox answered ${String(pushed)} for a new message id; nothing was sent`);
|
|
187
|
+
|
|
188
|
+
// Poll for the relay's write. A read fault ends the poll, not the command: the message IS accepted.
|
|
189
|
+
// Counted in polls rather than against `now()`: a test's fixed clock would otherwise poll forever, and the
|
|
190
|
+
// window only needs to be roughly ten seconds.
|
|
191
|
+
let seen = "queued";
|
|
192
|
+
const polls = Math.max(1, Math.ceil(ackTimeoutMs / pollMs));
|
|
193
|
+
for (let i = 0; i < polls; i++) {
|
|
194
|
+
await sleep(pollMs);
|
|
195
|
+
const s = await inbox.messageState({ redis: probe, runId, id });
|
|
196
|
+
if (typeof s !== "string") break;
|
|
197
|
+
seen = s;
|
|
198
|
+
if (s !== "queued") break;
|
|
199
|
+
}
|
|
200
|
+
if (seen === "late") return fail(`message ${id} for ${jobId}: the run ended before the worker could write it (late). The agent did not see it`);
|
|
201
|
+
if (seen === "withdrawn") {
|
|
202
|
+
write(`message ${id} for ${jobId}: withdrawn before it was written\n`);
|
|
203
|
+
return 0;
|
|
204
|
+
}
|
|
205
|
+
write(stateLine(id, jobId, mode, seen));
|
|
206
|
+
return 0;
|
|
207
|
+
} catch (error) {
|
|
208
|
+
return fail(error?.valkeyRefused ? error.message : `could not reach Valkey at ${(await import("./connection.mjs")).urlShown(url)}: ${(await import("./valkey-auth.mjs")).valkeyDownHint(url)}\n ${error.message}`);
|
|
209
|
+
} finally {
|
|
210
|
+
probe.disconnect?.();
|
|
211
|
+
for (const q of queues) await q.close().catch(() => {});
|
|
212
|
+
}
|
|
213
|
+
}
|