@edgehero/pi-dispatch-receiver 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.
package/src/steer.mjs ADDED
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Comment steering (issue #646, INT-STEER-INBOX-CONTRACT, DES-STEER-TARGET-POINTER-IS-LIVENESS-NOT-INDEX): before the
3
+ * receiver enqueues a phrase comment from a steer-armed trigger, it asks whether any steer-armed run is already going on
4
+ * the same issue or pull request, and if one is, it hands the comment to that run's inbox instead of starting a second
5
+ * paid job.
6
+ *
7
+ * PER TARGET, NOT PER TRIGGER. The worker's relay registers every armed run under its target alone (kind, repo, target
8
+ * type, number), so a phrase comment reaches whatever steer-armed run is live on that issue or pull request, whichever
9
+ * trigger started it: a label-triggered run takes a comment from the comment trigger. The comment trigger's own arm
10
+ * (`run.steer`, required for `steerMode`) is what lets its comments steer at all; the run's arm is what lets it take one.
11
+ *
12
+ * `trySteer` is the one body the four webhook arms and the poller's gate share, so no forge can weaken it alone. It is
13
+ * the receiver's only I/O beyond the queue, and it is NOT in a filter: the filters stay pure and only say THAT a
14
+ * comment may steer (`job.steerMode`); whether a run is live is a Valkey question asked here.
15
+ *
16
+ * WHAT IT DOES, in order, every free refusal before any Valkey call:
17
+ * 1. the comment body is bounded and scrubbed with the worker's own rule (`boundSteerText`), and the entry the
18
+ * inbox would store is validated (`normalizeSteerEntry`), so a refusal costs nothing and names no text;
19
+ * 2. the target pointer is the worker relay's own derivation (`steerTargetOf`, imported, never re-spelled): a key
20
+ * computed two ways is a key that one day differs, and then every comment silently starts a second job;
21
+ * 3. the target's live runs (`liveRunsForTarget`, at most STEER_FANOUT_LIMIT);
22
+ * 4. for each run, the fallback job FIRST, then the push.
23
+ *
24
+ * WHY THE FALLBACK IS STORED BEFORE THE PUSH, not after an `ok`. The run's final close (settle) can land between the
25
+ * two calls. Push then close then store would leave a `late` message whose fallback is not there yet when settle reads
26
+ * it, and the comment would be neither run nor answered: the silent loss the push and close pair exists to rule out.
27
+ * Store then push has no such gap. Its cost is a fallback entry under a run that then refused the push (`gone`,
28
+ * `closed`, `full`): an orphan with the inbox's hour, read by nothing, because settle only looks up the ids it closed.
29
+ * A failed store skips that run's push for the same reason: a message accepted without a fallback could go late with
30
+ * nothing to start.
31
+ *
32
+ * A STEERED COMMENT IS NEVER ENQUEUED, so the queue's delivery-GUID shield (REQ-DEDUP-BY-DELIVERY-GUID) never sees its
33
+ * id. Without a stand-in, a redelivery of the same GUID after its run ended (a forge redelivery, a poller cycle
34
+ * retried after a crash) would find no live run, fall through and start the paid job the steer replaced. So a steer
35
+ * sets `steer:steered:<delivery job id>` (NX, for `FORGE_JOB_RETENTION_S`, the GUID job id's own retention), and every
36
+ * fall-through that reached Valkey asks for it first: present, the delivery is answered as steered (`redelivered`) and
37
+ * never enqueued.
38
+ * Set AFTER a push succeeded, never before: a marker for a comment that was then enqueued would be harmless while the
39
+ * enqueue holds, but one whose enqueue failed (a 503, later redelivered by hand or resent by Azure) would swallow that
40
+ * redelivery, a lost job.
41
+ *
42
+ * WHAT COUNTS AS STEERED. Any `ok` or `dup`. `dup` is a redelivered webhook GUID the run already holds (the id is the
43
+ * GUID's digest), so the comment was handled the first time and enqueueing it now would double it. Everything else
44
+ * (no live run, every run `gone`, `closed` or `full`, a refusal, a Valkey fault) returns null and the caller enqueues
45
+ * exactly as it did before this feature: steering is an optimisation over a job, never a way to lose one.
46
+ *
47
+ * NO TIMEOUT on the Valkey calls, deliberately. The enqueue that follows a null uses the same Valkey with no timeout
48
+ * either, so a bound here would buy no availability; and a push that succeeded but answered after a timeout would
49
+ * fall through to an enqueue as well: one comment, a steer AND a new job.
50
+ *
51
+ * Logs carry the delivery, the kind, run ids and counts, never the body (no-pii-in-logs). Never throws.
52
+ */
53
+
54
+ import { forgeDeliveryJobId } from "@edgehero/pi-dispatch/job-id";
55
+ import { withJobDataVersion } from "@edgehero/pi-dispatch/job-data-version";
56
+ import { steerTargetOf } from "@edgehero/pi-dispatch/inbox-relay";
57
+ import { STEER_MODES, boundSteerText, forgeMessageId, liveRunsForTarget, normalizeSteerEntry, pushSteer, scrubSteerText, storeFallback } from "@edgehero/pi-dispatch/steer-inbox";
58
+ import { FORGE_JOB_RETENTION_S } from "@edgehero/pi-dispatch/retention";
59
+
60
+ /**
61
+ * How long a steered delivery's marker lives, in SECONDS (`EX`): the GUID job id's own retention (`enqueueForgeJob`,
62
+ * `FORGE_JOB_RETENTION_S`), since the marker stands in for that job key and must refuse a redelivery exactly as long.
63
+ */
64
+ export const STEER_MARKER_TTL_S = FORGE_JOB_RETENTION_S;
65
+
66
+ /** The marker a steered delivery leaves, keyed by the job id the comment would have been enqueued under; null without one. */
67
+ export function steeredMarkerKey(kind, deliveryId) {
68
+ try {
69
+ return `steer:steered:${forgeDeliveryJobId(kind, deliveryId)}`;
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ /** At most this many live runs of one target take a comment: the plan's fanout bound (replicas and parallel jobs). */
76
+ export const STEER_FANOUT_LIMIT = 4;
77
+
78
+ /**
79
+ * The association a non-GitHub forge's author is framed with. GitLab, Forgejo and Azure carry no author_association;
80
+ * their gate is the receiver's resolved write-access verdict (`authorized === true`), so that verdict is what the
81
+ * framing states. A fixed word rather than a guessed role: the receiver knows the person cleared the gate, not which
82
+ * role they hold.
83
+ */
84
+ export const FORGE_GATE_ASSOCIATION = "WRITE_ACCESS";
85
+
86
+ const nowOf = (now) => (typeof now === "function" ? now() : now);
87
+
88
+ /** The entry's author: the sender's id (numeric, or Azure DevOps's GUID or `azid-` digest) and the association the author gate judged. */
89
+ export function steerAuthorOf(job, kind) {
90
+ const association = kind === "github" ? job?.trigger?.comment?.author_association : FORGE_GATE_ASSOCIATION;
91
+ return { kind: "forge", id: job?.trigger?.sender?.id, association };
92
+ }
93
+
94
+ /** Why a body cannot be steered, as a fixed token for a log line (never the refusal's words, never the text). */
95
+ function skipReason(body) {
96
+ if (typeof body !== "string") return "no-body";
97
+ if (typeof body.isWellFormed === "function" && !body.isWellFormed()) return "malformed";
98
+ if (scrubSteerText(body).trim() === "") return "empty";
99
+ return "too-large";
100
+ }
101
+
102
+ /**
103
+ * The job the receiver would have enqueued, as late branch A starts it: the job, its kind and its delivery job id. Exported
104
+ * for held replies (issue #638, held.mjs), whose fallback is the same object for the same reason: one spelling of "the job
105
+ * this comment would have been".
106
+ *
107
+ * Stored for later and enqueued by whichever worker settles the run, which may be an older release than this receiver,
108
+ * so it is stamped with the version its fields need, by the worker's own rule (issue #761, `withJobDataVersion`):
109
+ * nothing while that is 1, so today's fallback is byte-identical. A worker that cannot read the stamp refuses to rebuild
110
+ * the job (`enqueueLateForgeJob` and the held settle throw) and answers the comment instead, never drops a field. Never
111
+ * throws: the filter's job carries no `dataVersion`, so nothing here can be from a newer release. `dataVersions` is the
112
+ * version rule, injectable for tests.
113
+ */
114
+ export function fallbackJobOf(job, kind, { dataVersions } = {}) {
115
+ const { steerMode: _mode, ...rest } = job;
116
+ let jobId = null;
117
+ try {
118
+ jobId = forgeDeliveryJobId(kind, job.trigger?.deliveryId);
119
+ } catch {}
120
+ return withJobDataVersion({ ...rest, kind, jobId }, dataVersions);
121
+ }
122
+
123
+ /**
124
+ * `trySteer({ redis, job, kind, now, log })` -> `{ steered, runIds }` when at least one live run took the comment
125
+ * (`ok` or `dup`); `{ steered: 0, runIds: [], redelivered: true }` when no run took it but this delivery was steered
126
+ * before (answer it, never enqueue it); else null and the caller enqueues. `job` is the filter's job (carrying `steerMode`); `kind` the forge.
127
+ */
128
+ export async function trySteer({ redis, job, kind, now = Date.now, log = () => {} } = {}) {
129
+ if (!job || typeof job !== "object" || !STEER_MODES.includes(job.steerMode)) return null;
130
+ const delivery = job.trigger?.deliveryId;
131
+ const fellThrough = (reason, extra = {}) => {
132
+ log({ event: "steer_fell_through", delivery, kind, reason, ...extra });
133
+ return null;
134
+ };
135
+
136
+ const id = forgeMessageId(delivery);
137
+ if (id === null) return fellThrough("no-delivery-id");
138
+ const body = job.trigger?.comment?.body;
139
+ const bounded = boundSteerText(body);
140
+ if (bounded.refusal) {
141
+ // Not `steer_skipped`: that is runContainer's line for a run that could not be armed (`{ container, reason }`).
142
+ log({ event: "steer_comment_skipped", delivery, kind, reason: skipReason(body) });
143
+ return null;
144
+ }
145
+ const at = nowOf(now);
146
+ const entry = { id, mode: job.steerMode, source: "forge", author: steerAuthorOf(job, kind), text: bounded.text, at, deliveryId: delivery };
147
+ // The push validates this too; asking first makes a refusal free and one log line rather than one per run.
148
+ if (normalizeSteerEntry(entry, now).refusal) return fellThrough("entry-refused");
149
+
150
+ // The repo is the job's own spelling. For the poller that is POLL_REPOS's spelling, not GitHub's canonical one: it
151
+ // is also the spelling the poller's own runs registered under (the relay derives from the queued job), so the two
152
+ // agree for a poller deployment, and canonicalising here alone would break exactly that case. Only a deployment
153
+ // running BOTH the webhook and the poller, with a POLL_REPOS entry cased unlike GitHub's name, keys the two apart;
154
+ // its comments then fall through to an ordinary job, never get lost.
155
+ const { targetKey } = steerTargetOf({ ...job, kind });
156
+ if (targetKey === null) return fellThrough("no-target");
157
+ if (!redis) {
158
+ log({ event: "steer_lookup_failed", delivery, kind, reason: "no-client" });
159
+ return null;
160
+ }
161
+
162
+ const runs = await liveRunsForTarget({ redis, targetKey, now, limit: STEER_FANOUT_LIMIT });
163
+ if (!Array.isArray(runs)) {
164
+ log({ event: "steer_lookup_failed", delivery, kind, reason: runs?.error ?? "error" });
165
+ return null;
166
+ }
167
+ const marker = steeredMarkerKey(kind, delivery);
168
+ // A fall-through that reached Valkey: a delivery already steered once is answered as steered, never enqueued.
169
+ const redeliveredOr = async (reason, extra = {}) => {
170
+ if (marker !== null) {
171
+ try {
172
+ if (Number(await redis.exists(marker)) === 1) {
173
+ log({ event: "steer_redelivered", delivery, kind, reason });
174
+ return { steered: 0, runIds: [], redelivered: true };
175
+ }
176
+ } catch (error) {
177
+ log({ event: "steer_marker_failed", delivery, kind, op: "read", reason: typeof error?.code === "string" ? error.code : "valkey" });
178
+ }
179
+ }
180
+ return fellThrough(reason, extra);
181
+ };
182
+ if (runs.length === 0) return redeliveredOr("no-live-run");
183
+
184
+ const fallback = fallbackJobOf(job, kind);
185
+ const runIds = [];
186
+ const outcomes = {};
187
+ let errored = null;
188
+ for (const runId of runs) {
189
+ const stored = await storeFallback({ redis, runId, id, job: fallback });
190
+ if (stored?.ok !== true) {
191
+ errored ??= stored?.error ?? "error";
192
+ outcomes.error = (outcomes.error ?? 0) + 1;
193
+ log({ event: "steer_fallback_failed", delivery, kind, runId, reason: stored?.error ?? "refused" });
194
+ continue;
195
+ }
196
+ const r = await pushSteer({ redis, runId, entry, now });
197
+ const outcome = typeof r === "string" ? r : "error";
198
+ outcomes[outcome] = (outcomes[outcome] ?? 0) + 1;
199
+ if (r === "ok" || r === "dup") runIds.push(runId);
200
+ else if (typeof r !== "string") errored ??= r?.error ?? "error";
201
+ }
202
+ if (runIds.length > 0) {
203
+ // A run that missed while another took the comment is named, so a fanout that half worked is visible.
204
+ if (runIds.length < runs.length) log({ event: "steer_partial", delivery, kind, runs: runs.length, steered: runIds.length, outcomes });
205
+ if (marker !== null) {
206
+ try {
207
+ await redis.set(marker, "1", "EX", STEER_MARKER_TTL_S, "NX");
208
+ } catch (error) {
209
+ // Still steered: the comment is in the run. Only a redelivery after the run ends is unshielded, and says so here.
210
+ log({ event: "steer_marker_failed", delivery, kind, op: "write", reason: typeof error?.code === "string" ? error.code : "valkey" });
211
+ }
212
+ }
213
+ return { steered: runIds.length, runIds };
214
+ }
215
+ if (errored !== null && Object.keys(outcomes).every((o) => o === "error")) {
216
+ log({ event: "steer_lookup_failed", delivery, kind, reason: errored });
217
+ return null;
218
+ }
219
+ const kinds = Object.keys(outcomes);
220
+ return redeliveredOr(kinds.length === 1 ? kinds[0] : "mixed", { runs: runs.length, outcomes });
221
+ }
@@ -0,0 +1,57 @@
1
+ /** The receiver's exact rule grouping, shared with read-only hypothetical matching (#750). */
2
+ import { FORGE_KINDS, PR_CLOSE_ACTIONS } from "@edgehero/pi-dispatch/triggers";
3
+
4
+ export function groupTriggers(parsed, { watchSources = {} } = {}) {
5
+ // Every forge gets a group whether or not the file names it, so the filter can read
6
+ // `cfg.triggers[kind].label` without a presence check and an unconfigured forge simply matches nothing.
7
+ //
8
+ // Built FROM the forge table rather than written out, because the failure of forgetting one is unusually
9
+ // quiet: `groups[run.kind]` would be undefined, `group.label.push` would throw, and `config.mjs` reload
10
+ // catches everything and KEEPS the previously-loaded triggers. The operator would see one
11
+ // "invalid" message and their old rules would go on firing indefinitely. A test asserts this object's
12
+ // keys are exactly FORGE_KINDS, so the two can never drift.
13
+ const groups = Object.fromEntries(FORGE_KINDS.map((kind) => [kind, emptyGroup()]));
14
+ const knownFlows = new Set();
15
+
16
+ for (const [index, { on, run }] of parsed.entries()) {
17
+ if (on.type === "cron" || on.type === "watch") continue;
18
+ // Guarded because a command trigger (issue #189) has NO run.flow, and `Set.add(undefined)` would put
19
+ // undefined into the comment `<phrase> <flow>` override allowlist. That set exists to bound which
20
+ // names a collaborator's comment may summon; polluting it with a non-name is how a later loose
21
+ // membership test (or a `[...set]` enumeration in a message) starts treating "no flow" as a flow.
22
+ if (typeof run.flow === "string") {
23
+ knownFlows.add(run.flow);
24
+ }
25
+ const group = groups[run.kind];
26
+ if (on.type === "label") {
27
+ group.label.push({ index, predicate: { any: on.any, all: on.all, none: on.none }, flow: run.flow, command: run.command, packages: run.packages, image: run.image, backend: run.backend, excludeTools: run.excludeTools, excludeExtensions: run.excludeExtensions, excludeExtensionTools: run.excludeExtensionTools, mcp: run.mcp, provider: run.provider, model: run.model, maxTurns: run.maxTurns, models: run.models, fallbackModels: run.fallbackModels, maxCostUsd: run.maxCostUsd, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, awaitInput: run.awaitInput, ...(run.gates !== undefined ? { gates: run.gates, ...(run.gateTimeout !== undefined ? { gateTimeout: run.gateTimeout } : {}), ...(run.gateBudgetRaiseUsd !== undefined ? { gateBudgetRaiseUsd: run.gateBudgetRaiseUsd } : {}) } : {}), progress: run.progress, statusComment: run.statusComment, recover: run.recover, steer: run.steer, secrets: run.secrets, secretsProfile: run.secretsProfile, waitFor: run.waitFor, replicas: run.replicas, repository: run.repository });
28
+ } else if (on.type === "comment") {
29
+ group.comment = { index, phrase: on.phrase, defaultFlow: run.flow, command: run.command, packages: run.packages, image: run.image, backend: run.backend, excludeTools: run.excludeTools, excludeExtensions: run.excludeExtensions, excludeExtensionTools: run.excludeExtensionTools, mcp: run.mcp, provider: run.provider, model: run.model, maxTurns: run.maxTurns, models: run.models, fallbackModels: run.fallbackModels, maxCostUsd: run.maxCostUsd, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, awaitInput: run.awaitInput, ...(run.gates !== undefined ? { gates: run.gates, ...(run.gateTimeout !== undefined ? { gateTimeout: run.gateTimeout } : {}), ...(run.gateBudgetRaiseUsd !== undefined ? { gateBudgetRaiseUsd: run.gateBudgetRaiseUsd } : {}) } : {}), progress: run.progress, statusComment: run.statusComment, recover: run.recover, steer: run.steer, secrets: run.secrets, secretsProfile: run.secretsProfile, waitFor: run.waitFor, replicas: run.replicas, repository: run.repository }; // parseTriggers guarantees at most one per forge
30
+ } else if (on.type === "pull_request") {
31
+ // The close-only split (issue #231): a close rule routes through the close gate (the CLOSER's
32
+ // resolved authority) and every other PR rule through the author gate, so they live in separate
33
+ // groups and neither route ever re-derives the distinction -- the loader already refused any
34
+ // list mixing the close word with another action, which is what makes this split total. The
35
+ // split reads the SHARED close-word table, never a re-typed word.
36
+ const closeWord = PR_CLOSE_ACTIONS[run.kind];
37
+ if (closeWord !== undefined && on.action.includes(closeWord)) {
38
+ group.prClose.push({ index, actions: new Set(on.action), number: on.number, once: on.once, flow: run.flow, command: run.command, packages: run.packages, image: run.image, backend: run.backend, excludeTools: run.excludeTools, excludeExtensions: run.excludeExtensions, excludeExtensionTools: run.excludeExtensionTools, mcp: run.mcp, provider: run.provider, model: run.model, maxTurns: run.maxTurns, models: run.models, fallbackModels: run.fallbackModels, maxCostUsd: run.maxCostUsd, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, awaitInput: run.awaitInput, ...(run.gates !== undefined ? { gates: run.gates, ...(run.gateTimeout !== undefined ? { gateTimeout: run.gateTimeout } : {}), ...(run.gateBudgetRaiseUsd !== undefined ? { gateBudgetRaiseUsd: run.gateBudgetRaiseUsd } : {}) } : {}), progress: run.progress, statusComment: run.statusComment, recover: run.recover, steer: run.steer, secrets: run.secrets, secretsProfile: run.secretsProfile, waitFor: run.waitFor, replicas: run.replicas });
39
+ } else {
40
+ // `reviewStates` is null rather than an empty Set when unnarrowed: the filter tests it for
41
+ // presence, and an empty Set would read as "no verdict matches" and silently refuse everything.
42
+ group.pullRequest.push({ index, actions: new Set(on.action), reviewStates: on.reviewState ? new Set(on.reviewState) : null, predicate: { any: on.any, all: on.all, none: on.none }, flow: run.flow, command: run.command, packages: run.packages, image: run.image, backend: run.backend, excludeTools: run.excludeTools, excludeExtensions: run.excludeExtensions, excludeExtensionTools: run.excludeExtensionTools, mcp: run.mcp, provider: run.provider, model: run.model, maxTurns: run.maxTurns, models: run.models, fallbackModels: run.fallbackModels, maxCostUsd: run.maxCostUsd, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, awaitInput: run.awaitInput, ...(run.gates !== undefined ? { gates: run.gates, ...(run.gateTimeout !== undefined ? { gateTimeout: run.gateTimeout } : {}), ...(run.gateBudgetRaiseUsd !== undefined ? { gateBudgetRaiseUsd: run.gateBudgetRaiseUsd } : {}) } : {}), progress: run.progress, statusComment: run.statusComment, recover: run.recover, steer: run.steer, secrets: run.secrets, secretsProfile: run.secretsProfile, waitFor: run.waitFor, replicas: run.replicas });
43
+ }
44
+ } else if (on.type === "issue") {
45
+ // The close-trigger kind (issue #231). No predicate and no reviewStates by construction (the
46
+ // loader refused both), and `number`/`once` ride conditionally-present exactly as the loader
47
+ // normalized them -- the filter's close route reads them, nothing else does.
48
+ group.issue.push({ index, actions: new Set(on.action), number: on.number, once: on.once, flow: run.flow, command: run.command, packages: run.packages, image: run.image, backend: run.backend, excludeTools: run.excludeTools, excludeExtensions: run.excludeExtensions, excludeExtensionTools: run.excludeExtensionTools, mcp: run.mcp, provider: run.provider, model: run.model, maxTurns: run.maxTurns, models: run.models, fallbackModels: run.fallbackModels, maxCostUsd: run.maxCostUsd, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, awaitInput: run.awaitInput, ...(run.gates !== undefined ? { gates: run.gates, ...(run.gateTimeout !== undefined ? { gateTimeout: run.gateTimeout } : {}), ...(run.gateBudgetRaiseUsd !== undefined ? { gateBudgetRaiseUsd: run.gateBudgetRaiseUsd } : {}) } : {}), progress: run.progress, statusComment: run.statusComment, recover: run.recover, steer: run.steer, secrets: run.secrets, secretsProfile: run.secretsProfile, waitFor: run.waitFor, replicas: run.replicas });
49
+ }
50
+ }
51
+
52
+ return { ...groups, knownFlows, ...(Object.keys(watchSources).length ? { watchSources, watch: parsed.filter(t => t.on.type === "watch") } : {}) };
53
+ }
54
+
55
+ function emptyGroup() {
56
+ return { label: [], comment: null, pullRequest: [], issue: [], prClose: [] };
57
+ }
@@ -28,7 +28,7 @@
28
28
  */
29
29
 
30
30
  import { timingSafeEqual } from "node:crypto";
31
- import { isJsonContentType, readRawBody, respond } from "./http-body.mjs";
31
+ import { isJsonContentType, readRawBody, respond } from "@edgehero/pi-dispatch/http-body";
32
32
 
33
33
  const MAX_BODY_BYTES = 2 * 1024 * 1024;
34
34
 
@@ -24,7 +24,7 @@
24
24
  */
25
25
 
26
26
  import { createHmac, timingSafeEqual, createHash } from "node:crypto";
27
- import { isJsonContentType, readRawBody, respond } from "./http-body.mjs";
27
+ import { isJsonContentType, readRawBody, respond } from "@edgehero/pi-dispatch/http-body";
28
28
 
29
29
  /**
30
30
  * Standard Webhooks' replay window. The dedup layer already refuses a repeated delivery id, but that
@@ -72,12 +72,13 @@ export function makeGitLabVerifiedHandler({ mode, secret, bodyLimit = 2 * 1024 *
72
72
  return respond(res, 400, { error: "missing gitlab event header" });
73
73
  }
74
74
 
75
- // The delivery id is stable across GitLab's own retries, which is exactly the property dedup
76
- // needs (REQ-DEDUP-BY-DELIVERY-GUID). `webhook-id` is the Standard Webhooks name (19.0+);
75
+ // The delivery id is the one GitLab keeps when a delivery is resent by hand ("Resend Request" or the
76
+ // API; GitLab never resends on its own), which is exactly the property dedup needs
77
+ // (REQ-DEDUP-BY-DELIVERY-GUID). `webhook-id` is the Standard Webhooks name (19.0+);
77
78
  // `Idempotency-Key` is the same value under its original name (17.4+).
78
79
  //
79
80
  // An instance too old for either is REFUSED rather than served on a synthesised key. A key
80
- // derived from the payload -- object id plus action, say -- is not stable across a retry that
81
+ // derived from the payload -- object id plus action, say -- is not stable across a resend that
81
82
  // changes nothing an operator can see, so it would dedup some redeliveries and bill for the
82
83
  // rest. Naming the minimum version is a message; a weaker key would be a silent second charge.
83
84
  delivery = req.headers["webhook-id"] ?? req.headers["idempotency-key"];
@@ -0,0 +1,40 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+ import { isJsonContentType, readRawBody, respond } from "@edgehero/pi-dispatch/http-body";
3
+
4
+ export const WATCH_TIMESTAMP_TOLERANCE_MS = 300000;
5
+ const DELIVERY_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/;
6
+ export function watchSigningMessage(timestamp, delivery, rawBody) {
7
+ return Buffer.concat([Buffer.from(`v1.${timestamp}.${delivery}.`), Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody)]);
8
+ }
9
+ export function verifyWatchSignature({ secret, timestamp, delivery, signature, rawBody, now = () => Date.now() }) {
10
+ if (typeof timestamp !== "string" || !/^[1-9][0-9]{0,11}$/.test(timestamp) || typeof delivery !== "string" || !DELIVERY_RE.test(delivery) || typeof signature !== "string" || !/^v1=[a-f0-9]{64}$/.test(signature)) return false;
11
+ if (Math.abs(now() - Number(timestamp) * 1000) > WATCH_TIMESTAMP_TOLERANCE_MS) return false;
12
+ const expected = createHmac("sha256", secret).update(watchSigningMessage(timestamp, delivery, rawBody)).digest();
13
+ return timingSafeEqual(expected, Buffer.from(signature.slice(3), "hex"));
14
+ }
15
+ export function makeWatchVerifiedHandler({ secret, bodyLimit = 65536, now = () => Date.now(), log = () => {} }, onVerified) {
16
+ if (typeof secret !== "string" || !secret) throw new Error("watch verification requires a non-empty secret");
17
+ if (!Number.isSafeInteger(bodyLimit) || bodyLimit < 1 || bodyLimit > 65536) throw new Error("watch body limit is invalid");
18
+ return async function watchVerifiedHandler(req, res) {
19
+ try {
20
+ if (req.method !== "POST") return respond(res, 405, { error: "method not allowed" });
21
+ if (!isJsonContentType(req.headers["content-type"])) return respond(res, 415, { error: "unsupported media type" });
22
+ const timestamp = req.headers["x-watch-timestamp"];
23
+ const delivery = req.headers["x-watch-id"];
24
+ const signature = req.headers["x-watch-signature"];
25
+ if (typeof timestamp !== "string" || typeof delivery !== "string" || typeof signature !== "string") { log("watch_signature", { verdict: "missing" }); return respond(res, 401, { error: "invalid watch signature" }); }
26
+ const length = Number(req.headers["content-length"]);
27
+ if (Number.isFinite(length) && length > bodyLimit) return respond(res, 413, { error: "payload too large" });
28
+ let raw;
29
+ try { raw = await readRawBody(req, bodyLimit); } catch (error) {
30
+ if (error?.code === "E_PAYLOAD_TOO_LARGE") return respond(res, 413, { error: "payload too large" });
31
+ throw error;
32
+ }
33
+ if (!verifyWatchSignature({ secret, timestamp, delivery, signature, rawBody: raw, now })) { log("watch_signature", { verdict: "invalid" }); return respond(res, 401, { error: "invalid watch signature" }); }
34
+ await onVerified({ rawBody: raw.toString("utf8"), delivery, timestamp, headers: req.headers }, res);
35
+ } catch {
36
+ // Neither body, header values nor exception messages belong in receiver logs.
37
+ if (!res.headersSent) respond(res, 503, { error: "watch delivery unavailable" });
38
+ }
39
+ };
40
+ }
package/src/verify.mjs CHANGED
@@ -19,7 +19,7 @@
19
19
  */
20
20
 
21
21
  import { Webhooks } from "@octokit/webhooks";
22
- import { isJsonContentType, readRawBody, respond } from "./http-body.mjs";
22
+ import { isJsonContentType, readRawBody, respond } from "@edgehero/pi-dispatch/http-body";
23
23
 
24
24
  /**
25
25
  * Thin wrapper delegating to `webhooks.verify(rawBody, signature)`. `rawBody` is the raw UTF-8
@@ -0,0 +1,94 @@
1
+ import fs from "node:fs/promises";
2
+ import { constants } from "node:fs";
3
+ import { execFile as execute } from "node:child_process";
4
+ import { promisify } from "node:util";
5
+ import { createHash } from "node:crypto";
6
+ import { parseWatchFinding } from "@edgehero/pi-dispatch/watch-contract";
7
+ const execFile = promisify(execute);
8
+ const MAX_BYTES = 65536;
9
+ const digest = text => createHash("sha256").update(text).digest("hex");
10
+
11
+ /** A poll is bounded and has no model. The caller checkpoints only after every finding is accepted. */
12
+ export function makeWatchPullAdapter(config, { fileSystem = fs, run = execFile, fetchFn = fetch, env = process.env, now = () => Date.now(), redact = () => "[redacted]" } = {}) {
13
+ if (!["file", "journald", "loki"].includes(config.kind)) throw new Error("unsupported watch pull source");
14
+ if (!config.fields || typeof config.fields !== "object" || Array.isArray(config.fields)) throw new Error("watch pull requires reviewed structured fields");
15
+ function finding(message, fields = {}, severity = config.severity ?? "error", at = new Date(now()).toISOString()) {
16
+ const sample = redact(String(message).slice(0, 2048));
17
+ return parseWatchFinding({ fields: { ...fields, ...config.fields }, severity, count: 1, firstSeen: at, lastSeen: at, samples: [sample] });
18
+ }
19
+ return {
20
+ async poll(cursor = {}) {
21
+ if (config.kind === "file") {
22
+ if (typeof config.path !== "string" || !config.path.startsWith("/")) throw new Error("watch file path must be absolute");
23
+ let handle;
24
+ try {
25
+ handle = await fileSystem.open(config.path, constants.O_RDONLY | constants.O_NOFOLLOW);
26
+ const stat = await handle.stat(); if (!stat.isFile()) throw new Error("watch input is not a regular file");
27
+ const identity = `${stat.dev}:${stat.ino}`;
28
+ const offset = cursor.identity === identity && Number.isSafeInteger(cursor.offset) && cursor.offset >= 0 && cursor.offset <= stat.size ? cursor.offset : 0;
29
+ const buffer = Buffer.alloc(MAX_BYTES); const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset);
30
+ const bytes = buffer.subarray(0, bytesRead); const end = bytes.lastIndexOf(10);
31
+ if (end < 0 && bytesRead === MAX_BYTES) throw new Error("watch log line exceeds read limit");
32
+ const findings = []; const deliveries = []; let lineOffset = offset;
33
+ if (end >= 0) for (const line of bytes.subarray(0, end).toString("utf8").split("\n")) {
34
+ const recordOffset = lineOffset; lineOffset += Buffer.byteLength(line) + 1;
35
+ if (!line || config.contains && !line.includes(config.contains)) continue;
36
+ findings.push(finding(line)); deliveries.push(digest(`${identity}:${recordOffset}:${line}`));
37
+ if (findings.length > 100) throw new Error("watch file batch exceeds finding limit");
38
+ }
39
+ return { findings, deliveries, cursor: { identity, offset: offset + end + 1 } };
40
+ } catch { throw new Error("watch file poll unavailable"); } finally { await handle?.close(); }
41
+ }
42
+ if (config.kind === "journald") {
43
+ if (typeof config.unit !== "string" || !/^[A-Za-z0-9_.@-]{1,128}$/.test(config.unit)) throw new Error("invalid watch journal unit");
44
+ if (cursor.after !== undefined && (typeof cursor.after !== "string" || cursor.after.length > 512 || /[\r\n\u0000]/.test(cursor.after))) throw new Error("invalid watch journal cursor");
45
+ const args = ["--unit", config.unit, "--output=json", "--no-pager", "--no-tail", ...(cursor.after ? ["--after-cursor", cursor.after] : ["--since", new Date(now() - 60000).toISOString()])];
46
+ let stdout;
47
+ try { ({ stdout } = await run("journalctl", args, { timeout: 5000, maxBuffer: MAX_BYTES, encoding: "utf8" })); } catch { throw new Error("watch journal poll unavailable"); }
48
+ if (Buffer.byteLength(stdout) > MAX_BYTES) throw new Error("watch journal response exceeds limit");
49
+ const findings = []; const deliveries = []; let after = cursor.after;
50
+ for (const line of stdout.trim().split("\n").filter(Boolean)) {
51
+ let entry; try { entry = JSON.parse(line); } catch { throw new Error("invalid watch journal response"); }
52
+ if (typeof entry.__CURSOR !== "string" || entry.__CURSOR.length > 512) throw new Error("watch journal cursor missing");
53
+ after = entry.__CURSOR;
54
+ if (typeof entry.MESSAGE !== "string" || config.contains && !entry.MESSAGE.includes(config.contains)) continue;
55
+ findings.push(finding(entry.MESSAGE)); deliveries.push(digest(entry.__CURSOR)); if (findings.length > 100) throw new Error("watch journal batch exceeds limit");
56
+ }
57
+ return { findings, deliveries, cursor: { after } };
58
+ }
59
+ let endpoint;
60
+ try { endpoint = new URL(config.url); } catch { throw new Error("invalid watch Loki endpoint"); }
61
+ if (endpoint.protocol !== "https:" || endpoint.username || endpoint.password || endpoint.search || endpoint.hash || typeof config.query !== "string" || config.query.length > 4096) throw new Error("invalid watch Loki configuration");
62
+ endpoint.pathname = endpoint.pathname.replace(/\/$/, "") + "/loki/api/v1/query_range";
63
+ const start = cursor.startNs ?? String(BigInt(now() - 60000) * 1000000n);
64
+ if (typeof start !== "string" || !/^[0-9]{1,24}$/.test(start)) throw new Error("invalid watch Loki cursor");
65
+ endpoint.search = new URLSearchParams({ query: config.query, start, end: String(BigInt(now()) * 1000000n), limit: "100", direction: "forward" }).toString();
66
+ const token = config.secretEnv ? env[config.secretEnv] : undefined;
67
+ if (config.secretEnv && !token) throw new Error("watch Loki credential unavailable");
68
+ let response;
69
+ try { response = await fetchFn(endpoint, { redirect: "error", signal: AbortSignal.timeout(5000), headers: token ? { authorization: `Bearer ${token}` } : {} }); } catch { throw new Error("watch Loki poll unavailable"); }
70
+ if (!response.ok) { await response.body?.cancel?.(); throw new Error("watch Loki request refused"); }
71
+ let bytes = 0; const chunks = []; const reader = response.body.getReader();
72
+ try { while (true) { const { done, value } = await reader.read(); if (done) break; bytes += value.byteLength; if (bytes > MAX_BYTES) throw new Error("limit"); chunks.push(Buffer.from(value)); } } catch { await reader.cancel().catch(() => {}); throw new Error("watch Loki response exceeds limit"); } finally { reader.releaseLock(); }
73
+ let result; try { result = JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch { throw new Error("invalid watch Loki response"); }
74
+ if (result.status !== "success" || result.data?.resultType !== "streams" || !Array.isArray(result.data.result)) throw new Error("invalid watch Loki response");
75
+ const entries = [];
76
+ for (const stream of result.data.result) {
77
+ if (!Array.isArray(stream.values)) throw new Error("invalid watch Loki stream");
78
+ for (const value of stream.values) { if (!Array.isArray(value) || !/^[0-9]{1,24}$/.test(value[0]) || typeof value[1] !== "string") throw new Error("invalid watch Loki value"); entries.push({ timestamp: value[0], message: value[1], fields: stream.stream ?? {} }); }
79
+ }
80
+ if (entries.length > 100) throw new Error("watch Loki batch exceeds limit");
81
+ entries.sort((a, b) => BigInt(a.timestamp) < BigInt(b.timestamp) ? -1 : BigInt(a.timestamp) > BigInt(b.timestamp) ? 1 : 0);
82
+ if (cursor.seen !== undefined && (!Array.isArray(cursor.seen) || cursor.seen.length > 100 || cursor.seen.some(x => typeof x !== "string" || !/^[a-f0-9]{64}$/.test(x)))) throw new Error("invalid watch Loki cursor");
83
+ const seen = new Set(cursor.seen ?? []); const findings = []; const deliveries = []; let last = start;
84
+ for (const entry of entries) {
85
+ if (BigInt(entry.timestamp) < BigInt(start)) throw new Error("watch Loki cursor moved backwards");
86
+ const id = digest(JSON.stringify(entry)); if (entry.timestamp === start && seen.has(id)) continue;
87
+ if (last !== entry.timestamp) seen.clear(); last = entry.timestamp; seen.add(id);
88
+ if (!config.contains || entry.message.includes(config.contains)) { findings.push(finding(entry.message, entry.fields, config.severity ?? "error", new Date(Number(BigInt(entry.timestamp) / 1000000n)).toISOString())); deliveries.push(id); }
89
+ }
90
+ if (entries.length === 100 && findings.length === 0) throw new Error("watch Loki timestamp batch cannot advance");
91
+ return { findings, deliveries, cursor: { startNs: last, seen: [...seen] } };
92
+ },
93
+ };
94
+ }
@@ -0,0 +1,82 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { loadReceiverConfig, reloadTriggers } from "./config.mjs";
3
+ import { makeWatchPullAdapter } from "./watch-pull.mjs";
4
+ import { ingestWatchFinding } from "./watch.mjs";
5
+ import { makeWatchStore } from "@edgehero/pi-dispatch/watch-store";
6
+ import { makeQueue, enqueueWatchJob } from "@edgehero/pi-dispatch/queue";
7
+ import { judgeValkeyAtStart, valkeyClientContext, makeRedisClient, parseConnection } from "@edgehero/pi-dispatch/connection";
8
+ import { configError } from "@edgehero/pi-dispatch/config";
9
+ const hash = value => createHash("sha256").update(JSON.stringify(value)).digest("hex");
10
+ const RELEASE = "if redis.call('GET',KEYS[1])==ARGV[1] then return redis.call('DEL',KEYS[1]) end return 0";
11
+ const CHECKPOINT = "if redis.call('GET',KEYS[1])~=ARGV[1] then return 0 end redis.call('SET',KEYS[2],ARGV[2],'PX',ARGV[3]); return 1";
12
+ /**
13
+ * How long a pull source's cursor is kept, in MILLISECONDS (the checkpoint's `PX`), renewed by every checkpoint: 35 days
14
+ * (docs/watch.md), so a collector stopped for weeks resumes where it left off. It is a resume point, NOT a dedup window,
15
+ * and deliberately not derived from the forge job retention (`FORGE_JOB_RETENTION_S`, the time a forge redelivery is
16
+ * refused): no forge job id stands behind it. Watch deliveries are deduplicated by the watch store's replay keys and
17
+ * the watch queue's job ids, both kept seven days, and a cursor that expires only costs a re-read (a file from its
18
+ * start, journald and Loki from a minute before the next poll), so tying it to the forge retention would move a
19
+ * collector's resume window for a reason that has nothing to do with it.
20
+ */
21
+ export const WATCH_PULL_CURSOR_TTL_MS = 35 * 86_400_000;
22
+
23
+ /** One bounded cycle, also the test seam for restart, partial enqueue and concurrent-producer behavior. */
24
+ export async function pollWatchSources({ cfg, redis, store, enqueue, adapterFactory = makeWatchPullAdapter, env = process.env, now = () => Date.now(), log = () => {}, due = new Map() }) {
25
+ const sources = cfg.triggers?.watchSources ?? cfg.watchSources ?? {};
26
+ for (const known of due.keys()) if (!Object.hasOwn(sources, known)) due.delete(known);
27
+ let failures = 0;
28
+ for (const [source, declaration] of Object.entries(sources)) {
29
+ if (!declaration.pull || (due.get(source) ?? 0) > now()) continue;
30
+ const identity = hash([source, declaration.pull]); const leaseKey = `watch:pull:lease:${identity}`; const cursorKey = `watch:pull:cursor:${identity}`; const token = randomUUID();
31
+ if (!await redis.set(leaseKey, token, "NX", "PX", 15000)) continue;
32
+ try {
33
+ const raw = await redis.get(cursorKey); const cursor = raw ? JSON.parse(raw) : {};
34
+ const adapter = adapterFactory(declaration.pull, { env, now });
35
+ const batch = await adapter.poll(cursor);
36
+ if (!Array.isArray(batch.findings) || !Array.isArray(batch.deliveries) || batch.findings.length !== batch.deliveries.length || batch.findings.length > 100 || Buffer.byteLength(JSON.stringify(batch.cursor)) > 32768) throw new Error("invalid watch pull batch");
37
+ for (const [index, finding] of batch.findings.entries()) await ingestWatchFinding({ cfg, store, enqueue, source, finding, delivery: batch.deliveries[index], log, signature: "host-pull" });
38
+ if (!await redis.eval(CHECKPOINT, 2, leaseKey, cursorKey, token, JSON.stringify(batch.cursor), WATCH_PULL_CURSOR_TTL_MS)) throw new Error("watch pull lease lost");
39
+ due.set(source, now() + (declaration.pull.intervalSeconds ?? 30) * 1000);
40
+ log("watch_pull_cycle", { source, count: batch.findings.length, status: "checkpointed" });
41
+ } catch { failures++; log("watch_pull_source_failed", { source, reason: "source-unavailable-cursor-retained" }); } finally { try { await redis.eval(RELEASE, 1, leaseKey, token); } catch { failures++; } }
42
+ }
43
+ if (failures) throw new Error("watch pull sources unavailable");
44
+ }
45
+
46
+ export async function startWatchPuller(env = process.env, deps = {}) {
47
+ const { now = () => Date.now(), signals = true, out = value => process.stdout.write(`${JSON.stringify(value)}\n`), loadConfig = loadReceiverConfig, reload = reloadTriggers, adapterFactory = makeWatchPullAdapter } = deps;
48
+ const cfg = loadConfig(env);
49
+ if (!Object.values(cfg.triggers?.watchSources ?? {}).some(source => source.pull)) throw configError("watch: no pull sources configured");
50
+ const ownedRedis = !deps.redis; const ownedQueue = !deps.enqueue;
51
+ if (ownedRedis || ownedQueue) await (deps.judgeValkey ?? judgeValkeyAtStart)(cfg.valkeyUrl, valkeyClientContext({ env }), { now });
52
+ const redis = deps.redis ?? makeRedisClient(cfg.valkeyUrl);
53
+ const queue = ownedQueue ? (deps.makeQueueFn ?? makeQueue)(parseConnection(cfg.valkeyUrl)) : null;
54
+ const enqueue = deps.enqueue ?? (fields => enqueueWatchJob(queue, fields, { now }));
55
+ const store = deps.store ?? makeWatchStore(redis, { now }); const due = new Map();
56
+ let stopped = false; let wake; let timer;
57
+ const log = (event, fields) => out({ event, ...fields });
58
+ const wait = () => new Promise(resolve => { wake = resolve; timer = setTimeout(resolve, 1000); });
59
+ const stop = () => {
60
+ stopped = true; clearTimeout(timer); wake?.();
61
+ // Disconnect our clients to release an in-flight reconnect wait. No cursor is advanced until
62
+ // every enqueue succeeded, so interruption converges on the same delivery and finding IDs.
63
+ if (ownedRedis) redis.disconnect();
64
+ if (ownedQueue) void queue.disconnect().catch(() => {});
65
+ };
66
+ const signal = () => stop();
67
+ if (signals) { process.once("SIGTERM", signal); process.once("SIGINT", signal); }
68
+ const done = (async () => {
69
+ try {
70
+ while (!stopped) {
71
+ const reloaded = reload(env, cfg);
72
+ if (reloaded.invalid) log("watch_pull_config_invalid", { reason: "invalid-config-kept-last-good" });
73
+ try { await pollWatchSources({ cfg, redis, store, enqueue, adapterFactory, env, now, log, due }); } catch { if (!stopped) log("watch_pull_failed", { reason: "source-unavailable-cursor-retained" }); }
74
+ if (!stopped) await wait(); clearTimeout(timer);
75
+ }
76
+ } finally {
77
+ clearTimeout(timer); if (signals) { process.removeListener("SIGTERM", signal); process.removeListener("SIGINT", signal); }
78
+ if (ownedRedis) redis.disconnect(); if (ownedQueue) await queue.disconnect().catch(() => {});
79
+ }
80
+ })();
81
+ return { stop, done };
82
+ }
package/src/watch.mjs ADDED
@@ -0,0 +1,60 @@
1
+ import { makeWatchVerifiedHandler } from "./verify-watch.mjs";
2
+ import { parseWatchFinding, matchesWatchFinding, watchFingerprint } from "@edgehero/pi-dispatch/watch-contract";
3
+ import { respond } from "@edgehero/pi-dispatch/http-body";
4
+ import { createHash } from "node:crypto";
5
+
6
+ /** Push and pull share the same deterministic admission; collector text is discarded by default. */
7
+ export async function ingestWatchFinding({ cfg, store, enqueue, source, finding, delivery, log = () => {}, redact = () => "[redacted]", signature = "verified" }) {
8
+ const sources = cfg.watchSources ?? cfg.triggers?.watchSources ?? {};
9
+ if (!Object.hasOwn(sources, source)) throw new Error("watch source not configured");
10
+ const declared = sources[source];
11
+ const safeFinding = parseWatchFinding({ ...finding, samples: finding.samples.map(sample => redact(sample)) });
12
+ const rules = cfg.watchTriggers ?? cfg.triggers?.watch ?? [];
13
+ const results = [];
14
+ // Validate every matching identity before any rule can enqueue, so one malformed finding
15
+ // cannot partially start work and then receive a determinate bad-input response.
16
+ for (const entry of rules) { const rule = entry.on ?? entry; if (rule.source === source && matchesWatchFinding(rule, safeFinding)) watchFingerprint(source, rule, safeFinding); }
17
+ for (const entry of rules) {
18
+ const rule = entry.on ?? entry;
19
+ if (rule.source !== source) continue;
20
+ if (!matchesWatchFinding(rule, safeFinding)) {
21
+ await store.recordVerdict?.(rule.id, source, "unmatched");
22
+ continue;
23
+ }
24
+ const policyDigest = createHash("sha256").update(JSON.stringify(entry)).digest("hex");
25
+ const result = await store.ingest({ source, rule, finding: { ...safeFinding, policyDigest }, delivery, sourceMaxPerDay: declared.maxPerDay });
26
+ if (entry.run?.issue && store.noteIssueActivity) await store.noteIssueActivity({ target: entry.run.issue, source, ruleId: rule.id, fingerprint: result.fingerprint ?? watchFingerprint(source, rule, safeFinding) });
27
+ await store.recordVerdict?.(rule.id, source, result.status);
28
+ if (result.id && store.appendTrace) {
29
+ const traceId = createHash("sha256").update(JSON.stringify([source, rule.id, delivery])).digest("hex");
30
+ await store.appendTrace(result.id, { id: `${traceId}-stream`, kind: "stream", source, signature, count: safeFinding.count, fingerprint: result.fingerprint });
31
+ await store.appendTrace(result.id, { id: `${traceId}-rule`, kind: "rule", ruleId: rule.id, status: result.status, checks: { match: "passed", threshold: "at-window-end", cooldown: result.status === "suppressed" ? "active" : "passed", cap: result.status === "capped" ? "refused" : "passed" } });
32
+ }
33
+ if (result.status === "held") await enqueue({ id: result.id, ruleId: rule.id, dueAt: result.dueAt });
34
+ log("watch_delivery", { source, trigger: rule.id, fingerprint: result.fingerprint?.slice(0, 12), verdict: result.status });
35
+ results.push({ trigger: rule.id, ...result });
36
+ }
37
+ const status = results.some(x => x.status === "held") ? "held" : results.some(x => x.status === "capped") ? "capped" : results.some(x => x.status === "suppressed") ? "suppressed" : "unmatched";
38
+ return { status, results };
39
+ }
40
+
41
+ /** Config is read per request so a reviewed source removal takes effect without restarting. */
42
+ export function makeWatchHandler({ cfg, store, enqueue, env = process.env, now = () => Date.now(), log = () => {}, redact = () => "[redacted]" }) {
43
+ return async function watchHandler(req, res, namedSource) {
44
+ const source = namedSource ?? String(req.url ?? "").split("?")[0].match(/^\/watch\/([a-zA-Z0-9_-]+)\/?$/)?.[1];
45
+ const sources = cfg.watchSources ?? cfg.triggers?.watchSources ?? {};
46
+ if (!source || !Object.hasOwn(sources, source)) return respond(res, 404, { error: "watch source not configured" });
47
+ const declared = sources[source]; const secret = env[declared.secretEnv];
48
+ if (typeof secret !== "string" || !secret) return respond(res, 503, { error: "watch source secret unavailable" });
49
+ const verified = makeWatchVerifiedHandler({ secret, bodyLimit: declared.maxBodyBytes, now, log: (_event, fields) => log("watch_signature", { source, ...fields }) }, async ({ rawBody, delivery }, reply) => {
50
+ let finding;
51
+ try { finding = parseWatchFinding(JSON.parse(rawBody)); } catch { return respond(reply, 400, { error: "invalid watch finding" }); }
52
+ if (finding.source !== undefined && finding.source !== source) return respond(reply, 400, { error: "finding source mismatch" });
53
+ try {
54
+ const result = await ingestWatchFinding({ cfg, store, enqueue, source, finding, delivery, log, redact });
55
+ return respond(reply, 202, result);
56
+ } catch (error) { if (error?.piDispatchConfig) return respond(reply, 400, { error: "invalid watch finding" }); throw error; }
57
+ });
58
+ return verified(req, res);
59
+ };
60
+ }