@edgehero/pi-dispatch 4.3.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (257) hide show
  1. package/.env.example +94 -4
  2. package/README.md +20 -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 +117 -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 +128 -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 +199 -30
  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 +159 -0
  58. package/src/console-client.mjs +848 -0
  59. package/src/console-probe.mjs +106 -0
  60. package/src/console-tokens.mjs +459 -0
  61. package/src/container-names.mjs +51 -0
  62. package/src/container-spec.mjs +20 -1
  63. package/src/cpu-reserve.mjs +4 -1
  64. package/src/defer-reasons.mjs +200 -0
  65. package/src/defer-write.mjs +208 -0
  66. package/src/deployment-venue.mjs +23 -6
  67. package/src/docker-run.mjs +11 -0
  68. package/src/doctor-bundles.mjs +130 -0
  69. package/src/doctor-model-proposals.mjs +109 -0
  70. package/src/doctor-openshell.mjs +238 -0
  71. package/src/doctor-results.mjs +602 -0
  72. package/src/doctor-spawner.mjs +215 -0
  73. package/src/doctor.mjs +1663 -198
  74. package/src/dollar-budget.mjs +212 -3
  75. package/src/endpoint-status.mjs +100 -0
  76. package/src/env-allowlist.mjs +103 -9
  77. package/src/env-file.mjs +6 -4
  78. package/src/file-expect.mjs +47 -0
  79. package/src/forgejo-auth.mjs +4 -0
  80. package/src/forgejo-host.mjs +127 -31
  81. package/src/forgejo-prompt.mjs +5 -4
  82. package/src/forges.mjs +73 -15
  83. package/src/fork-job.mjs +58 -0
  84. package/src/fork-policy.mjs +23 -0
  85. package/src/fork-store.mjs +338 -0
  86. package/src/get-token.mjs +1 -1
  87. package/src/git-dirty.mjs +119 -14
  88. package/src/git-hardening.mjs +85 -0
  89. package/src/github-host.mjs +31 -6
  90. package/src/github-prompt.mjs +7 -16
  91. package/src/gitlab-host.mjs +42 -3
  92. package/src/gitlab-prompt.mjs +5 -4
  93. package/src/held-document.mjs +23 -0
  94. package/src/held-replies.mjs +325 -0
  95. package/src/held-settle.mjs +266 -0
  96. package/src/hook-runs.mjs +188 -0
  97. package/src/host-budget.mjs +8 -5
  98. package/src/host-clone.mjs +72 -0
  99. package/src/host-loads.mjs +557 -0
  100. package/src/host-registry.mjs +66 -6
  101. package/src/host-telemetry.mjs +92 -0
  102. package/src/http-body.mjs +78 -0
  103. package/src/image-preflight.mjs +156 -13
  104. package/src/import-pi.mjs +60 -172
  105. package/src/inbox-relay.mjs +482 -0
  106. package/src/inbox-settle.mjs +196 -0
  107. package/src/index.mjs +740 -129
  108. package/src/init.mjs +35 -5
  109. package/src/job-data-version.mjs +75 -0
  110. package/src/job-id.mjs +30 -15
  111. package/src/job-layout.mjs +22 -0
  112. package/src/job-size.mjs +16 -0
  113. package/src/live-jobs.mjs +14 -6
  114. package/src/live-probes.mjs +6 -2
  115. package/src/live-transcript.mjs +872 -0
  116. package/src/loaded-record.mjs +609 -0
  117. package/src/mcp-config.mjs +1 -1
  118. package/src/mcp-preflight.mjs +36 -5
  119. package/src/model-attempts.mjs +84 -0
  120. package/src/model-catalog.mjs +83 -0
  121. package/src/model-cooldowns.mjs +22 -0
  122. package/src/model-failover-protocol.mjs +11 -0
  123. package/src/model-failover.mjs +134 -0
  124. package/src/model-observation.mjs +50 -0
  125. package/src/model-proposal-protocol.mjs +21 -0
  126. package/src/model-proposals.mjs +75 -0
  127. package/src/model-ref.mjs +67 -10
  128. package/src/model-roles-file.mjs +24 -0
  129. package/src/model-roles.mjs +73 -0
  130. package/src/model-status.mjs +34 -0
  131. package/src/models-json.mjs +4 -1
  132. package/src/money.mjs +1 -1
  133. package/src/oauth-openai.mjs +121 -0
  134. package/src/older-state.mjs +249 -0
  135. package/src/on-failure.mjs +17 -13
  136. package/src/open-browser.mjs +31 -2
  137. package/src/opener-cli.mjs +135 -0
  138. package/src/opener-protocol.mjs +27 -0
  139. package/src/opener-terminal.mjs +128 -0
  140. package/src/openshell-cli.mjs +155 -0
  141. package/src/openshell-policy.mjs +163 -0
  142. package/src/outbox.mjs +14 -0
  143. package/src/output-cap.mjs +1 -0
  144. package/src/package-canary-record.mjs +26 -0
  145. package/src/package-canary.mjs +135 -0
  146. package/src/package-names.mjs +9 -0
  147. package/src/package-stage.mjs +418 -0
  148. package/src/packages.mjs +24 -16
  149. package/src/parked.mjs +2 -1
  150. package/src/pause-windows.mjs +136 -17
  151. package/src/podman-stack.mjs +40 -14
  152. package/src/portfolio-snapshot.mjs +26 -7
  153. package/src/prepare-github.mjs +41 -77
  154. package/src/prepare-local.mjs +32 -2
  155. package/src/prepare.mjs +45 -11
  156. package/src/private-store-boundary.mjs +70 -0
  157. package/src/processor.mjs +1554 -167
  158. package/src/projects.mjs +23 -0
  159. package/src/prompt-fence.mjs +18 -0
  160. package/src/provider-key.mjs +7 -0
  161. package/src/provider-steering.mjs +1 -0
  162. package/src/question-context.mjs +35 -0
  163. package/src/question-group-runtime.mjs +91 -0
  164. package/src/question-groups.mjs +171 -0
  165. package/src/queue.mjs +296 -55
  166. package/src/reason-catalog.mjs +448 -0
  167. package/src/receiver-status.mjs +230 -0
  168. package/src/record-reason.mjs +37 -0
  169. package/src/recovery-queue.mjs +566 -0
  170. package/src/redact-secrets.mjs +219 -0
  171. package/src/reserve-estimate.mjs +101 -0
  172. package/src/reserved-env.mjs +36 -1
  173. package/src/retention.mjs +67 -0
  174. package/src/run-budget-ledger.mjs +283 -0
  175. package/src/run-checkpoint.mjs +627 -0
  176. package/src/run-container.mjs +749 -33
  177. package/src/run-history.mjs +791 -45
  178. package/src/run-mirror.mjs +75 -7
  179. package/src/run-progress.mjs +751 -0
  180. package/src/runtime-observations.mjs +3 -1
  181. package/src/runtime-settings.mjs +272 -37
  182. package/src/sandbox-cli.mjs +78 -2
  183. package/src/sandbox-deployment.mjs +51 -0
  184. package/src/sandbox-gateway-protocol.mjs +378 -0
  185. package/src/sandbox-hosts.mjs +100 -0
  186. package/src/sandbox-inventory.mjs +83 -0
  187. package/src/sandbox-policy.mjs +225 -0
  188. package/src/sandbox-prepare-requests.mjs +145 -0
  189. package/src/sandbox-prepare.mjs +285 -0
  190. package/src/sandbox-refs.mjs +70 -0
  191. package/src/sandbox-session-monitor.mjs +4 -0
  192. package/src/sandbox-session-watch.mjs +124 -0
  193. package/src/sandbox-sessions.mjs +161 -0
  194. package/src/sandbox-store.mjs +63 -6
  195. package/src/sandbox-workspace-guard.mjs +39 -0
  196. package/src/sandbox.mjs +27 -200
  197. package/src/scheduler-stall-guard.mjs +14 -8
  198. package/src/schedules.mjs +19 -6
  199. package/src/secret-bundles.mjs +245 -0
  200. package/src/secret-profiles.mjs +103 -3
  201. package/src/secrets.mjs +19 -9
  202. package/src/service-env.mjs +38 -5
  203. package/src/service.mjs +638 -106
  204. package/src/session-key.mjs +60 -0
  205. package/src/session-store.mjs +168 -10
  206. package/src/setup-contract.mjs +40 -0
  207. package/src/setup-egress.mjs +113 -0
  208. package/src/setup-host-entry.mjs +33 -0
  209. package/src/setup-host.mjs +242 -0
  210. package/src/setup-lock.mjs +41 -0
  211. package/src/size-records.mjs +15 -3
  212. package/src/start.mjs +1353 -135
  213. package/src/status-comment.mjs +415 -0
  214. package/src/stderr-transcript.mjs +318 -0
  215. package/src/steer-ack.mjs +122 -0
  216. package/src/steer-cli.mjs +213 -0
  217. package/src/steer-framing.mjs +185 -0
  218. package/src/steer-inbox.mjs +693 -0
  219. package/src/store-version.mjs +139 -0
  220. package/src/subscription-config.mjs +56 -0
  221. package/src/subscription-limits.mjs +3 -0
  222. package/src/subscription-quota.mjs +259 -0
  223. package/src/subscription-runtime.mjs +123 -0
  224. package/src/subscription-status.mjs +42 -0
  225. package/src/subscription-wait.mjs +11 -0
  226. package/src/subscriptions.mjs +7 -1
  227. package/src/thinking-level.mjs +19 -0
  228. package/src/touch-outbox.mjs +61 -0
  229. package/src/touch-state.mjs +133 -0
  230. package/src/triggers-file.mjs +299 -39
  231. package/src/triggers.mjs +782 -40
  232. package/src/trust-paths.mjs +171 -0
  233. package/src/up.mjs +398 -51
  234. package/src/updates-cli.mjs +61 -0
  235. package/src/updates-look.mjs +127 -0
  236. package/src/updates-reconcile.mjs +182 -0
  237. package/src/updates-subscription-adapters.mjs +8 -0
  238. package/src/updates.mjs +252 -0
  239. package/src/valkey-auth.mjs +32 -9
  240. package/src/valkey-endpoint.mjs +14 -4
  241. package/src/wait-for.mjs +1 -1
  242. package/src/wait-state.mjs +62 -3
  243. package/src/watch-board.mjs +38 -0
  244. package/src/watch-closer.mjs +1 -1
  245. package/src/watch-contract.mjs +102 -0
  246. package/src/watch-decision.mjs +78 -0
  247. package/src/watch-drainer.mjs +14 -0
  248. package/src/watch-issue-adapter.mjs +44 -0
  249. package/src/watch-issue.mjs +111 -0
  250. package/src/watch-record.mjs +10 -0
  251. package/src/watch-runtime.mjs +167 -0
  252. package/src/watch-store.mjs +183 -0
  253. package/src/watch-trace.mjs +34 -0
  254. package/src/workspace-checkpoint.mjs +1750 -0
  255. package/src/zero-touch-fixes.mjs +99 -0
  256. package/src/zero-touch.mjs +225 -0
  257. package/src/repeat-slot.mjs +0 -13
