@edgehero/pi-dispatch-receiver 4.3.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -2
- package/package.json +2 -2
- package/src/azure-members.mjs +6 -3
- package/src/boot-retry.mjs +1 -1
- package/src/cli.mjs +9 -1
- package/src/config.mjs +45 -57
- package/src/filter-azure.mjs +150 -99
- package/src/filter-forgejo.mjs +148 -88
- package/src/filter-gitlab.mjs +149 -90
- package/src/filter.mjs +168 -113
- package/src/forgejo-members.mjs +43 -9
- package/src/github-members.mjs +2 -1
- package/src/gitlab-members.mjs +2 -1
- package/src/held.mjs +120 -0
- package/src/poller.mjs +62 -15
- package/src/predicate.mjs +37 -0
- package/src/receiver.mjs +290 -60
- package/src/start.mjs +31 -6
- package/src/steer.mjs +221 -0
- package/src/trigger-groups.mjs +57 -0
- package/src/verify-azure.mjs +1 -1
- package/src/verify-gitlab.mjs +5 -4
- package/src/verify-watch.mjs +40 -0
- package/src/verify.mjs +1 -1
- package/src/watch-pull.mjs +94 -0
- package/src/watch-puller.mjs +82 -0
- package/src/watch.mjs +60 -0
- package/src/http-body.mjs +0 -73
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
|
+
}
|
package/src/verify-azure.mjs
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
30
|
import { timingSafeEqual } from "node:crypto";
|
|
31
|
-
import { isJsonContentType, readRawBody, respond } from "
|
|
31
|
+
import { isJsonContentType, readRawBody, respond } from "@edgehero/pi-dispatch/http-body";
|
|
32
32
|
|
|
33
33
|
const MAX_BODY_BYTES = 2 * 1024 * 1024;
|
|
34
34
|
|
package/src/verify-gitlab.mjs
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
import { createHmac, timingSafeEqual, createHash } from "node:crypto";
|
|
27
|
-
import { isJsonContentType, readRawBody, respond } from "
|
|
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
|
|
76
|
-
//
|
|
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
|
|
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 "
|
|
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
|
+
}
|