@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
package/src/projects.mjs CHANGED
@@ -25,10 +25,33 @@ import { hash16 } from "./fleet-lease.mjs";
25
25
  import { PROJECT_ID_RE, isProjectId } from "./project-id.mjs";
26
26
  import { parseScopeString, qualifiedScopeOf } from "./pause-windows.mjs";
27
27
  import { canonicalScope } from "./scoped-limits.mjs";
28
+ import { neededVersion } from "./store-version.mjs";
28
29
 
29
30
  /** The highest schema version this build reads. A file declaring a higher one is refused loudly. */
30
31
  export const PROJECTS_VERSION = 1;
31
32
 
33
+ /**
34
+ * Each project field whose ABSENCE an older build would misread, with the version that introduced it (issue #761,
35
+ * `DES-STORE-VERSION-WHEN-NEEDED`). A project entry is a closed shape its writers rebuild, so an older admin drops a
36
+ * field it does not know on its next write: a new field that changes which scope a project holds (and so which cap
37
+ * counts it) must declare its version here and raise `PROJECTS_VERSION` in the same commit. Empty until one does.
38
+ */
39
+ export const PROJECTS_FIELD_VERSIONS = Object.freeze({});
40
+
41
+ /**
42
+ * The version a file of `rows` (the projects as written, `{ id, name?, members }`) needs: the LOWEST that expresses
43
+ * them, 1 while no field needs more, and never above `PROJECTS_VERSION` (a table above this build's version is its own
44
+ * bug, held by the table test). `version` is required in this file, so the writer always writes the number this
45
+ * returns; it is a function rather than the constant so the file keeps the lowest version when the constant rises.
46
+ */
47
+ export function projectsVersionFor(rows, { table = PROJECTS_FIELD_VERSIONS, supported = PROJECTS_VERSION } = {}) {
48
+ const fields = new Set();
49
+ for (const row of Array.isArray(rows) ? rows : []) {
50
+ if (row !== null && typeof row === "object" && !Array.isArray(row)) for (const key of Object.keys(row)) fields.add(key);
51
+ }
52
+ return Math.min(neededVersion(fields, table), supported);
53
+ }
54
+
32
55
  // The id rule lives in an import-free module, so the run record can check an id without this module's graph.
33
56
  export { PROJECT_ID_RE, isProjectId };