@@ -13,10 +13,11 @@
13
13
  * says only whether it is set. An operator's own VALKEY_URL with its own userinfo (a managed Valkey, TLS) is left as it
14
14
  * is, and its password wins over this key (`connection.mjs`).
15
15
  *
16
- * HOW THE VALKEY LEARNS IT, measured on Podman 5.8.1 (Fedora 44) and 4.9.3 (Ubuntu 24.04) with valkey/valkey:8
17
- * (Valkey 8.1.10): the image runs `tini -- docker-entrypoint.sh <command>`, and tini keeps its whole argv as PID 1 for
18
- * the life of the container, so `valkey-server --requirepass X` put the password in a /proc/<pid>/cmdline that another
19
- * account read, on the host, for as long as the Valkey ran (the pid namespace hides nothing: /proc of the host lists
16
+ * HOW THE VALKEY LEARNS IT, measured on Podman 5.8.1 (Fedora 44) and 4.9.3 (Ubuntu 24.04) with Valkey 8.1.10 (pulled
17
+ * as valkey/valkey:8 then, the minor `VALKEY_IMAGE` pins since issue #761): the image runs
18
+ * `tini -- docker-entrypoint.sh <command>`, and tini keeps its whole argv as PID 1 for the life of the container, so
19
+ * `valkey-server --requirepass X` put the password in a /proc/<pid>/cmdline that another account read, on the host,
20
+ * for as long as the Valkey ran (the pid namespace hides nothing: /proc of the host lists
20
21
  * the container's processes, and cmdline is world-readable). The image's VALKEY_EXTRA_FLAGS only appends to that same
21
22
  * argv. So the password travels in the container's ENVIRONMENT (a Quadlet `EnvironmentFile=` of a 0600 file, compose's
22
23
  * `environment:`, `docker run -e VALKEY_PASSWORD` with the value in the CLI's own environment), which /proc shows only
@@ -38,6 +39,25 @@ import { basename, join } from "node:path";
38
39
  /** The `.env` key (issue #468). */
39
40
  export const VALKEY_PASSWORD_KEY = "VALKEY_PASSWORD";
40
41
 
42
+ /**
43
+ * The image of every Valkey this project starts or ships (issue #761): `up`'s `docker run` and the owner check below,
44
+ * `deploy/docker-compose.yml`, the Quadlet unit (as `docker.io/` and this), CI's services and its smoke script, each
45
+ * held to this value by `worker/test/valkey-image.test.mjs`. A minor, where an older release shipped the floating
46
+ * major `:8` on the reasoning that a bad pull breaks loudly and spends nothing. That is not enough:
47
+ * - the specs state measured facts about this Valkey (`HEXPIRE` answers `ERR unknown command`, `/bin/sh` is dash),
48
+ * and a floating tag makes each a claim about whatever was pulled last;
49
+ * - the hosts of one fleet must not cross a minor at different times, each on its own next pull;
50
+ * - a newer minor's data files need not load in an older one, so a pull nobody chose could rule out a rollback;
51
+ * - an upgrade guide needs a known starting point.
52
+ * Not a patch or a digest: `8.1` still takes 8.1's patch releases, security fixes among them, which a patch pin would
53
+ * hold back until someone moved it (the egress proxy, which IS the allowlist, stays digest-pinned). When pinned
54
+ * it was the image `:8` pulled (both resolved to Valkey 8.1.11, and no `:8.2` existed), so neither a fresh pull nor a
55
+ * host that pulled `:8` moved, and a volume written by 8.0.2 or by 8.1.11 loaded in the other, measured both ways.
56
+ * Moving it: change this value, add the old one to `OLDER_VALKEY_IMAGES` (older-state.mjs), re-measure the claims above
57
+ * and the load in both directions, and add a row to docs/upgrading.md.
58
+ */
59
+ export const VALKEY_IMAGE = "valkey/valkey:8.1";
60
+
41
61
  /**
42
62
  * A new password: 32 random bytes as 64 lowercase hex characters. Hex because every loader of `.env` reads it back
43
63
  * byte for byte (systemd, the shells, the cmd wrapper split on `=`), nothing in it is special to a Valkey config line,
@@ -115,7 +135,7 @@ export function valkeyDockerRunArgs({ port = 6379, deployment = null } = {}) {
115
135
  "3s",
116
136
  "--health-retries",
117
137
  "5",
118
- "valkey/valkey:8",
138
+ VALKEY_IMAGE,
119
139
  "sh",
120
140
  "-c",
121
141
  VALKEY_START_SCRIPT,
@@ -319,8 +339,10 @@ function ownerShown(info) {
319
339
  }
320
340
 
321
341
  /**
322
- * One container by name: `{ absent }`, `{ ours, owner }`, or `{ unknown }` when docker did not answer (which is never
323
- * taken as ours). `query(cmd, args)` resolves `{ code, stdout, stderr }`.
342
+ * One container by name: `{ absent }`, `{ ours, owner, image? }`, or `{ unknown }` when docker did not answer (which is
343
+ * never taken as ours). `query(cmd, args)` resolves `{ code, stdout, stderr }`. `image` is the record's `Config.Image`,
344
+ * the reference the container was created from as given (measured on docker 27.4: `valkey/valkey:8` and
345
+ * `docker.io/valkey/valkey:8` each come back unchanged), which `up` holds against the older images (issue #761).
324
346
  */
325
347
  export async function valkeyContainerOwner(name, { dirs, port, query }) {
326
348
  const res = await query("docker", ["container", "inspect", name]);
@@ -331,7 +353,8 @@ export async function valkeyContainerOwner(name, { dirs, port, query }) {
331
353
  } catch {
332
354
  return { unknown: `docker container inspect ${name} printed no record` };
333
355
  }
334
- return { ours: valkeyContainerIsOurs(info, { dirs, port }), owner: ownerShown(info) };
356
+ const image = info?.Config?.Image;
357
+ return { ours: valkeyContainerIsOurs(info, { dirs, port }), owner: ownerShown(info), ...(typeof image === "string" ? { image } : {}) };
335
358
  }
336
359
 
337
360
  /**
@@ -511,7 +534,7 @@ export const OWNER_CHECK_CONTAINER = "pi-dispatch-valkey-ownercheck";
511
534
  * AOF on, as always, so it loads the queue as the real one will; it is stopped (SIGTERM), never killed.
512
535
  */
513
536
  export function valkeyOwnerCheckArgs(deployment) {
514
- return ["run", "-d", "--name", OWNER_CHECK_CONTAINER, "--label", `${DEPLOYMENT_LABEL}=${deployment}`, "--network", "none", "-v", `${VALKEY_VOLUME}:/data`, "valkey/valkey:8", "valkey-server", "--appendonly", "yes"];
537
+ return ["run", "-d", "--name", OWNER_CHECK_CONTAINER, "--label", `${DEPLOYMENT_LABEL}=${deployment}`, "--network", "none", "-v", `${VALKEY_VOLUME}:/data`, VALKEY_IMAGE, "valkey-server", "--appendonly", "yes"];
515
538
  }
516
539
 
517
540
  /** `docker exec` reading the marker inside the owner check's Valkey. */
@@ -29,7 +29,7 @@ import { lookup as dnsLookup } from "node:dns/promises";
29
29
  import { readFileSync } from "node:fs";
30
30
  import { networkInterfaces, userInfo } from "node:os";
31
31
  import { join } from "node:path";
32
- import { venuesOf } from "./backends.mjs";
32
+ import { openshellSetupOf, venuesOf } from "./backends.mjs";
33
33
  import { DEFAULT_VALKEY_URL } from "./config.mjs";
34
34
  import { VALKEY_SHARED_KEY, judgeValkeyListeners, passwdNameFrom, pinnedValkeyUrl, probeTcpAddress, readStackKeys, readSubuidRanges, readValkeyKeys, valkeySharedOn, valkeySchemeOf } from "./podman-stack.mjs";
35
35
  import { VALKEY_PASSWORD_KEY, isLoopbackHost } from "./valkey-auth.mjs";
@@ -95,7 +95,11 @@ export function valkeyClientContext({ env = process.env, cwd = process.cwd(), pl
95
95
  if (content !== null) {
96
96
  const stack = readStackKeys(content, { loader: "systemd", path: envPath });
97
97
  if (stack.error) error = stack.error;
98
- else if (typeof stack.keys.PI_BACKENDS === "string") fileKeys.PI_BACKENDS = stack.keys.PI_BACKENDS;
98
+ else {
99
+ if (typeof stack.keys.PI_BACKENDS === "string") fileKeys.PI_BACKENDS = stack.keys.PI_BACKENDS;
100
+ // Issue #707: the engine decides whether the queue is this account's Quadlet Valkey (`openshellSetupOf`).
101
+ if (typeof stack.keys.PI_OPENSHELL_ENGINE === "string") fileKeys.PI_OPENSHELL_ENGINE = stack.keys.PI_OPENSHELL_ENGINE;
102
+ }
99
103
  const keys = readValkeyKeys(content, { loader: "systemd", path: envPath });
100
104
  if (keys.error) error ??= keys.error;
101
105
  else for (const k of [VALKEY_SHARED_KEY, VALKEY_PASSWORD_KEY, "VALKEY_URL"]) if (typeof keys.keys[k] === "string") fileKeys[k] = keys.keys[k];
@@ -119,12 +123,16 @@ export function valkeyContextFromResolution({ service, envPath = null, shared, p
119
123
  if (typeof fileValue(key) === "string") fileKeys[key] = fileValue(key);
120
124
  }
121
125
  if (typeof service?.env?.PI_BACKENDS === "string") env.PI_BACKENDS = service.env.PI_BACKENDS;
126
+ // Issue #707 (review round 2 of #715): the engine beside the list, which decides whether an openshell venue's queue is
127
+ // this account's Quadlet Valkey (`openshellSetupOf`).
128
+ if (typeof service?.env?.PI_OPENSHELL_ENGINE === "string") env.PI_OPENSHELL_ENGINE = service.env.PI_OPENSHELL_ENGINE;
122
129
  if (typeof shared === "string") fileKeys[VALKEY_SHARED_KEY] = shared;
123
130
  return valkeyContextFromKeys({ env, envPath, fileKeys, platform });
124
131
  }
125
132
 
126
133
  /** The keys a client's context takes from a deployment `.env` (`valkeyContextFromKeys`). */
127
- export const VALKEY_CONTEXT_KEYS = Object.freeze(["VALKEY_URL", VALKEY_PASSWORD_KEY, VALKEY_SHARED_KEY, "PI_BACKENDS"]);
134
+ // Issue #707 (review round 2 of #715): and PI_OPENSHELL_ENGINE, which `valkeyContextFromKeys` reads beside PI_BACKENDS.
135
+ export const VALKEY_CONTEXT_KEYS = Object.freeze(["VALKEY_URL", VALKEY_PASSWORD_KEY, VALKEY_SHARED_KEY, "PI_BACKENDS", "PI_OPENSHELL_ENGINE"]);
128
136
 
129
137
  /**
130
138
  * A context from keys a caller has already read out of the deployment's `.env` (`fileKeys`, the `VALKEY_CONTEXT_KEYS`
@@ -144,7 +152,9 @@ export function valkeyContextFromKeys({ env = process.env, envPath = null, fileK
144
152
  } catch {
145
153
  // An unparseable list: the default venue, as doctor reads it; the worker refuses to boot on it anyway.
146
154
  }
147
- const derived = platform === "linux" && venues.podmanUsed && !venues.localUsed;
155
+ // Issue #707 (review round 1 of #715): and an openshell venue on Podman that is the queue's engine, as the worker judges it.
156
+ const engine = typeof env.PI_OPENSHELL_ENGINE === "string" ? env.PI_OPENSHELL_ENGINE : fileKeys.PI_OPENSHELL_ENGINE;
157
+ const derived = platform === "linux" && ((venues.podmanUsed && !venues.localUsed) || openshellSetupOf(venues, { PI_OPENSHELL_ENGINE: engine }).valkeyEngine === "podman");
148
158
  const envUrl = typeof env.VALKEY_URL === "string" ? env.VALKEY_URL : null;
149
159
  return { envPath, shared, rootRefused: typeof rootRefused === "boolean" ? rootRefused : derived, error, password: { environment: envPassword, file: filePassword }, url: { environment: envUrl, file: fileUrl } };
150
160
  }
package/src/wait-for.mjs CHANGED
@@ -38,7 +38,7 @@ function configError(message) {
38
38
  * condition it asked for is structural now, and `scoped-limits.json` covers the wider per-scope case.
39
39
  * Accepting-and-ignoring it would be the silent no-op this project refuses.
40
40
  */
41
- export const WAIT_CONDITION_KEYS = ["after", "profile"];
41
+ export const WAIT_CONDITION_KEYS = Object.freeze(["after", "profile"]);
42
42
 
43
43
  /**
44
44
  * Every reason the wait gate refuses a job with (index.mjs `refuseWait`), each before the job holds a slot. A test
@@ -18,6 +18,12 @@
18
18
  * the worker rather than projected out of a delayed job's `.data`, which holds the issue title and
19
19
  * body.
20
20
  *
21
+ * Two fields say more of the same to an operator, for the same reason (issue #737): `after`, the job's own
22
+ * `after` instant, and `a:<profile>`, what each check last answered and when (`<go|hold|fault|refuse|unclear>:<ms>`).
23
+ * A console reads both from here, never from the job's data. `after` is not the hold's lease horizon: on the
24
+ * polled path that is the next check, and on a throttled wake it is the maximum wait, so a console showing it
25
+ * as the hold's end would draw a time nobody chose. The next wake is the defer record's (`defer-reasons.mjs`).
26
+ *
21
27
  * WHY THIS IS NOT THE REDIS STATE `OQ-008` AND #242 REFUSED. That refusal is about a claim that would
22
28
  * survive a crash WRONGLY -- an in-flight count asserting a container the boot reaper had just killed.
23
29
  * This describes DELAYED JOBS, which are themselves Redis-persisted, so it is the same source of truth
@@ -29,7 +35,8 @@
29
35
  *
30
36
  * The keys, all under one prefix so an operator can see the whole feature with one `KEYS wait:*`:
31
37
  * wait:held SET of held job ids -- an index the READER prunes, never a source of truth
32
- * wait:job:<jobId> HASH { since, faults, throttles, target, label, dedupId }, TTL sized to the hold
38
+ * wait:job:<jobId> HASH { since, faults, throttles, checks, target, label, dedupId, after, a:<profile> },
39
+ * TTL sized to the hold
33
40
  * wait:done:<dedupId> STRING jobId -- this target's wait was satisfied, briefly remembered
34
41
  * wait:key:<dedupId> STRING jobId -- the supersede lease, TTL sized to the expected hold
35
42
  */
@@ -61,6 +68,37 @@ export const satisfiedKey = (dedupId) => `wait:done:${dedupId}`;
61
68
  // longest two jobs that cleared together can wake apart.
62
69
  const SATISFIED_MS = 15 * 60 * 1000;
63
70
 
71
+ /** The hold hash's field prefix for a check's last answer: `a:<profile>`. */
72
+ export const WAIT_ANSWER_PREFIX = "a:";
73
+
74
+ /** The answers a check's field may hold, the gate's own vocabulary for what it was told (`answerOf`). */
75
+ export const WAIT_ANSWERS = Object.freeze(["go", "hold", "fault", "refuse", "unclear"]);
76
+
77
+ /**
78
+ * A profile name that may become a field. The wait loader's charset with a length bound, so a field name stays a
79
+ * short token whatever a newer loader admits; a longer name keeps its hold and simply records no answer.
80
+ */
81
+ export const WAIT_PROFILE_FIELD_RE = /^[A-Za-z0-9._-]{1,64}$/;
82
+
83
+ /**
84
+ * What a check's verdict says, as the hold hash records it, or null when it says nothing about the condition: an
85
+ * aborted check (a restart or a cancel stopped it), a profile this worker does not declare, and a target no check
86
+ * can be handed are each decided by the gate without an answer. `hold` with a fault is `fault` (the check could not
87
+ * tell); anything the gate does not understand is `unclear`, the same shape it holds rather than runs.
88
+ *
89
+ * In the gate's own order of tests (index.mjs, after the check loop), so a verdict carrying two signals is recorded
90
+ * as what the gate did with it: a `refuse` that also says aborted is refused there, and recorded so here.
91
+ */
92
+ export function answerOf(verdict) {
93
+ const v = verdict !== null && typeof verdict === "object" ? verdict : {};
94
+ if (v.unusableTarget || v.profileUnknown) return null;
95
+ if (v.verdict === "refuse") return "refuse";
96
+ if (v.aborted) return null;
97
+ if (v.verdict === "hold") return v.fault === true ? "fault" : "hold";
98
+ if (v.verdict === "go") return "go";
99
+ return "unclear";
100
+ }
101
+
64
102
  /**
65
103
  * Build the state accessor. `redis` is the same client the budget uses; nothing here is on the paid path,
66
104
  * so every method is written to FAIL OPEN: a redis blip must never refuse a job or wedge a hold, it may
@@ -74,13 +112,16 @@ export function makeWaitState({ redis, now = () => Date.now(), counterTtlMs = 25
74
112
  * Record (or refresh) a hold. `since` is written ONCE with HSETNX so a re-pick does not restart the
75
113
  * clock -- that is the whole point of not using `job.timestamp`, and a plain HSET here would
76
114
  * reintroduce the bug from the other side by resetting the wait on every wake.
115
+ *
116
+ * `afterMs` is the job's own `after` instant (`afterMs(job.data)`), written as `after` beside the label in
117
+ * the same HSET when it is a whole number of milliseconds; the hold's lease horizon `untilMs` never is.
77
118
  */
78
- async hold(jobId, { dedupId, target, label, untilMs }) {
119
+ async hold(jobId, { dedupId, target, label, untilMs, afterMs = null }) {
79
120
  try {
80
121
  const key = jobKey(jobId);
81
122
  const ttl = leaseMs(untilMs);
82
123
  await redis.hsetnx(key, "since", String(now()));
83
- await redis.hset(key, "target", target ?? "", "label", label ?? "", "dedupId", dedupId ?? "");
124
+ await redis.hset(key, "target", target ?? "", "label", label ?? "", "dedupId", dedupId ?? "", ...(Number.isSafeInteger(afterMs) ? ["after", String(afterMs)] : []));
84
125
  await redis.pexpire(key, ttl);
85
126
  await redis.sadd(HELD_SET, jobId);
86
127
  // Refresh OUR lease only, and re-read to find out whether it IS ours: `hold` is no longer
@@ -245,6 +286,24 @@ export function makeWaitState({ redis, now = () => Date.now(), counterTtlMs = 25
245
286
  }
246
287
  },
247
288
 
289
+ /**
290
+ * Record what one profile's check last answered, `a:<profile>` = `<answer>:<ms>`, for a console to show
291
+ * beside the hold. One field per profile, overwritten by its next answer, so the hash grows with the job's
292
+ * conditions and never with its wakes. Like a counter it may create the hash, so it carries the counters'
293
+ * TTL for their reason. A profile or an answer outside its rule is not written, silently: the answers are
294
+ * the gate's own vocabulary, and a profile name past the field rule keeps its hold without the detail.
295
+ * Fail open.
296
+ */
297
+ async noteAnswer(jobId, { profile, answer } = {}) {
298
+ if (typeof profile !== "string" || !WAIT_PROFILE_FIELD_RE.test(profile) || !WAIT_ANSWERS.includes(answer)) return;
299
+ try {
300
+ await redis.hset(jobKey(jobId), `${WAIT_ANSWER_PREFIX}${profile}`, `${answer}:${now()}`);
301
+ await redis.pexpire(jobKey(jobId), counterTtlMs);
302
+ } catch {
303
+ // Fail open: a lost answer costs a console its detail, never the gate its decision.
304
+ }
305
+ },
306
+
248
307
  /** How long this job has been held, in ms, or null when nothing was recorded. */
249
308
  async heldForMs(jobId) {
250
309
  try {
@@ -0,0 +1,38 @@
1
+ /** A board is inert data with provenance, never agent-authored markup or executable query config. */
2
+ export const WATCH_BOARD_MAX_BYTES = 16384;
3
+ export const WATCH_BOARD_TYPES = Object.freeze(["number", "line", "table", "note", "hypotheses", "timeline"]);
4
+ const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value) && [Object.prototype, null].includes(Object.getPrototypeOf(value));
5
+ const text = (value) => typeof value === "string" && value.length <= 2048 && !/[\u0000-\u0008\u000b-\u001f\u007f]/.test(value);
6
+ const scalar = (value) => value === null || text(value) || typeof value === "boolean" || (typeof value === "number" && Number.isFinite(value));
7
+ const id = (value) => typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/.test(value) && !["__proto__", "constructor", "prototype"].includes(value);
8
+ const keys = (value, allowed) => Object.keys(value).every((key) => allowed.includes(key));
9
+ export function validateWatchBoard(input, { traceIds } = {}) {
10
+ const refuse = (reason) => ({ ok: false, reason });
11
+ let raw;
12
+ try { raw = typeof input === "string" ? input : JSON.stringify(input); } catch { return refuse("watch-board-invalid"); }
13
+ if (typeof raw !== "string") return refuse("watch-board-invalid");
14
+ if (Buffer.byteLength(raw) > WATCH_BOARD_MAX_BYTES) return refuse("watch-board-oversize");
15
+ let board;
16
+ try { board = JSON.parse(raw); } catch { return refuse("watch-board-invalid"); }
17
+ if (!record(board) || !keys(board, ["version", "blocks"]) || board.version !== 1 || !Array.isArray(board.blocks) || board.blocks.length > 64) return refuse("watch-board-invalid");
18
+ const ids = new Set();
19
+ for (const block of board.blocks) {
20
+ if (!record(block) || !id(block.id) || ids.has(block.id) || !WATCH_BOARD_TYPES.includes(block.type) || !id(block.traceId) || (traceIds !== undefined && !traceIds.has(block.traceId)) || !text(block.title ?? "")) return refuse("watch-board-invalid");
21
+ ids.add(block.id);
22
+ const common = ["id", "type", "traceId", "title"];
23
+ if (block.type === "number") {
24
+ if (!keys(block, [...common, "value", "unit"]) || typeof block.value !== "number" || !Number.isFinite(block.value) || !text(block.unit ?? "")) return refuse("watch-board-invalid");
25
+ } else if (block.type === "note") {
26
+ if (!keys(block, [...common, "text"]) || !text(block.text)) return refuse("watch-board-invalid");
27
+ } else if (block.type === "line") {
28
+ if (!keys(block, [...common, "points"]) || !Array.isArray(block.points) || block.points.length > 256 || !block.points.every((p) => record(p) && keys(p, ["x", "y"]) && (text(p.x) || typeof p.x === "number" && Number.isFinite(p.x)) && typeof p.y === "number" && Number.isFinite(p.y))) return refuse("watch-board-invalid");
29
+ } else if (block.type === "table") {
30
+ if (!keys(block, [...common, "columns", "rows"]) || !Array.isArray(block.columns) || !block.columns.length || block.columns.length > 16 || !block.columns.every(text) || !Array.isArray(block.rows) || block.rows.length > 128 || !block.rows.every((row) => Array.isArray(row) && row.length === block.columns.length && row.every(scalar))) return refuse("watch-board-invalid");
31
+ } else if (block.type === "hypotheses") {
32
+ if (!keys(block, [...common, "items"]) || !Array.isArray(block.items) || block.items.length > 32 || !block.items.every(text)) return refuse("watch-board-invalid");
33
+ } else if (block.type === "timeline") {
34
+ if (!keys(block, [...common, "items"]) || !Array.isArray(block.items) || block.items.length > 64 || !block.items.every((item) => record(item) && keys(item, ["at", "text"]) && text(item.at) && Number.isFinite(Date.parse(item.at)) && text(item.text))) return refuse("watch-board-invalid");
35
+ }
36
+ }
37
+ return { ok: true, board };
38
+ }
@@ -86,7 +86,7 @@ export function makeWatchCloser(handles, log) {
86
86
  * The debounce every live-edit watch in this project uses, in one place (issue #386).
87
87
  *
88
88
  * The literal 150 was written four times -- the receiver's triggers watch and the worker's triggers,
89
- * pause-windows and scoped-limits watches -- and four copies of a number is the shape `CLAUDE.md` warns
89
+ * pause-windows and scoped-limits watches -- and four copies of a number is the shape `AGENTS.md` warns
90
90
  * about: they agree until one of them does not. It rides this module because this module is already what
91
91
  * both composition roots import for the same four watches.
92
92
  *
@@ -0,0 +1,102 @@
1
+ import { createHash } from "node:crypto";
2
+ import { isProjectId } from "./project-id.mjs";
3
+
4
+ export const WATCH_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
5
+ export const WATCH_SEVERITIES = Object.freeze(["debug", "info", "warning", "error", "critical"]);
6
+ const FIELD_RE = /^[a-zA-Z][a-zA-Z0-9_.-]{0,63}$/;
7
+ const forbidden = new Set(["__proto__", "prototype", "constructor"]);
8
+ function fail(message) { const error = new Error(`watch: ${message}`); error.piDispatchConfig = true; throw error; }
9
+ function object(value, name) { if (!value || typeof value !== "object" || Array.isArray(value)) fail(`${name} must be an object`); }
10
+ function keys(value, allowed, name) { for (const key of Object.keys(value)) if (!allowed.includes(key)) fail(`${name} has an unknown field`); }
11
+ function integer(value, min, max, name) { if (!Number.isSafeInteger(value) || value < min || value > max) fail(`${name} is out of bounds`); return value; }
12
+ function id(value, name) { if (typeof value !== "string" || !WATCH_ID_RE.test(value)) fail(`${name} must be a bounded identifier`); return value; }
13
+ function field(value) { if (typeof value !== "string" || !FIELD_RE.test(value) || value.split(".").some(x => forbidden.has(x))) fail("invalid structured field name"); return value; }
14
+ export function watchDurationMs(value) {
15
+ if (typeof value !== "string" || !/^[1-9][0-9]{0,4}[smhd]$/.test(value)) fail("duration must be a bounded s, m, h or d value");
16
+ const ms = Number(value.slice(0, -1)) * ({ s: 1000, m: 60000, h: 3600000, d: 86400000 })[value.at(-1)];
17
+ if (ms > 30 * 86400000) fail("duration exceeds 30 days");
18
+ return ms;
19
+ }
20
+ export function normalizeWatchSources(value = {}) {
21
+ object(value, "watchSources");
22
+ if (Object.keys(value).length > 64) fail("too many sources");
23
+ const sources = {};
24
+ for (const [name, source] of Object.entries(value)) {
25
+ id(name, "source"); object(source, "source"); keys(source, ["verify", "secretEnv", "maxBodyBytes", "project", "maxPerDay", "pull"], "source");
26
+ if (source.verify !== "hmac-sha256") fail("source requires hmac-sha256");
27
+ if (typeof source.secretEnv !== "string" || !/^[A-Z][A-Z0-9_]{0,127}$/.test(source.secretEnv)) fail("invalid source secretEnv");
28
+ if (source.project !== undefined && !isProjectId(source.project)) fail("invalid source project id");
29
+ sources[name] = { verify: source.verify, secretEnv: source.secretEnv, maxBodyBytes: integer(source.maxBodyBytes ?? 65536, 1, 65536, "maxBodyBytes"), maxPerDay: integer(source.maxPerDay ?? 1000, 1, 100000, "source maxPerDay"), ...(source.project !== undefined ? { project: source.project } : {}), ...(source.pull !== undefined ? { pull: normalizeWatchPullConfig(source.pull) } : {}) };
30
+ }
31
+ return sources;
32
+ }
33
+ export function normalizeWatchOn(on, sources) {
34
+ object(on, "on"); keys(on, ["type", "id", "source", "match", "fingerprint", "minCount", "window", "cooldown", "maxPerDay", "maxFingerprints"], "on");
35
+ if (on.type !== "watch") fail("on.type must be watch");
36
+ id(on.id, "trigger id"); id(on.source, "source");
37
+ if (sources && !Object.hasOwn(sources, on.source)) fail("source is not declared");
38
+ if (!Array.isArray(on.fingerprint) || !on.fingerprint.length || on.fingerprint.length > 8 || new Set(on.fingerprint).size !== on.fingerprint.length) fail("fingerprint requires 1 to 8 unique fields");
39
+ on.fingerprint.forEach(field);
40
+ const match = on.match ?? {};
41
+ object(match, "match"); keys(match, ["severity", "labels"], "match");
42
+ // Unique members bound it at WATCH_SEVERITIES.length; a count written as a number would refuse a rule naming every
43
+ // severity once the list grew past it.
44
+ if (match.severity !== undefined && (!Array.isArray(match.severity) || !match.severity.length || new Set(match.severity).size !== match.severity.length || match.severity.some(x => !WATCH_SEVERITIES.includes(x)))) fail("invalid match severity");
45
+ const labels = match.labels ?? {}; object(labels, "match labels");
46
+ if (Object.keys(labels).length > 16) fail("too many match labels");
47
+ for (const [key, values] of Object.entries(labels)) { field(key); if (!Array.isArray(values) || !values.length || values.length > 16 || values.some(x => typeof x !== "string" || Buffer.byteLength(x) > 256)) fail("invalid match label values"); }
48
+ if (watchDurationMs(on.window) > 86400000) fail("digest window exceeds one day"); if (watchDurationMs(on.window) + watchDurationMs(on.cooldown) > 7 * 86400000) fail("digest window plus cooldown exceeds seven-day finding retention");
49
+ return { type: "watch", id: on.id, source: on.source, match: structuredClone(match), fingerprint: [...on.fingerprint], minCount: integer(on.minCount ?? 1, 1, 1000000, "minCount"), window: on.window, cooldown: on.cooldown, maxPerDay: integer(on.maxPerDay, 1, 10000, "maxPerDay"), maxFingerprints: integer(on.maxFingerprints ?? 256, 1, 4096, "maxFingerprints") };
50
+ }
51
+ export function parseWatchFinding(value) {
52
+ object(value, "finding"); keys(value, ["source", "fields", "labels", "severity", "count", "firstSeen", "lastSeen", "samples"], "finding");
53
+ if (value.source !== undefined) id(value.source, "source");
54
+ if (!WATCH_SEVERITIES.includes(value.severity)) fail("invalid severity");
55
+ const fields = value.fields ?? value.labels ?? {}; object(fields, "fields");
56
+ if (Object.keys(fields).length > 32) fail("too many structured fields");
57
+ for (const [key, val] of Object.entries(fields)) { field(key); if (typeof val !== "string" || Buffer.byteLength(val) > 256 || /[\u0000-\u001f]/.test(val)) fail("invalid structured field value"); }
58
+ for (const key of ["firstSeen", "lastSeen"]) if (typeof value[key] !== "string" || value[key].length > 32 || !Number.isFinite(Date.parse(value[key]))) fail("invalid finding time");
59
+ if (Date.parse(value.firstSeen) > Date.parse(value.lastSeen)) fail("finding times are reversed");
60
+ const samples = value.samples ?? [];
61
+ if (!Array.isArray(samples) || samples.length > 8 || samples.some(x => typeof x !== "string" || Buffer.byteLength(x) > 2048)) fail("samples exceed bounds");
62
+ return { ...(value.source !== undefined ? { source: value.source } : {}), fields: { ...fields }, severity: value.severity, count: integer(value.count ?? 1, 1, 1000000, "count"), firstSeen: new Date(value.firstSeen).toISOString(), lastSeen: new Date(value.lastSeen).toISOString(), samples: [...samples] };
63
+ }
64
+ // A length-delimited JSON tuple prevents separators inside field values aliasing another identity.
65
+ // Cardinality still needs a source/rule cap in the atomic store: structured values are attacker data.
66
+ export function watchFingerprint(source, on, finding) {
67
+ id(source, "source"); id(on.id, "trigger id");
68
+ const values = on.fingerprint.map(key => { if (!Object.hasOwn(finding.fields, key)) fail("missing fingerprint field"); return [key, finding.fields[key]]; });
69
+ return createHash("sha256").update(JSON.stringify([source, on.id, values])).digest("hex");
70
+ }
71
+ export function matchesWatchFinding(on, finding) {
72
+ return (!on.match?.severity || on.match.severity.includes(finding.severity)) && Object.entries(on.match?.labels ?? {}).every(([key, values]) => values.includes(finding.fields[key]));
73
+ }
74
+
75
+ export function normalizeWatchRule(on, sources) {
76
+ const rule = normalizeWatchOn(on, sources);
77
+ return { ...rule, windowMs: watchDurationMs(rule.window), cooldownMs: watchDurationMs(rule.cooldown) };
78
+ }
79
+
80
+ export function normalizeWatchPullConfig(value) {
81
+ object(value, "pull");
82
+ const common = ["kind", "fields", "severity", "contains", "intervalSeconds"];
83
+ const kinds = { file: ["path"], journald: ["unit"], loki: ["url", "query", "secretEnv"] };
84
+ // An own key, of a string: a plain index finds `constructor` or `__proto__` on the prototype (a bare TypeError a line
85
+ // later), and ["file"] coerces to "file" and skips the path check below, as ["loki"] skips the HTTPS one.
86
+ const extra = typeof value.kind === "string" && Object.hasOwn(kinds, value.kind) ? kinds[value.kind] : null;
87
+ if (!extra) fail("pull kind must be file, journald or loki");
88
+ keys(value, [...common, ...extra], "pull");
89
+ object(value.fields, "pull fields");
90
+ if (!Object.keys(value.fields).length || Object.keys(value.fields).length > 32) fail("pull requires 1 to 32 reviewed fields");
91
+ for (const [key, val] of Object.entries(value.fields)) { field(key); if (typeof val !== "string" || Buffer.byteLength(val) > 256 || /[\u0000-\u001f]/.test(val)) fail("invalid pull field value"); }
92
+ if (value.severity !== undefined && !WATCH_SEVERITIES.includes(value.severity)) fail("invalid pull severity");
93
+ if (value.contains !== undefined && (typeof value.contains !== "string" || value.contains.length > 256)) fail("invalid pull match");
94
+ if (value.kind === "file" && (typeof value.path !== "string" || !value.path.startsWith("/") || value.path.length > 4096 || /[\u0000-\u001f]/.test(value.path))) fail("pull file path must be absolute");
95
+ if (value.kind === "journald" && (typeof value.unit !== "string" || !/^[A-Za-z0-9_.@-]{1,128}$/.test(value.unit))) fail("invalid pull journal unit");
96
+ if (value.kind === "loki") {
97
+ let url; try { url = new URL(value.url); } catch { fail("invalid pull Loki URL"); }
98
+ if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash || typeof value.query !== "string" || !value.query.length || value.query.length > 4096) fail("invalid pull Loki query endpoint");
99
+ if (value.secretEnv !== undefined && (typeof value.secretEnv !== "string" || !/^[A-Z][A-Z0-9_]{0,127}$/.test(value.secretEnv))) fail("invalid pull Loki secretEnv");
100
+ }
101
+ return { ...structuredClone(value), intervalSeconds: integer(value.intervalSeconds ?? 30, 5, 3600, "pull intervalSeconds") };
102
+ }
@@ -0,0 +1,78 @@
1
+ /** Pure watch admission policy. Provider calls and dollar reservations belong to the caller. */
2
+ const ACTIONS = new Set(["investigate", "file_issue", "ignore"]);
3
+ const NAME = /^[a-zA-Z][a-zA-Z0-9_.-]{0,63}$/;
4
+ const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value) && [Object.prototype, null].includes(Object.getPrototypeOf(value));
5
+ const fail = () => ({ ok: false, reason: "decide-refused" });
6
+ const finite = (value) => typeof value === "number" && Number.isFinite(value);
7
+ const safeName = (value) => typeof value === "string" && NAME.test(value) && !["constructor", "prototype", "__proto__", "samples", "message", "text"].includes(value);
8
+
9
+ export function validateWatchDecision(value, { resolveModel = () => null } = {}) {
10
+ if (!record(value) || Object.keys(value).some((key) => !["model", "maxCostUsd", "questions", "act", "fields"].includes(key))) return fail();
11
+ if (typeof value.model !== "string" || value.model.length > 256 || !value.model.includes("/")) return fail();
12
+ if (typeof value.maxCostUsd !== "string" || !/^(?:0|[1-9]\d*)(?:\.\d{1,6})?$/.test(value.maxCostUsd)) return fail();
13
+ const capMicros = Math.round(Number(value.maxCostUsd) * 1e6);
14
+ if (!Number.isSafeInteger(capMicros)) return fail();
15
+ let model;
16
+ try { model = resolveModel(value.model); } catch { return fail(); }
17
+ if (!model || model.type !== "classifier" || !record(model.cost) || !finite(model.cost.input) || model.cost.input < 0 || model.cost.output !== 0) return fail();
18
+ if (!record(value.questions) || !Object.keys(value.questions).length || Object.keys(value.questions).length > 16) return fail();
19
+ for (const [name, question] of Object.entries(value.questions)) {
20
+ if (!safeName(name) || !record(question) || Object.keys(question).some((key) => !["type", "criteria", "instructions"].includes(key))) return fail();
21
+ if (!["choice", "bool", "noul", "score"].includes(question.type) || !record(question.criteria) || !Object.keys(question.criteria).length || Object.keys(question.criteria).length > 16) return fail();
22
+ if (question.instructions !== undefined && (typeof question.instructions !== "string" || question.instructions.length > 2048)) return fail();
23
+ for (const [option, criteria] of Object.entries(question.criteria)) if (!safeName(option) || typeof criteria !== "string" || criteria.length > 2048) return fail();
24
+ if (["bool", "noul"].includes(question.type) && Object.keys(question.criteria).sort().join() !== "false,true") return fail();
25
+ }
26
+ if (value.questions.act?.type !== "choice" || Object.keys(value.questions.act.criteria).some((option) => !ACTIONS.has(option))) return fail();
27
+ if (!record(value.act) || Object.keys(value.act).some((key) => !ACTIONS.has(key) && key !== "otherwise")) return fail();
28
+ if (value.act.otherwise !== undefined && value.act.otherwise !== "file_issue") return fail();
29
+ for (const [action, threshold] of Object.entries(value.act)) {
30
+ if (action === "otherwise") continue;
31
+ if (!Object.hasOwn(value.questions.act.criteria, action) || !finite(threshold) || threshold <= 0.5 || threshold > 1) return fail();
32
+ }
33
+ if (value.fields !== undefined && (!Array.isArray(value.fields) || value.fields.length > 32 || new Set(value.fields).size !== value.fields.length || !value.fields.every(safeName))) return fail();
34
+ if (Buffer.byteLength(JSON.stringify(value)) > 16384) return fail();
35
+ return { ok: true, config: JSON.parse(JSON.stringify(value)), capMicros };
36
+ }
37
+
38
+ /** Arbitrary fingerprint values and sample text are deliberately excluded from the default state. */
39
+ export function projectWatchDecisionState(finding, fields = []) {
40
+ const state = {};
41
+ if (!record(finding)) return state;
42
+ for (const key of ["severity", "count", "firstSeen", "lastSeen", "window", ...fields]) {
43
+ if (!safeName(key)) continue;
44
+ const value = Object.hasOwn(finding, key) ? finding[key] : fields.includes(key) && record(finding.fields) && Object.hasOwn(finding.fields, key) ? finding.fields[key] : undefined;
45
+ if ((typeof value === "string" && Buffer.byteLength(value) <= 256 && !/[\u0000-\u001f\u007f]/.test(value)) || (finite(value) && value >= 0) || typeof value === "boolean") state[key] = value;
46
+ }
47
+ return state;
48
+ }
49
+
50
+ /** A trusted pinned-runtime bound is required; input-only pricing alone is no token bound. */
51
+ export function prepareWatchDecision(config, finding, { resolveModel, boundCostMicros } = {}) {
52
+ const checked = validateWatchDecision(config, { resolveModel });
53
+ if (!checked.ok || typeof boundCostMicros !== "function") return fail();
54
+ const state = projectWatchDecisionState(finding, checked.config.fields);
55
+ let boundMicros;
56
+ try { boundMicros = boundCostMicros({ model: checked.config.model, state, questions: checked.config.questions }); } catch { return fail(); }
57
+ if (!Number.isSafeInteger(boundMicros) || boundMicros < 0 || boundMicros > checked.capMicros) return fail();
58
+ return { ...checked, state, boundMicros };
59
+ }
60
+
61
+ export function selectWatchDecision(config, result) {
62
+ const fallback = { action: "file_issue", reason: "decide-unanswered" };
63
+ if (!record(result) || result.stopReason !== "stop" || !record(result.answers)) return fallback;
64
+ const answer = result.answers.act;
65
+ if (!record(answer) || answer.type !== "choice" || !ACTIONS.has(answer.choice) || !record(answer.probabilities) || !finite(answer.confidence) || answer.confidence < 0 || answer.confidence > 1) return fallback;
66
+ const allowed = config?.questions?.act?.criteria;
67
+ if (!record(allowed) || !Object.hasOwn(allowed, answer.choice) || Object.keys(answer.probabilities).sort().join() !== Object.keys(allowed).sort().join()) return fallback;
68
+ let sum = 0;
69
+ for (const probability of Object.values(answer.probabilities)) {
70
+ if (!finite(probability) || probability < 0 || probability > 1) return fallback;
71
+ sum += probability;
72
+ }
73
+ if (Math.abs(sum - 1) > 0.000001) return fallback;
74
+ const probability = answer.probabilities[answer.choice];
75
+ const threshold = config.act?.[answer.choice];
76
+ if (!finite(threshold) || probability < threshold || Object.values(answer.probabilities).some((p) => p > probability)) return { ...fallback, probabilities: { ...answer.probabilities }, confidence: answer.confidence };
77
+ return { action: answer.choice, ...(answer.choice === "ignore" && { reason: "watch-ignored" }), probabilities: { ...answer.probabilities }, confidence: answer.confidence };
78
+ }
@@ -0,0 +1,14 @@
1
+ /** The timer survives an initially empty registry: sources can be added by reviewed live configuration. */
2
+ export function makeWatchDrainer({ runtime, intervalMs = 5000, setInterval: start = globalThis.setInterval, clearInterval: stop = globalThis.clearInterval, log = () => {} }) {
3
+ let closed = false;
4
+ let active = false;
5
+ const timer = start(() => {
6
+ if (closed || active) return;
7
+ active = true;
8
+ void Promise.resolve().then(() => { if (!closed) return runtime.drain(); }).catch(() => {
9
+ if (!closed) log("watch-handoff-unavailable", {});
10
+ }).finally(() => { active = false; });
11
+ }, intervalMs);
12
+ timer?.unref?.();
13
+ return { close() { if (closed) return; closed = true; stop(timer); } };
14
+ }
@@ -0,0 +1,44 @@
1
+ /** Reviewed forge context supplies endpoint and credential; findings supply neither. */
2
+ export function makeWatchIssueAdapter({ getContext, fetchFn = fetch, maxPages = 10, timeoutMs = 10000 }) {
3
+ if (typeof getContext !== "function" || !Number.isSafeInteger(maxPages) || maxPages < 1 || maxPages > 20) throw new Error("invalid watch issue adapter configuration");
4
+ async function context(target) {
5
+ if (!["github", "gitlab", "forgejo"].includes(target?.kind)) throw new Error("watch issue forge unsupported");
6
+ if (typeof target.repo !== "string" || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(target.repo) || target.repo.split("/").some(x => [".", ".."].includes(x))) throw new Error("invalid watch issue repository");
7
+ const config = await getContext(target); const url = new URL(config.apiUrl);
8
+ if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash || typeof config.token !== "string" || !config.token) throw new Error("invalid watch issue API context");
9
+ const base = url.href.replace(/\/$/, "");
10
+ const repo = target.repo.split("/").map(encodeURIComponent).join("/");
11
+ const root = target.kind === "gitlab" ? `${base}${url.pathname.replace(/\/$/, "").endsWith("/api/v4") ? "" : "/api/v4"}/projects/${encodeURIComponent(target.repo)}/issues` : target.kind === "forgejo" ? `${base}${url.pathname.replace(/\/$/, "").endsWith("/api/v1") ? "" : "/api/v1"}/repos/${repo}/issues` : `${base}/repos/${repo}/issues`;
12
+ return { root, headers: target.kind === "gitlab" ? { "private-token": config.token } : { authorization: `Bearer ${config.token}` } };
13
+ }
14
+ async function request(ctx, suffix, method = "GET", data) {
15
+ let res;
16
+ try { res = await fetchFn(ctx.root + suffix, { method, redirect: "error", signal: AbortSignal.timeout(timeoutMs), headers: { ...ctx.headers, accept: "application/json", ...(data ? { "content-type": "application/json" } : {}) }, ...(data ? { body: JSON.stringify(data) } : {}) }); } catch { throw new Error("watch issue API unavailable"); }
17
+ if (!res.ok) { await res.body?.cancel?.(); throw new Error("watch issue API refused request"); }
18
+ if (Number(res.headers?.get?.("content-length")) > 1048576) { await res.body?.cancel?.(); throw new Error("watch issue response exceeds limit"); }
19
+ const chunks = []; let bytes = 0;
20
+ if (!res.body?.getReader) throw new Error("watch issue API response missing");
21
+ const reader = res.body.getReader();
22
+ try { while (true) { const { done, value } = await reader.read(); if (done) break; bytes += value.byteLength; if (bytes > 1048576) throw new Error("watch issue response exceeds limit"); chunks.push(Buffer.from(value)); } } catch { await reader.cancel().catch(() => {}); throw new Error("watch issue response unavailable"); } finally { reader.releaseLock(); }
23
+ try { return JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch { throw new Error("watch issue API response invalid"); }
24
+ }
25
+ function issueId(value, kind) { const id = kind === "gitlab" ? value?.iid : value?.number; if (!Number.isSafeInteger(id) || id < 1) throw new Error("watch issue API response invalid"); return { id }; }
26
+ return {
27
+ async createIssue({ target, title, body }) { const ctx = await context(target); const data = target.kind === "gitlab" ? { title, description: body } : { title, body }; return issueId(await request(ctx, "", "POST", data), target.kind); },
28
+ async updateIssue({ target, id, title, body, state }) { if (!/^[1-9][0-9]{0,14}$/.test(String(id))) throw new Error("invalid watch issue id"); const ctx = await context(target); const data = { ...(title !== undefined ? { title } : {}), ...(body !== undefined ? target.kind === "gitlab" ? { description: body } : { body } : {}), ...(state === "open" ? target.kind === "gitlab" ? { state_event: "reopen" } : { state: "open" } : {}) }; await request(ctx, `/${id}`, target.kind === "gitlab" ? "PUT" : "PATCH", data); },
29
+ async closeIssue({ target, id }) { if (!/^[1-9][0-9]{0,14}$/.test(String(id))) throw new Error("invalid watch issue id"); const ctx = await context(target); await request(ctx, `/${id}`, target.kind === "gitlab" ? "PUT" : "PATCH", target.kind === "gitlab" ? { state_event: "close" } : { state: "closed" }); },
30
+ async findIssue({ target, marker }) {
31
+ if (typeof marker !== "string" || !/^pi-dispatch-watch:[a-f0-9]{64}$/.test(marker)) throw new Error("invalid watch issue marker");
32
+ const ctx = await context(target);
33
+ for (let page = 1; page <= maxPages; page++) {
34
+ const rows = await request(ctx, `?state=all&${target.kind === "forgejo" ? "limit" : "per_page"}=100&page=${page}`);
35
+ if (!Array.isArray(rows) || rows.length > 100) throw new Error("watch issue API response invalid");
36
+ const found = rows.filter(row => !row.pull_request && String(target.kind === "gitlab" ? row.description ?? "" : row.body ?? "").includes(`<!-- ${marker} -->`));
37
+ if (found.length > 1) throw new Error("watch issue marker ambiguous");
38
+ if (found.length === 1) return issueId(found[0], target.kind);
39
+ if (rows.length < 100) return null;
40
+ }
41
+ throw new Error("watch issue reconciliation page limit reached");
42
+ },
43
+ };
44
+ }