@edgehero/pi-dispatch 4.3.1 → 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 (256) hide show
  1. package/.env.example +84 -3
  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 +190 -29
  54. package/src/connection.mjs +83 -7
  55. package/src/console-answer.mjs +113 -0
  56. package/src/console-audit.mjs +604 -0
  57. package/src/console-bootstrap.mjs +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 +1639 -196
  74. package/src/dollar-budget.mjs +212 -3
  75. package/src/endpoint-status.mjs +100 -0
  76. package/src/env-allowlist.mjs +96 -6
  77. package/src/env-file.mjs +6 -4
  78. package/src/file-expect.mjs +47 -0
  79. package/src/forgejo-host.mjs +42 -5
  80. package/src/forgejo-prompt.mjs +5 -4
  81. package/src/forges.mjs +33 -3
  82. package/src/fork-job.mjs +58 -0
  83. package/src/fork-policy.mjs +23 -0
  84. package/src/fork-store.mjs +338 -0
  85. package/src/get-token.mjs +1 -1
  86. package/src/git-dirty.mjs +119 -14
  87. package/src/git-hardening.mjs +85 -0
  88. package/src/github-host.mjs +31 -6
  89. package/src/github-prompt.mjs +7 -16
  90. package/src/gitlab-host.mjs +42 -3
  91. package/src/gitlab-prompt.mjs +5 -4
  92. package/src/held-document.mjs +23 -0
  93. package/src/held-replies.mjs +325 -0
  94. package/src/held-settle.mjs +266 -0
  95. package/src/hook-runs.mjs +188 -0
  96. package/src/host-budget.mjs +8 -5
  97. package/src/host-clone.mjs +72 -0
  98. package/src/host-loads.mjs +557 -0
  99. package/src/host-registry.mjs +66 -6
  100. package/src/host-telemetry.mjs +92 -0
  101. package/src/http-body.mjs +78 -0
  102. package/src/image-preflight.mjs +156 -13
  103. package/src/import-pi.mjs +60 -172
  104. package/src/inbox-relay.mjs +482 -0
  105. package/src/inbox-settle.mjs +196 -0
  106. package/src/index.mjs +734 -129
  107. package/src/init.mjs +35 -5
  108. package/src/job-data-version.mjs +75 -0
  109. package/src/job-id.mjs +30 -15
  110. package/src/job-layout.mjs +22 -0
  111. package/src/job-size.mjs +16 -0
  112. package/src/live-jobs.mjs +14 -6
  113. package/src/live-probes.mjs +6 -2
  114. package/src/live-transcript.mjs +872 -0
  115. package/src/loaded-record.mjs +609 -0
  116. package/src/mcp-config.mjs +1 -1
  117. package/src/mcp-preflight.mjs +36 -5
  118. package/src/model-attempts.mjs +84 -0
  119. package/src/model-catalog.mjs +83 -0
  120. package/src/model-cooldowns.mjs +22 -0
  121. package/src/model-failover-protocol.mjs +11 -0
  122. package/src/model-failover.mjs +134 -0
  123. package/src/model-observation.mjs +50 -0
  124. package/src/model-proposal-protocol.mjs +21 -0
  125. package/src/model-proposals.mjs +75 -0
  126. package/src/model-ref.mjs +67 -10
  127. package/src/model-roles-file.mjs +24 -0
  128. package/src/model-roles.mjs +73 -0
  129. package/src/model-status.mjs +34 -0
  130. package/src/models-json.mjs +4 -1
  131. package/src/money.mjs +1 -1
  132. package/src/oauth-openai.mjs +121 -0
  133. package/src/older-state.mjs +249 -0
  134. package/src/on-failure.mjs +17 -13
  135. package/src/open-browser.mjs +31 -2
  136. package/src/opener-cli.mjs +135 -0
  137. package/src/opener-protocol.mjs +27 -0
  138. package/src/opener-terminal.mjs +128 -0
  139. package/src/openshell-cli.mjs +155 -0
  140. package/src/openshell-policy.mjs +163 -0
  141. package/src/outbox.mjs +14 -0
  142. package/src/output-cap.mjs +1 -0
  143. package/src/package-canary-record.mjs +26 -0
  144. package/src/package-canary.mjs +135 -0
  145. package/src/package-names.mjs +9 -0
  146. package/src/package-stage.mjs +418 -0
  147. package/src/packages.mjs +24 -16
  148. package/src/parked.mjs +2 -1
  149. package/src/pause-windows.mjs +136 -17
  150. package/src/podman-stack.mjs +40 -14
  151. package/src/portfolio-snapshot.mjs +26 -7
  152. package/src/prepare-github.mjs +41 -77
  153. package/src/prepare-local.mjs +32 -2
  154. package/src/prepare.mjs +45 -11
  155. package/src/private-store-boundary.mjs +70 -0
  156. package/src/processor.mjs +1550 -173
  157. package/src/projects.mjs +23 -0
  158. package/src/prompt-fence.mjs +18 -0
  159. package/src/provider-key.mjs +7 -0
  160. package/src/provider-steering.mjs +1 -0
  161. package/src/question-context.mjs +35 -0
  162. package/src/question-group-runtime.mjs +91 -0
  163. package/src/question-groups.mjs +171 -0
  164. package/src/queue.mjs +264 -55
  165. package/src/reason-catalog.mjs +448 -0
  166. package/src/receiver-status.mjs +230 -0
  167. package/src/record-reason.mjs +37 -0
  168. package/src/recovery-queue.mjs +566 -0
  169. package/src/redact-secrets.mjs +219 -0
  170. package/src/reserve-estimate.mjs +101 -0
  171. package/src/reserved-env.mjs +36 -1
  172. package/src/retention.mjs +67 -0
  173. package/src/run-budget-ledger.mjs +283 -0
  174. package/src/run-checkpoint.mjs +627 -0
  175. package/src/run-container.mjs +749 -33
  176. package/src/run-history.mjs +791 -45
  177. package/src/run-mirror.mjs +75 -7
  178. package/src/run-progress.mjs +751 -0
  179. package/src/runtime-observations.mjs +3 -1
  180. package/src/runtime-settings.mjs +272 -37
  181. package/src/sandbox-cli.mjs +78 -2
  182. package/src/sandbox-deployment.mjs +51 -0
  183. package/src/sandbox-gateway-protocol.mjs +378 -0
  184. package/src/sandbox-hosts.mjs +100 -0
  185. package/src/sandbox-inventory.mjs +83 -0
  186. package/src/sandbox-policy.mjs +225 -0
  187. package/src/sandbox-prepare-requests.mjs +145 -0
  188. package/src/sandbox-prepare.mjs +285 -0
  189. package/src/sandbox-refs.mjs +70 -0
  190. package/src/sandbox-session-monitor.mjs +4 -0
  191. package/src/sandbox-session-watch.mjs +124 -0
  192. package/src/sandbox-sessions.mjs +161 -0
  193. package/src/sandbox-store.mjs +63 -6
  194. package/src/sandbox-workspace-guard.mjs +39 -0
  195. package/src/sandbox.mjs +27 -200
  196. package/src/scheduler-stall-guard.mjs +14 -8
  197. package/src/schedules.mjs +19 -6
  198. package/src/secret-bundles.mjs +245 -0
  199. package/src/secret-profiles.mjs +103 -3
  200. package/src/secrets.mjs +19 -9
  201. package/src/service-env.mjs +38 -5
  202. package/src/service.mjs +638 -106
  203. package/src/session-key.mjs +60 -0
  204. package/src/session-store.mjs +168 -10
  205. package/src/setup-contract.mjs +40 -0
  206. package/src/setup-egress.mjs +113 -0
  207. package/src/setup-host-entry.mjs +33 -0
  208. package/src/setup-host.mjs +242 -0
  209. package/src/setup-lock.mjs +41 -0
  210. package/src/size-records.mjs +15 -3
  211. package/src/start.mjs +1353 -135
  212. package/src/status-comment.mjs +415 -0
  213. package/src/stderr-transcript.mjs +318 -0
  214. package/src/steer-ack.mjs +122 -0
  215. package/src/steer-cli.mjs +213 -0
  216. package/src/steer-framing.mjs +185 -0
  217. package/src/steer-inbox.mjs +693 -0
  218. package/src/store-version.mjs +139 -0
  219. package/src/subscription-config.mjs +56 -0
  220. package/src/subscription-limits.mjs +3 -0
  221. package/src/subscription-quota.mjs +259 -0
  222. package/src/subscription-runtime.mjs +123 -0
  223. package/src/subscription-status.mjs +42 -0
  224. package/src/subscription-wait.mjs +11 -0
  225. package/src/subscriptions.mjs +7 -1
  226. package/src/thinking-level.mjs +19 -0
  227. package/src/touch-outbox.mjs +61 -0
  228. package/src/touch-state.mjs +133 -0
  229. package/src/triggers-file.mjs +299 -39
  230. package/src/triggers.mjs +780 -38
  231. package/src/trust-paths.mjs +171 -0
  232. package/src/up.mjs +398 -51
  233. package/src/updates-cli.mjs +61 -0
  234. package/src/updates-look.mjs +127 -0
  235. package/src/updates-reconcile.mjs +182 -0
  236. package/src/updates-subscription-adapters.mjs +8 -0
  237. package/src/updates.mjs +252 -0
  238. package/src/valkey-auth.mjs +32 -9
  239. package/src/valkey-endpoint.mjs +14 -4
  240. package/src/wait-for.mjs +1 -1
  241. package/src/wait-state.mjs +62 -3
  242. package/src/watch-board.mjs +38 -0
  243. package/src/watch-closer.mjs +1 -1
  244. package/src/watch-contract.mjs +102 -0
  245. package/src/watch-decision.mjs +78 -0
  246. package/src/watch-drainer.mjs +14 -0
  247. package/src/watch-issue-adapter.mjs +44 -0
  248. package/src/watch-issue.mjs +111 -0
  249. package/src/watch-record.mjs +10 -0
  250. package/src/watch-runtime.mjs +167 -0
  251. package/src/watch-store.mjs +183 -0
  252. package/src/watch-trace.mjs +34 -0
  253. package/src/workspace-checkpoint.mjs +1750 -0
  254. package/src/zero-touch-fixes.mjs +99 -0
  255. package/src/zero-touch.mjs +225 -0
  256. package/src/repeat-slot.mjs +0 -13
