@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.
Files changed (260) hide show
  1. package/.env.example +84 -3
  2. package/README.md +21 -7
  3. package/deploy/console.service +52 -0
  4. package/deploy/docker-compose.yml +3 -1
  5. package/deploy/pi-dispatch-valkey.container +4 -2
  6. package/deploy/sandbox-gateway.service +23 -0
  7. package/deploy/worker-env-wrapper.cmd +6 -0
  8. package/deploy/worker-env-wrapper.sh +10 -1
  9. package/package.json +118 -19
  10. package/src/agent-snapshot.mjs +49 -0
  11. package/src/answerer-runtime.mjs +194 -0
  12. package/src/answerer.mjs +178 -0
  13. package/src/approval-policy.mjs +76 -0
  14. package/src/approval-runtime.mjs +188 -0
  15. package/src/approval-session.mjs +137 -0
  16. package/src/approval-state.mjs +170 -0
  17. package/src/auth-cli.mjs +86 -0
  18. package/src/auth-connections.mjs +145 -0
  19. package/src/auth-policy.mjs +32 -0
  20. package/src/auth-store.mjs +97 -0
  21. package/src/azure-host.mjs +66 -4
  22. package/src/azure-prompt.mjs +5 -4
  23. package/src/backend-conformance.mjs +34 -2
  24. package/src/backend-local.mjs +103 -34
  25. package/src/backend-openshell.mjs +1540 -0
  26. package/src/backend-podman.mjs +63 -2
  27. package/src/backend-registry.mjs +61 -4
  28. package/src/backends.mjs +395 -6
  29. package/src/bullmq-internals.mjs +208 -0
  30. package/src/canary-cli.mjs +28 -0
  31. package/src/canary-lock.mjs +41 -0
  32. package/src/canary-runtime.mjs +237 -0
  33. package/src/canary.mjs +53 -0
  34. package/src/cancel-cli.mjs +47 -34
  35. package/src/cancel-state.mjs +213 -15
  36. package/src/capabilities.mjs +36 -1
  37. package/src/capacity-cli.mjs +28 -8
  38. package/src/capacity-records.mjs +75 -16
  39. package/src/capacity.mjs +65 -0
  40. package/src/chat-cli.mjs +288 -0
  41. package/src/chat-fork.mjs +547 -0
  42. package/src/chat-mode.mjs +156 -0
  43. package/src/chat-view.mjs +720 -0
  44. package/src/checkpoint-paths.mjs +113 -0
  45. package/src/cli.mjs +136 -41
  46. package/src/comment-tables.mjs +322 -0
  47. package/src/community-auth-adapters.mjs +87 -0
  48. package/src/community-auth-helper.mjs +76 -0
  49. package/src/community-auth-metadata.mjs +6 -0
  50. package/src/community-auth-output-pins.mjs +38 -0
  51. package/src/community-auth-patches.mjs +168 -0
  52. package/src/community-auth-pins.mjs +38 -0
  53. package/src/config.mjs +190 -29
  54. package/src/connection.mjs +83 -7
  55. package/src/console-answer.mjs +113 -0
  56. package/src/console-audit.mjs +604 -0
  57. package/src/console-bootstrap.mjs +325 -0
  58. package/src/console-client.mjs +848 -0
  59. package/src/console-devices.mjs +372 -0
  60. package/src/console-install.mjs +239 -0
  61. package/src/console-open.mjs +99 -0
  62. package/src/console-probe.mjs +106 -0
  63. package/src/console-tokens.mjs +459 -0
  64. package/src/container-names.mjs +51 -0
  65. package/src/container-spec.mjs +20 -1
  66. package/src/cpu-reserve.mjs +4 -1
  67. package/src/defer-reasons.mjs +200 -0
  68. package/src/defer-write.mjs +208 -0
  69. package/src/deployment-venue.mjs +23 -6
  70. package/src/docker-run.mjs +11 -0
  71. package/src/doctor-bundles.mjs +130 -0
  72. package/src/doctor-model-proposals.mjs +109 -0
  73. package/src/doctor-openshell.mjs +238 -0
  74. package/src/doctor-results.mjs +602 -0
  75. package/src/doctor-spawner.mjs +215 -0
  76. package/src/doctor.mjs +1657 -196
  77. package/src/dollar-budget.mjs +212 -3
  78. package/src/endpoint-status.mjs +100 -0
  79. package/src/env-allowlist.mjs +96 -6
  80. package/src/env-file.mjs +6 -4
  81. package/src/envelope.mjs +5 -3
  82. package/src/file-expect.mjs +47 -0
  83. package/src/forgejo-host.mjs +42 -5
  84. package/src/forgejo-prompt.mjs +5 -4
  85. package/src/forges.mjs +33 -3
  86. package/src/fork-job.mjs +58 -0
  87. package/src/fork-policy.mjs +23 -0
  88. package/src/fork-store.mjs +338 -0
  89. package/src/get-token.mjs +1 -1
  90. package/src/git-dirty.mjs +119 -14
  91. package/src/git-hardening.mjs +85 -0
  92. package/src/github-host.mjs +31 -6
  93. package/src/github-prompt.mjs +7 -16
  94. package/src/gitlab-host.mjs +42 -3
  95. package/src/gitlab-prompt.mjs +5 -4
  96. package/src/held-document.mjs +23 -0
  97. package/src/held-replies.mjs +325 -0
  98. package/src/held-settle.mjs +266 -0
  99. package/src/hook-runs.mjs +188 -0
  100. package/src/host-budget.mjs +8 -5
  101. package/src/host-clone.mjs +72 -0
  102. package/src/host-loads.mjs +557 -0
  103. package/src/host-registry.mjs +66 -6
  104. package/src/host-telemetry.mjs +92 -0
  105. package/src/http-body.mjs +78 -0
  106. package/src/image-preflight.mjs +156 -13
  107. package/src/import-pi.mjs +60 -172
  108. package/src/inbox-relay.mjs +482 -0
  109. package/src/inbox-settle.mjs +196 -0
  110. package/src/index.mjs +734 -129
  111. package/src/init.mjs +35 -5
  112. package/src/job-data-version.mjs +75 -0
  113. package/src/job-id.mjs +30 -15
  114. package/src/job-layout.mjs +22 -0
  115. package/src/job-size.mjs +16 -0
  116. package/src/live-jobs.mjs +14 -6
  117. package/src/live-probes.mjs +6 -2
  118. package/src/live-transcript.mjs +872 -0
  119. package/src/loaded-record.mjs +609 -0
  120. package/src/mcp-config.mjs +4 -2
  121. package/src/mcp-preflight.mjs +36 -5
  122. package/src/model-attempts.mjs +84 -0
  123. package/src/model-catalog.mjs +83 -0
  124. package/src/model-cooldowns.mjs +22 -0
  125. package/src/model-failover-protocol.mjs +11 -0
  126. package/src/model-failover.mjs +134 -0
  127. package/src/model-observation.mjs +50 -0
  128. package/src/model-proposal-protocol.mjs +21 -0
  129. package/src/model-proposals.mjs +75 -0
  130. package/src/model-ref.mjs +67 -10
  131. package/src/model-roles-file.mjs +24 -0
  132. package/src/model-roles.mjs +73 -0
  133. package/src/model-status.mjs +34 -0
  134. package/src/models-json.mjs +12 -2
  135. package/src/money.mjs +1 -1
  136. package/src/oauth-openai.mjs +121 -0
  137. package/src/older-state.mjs +249 -0
  138. package/src/on-failure.mjs +17 -13
  139. package/src/open-browser.mjs +31 -2
  140. package/src/opener-cli.mjs +135 -0
  141. package/src/opener-protocol.mjs +27 -0
  142. package/src/opener-terminal.mjs +128 -0
  143. package/src/openshell-cli.mjs +155 -0
  144. package/src/openshell-policy.mjs +163 -0
  145. package/src/outbox.mjs +14 -0
  146. package/src/output-cap.mjs +1 -0
  147. package/src/package-canary-record.mjs +26 -0
  148. package/src/package-canary.mjs +135 -0
  149. package/src/package-names.mjs +9 -0
  150. package/src/package-stage.mjs +418 -0
  151. package/src/packages.mjs +24 -16
  152. package/src/parked.mjs +2 -1
  153. package/src/pause-windows.mjs +136 -17
  154. package/src/podman-stack.mjs +40 -14
  155. package/src/portfolio-snapshot.mjs +26 -7
  156. package/src/prepare-github.mjs +41 -77
  157. package/src/prepare-local.mjs +32 -2
  158. package/src/prepare.mjs +45 -11
  159. package/src/private-store-boundary.mjs +70 -0
  160. package/src/processor.mjs +1550 -173
  161. package/src/projects.mjs +23 -0
  162. package/src/prompt-fence.mjs +18 -0
  163. package/src/provider-key.mjs +7 -0
  164. package/src/provider-steering.mjs +1 -0
  165. package/src/question-context.mjs +35 -0
  166. package/src/question-group-runtime.mjs +91 -0
  167. package/src/question-groups.mjs +171 -0
  168. package/src/queue.mjs +264 -55
  169. package/src/reason-catalog.mjs +448 -0
  170. package/src/receiver-status.mjs +230 -0
  171. package/src/record-reason.mjs +37 -0
  172. package/src/recovery-queue.mjs +566 -0
  173. package/src/redact-secrets.mjs +219 -0
  174. package/src/reserve-estimate.mjs +101 -0
  175. package/src/reserved-env.mjs +36 -1
  176. package/src/retention.mjs +67 -0
  177. package/src/run-budget-ledger.mjs +283 -0
  178. package/src/run-checkpoint.mjs +627 -0
  179. package/src/run-container.mjs +749 -33
  180. package/src/run-history.mjs +791 -45
  181. package/src/run-mirror.mjs +75 -7
  182. package/src/run-progress.mjs +751 -0
  183. package/src/runtime-observations.mjs +3 -1
  184. package/src/runtime-settings.mjs +272 -37
  185. package/src/sandbox-cli.mjs +78 -2
  186. package/src/sandbox-deployment.mjs +51 -0
  187. package/src/sandbox-gateway-protocol.mjs +378 -0
  188. package/src/sandbox-hosts.mjs +100 -0
  189. package/src/sandbox-inventory.mjs +83 -0
  190. package/src/sandbox-policy.mjs +225 -0
  191. package/src/sandbox-prepare-requests.mjs +145 -0
  192. package/src/sandbox-prepare.mjs +285 -0
  193. package/src/sandbox-refs.mjs +70 -0
  194. package/src/sandbox-session-monitor.mjs +4 -0
  195. package/src/sandbox-session-watch.mjs +124 -0
  196. package/src/sandbox-sessions.mjs +161 -0
  197. package/src/sandbox-store.mjs +63 -6
  198. package/src/sandbox-workspace-guard.mjs +39 -0
  199. package/src/sandbox.mjs +27 -200
  200. package/src/scheduler-stall-guard.mjs +14 -8
  201. package/src/schedules.mjs +19 -6
  202. package/src/secret-bundles.mjs +245 -0
  203. package/src/secret-profiles.mjs +103 -3
  204. package/src/secrets.mjs +19 -9
  205. package/src/service-env.mjs +38 -5
  206. package/src/service.mjs +638 -106
  207. package/src/session-key.mjs +60 -0
  208. package/src/session-store.mjs +168 -10
  209. package/src/setup-contract.mjs +40 -0
  210. package/src/setup-egress.mjs +113 -0
  211. package/src/setup-host-entry.mjs +36 -0
  212. package/src/setup-host.mjs +242 -0
  213. package/src/setup-lock.mjs +41 -0
  214. package/src/size-records.mjs +15 -3
  215. package/src/start.mjs +1353 -135
  216. package/src/status-comment.mjs +415 -0
  217. package/src/stderr-transcript.mjs +318 -0
  218. package/src/steer-ack.mjs +122 -0
  219. package/src/steer-cli.mjs +213 -0
  220. package/src/steer-framing.mjs +185 -0
  221. package/src/steer-inbox.mjs +693 -0
  222. package/src/store-version.mjs +139 -0
  223. package/src/subscription-config.mjs +56 -0
  224. package/src/subscription-limits.mjs +3 -0
  225. package/src/subscription-quota.mjs +259 -0
  226. package/src/subscription-runtime.mjs +123 -0
  227. package/src/subscription-status.mjs +42 -0
  228. package/src/subscription-wait.mjs +11 -0
  229. package/src/subscriptions.mjs +7 -1
  230. package/src/thinking-level.mjs +19 -0
  231. package/src/touch-outbox.mjs +61 -0
  232. package/src/touch-state.mjs +133 -0
  233. package/src/triggers-file.mjs +299 -39
  234. package/src/triggers.mjs +780 -38
  235. package/src/trust-paths.mjs +171 -0
  236. package/src/up.mjs +402 -53
  237. package/src/updates-cli.mjs +61 -0
  238. package/src/updates-look.mjs +127 -0
  239. package/src/updates-reconcile.mjs +182 -0
  240. package/src/updates-subscription-adapters.mjs +8 -0
  241. package/src/updates.mjs +252 -0
  242. package/src/valkey-auth.mjs +32 -9
  243. package/src/valkey-endpoint.mjs +14 -4
  244. package/src/wait-for.mjs +1 -1
  245. package/src/wait-state.mjs +62 -3
  246. package/src/watch-board.mjs +38 -0
  247. package/src/watch-closer.mjs +1 -1
  248. package/src/watch-contract.mjs +102 -0
  249. package/src/watch-decision.mjs +78 -0
  250. package/src/watch-drainer.mjs +14 -0
  251. package/src/watch-issue-adapter.mjs +44 -0
  252. package/src/watch-issue.mjs +111 -0
  253. package/src/watch-record.mjs +10 -0
  254. package/src/watch-runtime.mjs +167 -0
  255. package/src/watch-store.mjs +183 -0
  256. package/src/watch-trace.mjs +34 -0
  257. package/src/workspace-checkpoint.mjs +1750 -0
  258. package/src/zero-touch-fixes.mjs +99 -0
  259. package/src/zero-touch.mjs +225 -0
  260. package/src/repeat-slot.mjs +0 -13
@@ -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 function onValkeyError(emitter, what, write = (line) => process.stderr.write(line)) {
124
- emitter.on?.("error", (err) => write(`[pi-dispatch] Valkey error (${what}): ${String(scrubValkeyError(err)?.message ?? err)}\n`));
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
- return new Redis({ ...parseConnection(url, { failFast, servername, context, judge, withoutPassword }), ...(lazyConnect ? { lazyConnect: true } : {}), ...(Number.isSafeInteger(disconnectTimeoutMs) && disconnectTimeoutMs >= 0 ? { disconnectTimeout: disconnectTimeoutMs } : {}) });
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
- return (judge.judge ?? judgedEndpoint)(judge.url, judge.context).then(
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 `&amp;`, `&lt;` and `&gt;`. 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 `&#64;` 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, "&amp;")
104
+ .replace(/</g, "&lt;")
105
+ .replace(/>/g, "&gt;")
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
+ }