@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,111 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { watchDurationMs } from "./watch-contract.mjs";
3
+
4
+ export const WATCH_ISSUES_PER_SOURCE_MAX = 4096;
5
+ export function issueIdentity(target, source, ruleId, fingerprint) {
6
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(source) || !/^[A-Za-z0-9_-]{1,64}$/.test(ruleId) || !/^[a-f0-9]{64}$/.test(fingerprint) || !["github", "gitlab", "forgejo"].includes(target?.kind) || 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 identity");
7
+ // Quiet policy is mutable reviewed config, never part of the remote issue's permanent identity.
8
+ return createHash("sha256").update(JSON.stringify([{ kind: target.kind, repo: target.repo }, source, ruleId, fingerprint])).digest("hex");
9
+ }
10
+ export const WATCH_ISSUE_ACTIVITY_SCRIPT = `-- watch-issue-activity
11
+ if not redis.call('GET',KEYS[2]) then return cjson.encode({at=tonumber(ARGV[1]),version=0}) end
12
+ local raw=redis.call('GET',KEYS[1]); local activity=raw and cjson.decode(raw) or {at=0,version=0}
13
+ activity.at=math.max(activity.at,tonumber(ARGV[1])); activity.version=activity.version+1
14
+ redis.call('SET',KEYS[1],cjson.encode(activity),'PX',2678400000)
15
+ local mapped=redis.call('GET',KEYS[2]); if mapped then local m=cjson.decode(mapped); if m.closeAfterMs and m.state=='mapped' then redis.call('ZADD',KEYS[3],activity.at+m.closeAfterMs,ARGV[2]) end end
16
+ return cjson.encode(activity)`;
17
+ export async function noteWatchIssueActivity(redis, { target, source, ruleId, fingerprint }, { prefix = "watch:issue", now = () => Date.now() } = {}) {
18
+ const identity = issueIdentity(target, source, ruleId, fingerprint);
19
+ return JSON.parse(await redis.eval(WATCH_ISSUE_ACTIVITY_SCRIPT, 3, `${prefix}:activity:${identity}`, `${prefix}:${identity}`, `${prefix}:quiet`, now(), identity));
20
+ }
21
+ const RESERVE = `-- watch-issue-reserve
22
+ local existing=redis.call('GET',KEYS[1]); if existing then return cjson.encode({owner=false,mapping=cjson.decode(existing)}) end
23
+ if redis.call('ZCARD',KEYS[2])>=tonumber(ARGV[1]) then return cjson.encode({capped=true}) end
24
+ redis.call('ZADD',KEYS[2],ARGV[2],ARGV[3]); redis.call('SET',KEYS[1],ARGV[4]); return cjson.encode({owner=true,mapping=cjson.decode(ARGV[4])})`;
25
+ const CLAIM = `-- watch-issue-close-claim
26
+ if redis.call('GET',KEYS[1])~=ARGV[1] then return 0 end
27
+ if redis.call('GET',KEYS[2])~=ARGV[2] then return 0 end
28
+ return redis.call('SET',KEYS[3],ARGV[3],'NX','PX',30000) and 1 or 0`;
29
+ const COMMIT = `-- watch-issue-close-commit
30
+ if redis.call('GET',KEYS[3])~=ARGV[1] then return 0 end
31
+ if redis.call('GET',KEYS[1])~=ARGV[2] then return 0 end
32
+ if redis.call('GET',KEYS[2])~=ARGV[3] then return 0 end
33
+ redis.call('SET',KEYS[1],ARGV[4]); redis.call('ZREM',KEYS[4],ARGV[5]); return 1`;
34
+ const UNLOCK = `-- watch-issue-unlock
35
+ if redis.call('GET',KEYS[1])==ARGV[1] then return redis.call('DEL',KEYS[1]) end return 0`;
36
+
37
+ /** Forge adapters own transport. Pending writes reconcile by marker, never blind-create again. */
38
+ export function makeWatchIssuePublisher({ redis, adapter, prefix = "watch:issue", now = () => Date.now(), maxPerSource = WATCH_ISSUES_PER_SOURCE_MAX, canClose = async () => false, reportClose = async () => {} }) {
39
+ for (const method of ["findIssue", "createIssue", "updateIssue"]) if (typeof adapter?.[method] !== "function") throw new Error("watch issue adapter is incomplete");
40
+ if (!Number.isSafeInteger(maxPerSource) || maxPerSource < 1 || maxPerSource > WATCH_ISSUES_PER_SOURCE_MAX) throw new Error("invalid watch issue index bound");
41
+ const activityKey = id => `${prefix}:activity:${id}`;
42
+ const noteActivity = args => noteWatchIssueActivity(redis, args, { prefix, now });
43
+ const schedule = async (identity, mapping) => { if (mapping.closeAfterMs) { const activity = JSON.parse(await redis.get(activityKey(identity))); await redis.zadd(`${prefix}:quiet`, activity.at + mapping.closeAfterMs, identity); } };
44
+ const publish = async function publishWatchIssue({ source, ruleId, fingerprint, finding, target }) {
45
+ const identity = issueIdentity(target, source, ruleId, fingerprint);
46
+ const closeAfterMs = target.closeAfter === undefined ? undefined : watchDurationMs(target.closeAfter);
47
+ if (closeAfterMs && typeof adapter.closeIssue !== "function") throw new Error("watch issue quiet closure unsupported");
48
+ const key = `${prefix}:${identity}`; const marker = `pi-dispatch-watch:${identity}`;
49
+ const pending = { state: "pending", marker, source, ruleId, fingerprint, target: { kind: target.kind, repo: target.repo }, ...(closeAfterMs ? { closeAfterMs } : {}) };
50
+ const reserved = JSON.parse(await redis.eval(RESERVE, 2, key, `${prefix}:source:${source}`, maxPerSource, now(), identity, JSON.stringify(pending)));
51
+ if (reserved.capped) return { status: "capped", reason: "watch-issue-cap" };
52
+ await noteActivity({ source, ruleId, fingerprint, target });
53
+ let mapping = reserved.mapping;
54
+ const title = `[watch] ${ruleId} ${fingerprint.slice(0, 12)}`;
55
+ const count = Number.isSafeInteger(finding.count) && finding.count >= 0 ? finding.count : 0;
56
+ const severity = ["debug", "info", "warning", "error", "critical"].includes(finding.severity) ? finding.severity : "unknown";
57
+ const body = `<!-- ${marker} -->\nWatch rule: ${ruleId}\nSource: ${source}\nFingerprint: ${fingerprint}\nSeverity: ${severity}\nEvents: ${count}\n\nA person with repository write access must apply the configured label before code work starts.`;
58
+ if (!reserved.owner && mapping.state === "pending") {
59
+ let found; try { found = await adapter.findIssue({ target, marker }); } catch { throw new Error("watch issue reconciliation unavailable"); }
60
+ if (!found) return { status: "uncertain", reason: "watch-issue-uncertain" };
61
+ mapping = { ...pending, state: "mapped", issueId: String(found.id) };
62
+ await redis.set(key, JSON.stringify(mapping));
63
+ }
64
+ if (mapping.issueId !== undefined) {
65
+ try { await adapter.updateIssue({ target, id: mapping.issueId, title, body, state: "open" }); } catch { throw new Error("watch issue update unavailable"); }
66
+ mapping = { ...pending, state: "mapped", issueId: mapping.issueId };
67
+ await redis.set(key, JSON.stringify(mapping)); await schedule(identity, mapping);
68
+ if (!closeAfterMs) await redis.zrem(`${prefix}:quiet`, identity);
69
+ return { status: "updated", issueId: mapping.issueId };
70
+ }
71
+ let issue; try { issue = await adapter.createIssue({ target, title, body, marker }); } catch { throw new Error("watch issue create outcome unknown"); }
72
+ if (!issue || issue.id === undefined) throw new Error("watch issue create outcome unknown");
73
+ mapping = { ...pending, state: "mapped", issueId: String(issue.id) };
74
+ await redis.set(key, JSON.stringify(mapping)); await schedule(identity, mapping);
75
+ return { status: "created", issueId: mapping.issueId };
76
+ };
77
+ publish.noteActivity = noteActivity;
78
+ publish.sweep = async function sweep() {
79
+ const outcomes = [];
80
+ const report = async (outcome, mapping) => { outcomes.push(outcome); try { await reportClose(outcome, mapping); } catch {} };
81
+ for (const identity of await redis.zrangebyscore(`${prefix}:quiet`, "-inf", now(), "LIMIT", 0, 128)) {
82
+ if (!/^[a-f0-9]{64}$/.test(identity)) continue;
83
+ const key = `${prefix}:${identity}`; const lock = `${prefix}:closing:${identity}`;
84
+ const raw = await redis.get(key); const activityRaw = await redis.get(activityKey(identity));
85
+ let mapping; let activity;
86
+ try { mapping = JSON.parse(raw); activity = JSON.parse(activityRaw); } catch { await report({ identity, status: "uncertain", reason: "watch-issue-close-uncertain" }, null); continue; }
87
+ if (!mapping?.closeAfterMs || mapping.state !== "mapped") { await redis.zrem(`${prefix}:quiet`, identity); continue; }
88
+ if (!activity || activity.at + mapping.closeAfterMs > now()) { if (activity) await redis.zadd(`${prefix}:quiet`, activity.at + mapping.closeAfterMs, identity); continue; }
89
+ let allowed = false; try { allowed = await canClose(mapping); } catch {}
90
+ if (!allowed) { await redis.zrem(`${prefix}:quiet`, identity); await report({ identity, status: "suppressed", reason: "watch-issue-close-policy-changed" }, mapping); continue; }
91
+ const token = randomUUID();
92
+ if (!await redis.eval(CLAIM, 3, key, activityKey(identity), lock, raw, activityRaw, token)) continue;
93
+ try {
94
+ await adapter.closeIssue({ target: mapping.target, id: mapping.issueId });
95
+ const closed = { ...mapping, state: "closed" };
96
+ const committed = await redis.eval(COMMIT, 4, key, activityKey(identity), lock, `${prefix}:quiet`, token, raw, activityRaw, JSON.stringify(closed), identity);
97
+ if (!committed) {
98
+ // A collector advanced activity during the remote close. Reopen rather than hide that race.
99
+ await adapter.updateIssue({ target: mapping.target, id: mapping.issueId, state: "open" });
100
+ const current = JSON.parse(await redis.get(key));
101
+ if (current?.state === "closed") await redis.set(key, JSON.stringify({ ...current, state: "mapped" }));
102
+ await schedule(identity, mapping);
103
+ await report({ identity, status: "active", reason: "watch-issue-close-raced" }, mapping);
104
+ } else await report({ identity, status: "closed" }, mapping);
105
+ } catch { await report({ identity, status: "uncertain", reason: "watch-issue-close-uncertain" }, mapping); }
106
+ finally { await redis.eval(UNLOCK, 1, lock, token); }
107
+ }
108
+ return outcomes;
109
+ };
110
+ return publish;
111
+ }
@@ -0,0 +1,10 @@
1
+ /** Only reviewed names and numeric answers may enter a durable run record. */
2
+ export function recordedWatchDecision(value) {
3
+ if (!value || typeof value !== "object" || !["investigate", "file_issue", "ignore"].includes(value.action) || typeof value.model !== "string" || !/^[A-Za-z0-9_.:/@~-]{1,128}$/.test(value.model)) return null;
4
+ const out = { model: value.model, action: value.action, shadow: value.shadow === true };
5
+ if (Array.isArray(value.questions) && value.questions.length <= 16 && value.questions.every(n => /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/.test(n))) out.questions = [...value.questions];
6
+ if (value.probabilities && Object.keys(value.probabilities).every(k => ["investigate", "file_issue", "ignore"].includes(k)) && Object.values(value.probabilities).every(p => Number.isFinite(p) && p >= 0 && p <= 1)) out.probabilities = { ...value.probabilities };
7
+ if (Number.isFinite(value.confidence) && value.confidence >= 0 && value.confidence <= 1) out.confidence = value.confidence;
8
+ if (Number.isFinite(value.costUsd) && value.costUsd >= 0) out.costUsd = value.costUsd;
9
+ return out;
10
+ }
@@ -0,0 +1,167 @@
1
+ import { collectedWatchTrace } from "./watch-trace.mjs";
2
+ import { makeRedactor } from "./redact-secrets.mjs";
3
+ import { recordedWatchDecision } from "./watch-record.mjs";
4
+ import * as nodeFs from "node:fs";
5
+ import { join } from "node:path";
6
+ import { createHash } from "node:crypto";
7
+ import { parseTriggersDocument } from "./triggers.mjs";
8
+ import { selectWatchDecision, projectWatchDecisionState } from "./watch-decision.mjs";
9
+ import { validateWatchBoard } from "./watch-board.mjs";
10
+
11
+ const digest = value => createHash("sha256").update(JSON.stringify(value)).digest("hex");
12
+ const refusal = reason => ({ outcome: "policy", reason, budgetReserved: false });
13
+ const modelRef = ref => { const at = ref.indexOf("/"); return { provider: ref.slice(0, at), id: ref.slice(at + 1) }; };
14
+
15
+ /** Open the inode once: an agent may replace an outbox path, including with a FIFO or symlink. */
16
+ export function readWatchOutput(path, maxBytes, fs = nodeFs) {
17
+ let fd;
18
+ try {
19
+ fd = fs.openSync(path, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
20
+ const stat = fs.fstatSync(fd);
21
+ if (!stat.isFile() || stat.size > maxBytes) return null;
22
+ const bytes = Buffer.alloc(maxBytes + 1);
23
+ let used = 0;
24
+ while (used < bytes.length) { const n = fs.readSync(fd, bytes, used, bytes.length - used, null); if (!n) break; used += n; }
25
+ return used > maxBytes ? null : bytes.subarray(0, used).toString("utf8");
26
+ } catch { return null; } finally { if (fd !== undefined) fs.closeSync(fd); }
27
+ }
28
+
29
+ function refusedOutput(path, maxBytes, fs) {
30
+ try { const stat = fs.lstatSync(path); return stat.size > maxBytes ? "watch-board-oversize" : "watch-board-invalid"; }
31
+ catch (error) { return error?.code === "ENOENT" ? null : "watch-board-invalid"; }
32
+ }
33
+
34
+ /** Producer references are resolved again at pickup; queue bytes never select execution settings. */
35
+ export function makeWatchRuntime({ store, triggersPath, readFile = nodeFs.readFileSync, enqueue, publishIssue, getSettings = () => ({}), pauseUntil = () => null, now = Date.now, fs = nodeFs, log = () => {} }) {
36
+ const document = () => parseTriggersDocument(readFile(triggersPath, "utf8"), triggersPath);
37
+ const currentRule = (doc, id) => doc.triggers.find(t => t.on.type === "watch" && t.on.id === id);
38
+ let draining = false;
39
+ return {
40
+ async started(job) {
41
+ if (job.watch?.stage !== "decision") return;
42
+ await store.updateFinding(job.watch.findingId, { status: "deciding", runId: job.watch.runId });
43
+ await store.scheduleAction(job.watch.findingId, job.watch.ruleId, now() + 35 * 60 * 1000);
44
+ },
45
+ async drain() {
46
+ if (draining) return;
47
+ draining = true;
48
+ try {
49
+ if (publishIssue?.sweep) for (const outcome of await publishIssue.sweep()) log("watch_issue_close", { identity: outcome.identity, status: outcome.status, ...(outcome.reason ? { reason: outcome.reason } : {}) });
50
+ for (const fields of await store.pendingActions()) {
51
+ if (!await store.readFinding(fields.id)) { await store.acknowledgeAction(fields.id); continue; }
52
+ await enqueue(fields, { phase: "action" });
53
+ }
54
+ } finally { draining = false; }
55
+ },
56
+ async resolve(data, runId) {
57
+ if (data?.kind !== "watch" && !data?.watch) return { data };
58
+ const ref = data.watch;
59
+ if (!ref || !/^[a-f0-9]{64}$/.test(ref.findingId) || !["dispatch", "action"].includes(ref.phase)) return { result: refusal("watch-reference-invalid") };
60
+ // Issue #761: a read of a file a newer pi-dispatch wrote is `{ newer: <the store> }`, never `watch-config-changed`,
61
+ // and is answered BEFORE the finding is taken (a take cannot be undone): the processor defers the job
62
+ // (`newer-release`) and this build neither misreads the rule nor spends the finding.
63
+ let doc;
64
+ try { doc = document(); } catch (error) { if (error?.newer === true) return { newer: "the triggers file" }; if (error?.piDispatchConfig || error?.code === "ENOENT") return { result: refusal("watch-config-changed") }; throw error; }
65
+ const rule = currentRule(doc, ref.ruleId);
66
+ if (!rule || !doc.watchSources?.[rule.on.source]) return { result: refusal("watch-config-changed") };
67
+ const target = rule.run.mode === "issue" ? { kind: rule.run.issue.kind, repo: rule.run.issue.repo } : { kind: "local", folder: rule.run.folder };
68
+ const until = pauseUntil(target, now());
69
+ if (Number.isFinite(until) && until > now()) return { deferUntil: until };
70
+ // The settings a decision reads, read ONCE and before the take (issue #761), so a newer overlay defers the job
71
+ // with the finding still in place. Only a deciding rule's dispatch can become a decision, so only it reads them.
72
+ const settings = rule.decide && ref.phase === "dispatch" ? getSettings() : null;
73
+ if (settings?.invalid && settings.newer === true) return { newer: "the settings file" };
74
+ const taken = await store.takeFinding(ref.findingId);
75
+ if (taken.status !== "ready") return { result: refusal(taken.status === "below-threshold" ? "watch-threshold" : taken.status === "held" ? "watch-window-pending" : "watch-evidence-expired") };
76
+ const finding = taken.finding;
77
+ if (finding.ruleId !== ref.ruleId || finding.source !== rule.on.source) return { result: refusal("watch-reference-invalid") };
78
+ if (finding.policyDigest && finding.policyDigest !== digest(rule)) return { result: refusal("watch-config-changed") };
79
+ const latest = await store.readFinding(ref.findingId, { private: true });
80
+ if (ref.phase === "action") await store.acknowledgeAction(ref.findingId);
81
+ const decision = recordedWatchDecision(latest?.decision);
82
+ let phase = "investigate";
83
+ let action = rule.run.mode === "issue" ? "file_issue" : "investigate";
84
+ if (rule.decide) {
85
+ if (ref.phase === "dispatch" && !decision && latest?.status !== "deciding") phase = "decision";
86
+ else action = decision ? (rule.decide.shadow ? "investigate" : decision.action) : "file_issue";
87
+ }
88
+ if (phase !== "decision" && action === "ignore") { await store.updateFinding(ref.findingId, { status: "ignored", reason: "watch-ignored" }); return { result: { outcome: "completed", reason: "watch-ignored", budgetReserved: false, ...(decision ? { decision } : {}) } }; }
89
+ if (phase !== "decision" && action === "file_issue") {
90
+ if (!rule.run.issue || typeof publishIssue !== "function") return { result: refusal("watch-issue-target-missing") };
91
+ const published = await publishIssue({ source: finding.source, ruleId: ref.ruleId, fingerprint: finding.fingerprint, finding, target: rule.run.issue });
92
+ await store.updateFinding(ref.findingId, { status: published.status === "uncertain" ? "issue-uncertain" : published.status === "capped" ? "capped" : "issue", ...(published.reason ? { reason: published.reason } : {}) });
93
+ return { result: { outcome: ["uncertain", "capped"].includes(published.status) ? "policy" : "completed", budgetReserved: false, ...(published.reason ? { reason: published.reason } : {}), ...(decision ? { decision } : {}) } };
94
+ }
95
+ const board = await store.readBoard(ref.ruleId);
96
+ const execution = { ...rule.run };
97
+ delete execution.mode; delete execution.issue; delete execution.queryLogs;
98
+ const watch = { findingId: ref.findingId, ruleId: ref.ruleId, phase: ref.phase, configDigest: digest(rule), ...(decision ? { decision } : {}) };
99
+ // BullMQ deferrals may persist job.data. Private evidence must survive in memory without
100
+ // becoming queue data when an unrelated admission gate persists its data.
101
+ Object.defineProperties(watch, { stage: { value: phase }, project: { value: doc.watchSources[rule.on.source].project ?? null }, runId: { value: runId }, finding: { value: finding }, board: { value: { version: 1, blocks: board.blocks } }, queries: { value: rule.run.queryLogs ?? {} } });
102
+ if (phase === "decision") {
103
+ Object.defineProperty(watch, "gate", { value: { version: 1, model: modelRef(rule.decide.model), state: projectWatchDecisionState({ ...finding, window: rule.on.window }, rule.decide.fields), questions: rule.decide.questions } });
104
+ execution.maxCostUsd = rule.decide.maxCostUsd;
105
+ // The read from before the take: a broken overlay is still refused here, as it always was.
106
+ if (settings.invalid) return { result: refusal("watch-config-changed") };
107
+ const primary = `${execution.provider ?? settings.provider}/${execution.model ?? settings.model}`;
108
+ execution.models = [...new Set([...(execution.models ?? []), primary, rule.decide.model])];
109
+ await store.updateFinding(ref.findingId, { status: "decision-pending", runId });
110
+ } else await store.updateFinding(ref.findingId, { status: "running", runId });
111
+ await store.appendTrace(ref.findingId, { id: `${runId}:admit`, kind: "rule", status: phase, ruleId: ref.ruleId, count: finding.count });
112
+ return { data: { ...execution, kind: "local", github: false, packages: false, watch, ...(rule.decide ? { watchDecisionModel: modelRef(rule.decide.model) } : {}) } };
113
+ },
114
+ /** A completed paid attempt is never retried because collecting its output failed. */
115
+ async collect({ job, prepared, completed, policyRefused = false, usage }) {
116
+ if (!job.watch || !prepared?.outboxDir) return;
117
+ const watch = job.watch;
118
+ const redact = prepared.watchRedact ?? makeRedactor();
119
+ const privateEntry = entry => JSON.parse(redact(JSON.stringify(entry)));
120
+ try {
121
+ const raw = readWatchOutput(join(prepared.outboxDir, "watch-trace.jsonl"), 262144, fs);
122
+ const traceIds = new Set();
123
+ let traceUnavailable = false;
124
+ if (raw === null && refusedOutput(join(prepared.outboxDir, "watch-trace.jsonl"), 262144, fs)) traceUnavailable = true;
125
+ if (raw !== null) for (const line of raw.split("\n").filter(Boolean).slice(0, 128)) {
126
+ let entry; try { entry = JSON.parse(line); } catch { traceUnavailable = true; continue; }
127
+ const projected = collectedWatchTrace(entry, { model: job.watchDecisionModel ? `${job.watchDecisionModel.provider}/${job.watchDecisionModel.id}` : null, ruleId: watch.ruleId, runId: watch.runId });
128
+ if (!projected) { traceUnavailable = true; continue; }
129
+ if (["query", "decide"].includes(entry.kind) && entry.status === "completed") traceIds.add(entry.id);
130
+ try { await store.appendTrace(watch.findingId, privateEntry(projected)); } catch { traceUnavailable = true; }
131
+ }
132
+ let rule;
133
+ try { rule = currentRule(document(), watch.ruleId); } catch {}
134
+ if (!rule || digest(rule) !== watch.configDigest) { await store.updateFinding(watch.findingId, { status: "refused", reason: "watch-config-changed" }); return; }
135
+ if (watch.stage === "decision") {
136
+ let output; try { output = JSON.parse(readWatchOutput(join(prepared.outboxDir, "decision.json"), 16384, fs)); } catch {}
137
+ const selected = completed ? selectWatchDecision(rule.decide, output) : { action: "file_issue", reason: policyRefused ? "decide-refused" : "decide-unanswered" };
138
+ const summary = recordedWatchDecision({ ...selected, model: rule.decide.model, questions: Object.keys(rule.decide.questions), shadow: rule.decide.shadow, costUsd: usage?.costUsd });
139
+ watch.decision = summary;
140
+ try { await store.appendTrace(watch.findingId, privateEntry({ id: "decision", kind: "decide", state: watch.gate.state, questions: rule.decide.questions, ...summary, modelReviewed: true, status: selected.reason ?? "answered" })); } catch { traceUnavailable = true; }
141
+ await store.updateFinding(watch.findingId, { status: "decided", decision: summary, ...(selected.reason ? { reason: selected.reason } : {}) });
142
+ await store.scheduleAction(watch.findingId, watch.ruleId);
143
+ await enqueue({ id: watch.findingId, ruleId: watch.ruleId, dueAt: now() }, { phase: "action" });
144
+ } else {
145
+ const boardRaw = completed ? readWatchOutput(join(prepared.outboxDir, "board.json"), 16384, fs) : null;
146
+ const boardProblem = completed && boardRaw === null ? refusedOutput(join(prepared.outboxDir, "board.json"), 16384, fs) : null;
147
+ if (boardProblem) await store.appendTrace(watch.findingId, { id: "board-refused", kind: "board", status: boardProblem });
148
+ if (boardRaw !== null && !traceUnavailable) {
149
+ const checked = validateWatchBoard(redact(boardRaw), { traceIds });
150
+ if (checked.ok) {
151
+ for (const block of checked.board.blocks) block.traceId = `${watch.runId}:${block.traceId}`;
152
+ const remapped = validateWatchBoard(checked.board);
153
+ if (remapped.ok) await store.proposeBoard(watch.ruleId, checked.board, watch.findingId);
154
+ else await store.appendTrace(watch.findingId, { id: "board-refused", kind: "board", status: remapped.reason });
155
+ }
156
+ else await store.appendTrace(watch.findingId, { id: "board-refused", kind: "board", status: checked.reason });
157
+ }
158
+ await store.updateFinding(watch.findingId, { status: completed ? "completed" : "failed" });
159
+ }
160
+ if (traceUnavailable) await store.updateFinding(watch.findingId, { reason: "watch-trace-unavailable" });
161
+ } catch {
162
+ log("watch-output-unavailable", { ruleId: watch.ruleId });
163
+ try { await store.updateFinding(watch.findingId, { status: "unavailable", reason: "watch-output-unavailable" }); } catch {}
164
+ }
165
+ },
166
+ };
167
+ }
@@ -0,0 +1,183 @@
1
+ import { publicWatchTrace } from "./watch-trace.mjs";
2
+ import { recordedWatchDecision } from "./watch-record.mjs";
3
+ import { noteWatchIssueActivity } from "./watch-issue.mjs";
4
+ import { createHash } from "node:crypto";
5
+ import { validateWatchBoard as checkBoard } from "./watch-board.mjs";
6
+ import { watchFingerprint, matchesWatchFinding, watchDurationMs } from "./watch-contract.mjs";
7
+
8
+ const TTL_MS = 7 * 86400000;
9
+ // One script owns replay, digest and caps: a receiver crash between separate writes must never
10
+ // consume a delivery without leaving the same enqueueable finding for its retry.
11
+ export const WATCH_INGEST_SCRIPT = `
12
+ local now = tonumber(ARGV[1])
13
+ local ttl = tonumber(ARGV[2])
14
+ local replay = redis.call('GET', KEYS[1])
15
+ if replay then return replay end
16
+ local sourceCount = tonumber(redis.call('GET', KEYS[2]) or '0')
17
+ if sourceCount >= tonumber(ARGV[3]) then return cjson.encode({status='capped',reason='watch-cap'}) end
18
+ redis.call('INCR', KEYS[2]); redis.call('PEXPIRE', KEYS[2], 172800000)
19
+ redis.call('ZREMRANGEBYSCORE', KEYS[4], '-inf', now)
20
+ local active = redis.call('GET', KEYS[3])
21
+ local result
22
+ if active then
23
+ local a = cjson.decode(active)
24
+ if now > a.dueAt + tonumber(ARGV[7]) then redis.call('DEL', KEYS[3]); active=nil end
25
+ end
26
+ if active then
27
+ local a = cjson.decode(active)
28
+ if now <= a.dueAt then
29
+ local existing = redis.call('GET', ARGV[12] .. ':finding:' .. a.id)
30
+ if existing then
31
+ local f = cjson.decode(existing)
32
+ if not f.claimed then
33
+ local incoming = cjson.decode(ARGV[8])
34
+ f.count = math.min(1000000, f.count + incoming.count)
35
+ if incoming.firstSeen < f.firstSeen then f.firstSeen = incoming.firstSeen end
36
+ if incoming.lastSeen > f.lastSeen then f.lastSeen = incoming.lastSeen end
37
+ for _, sample in ipairs(incoming.samples) do if #f.samples < 8 then table.insert(f.samples, sample) end end
38
+ redis.call('SET', ARGV[12] .. ':finding:' .. a.id, cjson.encode(f), 'PX', ttl)
39
+ result = cjson.encode({status='held',id=a.id,fingerprint=ARGV[9],dueAt=a.dueAt})
40
+ end
41
+ end
42
+ end
43
+ if not result then result = cjson.encode({status='suppressed',id=a.id,fingerprint=ARGV[9],dueAt=a.dueAt,reason='watch-cooldown'}) end
44
+ elseif redis.call('ZCARD', KEYS[4]) >= tonumber(ARGV[4]) or tonumber(redis.call('GET', KEYS[5]) or '0') >= tonumber(ARGV[5]) then
45
+ result = cjson.encode({status='capped',reason='watch-cap',fingerprint=ARGV[9]})
46
+ else
47
+ local due = now + tonumber(ARGV[6])
48
+ local a = {id=ARGV[10],dueAt=due}
49
+ local f = cjson.decode(ARGV[8]); f.id=ARGV[10]; f.fingerprint=ARGV[9]; f.dueAt=due; f.createdAt=now
50
+ f.source=ARGV[11]; f.ruleId=ARGV[13]; f.status='held'; f.minCount=tonumber(ARGV[15])
51
+ redis.call('SET', KEYS[3], cjson.encode(a), 'PX', tonumber(ARGV[6])+tonumber(ARGV[7]))
52
+ redis.call('ZADD', KEYS[4], due+tonumber(ARGV[7]), ARGV[9]); redis.call('PEXPIRE', KEYS[4], ttl+tonumber(ARGV[7]))
53
+ redis.call('INCR', KEYS[5]); redis.call('PEXPIRE', KEYS[5], 172800000)
54
+ redis.call('SET', ARGV[12] .. ':finding:' .. ARGV[10], cjson.encode(f), 'PX', ttl)
55
+ redis.call('ZADD', KEYS[6], now, ARGV[10]); redis.call('ZREMRANGEBYSCORE', KEYS[6], '-inf', now-ttl); redis.call('PEXPIRE', KEYS[6], ttl)
56
+ redis.call('HSET', KEYS[7], ARGV[13], ARGV[14]); redis.call('PEXPIRE', KEYS[7], ttl)
57
+ result = cjson.encode({status='held',id=ARGV[10],fingerprint=ARGV[9],dueAt=due})
58
+ end
59
+ redis.call('SET', KEYS[1], result, 'PX', ttl)
60
+ return result
61
+ `;
62
+ const BOARD_SCRIPT = `
63
+ local raw=redis.call('GET',KEYS[1]); local b
64
+ if raw then b=cjson.decode(raw) else b=cjson.decode(ARGV[1]) end
65
+ if b.version ~= tonumber(ARGV[2]) then return cjson.encode({ok=false,reason='board-conflict',board=b}) end
66
+ local change=cjson.decode(ARGV[3])
67
+ if change.action=='freeze' then b.frozen=change.frozen
68
+ elseif change.action=='clear' then b.blocks={}; b.proposals={}
69
+ elseif change.action=='adopt' then
70
+ if b.frozen then return cjson.encode({ok=false,reason='board-frozen',board=b}) end
71
+ local found=false
72
+ for _,p in ipairs(b.proposals or {}) do if p.id==change.proposalId then b.blocks=p.blocks; found=true end end
73
+ if not found then return cjson.encode({ok=false,reason='board-proposal-missing',board=b}) end
74
+ elseif change.action=='revert' then
75
+ local found=false
76
+ for _,v in ipairs(b.versions) do if v.version==change.version then b.blocks=v.blocks; found=true end end
77
+ if not found then return cjson.encode({ok=false,reason='board-version-missing',board=b}) end
78
+ else return cjson.encode({ok=false,reason='board-action-invalid',board=b}) end
79
+ b.version=b.version+1
80
+ table.insert(b.versions,{version=b.version,blocks=b.blocks})
81
+ while #b.versions>20 do table.remove(b.versions,1) end
82
+ redis.call('SET',KEYS[1],cjson.encode(b),'PX',ARGV[4])
83
+ return cjson.encode({ok=true,board=b})
84
+ `;
85
+ function validId(value) { if (typeof value !== "string" || !/^[a-zA-Z0-9_-]{1,128}$/.test(value)) throw new Error("invalid watch identifier"); return value; }
86
+ const hash = value => createHash("sha256").update(value).digest("hex");
87
+ const parse = raw => raw ? JSON.parse(raw) : null;
88
+ function boardArrays(board) { if (!Array.isArray(board.blocks)) board.blocks = []; if (!Array.isArray(board.versions)) board.versions = []; if (!Array.isArray(board.proposals)) board.proposals = []; for (const proposal of board.proposals) if (!Array.isArray(proposal.blocks)) proposal.blocks = []; for (const version of board.versions) if (!Array.isArray(version.blocks)) version.blocks = []; return board; }
89
+ const publicFinding = f => f && { ...Object.fromEntries(["id", "source", "ruleId", "fingerprint", "severity", "count", "firstSeen", "lastSeen", "dueAt", "createdAt", "status", "reason", "runId"].filter(k => f[k] !== undefined).map(k => [k, f[k]])), ...(recordedWatchDecision(f.decision) ? { decision: recordedWatchDecision(f.decision) } : {}) };
90
+ export function makeWatchStore(redis, { now = () => Date.now(), prefix = "watch", sourceCaps = {} } = {}) {
91
+ const key = suffix => `${prefix}:${suffix}`;
92
+ const emptyBoard = ruleId => ({ ruleId, version: 0, frozen: false, blocks: [], versions: [], proposals: [] });
93
+ return {
94
+ async noteIssueActivity(args) { return noteWatchIssueActivity(redis, args, { now }); },
95
+ async recordIssueOutcome(ruleId, outcome) {
96
+ validId(ruleId);
97
+ if (!["closed", "active", "uncertain", "suppressed"].includes(outcome.status) || (outcome.reason !== undefined && !["watch-issue-close-uncertain", "watch-issue-close-raced", "watch-issue-close-policy-changed"].includes(outcome.reason))) throw new Error("invalid watch issue outcome");
98
+ await redis.multi().hset(key(`summary:${ruleId}`), "lastIssueStatus", outcome.status, "lastIssueReason", outcome.reason ?? "").pexpire(key(`summary:${ruleId}`), TTL_MS).exec();
99
+ },
100
+ async scheduleAction(id, ruleId, dueAt = now()) {
101
+ validId(id); validId(ruleId);
102
+ await redis.multi().hset(key("pending-action-rules"), id, ruleId).zadd(key("pending-actions"), dueAt, id).exec();
103
+ },
104
+ async pendingActions() {
105
+ const ids = await redis.zrangebyscore(key("pending-actions"), "-inf", now(), "LIMIT", 0, 100);
106
+ return (await Promise.all(ids.map(async id => ({ id, ruleId: await redis.hget(key("pending-action-rules"), id), dueAt: now() })))).filter(row => row.ruleId);
107
+ },
108
+ async acknowledgeAction(id) { validId(id); await redis.multi().zrem(key("pending-actions"), id).hdel(key("pending-action-rules"), id).exec(); },
109
+ async ingest({ source, rule, finding, delivery, sourceMaxPerDay }) {
110
+ validId(source); validId(rule.id); validId(delivery);
111
+ if (!matchesWatchFinding(rule, finding)) return { status: "unmatched", reason: "watch-unmatched" };
112
+ const fingerprint = watchFingerprint(source, rule, finding);
113
+ const clock = now(); const day = new Date(clock).toISOString().slice(0, 10);
114
+ const id = hash(JSON.stringify([source, rule.id, fingerprint, delivery, clock]));
115
+ const ruleKey = `${source}:${rule.id}`;
116
+ const result = await redis.eval(WATCH_INGEST_SCRIPT, 7,
117
+ key(`delivery:${source}:${rule.id}:${delivery}`), key(`source-day:${source}:${day}`), key(`active:${ruleKey}:${fingerprint}`), key(`fingerprints:${ruleKey}`), key(`rule-day:${ruleKey}:${day}`), key(`findings:${rule.id}`), key("rules"),
118
+ clock, TTL_MS, sourceMaxPerDay ?? sourceCaps[source] ?? 1000, rule.maxFingerprints ?? 256, rule.maxPerDay, rule.windowMs ?? watchDurationMs(rule.window), rule.cooldownMs ?? watchDurationMs(rule.cooldown), JSON.stringify(finding), fingerprint, id, source, prefix, rule.id, JSON.stringify({ id: rule.id, source, maxPerDay: rule.maxPerDay }), rule.minCount ?? 1);
119
+ return parse(result);
120
+ },
121
+ async takeFinding(id) {
122
+ validId(id);
123
+ const script = `local raw=redis.call('GET',KEYS[1]); if not raw then return cjson.encode({status='missing'}) end
124
+ local f=cjson.decode(raw)
125
+ if tonumber(ARGV[1]) < f.dueAt then return cjson.encode({status='held',dueAt=f.dueAt}) end
126
+ if not f.claimed then f.claimed=true; f.status=f.count>=f.minCount and 'ready' or 'below-threshold'; redis.call('SET',KEYS[1],cjson.encode(f),'KEEPTTL') end
127
+ return cjson.encode({status=f.count>=f.minCount and 'ready' or 'below-threshold',finding=f,source=f.source,ruleId=f.ruleId,fingerprint=f.fingerprint})`;
128
+ const result = parse(await redis.eval(script, 1, key(`finding:${id}`), now()));
129
+ if (result.finding && !Array.isArray(result.finding.samples)) result.finding.samples = [];
130
+ return result;
131
+ },
132
+ async proposeBoard(ruleId, board, runId) {
133
+ validId(ruleId); validId(runId); validateWatchBoard(board);
134
+ const proposal = { id: hash(JSON.stringify([ruleId, runId, board])), runId, blocks: board.blocks, createdAt: now() };
135
+ const script = `local raw=redis.call('GET',KEYS[1]); local b=raw and cjson.decode(raw) or cjson.decode(ARGV[1]); b.proposals=b.proposals or {}; local p=cjson.decode(ARGV[2]); for _,old in ipairs(b.proposals) do if old.id==p.id then return cjson.encode(old) end end; table.insert(b.proposals,p); while #b.proposals>20 do table.remove(b.proposals,1) end; redis.call('SET',KEYS[1],cjson.encode(b),'PX',ARGV[3]); return cjson.encode(p)`;
136
+ return parse(await redis.eval(script, 1, key(`board:${ruleId}`), JSON.stringify(emptyBoard(ruleId)), JSON.stringify(proposal), TTL_MS));
137
+ },
138
+ async recordVerdict(ruleId, source, status) {
139
+ validId(ruleId); validId(source);
140
+ if (!["held", "suppressed", "capped", "unmatched"].includes(status)) throw new Error("invalid watch verdict");
141
+ await redis.multi().hset(key("rules"), ruleId, JSON.stringify({ id: ruleId, source })).hincrby(key(`summary:${ruleId}`), status, 1).hset(key(`summary:${ruleId}`), "lastDeliveryAt", String(now())).pexpire(key(`summary:${ruleId}`), TTL_MS).pexpire(key("rules"), TTL_MS).exec();
142
+ },
143
+ async listRules() { return Promise.all(Object.values(await redis.hgetall(key("rules"))).map(parse).map(async rule => ({ ...rule, summary: await redis.hgetall(key(`summary:${rule.id}`)) }))); },
144
+ async listFindings(ruleId, { limit = 1000 } = {}) {
145
+ validId(ruleId); if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000) throw new Error("invalid watch findings limit");
146
+ const ids = await redis.zrevrange(key(`findings:${ruleId}`), 0, limit - 1);
147
+ return (await Promise.all(ids.map(id => this.readFinding(id)))).filter(Boolean);
148
+ },
149
+ async readFinding(id, { private: privateRead = false } = {}) { validId(id); const f = parse(await redis.get(key(`finding:${id}`))); return privateRead ? f : publicFinding(f); },
150
+ async readTrace(id, { private: privateRead = false } = {}) {
151
+ validId(id); const entries = (await redis.lrange(key(`trace:${id}`), 0, 999)).map(parse);
152
+ const dropped = Number(await redis.get(key(`trace-dropped:${id}`)) ?? 0);
153
+ if (dropped > 0) entries.unshift({ id: "trace-trimmed", kind: "rule", status: "trace-trimmed", count: dropped });
154
+ return privateRead ? entries : entries.map(publicWatchTrace);
155
+ },
156
+ async appendTrace(id, entry) {
157
+ validId(id); const text = JSON.stringify({ ...entry, at: now() });
158
+ if (Buffer.byteLength(text) > 32768) throw new Error("watch trace entry exceeds limit");
159
+ const script = `local n=redis.call('RPUSH',KEYS[1],ARGV[1]); if n>1000 then redis.call('INCRBY',KEYS[2],n-1000); redis.call('LTRIM',KEYS[1],-1000,-1); redis.call('PEXPIRE',KEYS[2],ARGV[2]) end; redis.call('PEXPIRE',KEYS[1],ARGV[2]); return n`;
160
+ await redis.eval(script, 2, key(`trace:${id}`), key(`trace-dropped:${id}`), text, TTL_MS);
161
+ },
162
+ async updateFinding(id, patch) {
163
+ validId(id); const allowed = ["status", "decision", "reason", "runId"];
164
+ if (Object.keys(patch).some(k => !allowed.includes(k))) throw new Error("invalid watch finding update");
165
+ const script = `local raw=redis.call('GET',KEYS[1]); if not raw then return 0 end; local f=cjson.decode(raw); local p=cjson.decode(ARGV[1]); for k,v in pairs(p) do f[k]=v end; redis.call('SET',KEYS[1],cjson.encode(f),'KEEPTTL'); return 1`;
166
+ return Boolean(await redis.eval(script, 1, key(`finding:${id}`), JSON.stringify(patch)));
167
+ },
168
+ async readBoard(ruleId) { validId(ruleId); return boardArrays(parse(await redis.get(key(`board:${ruleId}`))) ?? emptyBoard(ruleId)); },
169
+ async mutateBoard(ruleId, change, expectedVersion) {
170
+ validId(ruleId);
171
+ if (!Number.isSafeInteger(expectedVersion) || expectedVersion < 0) throw new Error("invalid board version");
172
+ if (!["freeze", "clear", "revert", "adopt"].includes(change.action) || (change.action === "freeze" && typeof change.frozen !== "boolean")) throw new Error("invalid board change");
173
+ if (change.action === "adopt") validId(change.proposalId);
174
+ const result = parse(await redis.eval(BOARD_SCRIPT, 1, key(`board:${ruleId}`), JSON.stringify(emptyBoard(ruleId)), expectedVersion, JSON.stringify(change), TTL_MS));
175
+ result.board = boardArrays(result.board); return result;
176
+ },
177
+ };
178
+ }
179
+ export function validateWatchBoard(board) {
180
+ const result = checkBoard({ version: 1, ...board });
181
+ if (!result.ok) throw new Error(result.reason);
182
+ return board;
183
+ }
@@ -0,0 +1,34 @@
1
+ const ACTIONS = ["investigate", "file_issue", "ignore"];
2
+ const STATUSES = ["started", "completed", "failed", "proposed", "held", "suppressed", "capped", "unmatched", "decision", "investigate", "answered", "decide-unanswered", "decide-refused", "watch-ignored", "watch-board-invalid", "watch-board-oversize", "trace-trimmed"];
3
+ const token = value => typeof value === "string" && /^[A-Za-z0-9_.:-]{1,128}$/.test(value);
4
+ const integer = value => Number.isSafeInteger(value) && value >= 0 && value <= 1000000;
5
+
6
+ /** Public trace values have types and closed vocabularies, not merely familiar key names. */
7
+ export function publicWatchTrace(entry) {
8
+ const out = {};
9
+ if (token(entry.id)) out.id = entry.id;
10
+ if (["stream", "rule", "decide", "query", "turn", "board"].includes(entry.kind)) out.kind = entry.kind;
11
+ if (Number.isSafeInteger(entry.at) && entry.at >= 0) out.at = entry.at;
12
+ if (STATUSES.includes(entry.status)) out.status = entry.status;
13
+ for (const key of ["count", "rows"]) if (integer(entry[key])) out[key] = entry[key];
14
+ if (typeof entry.fingerprint === "string" && /^[a-f0-9]{64}$/.test(entry.fingerprint)) out.fingerprint = entry.fingerprint;
15
+ for (const key of ["ruleId", "source"]) if (token(entry[key])) out[key] = entry[key];
16
+ if (["verified", "pull"].includes(entry.signature)) out.signature = entry.signature;
17
+ if (ACTIONS.includes(entry.action ?? entry.option)) out.action = entry.action ?? entry.option;
18
+ if (entry.modelReviewed === true && typeof entry.model === "string" && /^[A-Za-z0-9_./:@~-]{1,128}$/.test(entry.model)) { out.model = entry.model; out.modelReviewed = true; }
19
+ if (Number.isFinite(entry.confidence) && entry.confidence >= 0 && entry.confidence <= 1) out.confidence = entry.confidence;
20
+ if (Number.isFinite(entry.costUsd) && entry.costUsd >= 0) out.costUsd = entry.costUsd;
21
+ if (entry.probabilities && !Array.isArray(entry.probabilities) && Object.keys(entry.probabilities).every(k => ACTIONS.includes(k)) && Object.values(entry.probabilities).every(p => Number.isFinite(p) && p >= 0 && p <= 1)) out.probabilities = { ...entry.probabilities };
22
+ if (entry.checks && Object.keys(entry.checks).every(k => ["match", "threshold", "cooldown", "cap"].includes(k)) && Object.values(entry.checks).every(v => ["passed", "refused", "active", "at-window-end"].includes(v))) out.checks = { ...entry.checks };
23
+ return out;
24
+ }
25
+
26
+ export function collectedWatchTrace(entry, { model, ruleId, runId }) {
27
+ if (!entry || !["query", "decide", "board"].includes(entry.kind) || !/^watch-\d{1,4}$/.test(entry.id) || !STATUSES.includes(entry.status)) return null;
28
+ const out = { id: `${runId}:${entry.id}`, kind: entry.kind, status: entry.status, ruleId };
29
+ if (entry.kind === "decide" && model) Object.assign(out, { model, modelReviewed: true });
30
+ for (const key of ["rows", "blocks"]) if (integer(entry[key])) out[key] = entry[key];
31
+ // Query text, agent-chosen questions and state remain in the operator projection alone.
32
+ for (const key of ["state", "questions", "answers", "usage", "query", "callId"]) if (entry[key] !== undefined && Buffer.byteLength(JSON.stringify(entry[key])) <= 16384) out[key] = entry[key];
33
+ return out;
34
+ }