@@ -0,0 +1,99 @@
1
+ import { createHash } from "node:crypto";
2
+ import { GROUP_ID_RE, QUESTION_RUN_RE, QUESTION_SCOPE_RE } from "./question-groups.mjs";
3
+ import { REASONS } from "./reason-catalog.mjs";
4
+ export const FIX_KINDS = Object.freeze(["instructions", "config"]);
5
+ const DAY = 86400000;
6
+ const idOf = value => createHash("sha256").update(JSON.stringify(value)).digest("hex").slice(0, 32);
7
+ export function validFix(value) {
8
+ return value && Object.keys(value).every(key => ["id", "kind", "at", "runId", "scopeId", "project", "groupId", "reason", "triggerIndex", "undoneAt", "state"].includes(key)) && /^[a-f0-9]{32}$/.test(value.id) && FIX_KINDS.includes(value.kind) && (value.state === undefined || ["pending", "applied", "refused"].includes(value.state)) && Number.isSafeInteger(value.at) && value.at >= 0 && QUESTION_RUN_RE.test(value.runId) && QUESTION_SCOPE_RE.test(value.scopeId) && (value.project === null || typeof value.project === "string" && /^[a-z][a-z0-9_-]{0,63}$/.test(value.project)) && ((GROUP_ID_RE.test(value.groupId ?? "") && value.reason === null) || (value.groupId === null && Object.hasOwn(REASONS, value.reason))) && (value.triggerIndex === null || Number.isSafeInteger(value.triggerIndex) && value.triggerIndex >= 0) && (value.undoneAt === undefined || Number.isSafeInteger(value.undoneAt) && value.undoneAt >= value.at);
9
+ }
10
+ /** Safe fix telemetry is public; the exact human instruction used for Undo is private. */
11
+ export function makeZeroTouchFixStore({ redis, now = Date.now, retentionDays = 30, prefix = "zero-touch:fixes:" } = {}) {
12
+ const bounded = promise => { let timer; return Promise.race([Promise.resolve(promise), new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("fix-store-timeout")), 2000); })]).finally(() => clearTimeout(timer)); };
13
+ const command = (method, ...args) => bounded(redis[method](...args));
14
+ const ttl = Math.min(Number(retentionDays) > 0 ? Number(retentionDays) : 365, 365) * DAY;
15
+ return {
16
+ async add(value, { privateUndo = null, identity = null, pending = false } = {}) {
17
+ const fix = { id: idOf(identity ?? value), kind: value.kind, at: value.at ?? now(), runId: value.runId, scopeId: value.scopeId, project: value.project ?? null, groupId: value.groupId ?? null, reason: value.reason ?? null, triggerIndex: value.triggerIndex ?? null, state: pending ? "pending" : "applied" };
18
+ if (!validFix(fix)) return { invalid: "the fix has no validated source cause" };
19
+ const tx = redis.multi();
20
+ tx.set(`${prefix}meta:${fix.id}`, JSON.stringify(fix), "PX", ttl, "NX");
21
+ if (privateUndo) tx.set(`${prefix}private:${fix.id}`, JSON.stringify(privateUndo), "PX", ttl, "NX");
22
+ tx.zadd(`${prefix}index`, fix.at, fix.id); tx.zremrangebyrank(`${prefix}index`, 0, -5001); tx.pexpire(`${prefix}index`, ttl);
23
+ const result = await bounded(tx.exec());
24
+ if (!Array.isArray(result) || result.some(row => row[0])) throw new Error("fix metadata unavailable");
25
+ return { ok: true, fix };
26
+ },
27
+ async read(id, { includeUndo = false } = {}) {
28
+ if (!/^[a-f0-9]{32}$/.test(id ?? "")) return null;
29
+ const raw = await command("get", `${prefix}meta:${id}`);
30
+ let fix; try { if (typeof raw !== "string" || raw.length > 2048) return null; fix = JSON.parse(raw); } catch { return null; }
31
+ if (!validFix(fix)) return null;
32
+ if (!includeUndo) return fix;
33
+ const body = await command("get", `${prefix}private:${id}`);
34
+ let undo; try { if (typeof body !== "string" || Buffer.byteLength(body) > 8192) return null; undo = JSON.parse(body); } catch { return null; }
35
+ if (!undo) return null;
36
+ const instructionProof = Object.keys(undo).sort().join() === "after,before,index,triggerDigest" && Number.isSafeInteger(undo.index) && (undo.before === null || typeof undo.before === "string") && typeof undo.after === "string" && /^[a-f0-9]{64}$/.test(undo.triggerDigest ?? "");
37
+ const fileProof = Object.keys(undo).sort().join() === "afterDigest,file" && typeof undo.file === "string" && undo.file.length <= 4096 && !/[\u0000-\u001f]/u.test(undo.file) && /^[a-f0-9]{64}$/.test(undo.afterDigest ?? "");
38
+ if (!instructionProof && !fileProof) return null;
39
+ return { ...fix, undo };
40
+ },
41
+ async publicSnapshot({ sinceMs = 0 } = {}) {
42
+ try {
43
+ const ids = await command("zrevrangebyscore", `${prefix}index`, "+inf", sinceMs, "LIMIT", 0, 5000);
44
+ const fixes = (await Promise.all(ids.map(id => this.read(id)))).filter(Boolean);
45
+ const pendingCount = fixes.filter(fix => fix.state === "pending").length;
46
+ return { state: "ok", fixes: fixes.filter(fix => fix.state === undefined || fix.state === "applied"), pendingCount, partial: fixes.length !== ids.length || ids.length === 5000 || pendingCount > 0 };
47
+ } catch { return { state: "unavailable", fixes: [], partial: true }; }
48
+ },
49
+ async stage(value, options = {}) { return this.add(value, { ...options, pending: true }); },
50
+ async commit(id) {
51
+ const fix = await this.read(id); if (!fix || fix.state === "refused") return false;
52
+ await command("set", `${prefix}meta:${id}`, JSON.stringify({ ...fix, state: "applied" }), "PX", ttl); return true;
53
+ },
54
+ async reject(id) {
55
+ const fix = await this.read(id); if (!fix || fix.state !== "pending") return false;
56
+ await command("set", `${prefix}meta:${id}`, JSON.stringify({ ...fix, state: "refused" }), "PX", ttl); return true;
57
+ },
58
+ async repair({ readTrigger, readFile } = {}) {
59
+ if (typeof readTrigger !== "function" && typeof readFile !== "function") return;
60
+ const ids = await command("zrevrangebyscore", `${prefix}index`, "+inf", 0, "LIMIT", 0, 5000);
61
+ for (const id of ids) {
62
+ const fix = await this.read(id, { includeUndo: true });
63
+ if (!fix || fix.state !== "pending") continue;
64
+ try {
65
+ if (fix.undo.file && typeof readFile === "function") {
66
+ // The caller owns the configuration path allowlist. A peer-written path cannot choose a read here.
67
+ const bytes = await readFile(fix.undo.file);
68
+ if ((typeof bytes === "string" || Buffer.isBuffer(bytes)) && createHash("sha256").update(bytes).digest("hex") === fix.undo.afterDigest) await this.commit(id);
69
+ } else if (typeof readTrigger === "function") {
70
+ const current = await readTrigger(fix.undo.index);
71
+ if (current && createHash("sha256").update(JSON.stringify(current)).digest("hex") === fix.undo.triggerDigest) await this.commit(id);
72
+ }
73
+ } catch { /* An unprovable outcome remains pending, never replayed. */ }
74
+ }
75
+ },
76
+ async markUndone(id, at = now()) {
77
+ const fix = await this.read(id); if (!fix || fix.undoneAt) return false;
78
+ await command("set", `${prefix}meta:${id}`, JSON.stringify({ ...fix, undoneAt: at }), "PX", ttl); return true;
79
+ },
80
+ };
81
+ }
82
+
83
+ /** Occurrences, not touch attribution: an unanswered question or untouched refusal still demonstrates recurrence. */
84
+ export function compareZeroTouchFixes({ fixes = [], records = [], assignments = [], coverageFromMs, nowMs, windowDays = 30 } = {}) {
85
+ const at = row => Date.parse(row.endedAt ?? "");
86
+ return fixes.filter(validFix).map(fix => {
87
+ const spanMs = Math.max(0, Math.min(windowDays * DAY, nowMs - fix.at, fix.undoneAt === undefined ? Infinity : fix.undoneAt - fix.at));
88
+ const beforeFrom = fix.at - spanMs, afterTo = fix.at + spanMs;
89
+ const sameScope = row => (row.touchScopeId ?? row.scopeId) === fix.scopeId;
90
+ const inSpan = (value, from, to) => value >= from && value < to;
91
+ const matches = fix.groupId ? assignments.filter(row => row.groupId === fix.groupId && (row.scopeId === fix.scopeId)) : records.filter(row => sameScope(row) && row.reason === fix.reason);
92
+ const time = row => Number.isSafeInteger(row.at) ? row.at : at(row);
93
+ const beforeCount = matches.filter(row => inSpan(time(row), beforeFrom, fix.at)).length;
94
+ const afterCount = matches.filter(row => inSpan(time(row), fix.at, afterTo)).length;
95
+ const subsequentRuns = records.filter(row => sameScope(row) && row.purpose !== "zero-touch-grouping" && inSpan(at(row), fix.at, afterTo)).length;
96
+ const partial = !Number.isFinite(coverageFromMs) || beforeFrom < coverageFromMs || spanMs <= 0;
97
+ return { ...fix, spanMs, beforeCount, afterCount, subsequentRuns, partial, status: partial || subsequentRuns === 0 || fix.undoneAt !== undefined ? "observing" : afterCount < beforeCount ? "improved" : afterCount > beforeCount ? "worse" : "unchanged" };
98
+ });
99
+ }
@@ -0,0 +1,225 @@
1
+ import { readCapacityRecords } from "./capacity-records.mjs";
2
+ import { RECORD_CLOCK_SKEW_MS } from "./run-history.mjs";
3
+ import { TOUCH_CAUSES, WAIT_END_REASONS, makeTouchStore } from "./touch-state.mjs";
4
+
5
+ const DAY = 86400_000;
6
+ const ID = /^[A-Za-z0-9._:-]{1,200}$/;
7
+ const time = (v) => typeof v === "number" ? v : Date.parse(v ?? "");
8
+ const validTime = (v) => Number.isSafeInteger(v) && v >= 0;
9
+ const percentage = (touched, runs) => runs > 0 ? touched / runs * 100 : null;
10
+ const emptyCauses = () => TOUCH_CAUSES.map((cause) => ({ cause, count: 0 }));
11
+ const median = (values) => { if (!values.length) return null; const sorted = [...values].sort((a, b) => a - b); const i = Math.floor(sorted.length / 2); return sorted.length % 2 ? sorted[i] : (sorted[i - 1] + sorted[i]) / 2; };
12
+ const scopeOf = (record) => record.touchScopeId ?? record.scopeId ?? `legacy:${record.kind ?? "unknown"}:${record.target ?? "unknown"}`;
13
+ // A configured project may be named "unassigned". Only this reserved query value means no project;
14
+ // it cannot collide with the ordinary project-id grammar and never enters a record or touch event.
15
+ export const ZERO_TOUCH_NO_PROJECT = "~";
16
+ export const zeroTouchProjectMatches = (recordProject, project) => project === null || project === undefined || (project === ZERO_TOUCH_NO_PROJECT ? recordProject === null || recordProject === undefined : recordProject === project);
17
+ const repoLabel = (record) => {
18
+ if (record.kind === "local" || record.target?.startsWith("local:")) return record.target ?? "Local folder";
19
+ return typeof record.target === "string" ? record.target.replace(/(?:#|!)\d+$/, "") : "Repository not recorded";
20
+ };
21
+
22
+ /**
23
+ * A recount, independent of the console and of audit history. The requested interval is preserved even when only its
24
+ * covered suffix is measurable. Legacy records cannot be presumed untouched: their missing lineage is not evidence.
25
+ */
26
+ export function computeZeroTouch({ records = [], events = [], stops = [], fixes = [], groups = [], nowMs, windowDays = 30, coverage = {}, project = null, scopeId = null } = {}) {
27
+ if (!validTime(nowMs)) throw new TypeError("nowMs must be an injected non-negative millisecond clock");
28
+ if (![7, 30, 90].includes(windowDays)) throw new TypeError("windowDays must be 7, 30 or 90");
29
+ const requestedFromMs = Math.max(0, nowMs - windowDays * DAY);
30
+ const reasons = [...new Set(Array.isArray(coverage.reasons) ? coverage.reasons : [])];
31
+ const byId = new Map();
32
+ for (const r of records) {
33
+ if (!r || !ID.test(r.jobId ?? "") || !validTime(time(r.endedAt)) || time(r.endedAt) > nowMs || ["zero-touch-grouping", "answer-draft"].includes(r.purpose)) continue;
34
+ const prior = byId.get(r.jobId);
35
+ if (!prior || time(prior.endedAt) < time(r.endedAt)) byId.set(r.jobId, r);
36
+ }
37
+ let fromMs = Math.min(nowMs, Math.max(0, Number.isFinite(coverage.fromMs) ? coverage.fromMs : nowMs));
38
+ const legacy = [...byId.values()].filter((r) => r.touchTrackingVersion !== 1 && time(r.endedAt) >= fromMs);
39
+ if (legacy.length) { fromMs = Math.min(nowMs, Math.max(fromMs, ...legacy.map((r) => time(r.endedAt) + 1))); if (legacy.some((r) => time(r.endedAt) >= requestedFromMs)) reasons.push("legacy-touch-history-incomplete"); }
40
+ const selected = [...byId.values()].filter((r) => r.touchTrackingVersion === 1 && zeroTouchProjectMatches(r.project, project) && (scopeId === null || scopeOf(r) === scopeId));
41
+ if (selected.some((r) => r.touchLineageSkipped === "source-unavailable" && time(r.endedAt) >= fromMs)) {
42
+ // A resumed answer with an unprovable parent may have touched any retained park in this scope. It
43
+ // cannot be counted as an untouched run, or leave its unknown parent looking untouched either.
44
+ fromMs = nowMs;
45
+ reasons.push("answer-source-unavailable");
46
+ }
47
+ const sourceEvents = [...events, ...[...byId.values()].flatMap((r) => Array.isArray(r.touches) ? r.touches : [])];
48
+ const allEvents = sourceEvents.filter((e) => e?.type !== "stop" && e?.type !== "wait-end" && TOUCH_CAUSES.includes(e?.cause) && ID.test(e.runId ?? "") && validTime(e.at) && e.at <= nowMs);
49
+ // A run's touches start when its job was created (issue #811). A job id comes back after a lost queue and a hand
50
+ // redelivery, while the touch index keeps an older job's events under that id, so an event counts against a run only
51
+ // when it is no earlier than the run's `jobCreatedAt`, less the clock skew between the host that stamped the event
52
+ // and the one that created the job. A lower bound only: a late answer, an approval and a re-run come after the run
53
+ // ended and are its own. Not the record's own start (`startedAt` is this attempt's, and a touch during an earlier,
54
+ // stalled attempt is this run's too), and not `queuedAt` (null on a retry, a stall and a `run.waitFor` job). A record
55
+ // without the field (written before it, or for a job with no creation time) applies no bound, as before. Every
56
+ // per-run read is held to it: the wait ends, the record's own fallback evidence below and the per-run counts. The
57
+ // scope and project streaks read every event, since an older job's intervention in a repository is still activity
58
+ // in that repository.
59
+ const createdAtOf = new Map([...byId.values()].map((r) => [r.jobId, time(r.jobCreatedAt)]).filter(([, at]) => validTime(at)));
60
+ const ownEvent = (e) => { const createdAt = createdAtOf.get(e.runId); return createdAt === undefined || e.at >= createdAt - RECORD_CLOCK_SKEW_MS; };
61
+ const waitEndsByRun = new Map();
62
+ for (const event of sourceEvents) {
63
+ if (event?.type !== "wait-end" || !WAIT_END_REASONS.includes(event.reason) || !ID.test(event.runId ?? "") || !validTime(event.at) || event.at > nowMs || !ownEvent(event)) continue;
64
+ if (!waitEndsByRun.has(event.runId) || event.at < waitEndsByRun.get(event.runId)) waitEndsByRun.set(event.runId, event.at);
65
+ }
66
+ // A terminal cancel and the authenticated delivery counter are durable fallback evidence when their index write
67
+ // is being repaired. The index supplies the actual intervention time; record end is used only for older evidence.
68
+ // Only the run's own index event stands in for it: an older job's event of that cause under the id is not one.
69
+ for (const r of selected) {
70
+ for (const cause of [...(r.reason === "operator-cancel" ? ["cancel"] : []), ...(r.steered?.delivered > 0 ? ["steer"] : [])]) {
71
+ if (!allEvents.some((e) => e.runId === r.jobId && e.cause === cause && ownEvent(e))) allEvents.push({ runId: r.jobId, cause, at: time(r.endedAt), project: r.project ?? null, scopeId: scopeOf(r) });
72
+ }
73
+ }
74
+ const eventsByRun = new Map();
75
+ for (const e of allEvents) {
76
+ if (!ownEvent(e)) continue;
77
+ if (!eventsByRun.has(e.runId)) eventsByRun.set(e.runId, []);
78
+ eventsByRun.get(e.runId).push(e);
79
+ }
80
+ for (const list of eventsByRun.values()) list.sort((a, b) => a.at - b.at || TOUCH_CAUSES.indexOf(a.cause) - TOUCH_CAUSES.indexOf(b.cause));
81
+ const inInterval = (rows, start, end) => rows.filter((r) => time(r.endedAt) >= start && time(r.endedAt) < end);
82
+ const count = (rows) => {
83
+ const touched = rows.filter((r) => eventsByRun.has(r.jobId));
84
+ const causes = emptyCauses();
85
+ for (const r of touched) causes.find((x) => x.cause === eventsByRun.get(r.jobId)[0].cause).count++;
86
+ return { runs: rows.length, touched: touched.length, rate: percentage(touched.length, rows.length), causes, touchedRunIds: touched.map((r) => r.jobId).sort() };
87
+ };
88
+ const intervalFrom = Math.max(requestedFromMs, fromMs);
89
+ const currentRows = inInterval(selected, intervalFrom, nowMs);
90
+ const current = count(currentRows);
91
+ const previousFrom = requestedFromMs - windowDays * DAY;
92
+ const previous = fromMs <= previousFrom && !coverage.partial ? count(inInterval(selected, previousFrom, requestedFromMs)) : null;
93
+ const waitsOf = (rows, start, end) => {
94
+ let waitingMs = 0;
95
+ const completed = [];
96
+ const pending = [];
97
+ let unknown = 0;
98
+ for (const r of rows) {
99
+ if (!["awaiting-input", "awaiting-approval"].includes(r.reason)) continue;
100
+ const parkedAt = validTime(r.parkedAt) ? r.parkedAt : time(r.endedAt);
101
+ if (parkedAt > end) continue;
102
+ const answer = (eventsByRun.get(r.jobId) ?? []).find((e) => e.cause === "question-answer" || e.cause === "approval-decision");
103
+ const answeredAt = answer ? Math.max(parkedAt, answer.receivedAt ?? answer.at) : null;
104
+ const terminal = r.reason === "awaiting-approval" ? waitEndsByRun.get(r.jobId) : undefined;
105
+ const closedAt = answeredAt === null && terminal === undefined ? null : Math.max(parkedAt, Math.min(answeredAt ?? Infinity, terminal ?? Infinity));
106
+ // An old park can outlive the index entry that ended it. Without a retained terminal answer,
107
+ // absence before the coverage epoch proves neither an open wait nor a completed one.
108
+ if (closedAt === null && parkedAt < fromMs) { unknown++; continue; }
109
+ waitingMs += Math.max(0, Math.min(end, closedAt ?? end) - Math.max(start, parkedAt));
110
+ // The median describes a person's answer. An automatic deadline/revocation closes the wait,
111
+ // but it must not manufacture either an answer sample or a human intervention.
112
+ if (answeredAt !== null && closedAt === answeredAt && answeredAt >= start && answeredAt < end) completed.push(answeredAt - parkedAt);
113
+ if (closedAt === null) pending.push({ runId: r.jobId, parkedAt, project: r.project ?? null, scopeId: scopeOf(r) });
114
+ }
115
+ return { waitingHours: waitingMs / 3600_000, medianWaitMs: median(completed), pending, unknown, partial: unknown > 0 };
116
+ };
117
+ const waits = waitsOf(selected, intervalFrom, nowMs);
118
+ const rowMetric = (rows, name, extra) => {
119
+ const cur = count(inInterval(rows, intervalFrom, nowMs));
120
+ const prev = previous ? count(inInterval(rows, previousFrom, requestedFromMs)) : null;
121
+ const ids = new Set(rows.map((r) => r.jobId));
122
+ // Repository rows also carry their project's id. Only project rows may use it to find events whose
123
+ // source record has expired; otherwise activity in one repository resets every sibling's streak.
124
+ const related = allEvents.filter((e) => ids.has(e.runId) || (extra.scopeId ? e.scopeId === extra.scopeId : extra.project && e.project === extra.project));
125
+ const lastTouch = related.length ? Math.max(...related.map((e) => e.at)) : null;
126
+ const waiting = waitsOf(rows, intervalFrom, nowMs);
127
+ const pending = waiting.pending;
128
+ const observedSince = Math.max(fromMs, lastTouch ?? fromMs);
129
+ const streakDays = waiting.partial ? null : pending.length ? 0 : Math.max(0, Math.floor((nowMs - observedSince) / DAY));
130
+ const topCause = [...cur.causes].sort((a, b) => b.count - a.count || TOUCH_CAUSES.indexOf(a.cause) - TOUCH_CAUSES.indexOf(b.cause))[0];
131
+ return { ...extra, name, ...cur, previousRate: prev?.rate ?? null, deltaPoints: cur.rate !== null && prev?.rate !== null && prev?.rate !== undefined ? cur.rate - prev.rate : null,
132
+ streakDays, zeroTouch: !waiting.partial && streakDays >= 30 && fromMs <= nowMs - 30 * DAY && !coverage.partial && rows.some((r) => time(r.endedAt) >= nowMs - 30 * DAY),
133
+ waiting: waiting.partial ? null : pending.length, waitingNow: waiting.partial ? null : pending.length, waitingPartial: waiting.partial, topCause: topCause?.count > 0 ? topCause.cause : null, lastTouchAt: lastTouch };
134
+ };
135
+ const projectGroups = new Map();
136
+ for (const r of selected) { const id = r.project ?? ZERO_TOUCH_NO_PROJECT; if (!projectGroups.has(id)) projectGroups.set(id, []); projectGroups.get(id).push(r); }
137
+ const projects = [...projectGroups].map(([id, rows]) => {
138
+ const repoGroups = new Map();
139
+ for (const r of rows) { const scope = scopeOf(r); if (!repoGroups.has(scope)) repoGroups.set(scope, []); repoGroups.get(scope).push(r); }
140
+ const repositories = [...repoGroups].map(([scope, repoRows]) => rowMetric(repoRows, repoLabel(repoRows[0]), { scopeId: scope, target: repoLabel(repoRows[0]), project: id }));
141
+ return { ...rowMetric(rows, id === ZERO_TOUCH_NO_PROJECT ? "No project recorded" : id, { project: id }), repositories };
142
+ }).sort((a, b) => a.project.localeCompare(b.project));
143
+ const stopped = new Map();
144
+ const stoppedUnattributed = new Map();
145
+ const projectsByScope = new Map();
146
+ for (const record of byId.values()) {
147
+ const key = scopeOf(record);
148
+ if (!projectsByScope.has(key)) projectsByScope.set(key, new Set());
149
+ projectsByScope.get(key).add(record.project ?? null);
150
+ }
151
+ for (const e of [...events.filter((e) => e?.type === "stop"), ...stops]) {
152
+ const id = e?.runId ?? e?.jobId;
153
+ if (!ID.test(id ?? "") || !validTime(e.at) || e.at < intervalFrom || e.at >= nowMs) continue;
154
+ const knownProjects = e.scopeId ? projectsByScope.get(e.scopeId) : null;
155
+ const knownProject = e.project ?? (knownProjects?.size === 1 ? [...knownProjects][0] : undefined);
156
+ if (scopeId !== null && e.scopeId && e.scopeId !== scopeId || project !== null && knownProject !== undefined && !zeroTouchProjectMatches(knownProject, project)) continue;
157
+ const unknown = scopeId !== null && !e.scopeId || project !== null && knownProject === undefined;
158
+ if (unknown) stoppedUnattributed.set(id, e);
159
+ else {
160
+ stopped.set(id, e);
161
+ if (project === null && scopeId === null && !e.scopeId && knownProject === undefined) stoppedUnattributed.set(id, e);
162
+ }
163
+ }
164
+ const trend = Array.from({ length: 8 }, (_, i) => {
165
+ const atMs = nowMs - (7 - i) * 7 * DAY;
166
+ const start = atMs - windowDays * DAY;
167
+ const knownStart = Math.max(start, fromMs);
168
+ const part = knownStart >= atMs;
169
+ const facts = count(part ? [] : inInterval(selected, knownStart, atMs));
170
+ return { atMs, ...facts, partial: part || knownStart > start || coverage.partial === true };
171
+ });
172
+ const assignedQuestionRuns = new Set(groups.filter((g) => g.groupId ?? g.id).flatMap((g) => g.runIds ?? []));
173
+ const fixed = fixes.filter((f) => validTime(f?.at) && f.at <= nowMs && zeroTouchProjectMatches(f.project, project) && (scopeId === null || f.scopeId === scopeId)).map((f) => {
174
+ const reverted = validTime(f.undoneAt);
175
+ const spanMs = Math.max(0, Math.min(windowDays * DAY, nowMs - f.at, reverted ? f.undoneAt - f.at : Infinity));
176
+ const eligible = selected.filter((r) => (!f.scopeId || scopeOf(r) === f.scopeId) && (!f.project || r.project === f.project));
177
+ const group = groups.find((g) => (g.groupId ?? g.id) === f.groupId);
178
+ const members = new Set(group?.runIds ?? []);
179
+ const matches = (r) => f.reason ? r.reason === f.reason : f.groupId ? members.has(r.jobId) : (eventsByRun.get(r.jobId) ?? []).some((e) => e.cause === f.cause);
180
+ const beforeRows = inInterval(eligible, f.at - spanMs, f.at);
181
+ const afterRows = inInterval(eligible, f.at, f.at + spanMs);
182
+ const before = beforeRows.filter(matches).length;
183
+ const after = afterRows.filter(matches).length;
184
+ // A new question not yet classified could be this group's recurrence. Absence from this group's members
185
+ // is evidence only after classification; the unassigned bucket cannot prove that a fix worked.
186
+ const unclassified = f.groupId && [...beforeRows, ...afterRows].some((r) => r.reason === "awaiting-input" && !assignedQuestionRuns.has(r.jobId));
187
+ const partial = coverage.partial === true || fromMs > f.at - spanMs || spanMs === 0 || Boolean(unclassified);
188
+ const status = partial || afterRows.length === 0 || reverted ? "observing" : after < before ? "improved" : after > before ? "worse" : "unchanged";
189
+ return { ...f, spanMs, before, after, beforeRuns: beforeRows.length, afterRuns: afterRows.length, beforeCount: before, afterCount: after, subsequentRuns: afterRows.length, partial, status, worked: status === "improved" };
190
+ });
191
+ const medianWaitMs = waits.partial ? null : waits.medianWaitMs;
192
+ return { nowMs, windowDays, coverage: { requestedFromMs, fromMs: intervalFrom, toMs: nowMs, partial: coverage.partial === true || intervalFrom > requestedFromMs || reasons.length > 0, reasons: [...new Set(reasons)], unknownRuns: legacy.filter((r) => time(r.endedAt) >= requestedFromMs).length, stopsPartial: stoppedUnattributed.size > 0 },
193
+ totals: { runs: current.runs, touched: current.touched, rate: current.rate, previousRate: previous?.rate ?? null, deltaPoints: current.rate !== null && previous?.rate !== null && previous?.rate !== undefined ? current.rate - previous.rate : null, stopped: stopped.size, stoppedUnattributed: stoppedUnattributed.size, waitingHours: waits.partial ? null : waits.waitingHours, waitingPartial: waits.partial, waitingUnknown: waits.unknown, medianWaitMs, medianWaitHours: medianWaitMs === null ? null : medianWaitMs / 3600_000, pending: waits.partial ? null : waits.pending.length, waitingNow: waits.pending, draftAnswers: current.touchedRunIds.filter((id) => eventsByRun.get(id).some((e) => e.cause === "question-answer" && e.answerSource === "draft")).length },
194
+ trend, projects, causes: current.causes, fixes: fixed, groups, touchedRunIds: current.touchedRunIds };
195
+ }
196
+
197
+ /**
198
+ * The expensive scan is on demand, never the short dashboard snapshot or a mutating mirror reader. `indexMax` is the
199
+ * deployment's run index cap (`PI_RUN_MIRROR_MAX_RUNS`, issue #761), handed to the run records read so an index of a
200
+ * raised cap is not read as cut by count (capacity-records.mjs `readMirrorWindow`); absent, that read's built-in default.
201
+ */
202
+ export async function readZeroTouch({ redis = null, logsDir, retentionDays = 30, localHost = null, now = Date.now, windowDays = 30, project = null, scopeId = null, fixes = [], groups = [], fs, timeoutMs, indexMax, includeRecords = false } = {}) {
203
+ const nowMs = now();
204
+ // A park may predate every plotted interval and still wait throughout this one. Read retained history,
205
+ // not only the trend's lookback, or a long-lived unanswered run disappears from current waiting hours.
206
+ const retainedFromMs = Number(retentionDays) > 0 ? nowMs - Number(retentionDays) * DAY : 0;
207
+ const sinceMs = Math.max(0, Math.min(retainedFromMs, nowMs - 2 * windowDays * DAY, nowMs - (windowDays + 49) * DAY, nowMs - 30 * DAY));
208
+ const [runs, touches] = await Promise.all([
209
+ readCapacityRecords({ redis, logsDir, sinceMs, nowMs, retentionDays, localHost, fs, ...(timeoutMs ? { timeoutMs } : {}), ...(indexMax ? { indexMax } : {}) }),
210
+ makeTouchStore({ redis, retentionDays, now, ...(timeoutMs ? { timeoutMs } : {}) }).read(),
211
+ ]);
212
+ const boundaries = [touches.coverage.fromMs];
213
+ if (runs.coverage.local) boundaries.push(runs.coverage.local.fromMs);
214
+ if (runs.coverage.mirror) boundaries.push(runs.coverage.mirror.fromMs);
215
+ const reasons = [...touches.coverage.reasons];
216
+ if (runs.coverage.reason) reasons.push(runs.coverage.reason);
217
+ const unavailable = !runs.coverage.local && !runs.coverage.mirror;
218
+ const report = computeZeroTouch({ records: runs.records, events: touches.events, fixes, groups, nowMs, windowDays, project, scopeId,
219
+ coverage: { fromMs: unavailable ? nowMs : Math.max(...boundaries), partial: touches.coverage.partial || unavailable || runs.coverage.skipped > 0 || runs.mirrorState.startsWith("unreachable"), reasons } });
220
+ // Local records cannot establish the absence of fleet interventions when their index is unreadable. A collapsed
221
+ // coverage window likewise contains no observed interval, unlike a useful shorter retained window.
222
+ report.state = unavailable || reasons.includes("touch-index-unavailable") || report.coverage.partial && report.coverage.fromMs >= nowMs ? "unavailable" : "ready";
223
+ if (includeRecords) Object.defineProperties(report, { records: { value: runs.records }, recordMirror: { value: runs.mirrorState } });
224
+ return report;
225
+ }
@@ -1,13 +0,0 @@
1
- /**
2
- * The scheduled-for instant of a BullMQ job scheduler's job, read from the deterministic `repeat:<id>:<millis>` id it
3
- * mints (DES-CRON-VIA-BULLMQ-SCHEDULER). A leaf module, so the run record and the prepare step read one parser.
4
- */
5
-
6
- /** Parse the millis out of a `repeat:<id>:<millis>` BullMQ scheduled jobId, or null. */
7
- export function scheduledForMillis(queueJobId) {
8
- if (typeof queueJobId !== "string" || !queueJobId.startsWith("repeat:")) return null;
9
- const tail = queueJobId.slice(queueJobId.lastIndexOf(":") + 1);
10
- if (tail === "") return null;
11
- const millis = Number(tail);
12
- return Number.isFinite(millis) ? millis : null;
13
- }