@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/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 +26 -56
- 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 +4 -3
- 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 +26 -5
- 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/poller.mjs
CHANGED
|
@@ -75,8 +75,9 @@
|
|
|
75
75
|
* close event (issue #231). Deliberately OUTSIDE the touched
|
|
76
76
|
* family, with a short ~1-day TTL of its own: the counter must
|
|
77
77
|
* decay with the outage it measures, not live with the repo.
|
|
78
|
-
* All keys carry a ~35-day TTL and are refreshed TOGETHER after each successful
|
|
79
|
-
* deliberately exceeds the
|
|
78
|
+
* All keys carry a ~35-day TTL (`CURSOR_TTL_SECONDS`) and are refreshed TOGETHER after each successful
|
|
79
|
+
* repo poll. It deliberately exceeds the gh-* jobId retention (`FORGE_JOB_RETENTION_S`,
|
|
80
|
+
* REQ-DEDUP-BY-DELIVERY-GUID) by four days, and is derived from it: the cursor and
|
|
80
81
|
* the jobId are the poller's two dedup layers, and refreshing/expiring the cursor family as a unit
|
|
81
82
|
* means an actively polled repo always has a coherent live family, while a repo REMOVED from the set
|
|
82
83
|
* decays cleanly past both windows and re-arms from scratch if it ever returns. A partially expired
|
|
@@ -111,6 +112,9 @@ import { configError } from "@edgehero/pi-dispatch/config";
|
|
|
111
112
|
import { judgeValkeyAtStart, makeRedisClient, parseConnection, valkeyClientContext } from "@edgehero/pi-dispatch/connection";
|
|
112
113
|
import { makeGitHubAuth } from "@edgehero/pi-dispatch/get-token";
|
|
113
114
|
import { enqueueGitHubJob, makeQueue } from "@edgehero/pi-dispatch/queue";
|
|
115
|
+
import { FORGE_JOB_RETENTION_S } from "@edgehero/pi-dispatch/retention";
|
|
116
|
+
import { trySteer } from "./steer.mjs";
|
|
117
|
+
import { holdAfterDedup, holdBeforeLabel } from "./held.mjs";
|
|
114
118
|
import { filter, hasCloseTriggers, wantsCloserAuthority } from "./filter.mjs";
|
|
115
119
|
import { makeResolveGitHubAuthority } from "./github-members.mjs";
|
|
116
120
|
import { parseSubset } from "./receiver.mjs";
|
|
@@ -118,8 +122,10 @@ import { loadPollerConfig } from "./poller-config.mjs";
|
|
|
118
122
|
import { retryIdentity } from "./boot-retry.mjs";
|
|
119
123
|
|
|
120
124
|
const API_URL = "https://api.github.com";
|
|
121
|
-
//
|
|
122
|
-
|
|
125
|
+
// In SECONDS (`EX`): strictly outlives the gh-* job retention (`FORGE_JOB_RETENTION_S`) by four days, derived so that
|
|
126
|
+
// lengthening the retention cannot leave the cursor the shorter of the poller's two dedup layers -- see the header's
|
|
127
|
+
// cursor/TTL section.
|
|
128
|
+
export const CURSOR_TTL_SECONDS = FORGE_JOB_RETENTION_S + 4 * 86_400;
|
|
123
129
|
// Installation repo-list refresh cadence, in cycles (~10 minutes at the default interval): fresh
|
|
124
130
|
// enough that a newly installed repo starts polling soon, cheap enough to be invisible in the quota.
|
|
125
131
|
const DISCOVERY_EVERY = 10;
|
|
@@ -347,7 +353,9 @@ export async function startPoller(env = process.env, deps = {}) {
|
|
|
347
353
|
}
|
|
348
354
|
|
|
349
355
|
cycleNo++;
|
|
350
|
-
|
|
356
|
+
// `steered` (issue #646) only when a comment steered a run this cycle, so a cycle line without one is unchanged;
|
|
357
|
+
// `held` (issue #638) by the same rule, when a reply was held for a parking run.
|
|
358
|
+
out({ event: "poll_cycle", repos: repos.length, fetched: stats.fetched, enqueued: stats.enqueued, notModified: stats.notModified, ...(stats.steered > 0 ? { steered: stats.steered } : {}), ...(stats.held > 0 ? { held: stats.held } : {}), ms: now() - startedAt });
|
|
351
359
|
if (stopped) break;
|
|
352
360
|
|
|
353
361
|
// The delay: the configured interval, raised by any x-poll-interval hint seen this cycle, and
|
|
@@ -390,7 +398,7 @@ export async function startPoller(env = process.env, deps = {}) {
|
|
|
390
398
|
}
|
|
391
399
|
|
|
392
400
|
function emptyStats() {
|
|
393
|
-
return { fetched: 0, enqueued: 0, notModified: 0, minDelaySeconds: 0 };
|
|
401
|
+
return { fetched: 0, enqueued: 0, notModified: 0, minDelaySeconds: 0, steered: 0, held: 0 };
|
|
394
402
|
}
|
|
395
403
|
|
|
396
404
|
/** 1-30s of jitter for the post-rate-limit wake, spread by the injected `random`. */
|
|
@@ -932,9 +940,10 @@ async function pollReviews(ctx, api, repo, open, stats) {
|
|
|
932
940
|
* stranger's close is the filter's own `closer-not-allowed` drop and the events cursor advances
|
|
933
941
|
* past it -- an unauthorized close never wedges the feed.
|
|
934
942
|
*
|
|
935
|
-
* An INDETERMINATE lookup has no honest verdict, and the webhook arm's answer (503,
|
|
936
|
-
*
|
|
937
|
-
* loud skip. Below the bound this THROWS, on purpose, into
|
|
943
|
+
* An INDETERMINATE lookup has no honest verdict, and the webhook arm's answer (a 503, which GitHub
|
|
944
|
+
* never resends, so it waits for a hand redelivery) has no analogue here -- the poller IS its own
|
|
945
|
+
* retry. So: bounded retry, then a loud skip. Below the bound this THROWS, on purpose, into
|
|
946
|
+
* pollLabelEvents' scoped catch: the cycle
|
|
938
947
|
* logs `poll_close_gate_retry`, the events cursor is still sitting BEFORE this event (the caller
|
|
939
948
|
* advances it only after a handler returns), so the next cycle retries exactly this close -- and
|
|
940
949
|
* only the EVENTS feed ends for the cycle, because a monotone cursor cannot skip an entry and come
|
|
@@ -973,24 +982,62 @@ async function gate(ctx, eventName, payload, deliveryId, stats) {
|
|
|
973
982
|
|
|
974
983
|
const result = filter(eventName, subset, ctx.cfg, ctx.selfId, deliveryId, closerAuthorized);
|
|
975
984
|
if (!result.enqueue) {
|
|
976
|
-
|
|
977
|
-
|
|
985
|
+
// Issue #638, the webhook arms' `heldBeforeLabelOr` at the poller's seam: a phrase-free reply that missed only the
|
|
986
|
+
// parked marker is held for an awaitInput run parking on its target; otherwise today's drop.
|
|
987
|
+
let rescued = null;
|
|
988
|
+
if (result.heldCandidate !== undefined) {
|
|
989
|
+
const c = result.heldCandidate;
|
|
990
|
+
rescued = await holdBeforeLabel({ redis: ctx.redis, candidate: c, kind: "github", now: ctx.now, log: ctx.out, delivery: deliveryId, target: `${c.target.type}#${c.target.number}` });
|
|
991
|
+
if (rescued !== null && rescued.enqueue !== true) {
|
|
992
|
+
stats.held = (stats.held ?? 0) + 1;
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
// A run just parked on the target (held.mjs): the reply is the parked reply it would have been, enqueued below.
|
|
997
|
+
if (rescued?.enqueue !== true) {
|
|
998
|
+
ctx.out({ event: "dropped", delivery: deliveryId, reason: result.reason });
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
978
1001
|
}
|
|
979
|
-
|
|
1002
|
+
// Comment steering (issue #646), the webhook arms' `steerOrEnqueue` at the poller's seam: the same `trySteer` over
|
|
1003
|
+
// the poller's own Valkey client, before any enqueue, only for a job the filter marked `steerMode`. A null falls
|
|
1004
|
+
// through to the enqueue below unchanged, minus the marker.
|
|
1005
|
+
let job = result.enqueue ? result.job : result.heldCandidate;
|
|
1006
|
+
if (job.steerMode !== undefined) {
|
|
1007
|
+
const { steerMode: mode, ...rest } = job;
|
|
1008
|
+
const steered = await trySteer({ redis: ctx.redis, job, kind: "github", now: ctx.now, log: ctx.out });
|
|
1009
|
+
// A retried cycle's comment steered before: trySteer logged `steer_redelivered`; nothing to enqueue.
|
|
1010
|
+
if (steered?.redelivered === true) return;
|
|
1011
|
+
if (steered !== null) {
|
|
1012
|
+
stats.steered = (stats.steered ?? 0) + 1;
|
|
1013
|
+
ctx.out({ event: "steered", delivery: deliveryId, repo: job.repo, target: `${job.target.type}#${job.target.number}`, mode, runs: steered.steered, runIds: steered.runIds });
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
job = rest;
|
|
1017
|
+
}
|
|
1018
|
+
const replicas = job.replicas ?? 1;
|
|
980
1019
|
let created = 0;
|
|
1020
|
+
const deduplicated = [];
|
|
981
1021
|
for (let i = 1; i <= replicas; i++) {
|
|
982
|
-
const r = await ctx.enqueue(replicas > 1 ? { ...
|
|
1022
|
+
const r = await ctx.enqueue(replicas > 1 ? { ...job, replica: i } : job);
|
|
983
1023
|
// Issue #289, the receiver arms' honesty rule at the poller's own seam: a semantic-window swallow
|
|
984
1024
|
// gets its own line with the surviving id, `enqueued` counts only what was CREATED, and a bare-id
|
|
985
1025
|
// return from an enqueue seam predating the shape counts as created -- the old behaviour exactly.
|
|
986
1026
|
if (r && typeof r === "object" && r.deduplicated === true) {
|
|
987
|
-
ctx.out({ event: "deduplicated", delivery: deliveryId, repo:
|
|
1027
|
+
ctx.out({ event: "deduplicated", delivery: deliveryId, repo: job.repo, target: `${job.target.type}#${job.target.number}`, flow: job.flow, jobId: r.jobId, survivingJobId: r.survivingJobId });
|
|
1028
|
+
deduplicated.push({ jobId: r.jobId, survivingJobId: r.survivingJobId });
|
|
988
1029
|
} else {
|
|
989
1030
|
created += 1;
|
|
990
1031
|
}
|
|
991
1032
|
}
|
|
1033
|
+
// Issue #638: a resume comment the window swallowed against an awaitInput run still going is held for that run's
|
|
1034
|
+
// settle (held.mjs asks nothing of Valkey unless the job and the swallow say so); `held` lines are its own.
|
|
1035
|
+
if (deduplicated.length > 0) {
|
|
1036
|
+
const held = await holdAfterDedup({ redis: ctx.redis, job, kind: "github", outcome: { created, deduplicated }, now: ctx.now, log: ctx.out, delivery: deliveryId, target: `${job.target.type}#${job.target.number}` });
|
|
1037
|
+
if (held !== null) stats.held = (stats.held ?? 0) + 1;
|
|
1038
|
+
}
|
|
992
1039
|
stats.enqueued += created;
|
|
993
|
-
if (created > 0) ctx.out({ event: "enqueued", delivery: deliveryId, repo:
|
|
1040
|
+
if (created > 0) ctx.out({ event: "enqueued", delivery: deliveryId, repo: job.repo, target: `${job.target.type}#${job.target.number}`, flow: job.flow, replicas: created });
|
|
994
1041
|
}
|
|
995
1042
|
|
|
996
1043
|
/**
|
package/src/predicate.mjs
CHANGED
|
@@ -60,3 +60,40 @@ export function matchesRule(L, rule) {
|
|
|
60
60
|
export function escapeRegExp(literal) {
|
|
61
61
|
return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
62
62
|
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Which way a phrase comment steers a running job (issue #646): `"followUp"` when the word right after the FIRST
|
|
66
|
+
* occurrence of the phrase is `later` (lowercase, a whole word, separated from the phrase by spaces or tabs only),
|
|
67
|
+
* else `"steer"`. The caller has already established that the body holds the phrase.
|
|
68
|
+
*
|
|
69
|
+
* The first occurrence only, and the word immediately after it only: anywhere else `later` is ordinary prose ("fix it
|
|
70
|
+
* later today" further down must not turn a correction into a follow-up). A newline between the phrase and the word
|
|
71
|
+
* does not count, because a phrase on its own line followed by a paragraph that opens with "Later" or "later" is
|
|
72
|
+
* prose, not a grammar word. Case-sensitive for the same reason. The delivered text is always the whole body: the
|
|
73
|
+
* word chooses the mode, it is never stripped.
|
|
74
|
+
*/
|
|
75
|
+
export function steerModeOf(body, phrase) {
|
|
76
|
+
if (typeof body !== "string" || typeof phrase !== "string" || phrase === "") return "steer";
|
|
77
|
+
const at = body.indexOf(phrase);
|
|
78
|
+
if (at < 0) return "steer";
|
|
79
|
+
return /^[ \t]+later(?=\s|$)/.test(body.slice(at + phrase.length)) ? "followUp" : "steer";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A filter's drop result as the receiver sees it (issue #638, DES-AWAIT-INPUT-ENDS-THE-JOB). A comment route that
|
|
84
|
+
* dropped a phrase-free reply ONLY because the parked marker was not on the issue yet returns `heldResolved`, the route
|
|
85
|
+
* it would have taken with the marker; this turns that into `heldCandidate`, the JOB that route builds, through the
|
|
86
|
+
* filter's own `build`, and returns every other drop exactly as it came (no key added, so its bytes are today's). A
|
|
87
|
+
* candidate whose build refuses (an Azure work item with no repository) is no candidate.
|
|
88
|
+
*
|
|
89
|
+
* Why the filter says it and not the receiver: the agent posts its question itself and only then calls the tool, so a
|
|
90
|
+
* person can answer in the seconds before the run has parked and the marker is set. Whether that reply would have
|
|
91
|
+
* resumed the run is a question about the trigger file and the payload, which only the filter reads; whether a run is
|
|
92
|
+
* parking right now is a Valkey question, which only the receiver asks. Still a drop: the filter stays pure.
|
|
93
|
+
*/
|
|
94
|
+
export function heldCandidateOr(dropped, build) {
|
|
95
|
+
if (dropped?.heldResolved === undefined) return dropped;
|
|
96
|
+
const { heldResolved, ...drop } = dropped;
|
|
97
|
+
const built = build(heldResolved);
|
|
98
|
+
return built?.enqueue === true ? { ...drop, heldCandidate: built.job } : drop;
|
|
99
|
+
}
|