@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
package/src/connection.mjs
CHANGED
|
@@ -119,12 +119,40 @@ if (!Redis.prototype[SELECT_HOOK]) {
|
|
|
119
119
|
* The `error` listener every BullMQ Queue and Worker of the project carries (issue #468): without one, BullMQ prints
|
|
120
120
|
* the whole error object with `console.error`. This writes one line, the message only (scrubbed of any command), and
|
|
121
121
|
* `what` naming whose error it is.
|
|
122
|
+
*
|
|
123
|
+
* `quietly`: the same line for the same `what` is said once per `VALKEY_ERROR_QUIET_MS`, across every emitter of the
|
|
124
|
+
* process, for the reader's raw client and for a queue that gives up (`makeQueue`): a reader that replaces its queues
|
|
125
|
+
* (`createFleetReader`) repeats the line on a new emitter per read, and a one-shot queue of a CLI verb or a worker's boot says
|
|
126
|
+
* it once instead of a few times. A message not said in that minute is said at once, so a new fault is not held back; two
|
|
127
|
+
* faults taking turns are each said once a minute. The raw client going ready ends the quiet for every line (a BullMQ queue
|
|
128
|
+
* does not pass that event on), since a fault after that is a new outage in the same words. Off for a client that rides a
|
|
129
|
+
* restart out: a running worker's and a receiver's retry lines are how a second outage shows in their logs.
|
|
122
130
|
*/
|
|
123
|
-
export
|
|
124
|
-
|
|
131
|
+
export const VALKEY_ERROR_QUIET_MS = 60_000;
|
|
132
|
+
const VALKEY_ERRORS_HELD = 256;
|
|
133
|
+
const saidAt = new Map();
|
|
134
|
+
export function onValkeyError(emitter, what, write = (line) => process.stderr.write(line), { quietly = false, now = () => Date.now() } = {}) {
|
|
135
|
+
if (quietly) emitter.on?.("ready", () => saidAt.clear());
|
|
136
|
+
emitter.on?.("error", (err) => {
|
|
137
|
+
const message = String(scrubValkeyError(err)?.message ?? err);
|
|
138
|
+
if (quietly) {
|
|
139
|
+
const key = `${what}\u0000${message}`;
|
|
140
|
+
const at = now();
|
|
141
|
+
const last = saidAt.get(key);
|
|
142
|
+
if (last !== undefined && at >= last && at - last < VALKEY_ERROR_QUIET_MS) return;
|
|
143
|
+
if (saidAt.size >= VALKEY_ERRORS_HELD) saidAt.clear();
|
|
144
|
+
saidAt.set(key, at);
|
|
145
|
+
}
|
|
146
|
+
write(`[pi-dispatch] Valkey error (${what}): ${message}\n`);
|
|
147
|
+
});
|
|
125
148
|
return emitter;
|
|
126
149
|
}
|
|
127
150
|
|
|
151
|
+
/** Whether `parseConnection` built this connection to give up after a couple of tries (`failFast`). */
|
|
152
|
+
export function givesUp(connection) {
|
|
153
|
+
return connection?.[JUDGE]?.failFast === true;
|
|
154
|
+
}
|
|
155
|
+
|
|
128
156
|
/**
|
|
129
157
|
* Connection helpers for BullMQ and the budget's raw Redis client, both from one VALKEY_URL.
|
|
130
158
|
*
|
|
@@ -139,8 +167,15 @@ export function onValkeyError(emitter, what, write = (line) => process.stderr.wr
|
|
|
139
167
|
* `failFast` is for the CLI producer (a one-shot enqueue): if Valkey is unreachable it should
|
|
140
168
|
* error in a couple of seconds with a clear message, not hang forever. The long-running WORKER
|
|
141
169
|
* uses the default (persistent) options -- it should ride out a Valkey restart, not give up.
|
|
170
|
+
* A failFast client whose retries ran out has ended for good, so anything that holds one for longer than a question must
|
|
171
|
+
* replace it (`createFleetReader`), or hold a persistent one instead (`noOfflineQueue` on `makeRedisClient`).
|
|
172
|
+
*
|
|
173
|
+
* `freshContext` is for a persistent client of a long-running surface that names no context: each connect judges by the
|
|
174
|
+
* context as it is then, not as it was at construction, so an operator's fix of the deployment's `.env` reaches a client
|
|
175
|
+
* that has been refused since it started. Off by default, so a worker's judgement stays the one it booted on. The password
|
|
176
|
+
* is still the one read at construction, since ioredis has settled it before the connector runs.
|
|
142
177
|
*/
|
|
143
|
-
export function parseConnection(url, { failFast = false, servername = null, context = null, judge = null, withoutPassword = false } = {}) {
|
|
178
|
+
export function parseConnection(url, { failFast = false, servername = null, context = null, judge = null, withoutPassword = false, freshContext = false } = {}) {
|
|
144
179
|
const u = new URL(url);
|
|
145
180
|
// PR #478's gate: a path that is not a database number was `db: NaN`, and the SELECT ioredis then sent failed outside
|
|
146
181
|
// every caller's await (an unhandled rejection). Refused here, where every client of the project is made, as the
|
|
@@ -159,7 +194,7 @@ export function parseConnection(url, { failFast = false, servername = null, cont
|
|
|
159
194
|
// reports. `context` is where the client stands (`valkeyClientContext`), the process's own when not given.
|
|
160
195
|
Connector: JudgedConnector,
|
|
161
196
|
// `judge` is a test seam only: the function asked for the address (judgedEndpoint when not given).
|
|
162
|
-
[JUDGE]: { url, context: where, ...(judge ? { judge } : {}) },
|
|
197
|
+
[JUDGE]: { url, context: where, ...(judge ? { judge } : {}), ...(freshContext && !context ? { freshContext: true } : {}), ...(failFast ? { failFast: true } : {}) },
|
|
163
198
|
// WHATWG URL keeps an IPv6 literal's brackets in `hostname` (`[::1]`), and net.connect then looks up the name
|
|
164
199
|
// "[::1]", which never resolves, so a `redis://[::1]:6379` VALKEY_URL never connected (issue #464, measured on
|
|
165
200
|
// Fedora 44: ETIMEDOUT). The address is the part inside them.
|
|
@@ -184,15 +219,54 @@ export function parseConnection(url, { failFast = false, servername = null, cont
|
|
|
184
219
|
};
|
|
185
220
|
}
|
|
186
221
|
|
|
222
|
+
/**
|
|
223
|
+
* A client whose commands have a bound. Its caller has been told a command that timed out failed, so that command must
|
|
224
|
+
* not go out later, and ioredis would send it: the timer rejects it and leaves it in the offline queue for when Valkey
|
|
225
|
+
* returns. A cancel the page said was not made then stopped the run hours later. One already on the socket of a Valkey
|
|
226
|
+
* that stopped answering cannot be taken back, so its error says so (`valkeyMayStillRun`), and the caller can too.
|
|
227
|
+
*
|
|
228
|
+
* ioredis' timer is also ref'd, so a client told to stop with commands still waiting held its process for up to one more
|
|
229
|
+
* bound. Reaches into two internals of the pinned ioredis, both held by
|
|
230
|
+
* `worker/test/connection.test.mjs`. REJECTED: `enableOfflineQueue: false`, which refuses a client's first command too,
|
|
231
|
+
* before it has had a chance to connect.
|
|
232
|
+
*/
|
|
233
|
+
class BoundedRedis extends Redis {
|
|
234
|
+
sendCommand(command, stream) {
|
|
235
|
+
const answer = super.sendCommand(command, stream);
|
|
236
|
+
command._commandTimeoutTimer?.unref?.();
|
|
237
|
+
answer?.catch?.((err) => {
|
|
238
|
+
if (err?.message !== "Command timed out") return;
|
|
239
|
+
err.code ??= "ETIMEDOUT";
|
|
240
|
+
const queue = this.offlineQueue;
|
|
241
|
+
for (let i = 0; i < queue.length; i += 1) {
|
|
242
|
+
if (queue.peekAt(i)?.command === command) {
|
|
243
|
+
queue.removeOne(i);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
err.valkeyMayStillRun = true;
|
|
248
|
+
});
|
|
249
|
+
return answer;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
187
253
|
/**
|
|
188
254
|
* A raw ioredis client (the budget's INCR/EXPIRE, the registry and fleet reads, doctor's PING). Built from
|
|
189
255
|
* `parseConnection`'s options, so it connects through `JudgedConnector` like every other client. `lazyConnect` and
|
|
190
256
|
* `failFast` as ioredis and `parseConnection` read them.
|
|
191
257
|
*/
|
|
192
|
-
export function makeRedisClient(url, { servername = null, context = null, failFast = false, lazyConnect = false, judge = null, withoutPassword = false, disconnectTimeoutMs = null } = {}) {
|
|
258
|
+
export function makeRedisClient(url, { servername = null, context = null, failFast = false, lazyConnect = false, judge = null, withoutPassword = false, disconnectTimeoutMs = null, noOfflineQueue = false, commandTimeoutMs = null, freshContext = false } = {}) {
|
|
193
259
|
// `disconnectTimeoutMs` (issue #599): how long ioredis's `disconnect()` waits for a socket to close before it destroys
|
|
194
260
|
// it (its default is 2 s, on a ref'd timer). A one-shot read that must not hold its process passes 0.
|
|
195
|
-
|
|
261
|
+
// A shell monitor must reconnect across an outage without accumulating stale control commands (#771).
|
|
262
|
+
// Keep the persistent retry strategy, but reject each command while disconnected and bound in-flight retries.
|
|
263
|
+
// `commandTimeoutMs`: how long one command may wait for its answer, in the offline queue or on a Valkey that stopped
|
|
264
|
+
// answering. Without it a persistent client's command waits for as long as the outage lasts. With it, what timed out is
|
|
265
|
+
// never sent again: `BoundedRedis` takes it out of the offline queue, and no resend covers what was on a connection that
|
|
266
|
+
// died.
|
|
267
|
+
const bounded = Number.isSafeInteger(commandTimeoutMs) && commandTimeoutMs > 0;
|
|
268
|
+
const options = { ...parseConnection(url, { failFast, servername, context, judge, withoutPassword, freshContext }), ...(noOfflineQueue ? { enableOfflineQueue: false, maxRetriesPerRequest: 1 } : {}), ...(lazyConnect ? { lazyConnect: true } : {}), ...(Number.isSafeInteger(disconnectTimeoutMs) && disconnectTimeoutMs >= 0 ? { disconnectTimeout: disconnectTimeoutMs } : {}), ...(bounded ? { commandTimeout: commandTimeoutMs, autoResendUnfulfilledCommands: false } : {}) };
|
|
269
|
+
return bounded ? new BoundedRedis(options) : new Redis(options);
|
|
196
270
|
}
|
|
197
271
|
|
|
198
272
|
/**
|
|
@@ -334,7 +408,9 @@ export class JudgedConnector extends AbstractConnector {
|
|
|
334
408
|
this.connecting = true;
|
|
335
409
|
const judge = options[JUDGE];
|
|
336
410
|
if (!judge) return Promise.reject(new Error("a Valkey client was built without parseConnection's judged options"));
|
|
337
|
-
|
|
411
|
+
// A promise chain, so a context read that throws takes the same path as a judgement that does: a client's connect
|
|
412
|
+
// must not reject (below), or ioredis ends it for good.
|
|
413
|
+
return new Promise((resolve) => resolve((judge.judge ?? judgedEndpoint)(judge.url, judge.freshContext ? defaultValkeyContext() : judge.context))).then(
|
|
338
414
|
(endpoint) => {
|
|
339
415
|
if (!this.connecting) throw new Error("Connection is closed.");
|
|
340
416
|
const target = { host: endpoint.host, port: options.port, ...(options.family != null ? { family: options.family } : {}) };
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An answer to a parked run typed into the operator console (issue #642, REQ-AWAIT-INPUT's console source).
|
|
3
|
+
*
|
|
4
|
+
* Shared by the two sides that must agree on it: the admin, which bounds and scrubs the text before it builds the
|
|
5
|
+
* reply job (`planParkedAnswer`), and the worker, which posts the one trail comment that says on the forge who
|
|
6
|
+
* answered (processor.mjs, after the `not-parked` gate and before any spend). One module, so the text the job carries
|
|
7
|
+
* and the text the trail quotes are scrubbed by one rule.
|
|
8
|
+
*
|
|
9
|
+
* WHY A TRAIL AT ALL. A forge reply leaves its own comment on the issue: anyone reading the thread sees who answered
|
|
10
|
+
* and what. A console answer reaches the job without one, so without the trail the issue would show a question, then
|
|
11
|
+
* a run, with the answer that steered it visible nowhere a reviewer looks. The trail is posted as the deployment's own
|
|
12
|
+
* identity (the `comment` adapter), which the receiver's self-guard drops as `self`, so it never starts a job.
|
|
13
|
+
*
|
|
14
|
+
* Import-light (console-tokens.mjs for the name rule, which imports only node built-ins), so the receiver-free admin
|
|
15
|
+
* bundle can take it.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { TOKEN_NAME_RE } from "./console-tokens.mjs";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The `by` an answer from the panel's `/dispatch chat` carries (issue #647, part 2): it has no console token, so it is
|
|
22
|
+
* signed with this reserved word, and its trail says it came from the panel rather than naming a token. RESERVED: the
|
|
23
|
+
* console refuses to answer as a token of this name (`planParkedAnswer`'s `from`), so no token can sign as the panel.
|
|
24
|
+
*/
|
|
25
|
+
export const PANEL_ANSWER_BY = "panel";
|
|
26
|
+
|
|
27
|
+
/** The most an answer may carry, in UTF-8 bytes AFTER scrubbing. A person's reply to a question, not a document. */
|
|
28
|
+
export const ANSWER_TEXT_MAX_BYTES = 8 * 1024;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Who answered, as `job.console.by` carries it: a console token's name (`TOKEN_NAME_RE`: lowercase letters, digits,
|
|
32
|
+
* dot, dash and underscore, at most 32), else null. The charset is what makes it safe to print on a forge: no `@`, no
|
|
33
|
+
* markdown, no control byte, no space.
|
|
34
|
+
*/
|
|
35
|
+
export function consoleAnswerBy(meta) {
|
|
36
|
+
const by = meta?.by;
|
|
37
|
+
return typeof by === "string" && TOKEN_NAME_RE.test(by) ? by : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The provenance is all-or-none. A model's free-text source label never reaches the trail or touch index. */
|
|
41
|
+
export function consoleDraftOf(meta) {
|
|
42
|
+
const fields = ["draftRunId", "draftDigest", "draftSourceRunId"];
|
|
43
|
+
if (!fields.some(k => meta && Object.hasOwn(meta, k))) return null;
|
|
44
|
+
if (!/^[A-Za-z0-9._:-]{1,200}$/.test(meta?.draftRunId ?? "") || !/^[a-f0-9]{64}$/.test(meta?.draftDigest ?? "") || !/^[A-Za-z0-9._:-]{1,200}$/.test(meta?.draftSourceRunId ?? "")) return { invalid: true };
|
|
45
|
+
return { draftRunId: meta.draftRunId, draftDigest: meta.draftDigest, draftSourceRunId: meta.draftSourceRunId };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The answer text as a job may carry it. Never throws; a non-string is "". Newlines are kept: a reply can be several
|
|
50
|
+
* lines.
|
|
51
|
+
* - LINE ENDINGS: `\r\n` and a lone `\r` become `\n`, and so do the Unicode line and paragraph separators (U+2028,
|
|
52
|
+
* U+2029). Made newlines rather than removed: a person who typed a separator meant a line break, and removing it
|
|
53
|
+
* would join two words the person kept apart. On a forge they would otherwise render as a break INSIDE one quoted
|
|
54
|
+
* line, or not at all, so they never reach the trail as themselves.
|
|
55
|
+
* - A TAB becomes a space; every other C0 control, DEL and every C1 control is removed.
|
|
56
|
+
* - EVERY BIDIRECTIONAL FORMAT CHARACTER is removed: the embeddings and overrides (U+202A-U+202E), the isolates
|
|
57
|
+
* (U+2066-U+2069) and the marks (U+200E, U+200F and the Arabic letter mark U+061C). A forge renders them, so a quoted
|
|
58
|
+
* line could otherwise display in an order that is not the order it was typed (the "Trojan Source" class), in a
|
|
59
|
+
* comment that claims to quote the operator verbatim. The marks are not overrides, but a mark beside a neutral
|
|
60
|
+
* character moves it across a run boundary, which is the same lie at a smaller scale; nothing an operator types to
|
|
61
|
+
* answer a question needs one.
|
|
62
|
+
*/
|
|
63
|
+
export function scrubAnswerText(text) {
|
|
64
|
+
if (typeof text !== "string") return "";
|
|
65
|
+
return text
|
|
66
|
+
.replace(/\r\n?/g, "\n")
|
|
67
|
+
.replace(/[\u2028\u2029]/g, "\n")
|
|
68
|
+
.replace(/\t/g, " ")
|
|
69
|
+
.replace(/[\u0000-\u0009\u000b-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, "");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Bound a scrubbed answer: `{ text }` when it has something to say and fits `ANSWER_TEXT_MAX_BYTES`, else `{ refusal }`.
|
|
74
|
+
* Refused rather than cut: a truncated answer is an answer the person did not give.
|
|
75
|
+
*/
|
|
76
|
+
export function boundAnswerText(raw) {
|
|
77
|
+
const text = scrubAnswerText(raw);
|
|
78
|
+
if (text.trim() === "") return { refusal: "the answer is empty (after control characters are removed); nothing was queued" };
|
|
79
|
+
const bytes = Buffer.byteLength(text, "utf8");
|
|
80
|
+
if (bytes > ANSWER_TEXT_MAX_BYTES) return { refusal: `the answer is ${bytes} bytes, above the ${ANSWER_TEXT_MAX_BYTES}-byte limit; shorten it. Nothing was queued` };
|
|
81
|
+
return { text };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The trail comment for an answer: the name, then the text as a markdown blockquote, one `> ` per line (an empty line
|
|
86
|
+
* too, `> ` alone, so the quote never breaks and nothing below it reads as the deployment's own words).
|
|
87
|
+
*
|
|
88
|
+
* HTML IS ESCAPED FIRST: `&`, `<` and `>` become `&`, `<` and `>`. Every forge renders HTML inside a comment's
|
|
89
|
+
* markdown, so without it an answer holding `<!--` hid every quoted line after it (the forge showed a truncated quote
|
|
90
|
+
* under the operator's name), a tag could restyle or hide the rest, and an entity such as `@` decoded to an `@`
|
|
91
|
+
* AFTER the mention neutraliser below had looked for one. With `&` escaped no entity survives, with `<` escaped no
|
|
92
|
+
* comment or tag opens, and with `>` escaped a line cannot nest a deeper quote. The escape runs before the
|
|
93
|
+
* neutraliser, so the zero-width space it adds is never inside an entity.
|
|
94
|
+
*
|
|
95
|
+
* MENTIONS ARE NEUTRALISED by a zero-width space (U+200B) after every `@`: `@alice` renders as the same text but is no
|
|
96
|
+
* longer a mention, so an operator's answer cannot ping people or teams on the forge under the deployment's identity.
|
|
97
|
+
* Chosen over wrapping in backticks, which a backtick in the text could close. U+200B is a format character, not a
|
|
98
|
+
* control one, and the scrub above has already removed every control the text had. The name needs no neutralising:
|
|
99
|
+
* its charset holds no `@`. The panel's reserved name (`PANEL_ANSWER_BY`) gets its own head line.
|
|
100
|
+
*/
|
|
101
|
+
export function answerTrailComment(by, text, draftRunId = null) {
|
|
102
|
+
const quoted = scrubAnswerText(text)
|
|
103
|
+
.replace(/&/g, "&")
|
|
104
|
+
.replace(/</g, "<")
|
|
105
|
+
.replace(/>/g, ">")
|
|
106
|
+
.replace(/@/g, "@\u200b")
|
|
107
|
+
.split("\n")
|
|
108
|
+
.map((line) => `> ${line}`)
|
|
109
|
+
.join("\n");
|
|
110
|
+
const head = by === PANEL_ANSWER_BY ? "Answered from an operator's /dispatch panel" : `Answered from the operator console by ${by}`;
|
|
111
|
+
const draft = typeof draftRunId === "string" && /^[A-Za-z0-9._:-]{1,200}$/.test(draftRunId) ? `\n\nDrafted in run ${draftRunId}.` : "";
|
|
112
|
+
return `${head}:\n\n${quoted}${draft}`;
|
|
113
|
+
}
|