34
57
 
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The code fence every piece of untrusted text in a prompt is wrapped in (CONST-ISSUE-TEXT-IS-DATA's render barrier).
3
+ *
4
+ * Moved here from github-prompt.mjs (issue #646) so the steer framing (steer-framing.mjs) can fence a running job's
5
+ * message with the SAME rule the trigger's own text gets, without importing a prompt builder (and its branch and
6
+ * session imports) to reach one function. github-prompt.mjs imports it back, so there is still exactly one fence rule:
7
+ * two copies would drift, and the one that drifted would be the one a crafted body escapes.
8
+ *
9
+ * Wrap untrusted content in a code fence long enough that the content cannot close it early. A `##` heading or a
10
+ * shorter backtick run inside the payload then renders literally, inside the fence, rather than escaping the data
11
+ * region. The fence is defense in depth, not the boundary: placement as a user turn is (see github-prompt.mjs).
12
+ */
13
+ export function fenceBlock(content) {
14
+ const runs = String(content).match(/`+/g) ?? [];
15
+ const longest = runs.reduce((max, run) => Math.max(max, run.length), 0);
16
+ const fence = "`".repeat(Math.max(3, longest + 1));
17
+ return `${fence}text\n${content}\n${fence}`;
18
+ }
@@ -40,6 +40,13 @@ export const OAUTH_KEY_RE = /_OAUTH_TOKEN$/;
40
40
  // bless the next provider's bearer variable. Pinned against pi in worker/test/provider-key.test.mjs.
41
41
  export const BEARER_KEY_RE = /_AUTH_TOKEN$/;
42
42
 
43
+ // These browser setup routes require host authentication guidance even when pi exposes
44
+ // a token-shaped environment slot. This policy does not change runtime credential loading.
45
+ export const HOST_AUTH_PROVIDERS = Object.freeze(["amazon-bedrock", "github-copilot", "google-vertex", "openai-codex"]);
46
+ export function needsHostAuthentication(provider) {
47
+ return HOST_AUTH_PROVIDERS.includes(provider);
48
+ }
49
+
43
50
  /**
44
51
  * Why a variable pi reads is not an API key, or null when it is one. The two answers need different
45
52
  * advice, so the caller is told which: an OAuth token is a subscription login that expires, a bearer token
@@ -174,6 +174,7 @@ const PI_OWN_READS = [
174
174
  "PI_PACKAGE_DIR",
175
175
  "PI_PROGRAM_STATUS",
176
176
  "PI_RADIUS_GATEWAY",
177
+ "PI_SELF_UPDATE_COMMAND",
177
178
  "PI_SHARE_VIEWER_URL",
178
179
  "PI_SKIP_VERSION_CHECK",
179
180
  "PI_STARTUP_BENCHMARK",
@@ -0,0 +1,35 @@
1
+ import * as nodeFs from "node:fs";
2
+
3
+ /**
4
+ * Private final context for semantic grouping. An oversized final entry is not skipped to find an older convenient
5
+ * question. The reader follows no link, holds a descriptor identity, and never reads an entire unbounded transcript.
6
+ */
7
+ export function readQuestionContext({ path, redact, fs = nodeFs, readBytes = 64 * 1024, excerptBytes = 16 * 1024 } = {}) {
8
+ if (typeof redact !== "function") return { status: "source-unavailable" };
9
+ let fd;
10
+ try {
11
+ const before = fs.lstatSync(path);
12
+ if (!before.isFile() || before.isSymbolicLink()) return { status: "source-unavailable" };
13
+ fd = fs.openSync(path, nodeFs.constants.O_RDONLY | (nodeFs.constants.O_NOFOLLOW ?? 0));
14
+ const stat = fs.fstatSync(fd);
15
+ if (!stat.isFile() || stat.dev !== before.dev || stat.ino !== before.ino) return { status: "source-unavailable" };
16
+ const offset = Math.max(0, stat.size - readBytes);
17
+ const buffer = Buffer.alloc(Math.min(stat.size, readBytes));
18
+ const bytes = fs.readSync(fd, buffer, 0, buffer.length, offset);
19
+ const after = fs.fstatSync(fd);
20
+ if (bytes !== buffer.length || after.size !== stat.size || after.mtimeMs !== stat.mtimeMs) return { status: "source-unavailable" };
21
+ const raw = buffer.toString("utf8");
22
+ const complete = offset > 0 ? raw.slice(raw.indexOf("\n") + 1) : raw;
23
+ if (offset > 0 && (!raw.includes("\n") || !complete.trim() && raw.trim())) return { status: "source-oversize" };
24
+ const clean = redact(complete);
25
+ if (typeof clean !== "string" || !clean.trim()) return { status: "source-unavailable" };
26
+ if (Buffer.byteLength(clean) <= excerptBytes) return { status: "ready", text: clean, truncated: offset > 0 };
27
+ let kept = "";
28
+ for (const line of clean.split("\n").reverse()) {
29
+ if (Buffer.byteLength(`${line}\n${kept}`) > excerptBytes) break;
30
+ kept = `${line}\n${kept}`;
31
+ }
32
+ return kept.trim() ? { status: "ready", text: kept, truncated: true } : { status: "source-oversize" };
33
+ } catch { return { status: "source-unavailable" }; }
34
+ finally { if (fd !== undefined) try { fs.closeSync(fd); } catch {} }
35
+ }
@@ -0,0 +1,91 @@
1
+ import * as nodeFs from "node:fs";
2
+ import { join } from "node:path";
3
+ import { parseTriggersDocument } from "./triggers.mjs";
4
+ import { questionDigest, QUESTION_RUN_RE, QUESTION_GROUP_LIMITS } from "./question-groups.mjs";
5
+
6
+ export const QUESTION_GROUP_REASONS = Object.freeze(["question-group-unavailable", "question-group-config-changed", "question-group-source-expired", "question-group-source-unavailable", "question-group-source-oversize", "question-group-candidates-expired", "question-group-limit", "question-group-stale", "question-group-refused", "question-group-unanswered", "question-group-output-invalid"]);
7
+ const policy = (reason) => ({ outcome: "policy", reason, budgetReserved: false });
8
+ const modelRef = (text) => { const slash = text.indexOf("/"); return { provider: text.slice(0, slash), id: text.slice(slash + 1) }; };
9
+ export const questionGroupConfigDigest = (config) => questionDigest(config);
10
+
11
+ /** Queue data is references only. Private context is non-enumerable because queue deferrals serialize job.data. */
12
+ export function makeQuestionGroupRuntime({ store, triggersPath, getSettings = () => ({}), resolveClassifier = () => null, readFile = nodeFs.readFileSync, fs = nodeFs } = {}) {
13
+ const config = () => parseTriggersDocument(readFile(triggersPath, "utf8"), triggersPath).zeroTouch;
14
+ return {
15
+ async resolve(data, runId) {
16
+ const ref = data?.questionGrouping;
17
+ // Admission deferrals can persist the resolved, text-free local wrapper. Rebuild it from the same narrow
18
+ // reference on every pickup; no queued execution setting or lost private property is reused.
19
+ if (!(data?.kind === "question-grouping" || data?.kind === "local" && data?.purpose === "zero-touch-grouping") || !ref || Object.keys(ref).sort().join() !== "configDigest,digest,runId" || !QUESTION_RUN_RE.test(ref.runId ?? "") || !/^[a-f0-9]{64}$/.test(ref.digest ?? "") || !/^[a-f0-9]{64}$/.test(ref.configDigest ?? "")) return { result: policy("question-group-refused") };
20
+ // Issue #761: a file a newer pi-dispatch wrote is `{ newer: <the store> }`, never a refusal, and is answered
21
+ // BEFORE any status is set: the processor defers the job (`newer-release`), and the grouping, queued with one
22
+ // attempt, is neither refused nor lost. The settings are read here, once, for that reason; a broken overlay is
23
+ // still refused below, where it always was.
24
+ const settings = getSettings();
25
+ if (settings?.invalid && settings.newer === true) return { newer: "the settings file" };
26
+ let current;
27
+ try { current = config(); } catch (error) { if (error?.newer === true) return { newer: "the triggers file" }; return { result: policy("question-group-config-changed") }; }
28
+ if (!current?.capture || questionGroupConfigDigest(current) !== ref.configDigest) return { result: policy("question-group-config-changed") };
29
+ const source = await store.privateInput(ref.runId);
30
+ if (source.status !== "ready") {
31
+ await store.setStatus(ref.runId, source.status);
32
+ const reason = ({ "source-expired": "question-group-source-expired", "source-unavailable": "question-group-source-unavailable", "source-oversize": "question-group-source-oversize", "candidates-expired": "question-group-candidates-expired", "group-limit": "question-group-limit" })[source.status] ?? "question-group-refused";
33
+ return { result: policy(reason) };
34
+ }
35
+ if (source.meta.digest !== ref.digest) return { result: policy("question-group-stale") };
36
+ if (settings.invalid || typeof settings.provider !== "string" || typeof settings.model !== "string") return { result: policy("question-group-refused") };
37
+ const classifier = modelRef(current.model);
38
+ let selected;
39
+ try { selected = await resolveClassifier(classifier); } catch { return { result: policy("question-group-refused") }; }
40
+ // The full reviewed context window is a conservative input-only price ceiling, not a guessed tokenizer.
41
+ // The runner still checks the exact call at its dispatch boundary before it can reach the provider.
42
+ if (!selected || selected.type !== "classifier" || !Number.isFinite(selected.cost?.input) || selected.cost.input < 0 || selected.cost.output !== 0 || !Number.isSafeInteger(selected.contextWindow) || selected.contextWindow <= 0) return { result: policy("question-group-refused") };
43
+ const boundMicros = Math.ceil(selected.contextWindow * selected.cost.input);
44
+ if (!Number.isSafeInteger(boundMicros) || boundMicros > Math.round(Number(current.maxCostUsd) * 1000000)) return { result: policy("question-group-refused") };
45
+ const grouping = { ...ref };
46
+ Object.defineProperties(grouping, {
47
+ input: { value: { version: 1, model: classifier, source: source.excerpt, candidates: source.candidates.map((candidate, i) => ({ slot: `g${i}`, excerpt: candidate.excerpt })), confidence: current.confidence } },
48
+ candidateIds: { value: source.candidates.map((candidate) => candidate.id) },
49
+ revision: { value: source.revision }, project: { value: source.meta.project }, jobId: { value: runId },
50
+ });
51
+ await store.setStatus(ref.runId, "pending");
52
+ return { data: { kind: "local", purpose: "zero-touch-grouping", provider: settings.provider, model: settings.model, maxCostUsd: current.maxCostUsd, models: [...new Set([`${settings.provider}/${settings.model}`, current.model])], maxTurns: 1, packages: false, github: false, questionGroupModel: classifier, questionGrouping: grouping } };
53
+ },
54
+ /** Collection never throws: neither invalid output nor a store failure is permission to buy the call again. */
55
+ async collect({ job, prepared, completed }) {
56
+ if (!job?.questionGrouping || !prepared?.outboxDir) return null;
57
+ const ref = job.questionGrouping;
58
+ try {
59
+ if (!completed) { await store.setStatus(ref.runId, "failed"); return { status: "failed" }; }
60
+ const current = config();
61
+ if (!current?.capture || questionGroupConfigDigest(current) !== ref.configDigest) { await store.setStatus(ref.runId, "stale"); return { status: "stale" }; }
62
+ const path = join(prepared.outboxDir, "question-group.json");
63
+ const stat = fs.lstatSync(path);
64
+ if (!stat.isFile() || stat.isSymbolicLink?.() || stat.size > 1024) throw new Error("invalid output");
65
+ const bytes = fs.readFileSync(path);
66
+ if (Buffer.byteLength(bytes) > 1024) throw new Error("invalid output");
67
+ const output = JSON.parse(bytes);
68
+ const allowed = ["new", "unassigned", ...ref.candidateIds.map((_, i) => `g${i}`)];
69
+ if (!output || Object.keys(output).sort().join() !== "confidence,slot,version" || output.version !== 1 || !allowed.includes(output.slot) || typeof output.confidence !== "number" || !Number.isFinite(output.confidence) || output.confidence < current.confidence || output.confidence > 1) throw new Error("invalid output");
70
+ if (output.slot === "unassigned") { await store.setStatus(ref.runId, "unassigned"); return { status: "unassigned" }; }
71
+ const groupId = output.slot === "new" ? null : ref.candidateIds[Number(output.slot.slice(1))];
72
+ const result = await store.assign({ runId: ref.runId, digest: ref.digest, revision: ref.revision, groupId });
73
+ if (result.status !== "assigned" && result.status !== "existing") await store.setStatus(ref.runId, "stale");
74
+ return result;
75
+ } catch { try { await store.setStatus(ref.runId, "failed"); } catch {} return { status: "failed" }; }
76
+ },
77
+ };
78
+ }
79
+
80
+ /** A classifier needs no repository tree or credentials; the existing container's /job and /outbox suffice. */
81
+ export function prepareQuestionGrouping(job, { jobDir, fs = nodeFs } = {}) {
82
+ const input = job?.questionGrouping?.input;
83
+ if (!input || Buffer.byteLength(JSON.stringify(input)) > QUESTION_GROUP_LIMITS.inputBytes) return policy("question-group-source-oversize");
84
+ const workspace = join(jobDir, "workspace");
85
+ const outboxDir = join(jobDir, "outbox");
86
+ fs.mkdirSync(workspace, { recursive: true }); fs.mkdirSync(outboxDir, { recursive: true });
87
+ fs.writeFileSync(join(jobDir, "prompt.md"), "", { mode: 0o444 });
88
+ fs.writeFileSync(join(jobDir, "event.json"), JSON.stringify({ source: "zero-touch-grouping" }), { mode: 0o444 });
89
+ fs.writeFileSync(join(jobDir, "question-grouping.json"), JSON.stringify(input), { mode: 0o444 });
90
+ return { workspace, jobDir, outboxDir, sha: null, materialised: [], workspaceReadOnly: true };
91
+ }
@@ -0,0 +1,171 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ /** Private question context and public assignment metadata are separate stores. A public read never fetches text. */
4
+ export const QUESTION_GROUP_LIMITS = Object.freeze({ excerptBytes: 16384, inputBytes: 262144, corpusEntries: 1000, metadataEntries: 5000, candidates: 14, retentionDays: 90, timeoutMs: 2000 });
5
+ export const QUESTION_GROUP_STATUSES = Object.freeze(["unassigned", "pending", "assigned", "source-expired", "source-unavailable", "source-oversize", "candidates-expired", "group-limit", "stale", "refused", "failed"]);
6
+ export const GROUP_ID_RE = /^[a-f0-9]{32}$/;
7
+ export const QUESTION_SCOPE_RE = /^[a-f0-9]{64}$/;
8
+ export const QUESTION_RUN_RE = /^[A-Za-z0-9._:-]{1,200}$/;
9
+ const sha = (value) => createHash("sha256").update(value).digest("hex");
10
+ const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
11
+ const integer = (value) => Number.isSafeInteger(value) && value >= 0;
12
+ const own = (value, keys) => object(value) && Object.keys(value).every((key) => keys.includes(key));
13
+ const safeWord = (value, max = 200) => typeof value === "string" && value.length <= max && /^[A-Za-z0-9._:/ @!#-]+$/.test(value);
14
+ export const questionDigest = (value) => sha(JSON.stringify(value));
15
+
16
+ /** No model-owned names or prompts live here. Names are operator-owned and never sent as classification criteria. */
17
+ export function validateZeroTouchConfig(value) {
18
+ if (value === undefined || value === null) return null;
19
+ if (!own(value, ["capture", "model", "maxCostUsd", "confidence", "names"]) || typeof value.capture !== "boolean") throw new Error("zeroTouch requires capture and only declared grouping settings");
20
+ if (typeof value.model !== "string" || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_./:@~+-]{1,128}$/.test(value.model)) throw new Error("zeroTouch.model requires an explicit provider/model");
21
+ if (typeof value.maxCostUsd !== "string" || !/^(?:0|[1-9]\d*)(?:\.\d{1,6})?$/.test(value.maxCostUsd) || !Number.isSafeInteger(Number(value.maxCostUsd) * 1e6)) throw new Error("zeroTouch.maxCostUsd must be a bounded dollar string");
22
+ const confidence = value.confidence ?? 0.9;
23
+ if (typeof confidence !== "number" || confidence <= 0.5 || confidence > 1) throw new Error("zeroTouch.confidence must be above 0.5 and at most 1");
24
+ const names = value.names ?? {};
25
+ if (!object(names) || Object.keys(names).length > 1000) throw new Error("zeroTouch.names must hold at most 1000 human names");
26
+ for (const [id, name] of Object.entries(names)) if (!GROUP_ID_RE.test(id) || typeof name !== "string" || !name.trim() || [...name].length > 120 || /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u.test(name)) throw new Error("zeroTouch group names must be human text of 1 to 120 characters without controls");
27
+ return { capture: value.capture, model: value.model, maxCostUsd: value.maxCostUsd, confidence, names: { ...names } };
28
+ }
29
+
30
+ /** Redact before bounding; silently cutting before redaction can expose the head of a secret. */
31
+ export function privateQuestionExcerpt(text, redact) {
32
+ if (typeof redact !== "function" || typeof text !== "string" || text === "") return { status: "source-unavailable" };
33
+ let clean;
34
+ try { clean = redact(text); } catch { return { status: "source-unavailable" }; }
35
+ if (typeof clean !== "string" || !clean.trim()) return { status: "source-unavailable" };
36
+ // The caller supplies the run's final bounded context, not a model-generated summary. Overlong context is
37
+ // refused rather than pretending the omitted half of a question was understood.
38
+ if (Buffer.byteLength(clean) > QUESTION_GROUP_LIMITS.excerptBytes) return { status: "source-oversize" };
39
+ return { status: "unassigned", excerpt: clean };
40
+ }
41
+
42
+ export const QUESTION_CAPTURE_SCRIPT = `
43
+ if redis.call("EXISTS", KEYS[2]) == 1 then return "existing" end
44
+ redis.call("SET", KEYS[1], ARGV[1], "PX", ARGV[4])
45
+ redis.call("SET", KEYS[2], ARGV[2], "PX", ARGV[5])
46
+ redis.call("ZADD", KEYS[3], ARGV[3], ARGV[6])
47
+ redis.call("ZADD", KEYS[4], ARGV[3], ARGV[6])
48
+ local cut = redis.call("ZRANGE", KEYS[3], 0, -tonumber(ARGV[7])-1)
49
+ for _, id in ipairs(cut) do redis.call("DEL", ARGV[9] .. id) end
50
+ if #cut > 0 then redis.call("ZREM", KEYS[3], unpack(cut)) end
51
+ redis.call("ZREMRANGEBYRANK", KEYS[4], 0, -tonumber(ARGV[8])-1)
52
+ redis.call("PEXPIRE", KEYS[3], ARGV[4]); redis.call("PEXPIRE", KEYS[4], ARGV[5])
53
+ return "captured"
54
+ `;
55
+ export const QUESTION_ASSIGN_SCRIPT = `
56
+ local old = redis.call("GET", KEYS[2])
57
+ if not old then return "source-expired" end
58
+ local meta = cjson.decode(old)
59
+ if meta.groupId then return "existing" end
60
+ if meta.digest ~= ARGV[1] then return "stale" end
61
+ local rev = tonumber(redis.call("HGET", KEYS[1], "revision") or "0")
62
+ if rev ~= tonumber(ARGV[2]) then return "stale" end
63
+ if ARGV[3] == "new" then
64
+ redis.call("HSET", KEYS[1], ARGV[4], ARGV[5], "revision", rev + 1)
65
+ elseif redis.call("HEXISTS", KEYS[1], ARGV[4]) == 0 then return "stale" end
66
+ meta.groupId = ARGV[4]; meta.status = "assigned"
67
+ redis.call("SET", KEYS[2], cjson.encode(meta), "PX", ARGV[6])
68
+ redis.call("PEXPIRE", KEYS[1], ARGV[6])
69
+ return "assigned"
70
+ `;
71
+
72
+ function bounded(promise, ms) {
73
+ let timer;
74
+ return Promise.race([Promise.resolve(promise), new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("question-store-timeout")), ms); })]).finally(() => clearTimeout(timer));
75
+ }
76
+ const json = (value) => { try { return JSON.parse(value); } catch { return null; } };
77
+ const validMeta = (m) => own(m, ["v", "runId", "scopeId", "project", "repo", "at", "digest", "status", "groupId", "triggerIndex", "contextTruncated"]) && m.v === 1 && QUESTION_RUN_RE.test(m.runId) && QUESTION_SCOPE_RE.test(m.scopeId) && integer(m.at) && /^[a-f0-9]{64}$/.test(m.digest) && QUESTION_GROUP_STATUSES.includes(m.status) && (m.groupId === undefined || GROUP_ID_RE.test(m.groupId)) && (m.project === null || safeWord(m.project, 64)) && (m.repo === null || safeWord(m.repo)) && (m.triggerIndex === undefined || integer(m.triggerIndex)) && (m.contextTruncated === undefined || typeof m.contextTruncated === "boolean");
78
+
79
+ export function makeQuestionGroupStore({ redis, now = Date.now, retentionDays = 30, prefix = "zero-touch:questions:", timeoutMs = QUESTION_GROUP_LIMITS.timeoutMs } = {}) {
80
+ const days = Number(retentionDays);
81
+ // Even keep-forever deployments have a finite private corpus. Its shorter availability is explicit.
82
+ const ttlMs = Math.min(Number.isFinite(days) && days > 0 ? days : QUESTION_GROUP_LIMITS.retentionDays, QUESTION_GROUP_LIMITS.retentionDays) * 86400000;
83
+ const metaTtlMs = Math.max(ttlMs, 86400000);
84
+ const token = (runId) => sha(runId);
85
+ const privateKey = (runId) => `${prefix}private:${token(runId)}`;
86
+ const metaKey = (runId) => `${prefix}meta:${token(runId)}`;
87
+ const command = (method, ...args) => bounded(redis[method](...args), timeoutMs);
88
+ async function readMeta(runId) {
89
+ if (!QUESTION_RUN_RE.test(runId ?? "")) return null;
90
+ const raw = await command("get", metaKey(runId));
91
+ if (typeof raw !== "string" || Buffer.byteLength(raw) > 2048) return null;
92
+ const meta = json(raw);
93
+ return validMeta(meta) ? meta : null;
94
+ }
95
+ return {
96
+ async capture({ runId, scopeId, project = null, repo = null, at = now(), text, redact, triggerIndex, sourceStatus = null, contextTruncated = false } = {}) {
97
+ if (!QUESTION_RUN_RE.test(runId ?? "") || !QUESTION_SCOPE_RE.test(scopeId ?? "") || !integer(at) || project !== null && !safeWord(project, 64) || repo !== null && !safeWord(repo) || triggerIndex !== undefined && !integer(triggerIndex)) return { ok: false, status: "source-unavailable" };
98
+ if (sourceStatus !== null && !["source-unavailable", "source-oversize"].includes(sourceStatus) || typeof contextTruncated !== "boolean") return { ok: false, status: "source-unavailable" };
99
+ const selected = sourceStatus === null ? privateQuestionExcerpt(text, redact) : { status: sourceStatus };
100
+ const digest = sha(selected.excerpt ?? `${runId}:${selected.status}`);
101
+ const meta = { v: 1, runId, scopeId, project, repo, at, digest, status: selected.status, ...(contextTruncated ? { contextTruncated: true } : {}), ...(triggerIndex !== undefined ? { triggerIndex } : {}) };
102
+ try {
103
+ if (selected.excerpt === undefined) {
104
+ await command("set", metaKey(runId), JSON.stringify(meta), "PX", metaTtlMs, "NX");
105
+ await command("zadd", `${prefix}index`, at, token(runId));
106
+ await command("pexpire", `${prefix}index`, metaTtlMs);
107
+ return { ok: true, status: selected.status };
108
+ }
109
+ await command("eval", QUESTION_CAPTURE_SCRIPT, 4, privateKey(runId), metaKey(runId), `${prefix}corpus`, `${prefix}index`, JSON.stringify({ v: 1, runId, scopeId, at, digest, excerpt: selected.excerpt }), JSON.stringify(meta), at, ttlMs, metaTtlMs, token(runId), QUESTION_GROUP_LIMITS.corpusEntries, QUESTION_GROUP_LIMITS.metadataEntries, `${prefix}private:`);
110
+ return { ok: true, status: "unassigned" };
111
+ } catch { return { ok: false, status: "source-unavailable" }; }
112
+ },
113
+ readMeta,
114
+ async publicSnapshot({ sinceMs = 0, names = {} } = {}) {
115
+ try {
116
+ const ids = await command("zrevrangebyscore", `${prefix}index`, "+inf", sinceMs, "LIMIT", 0, QUESTION_GROUP_LIMITS.metadataEntries);
117
+ if (!Array.isArray(ids) || ids.some((id) => !/^[a-f0-9]{64}$/.test(id))) throw new Error("invalid index");
118
+ const raw = ids.length ? await command("mget", ...ids.map((id) => `${prefix}meta:${id}`)) : [];
119
+ const rows = raw.map(json).filter(validMeta);
120
+ // Availability probes read only key existence. A public projection never loads private bytes.
121
+ for (let i = 0; i < rows.length; i += 100) {
122
+ await Promise.all(rows.slice(i, i + 100).map(async row => {
123
+ if (!row.groupId && ["unassigned", "pending", "failed", "stale"].includes(row.status) && !await command("exists", privateKey(row.runId))) row.status = "source-expired";
124
+ }));
125
+ }
126
+ const groups = new Map();
127
+ for (const row of rows) {
128
+ const id = row.groupId ?? `unassigned-${row.scopeId}`;
129
+ if (!groups.has(id)) groups.set(id, { id: row.groupId ?? null, name: row.groupId && typeof names[row.groupId] === "string" ? names[row.groupId] : null, scopeId: row.scopeId, repo: row.repo, project: row.project, count: 0, runIds: [], status: row.groupId ? "open" : "unassigned", sourceStatus: row.status, ...(row.triggerIndex !== undefined ? { triggerIndex: row.triggerIndex } : {}) });
130
+ const group = groups.get(id); group.count++; group.runIds.push(row.runId);
131
+ }
132
+ return { state: "ok", groups: [...groups.values()], rows, partial: rows.length !== raw.length || ids.length === QUESTION_GROUP_LIMITS.metadataEntries || rows.some(row => row.contextTruncated === true) };
133
+ } catch { return { state: "unavailable", groups: [], rows: [], partial: true }; }
134
+ },
135
+ async privateInput(runId) {
136
+ const meta = await readMeta(runId);
137
+ if (!meta || !["unassigned", "pending", "assigned", "failed", "stale", "refused"].includes(meta.status)) return { status: meta?.status ?? "source-expired" };
138
+ const raw = await command("get", privateKey(runId));
139
+ if (typeof raw !== "string" || Buffer.byteLength(raw) > QUESTION_GROUP_LIMITS.excerptBytes + 1024) return { status: "source-expired" };
140
+ const source = json(raw);
141
+ if (!object(source) || source.runId !== runId || source.scopeId !== meta.scopeId || source.digest !== meta.digest || typeof source.excerpt !== "string" || sha(source.excerpt) !== meta.digest) return { status: "source-unavailable" };
142
+ const groupRaw = await command("hgetall", `${prefix}groups:${meta.scopeId}`);
143
+ const revision = Number(groupRaw.revision ?? 0);
144
+ const candidates = [];
145
+ for (const [id, encoded] of Object.entries(groupRaw)) {
146
+ if (id === "revision") continue;
147
+ const candidate = json(encoded);
148
+ if (!GROUP_ID_RE.test(id) || !own(candidate, ["id", "runId", "at"]) || candidate.id !== id || !QUESTION_RUN_RE.test(candidate.runId) || !integer(candidate.at)) return { status: "source-unavailable" };
149
+ const exemplarRaw = await command("get", privateKey(candidate.runId));
150
+ const exemplar = typeof exemplarRaw === "string" && Buffer.byteLength(exemplarRaw) <= QUESTION_GROUP_LIMITS.excerptBytes + 1024 ? json(exemplarRaw) : null;
151
+ if (!exemplar || exemplar.scopeId !== meta.scopeId || typeof exemplar.excerpt !== "string" || sha(exemplar.excerpt) !== exemplar.digest) return { status: "candidates-expired" };
152
+ candidates.push({ id, runId: candidate.runId, excerpt: exemplar.excerpt });
153
+ }
154
+ if (candidates.length > QUESTION_GROUP_LIMITS.candidates) return { status: "group-limit" };
155
+ return { status: "ready", meta, revision, excerpt: source.excerpt, candidates: candidates.sort((a, b) => a.id.localeCompare(b.id)) };
156
+ },
157
+ async assign({ runId, digest, revision, groupId = null } = {}) {
158
+ const meta = await readMeta(runId);
159
+ if (!meta || meta.digest !== digest || !integer(revision) || groupId !== null && !GROUP_ID_RE.test(groupId)) return { status: "stale" };
160
+ const id = groupId ?? sha(`${meta.scopeId}\0${runId}`).slice(0, 32);
161
+ const status = await command("eval", QUESTION_ASSIGN_SCRIPT, 2, `${prefix}groups:${meta.scopeId}`, metaKey(runId), digest, revision, groupId === null ? "new" : "existing", id, JSON.stringify({ id, runId, at: meta.at }), metaTtlMs);
162
+ return { status, ...(status === "assigned" || status === "existing" ? { groupId: id } : {}) };
163
+ },
164
+ async setStatus(runId, status) {
165
+ if (!QUESTION_GROUP_STATUSES.includes(status)) return false;
166
+ if (!QUESTION_RUN_RE.test(runId ?? "")) return false;
167
+ const script = `local raw=redis.call("GET",KEYS[1]); if not raw then return 0 end; local meta=cjson.decode(raw); if meta.groupId then return 0 end; meta.status=ARGV[1]; redis.call("SET",KEYS[1],cjson.encode(meta),"PX",ARGV[2]); return 1`;
168
+ return await command("eval", script, 1, metaKey(runId), status, metaTtlMs) === 1;
169
+ },
170
+ };
171
+ }