agents-relay 2.0.4 → 2.0.6
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 +1 -1
- package/dist/dashboard-html.js +4 -49
- package/dist/dashboard.js +11 -8
- package/dist/github-webhook.js +33 -14
- package/dist/pool.js +6 -71
- package/dist/projected-store.js +3 -0
- package/dist/relayd.js +21 -44
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -160,7 +160,7 @@ reconcile leases and launches ready tasks without waiting inside the runtime sch
|
|
|
160
160
|
|
|
161
161
|
`agents-relayd` without `--pr`/`--id` runs in workspace mode by default: it discovers nested Git repositories under `~/Workspace`, keeps only GitHub `origin` remotes, and aggregates their managed PR jobs into one dashboard and worker pool. Pass `--workspace PATH` to use another root. `--repo OWNER/REPO` remains the single-repository pool mode.
|
|
162
162
|
|
|
163
|
-
For automation, Agents Relay can use GitHub App installation authentication via `AGENTS_RELAY_GITHUB_APP_ID`, `AGENTS_RELAY_GITHUB_APP_INSTALLATION_ID`, and `AGENTS_RELAY_GITHUB_APP_PRIVATE_KEY_FILE` (or matching CLI flags). Webhook mode is
|
|
163
|
+
For automation, Agents Relay can use GitHub App installation authentication via `AGENTS_RELAY_GITHUB_APP_ID`, `AGENTS_RELAY_GITHUB_APP_INSTALLATION_ID`, and `AGENTS_RELAY_GITHUB_APP_PRIVATE_KEY_FILE` (or matching CLI flags). Webhook mode is local-first: targeted webhook reconciliation is primary, dashboard auto-refresh reads the disposable in-process view restored from SQLite RelayStore state, and the broad watchdog drops to a 30-minute fallback. No separate persisted dashboard Job cache is maintained. See [docs/github-app-rate-limit.md](docs/github-app-rate-limit.md).
|
|
164
164
|
|
|
165
165
|
V1 assumes one active runner/daemon per job. Use a distributed atomic lease before running multiple reconcilers. Do not put secrets, credentials, private prompts, or large private payloads in the PR-body Job marker or PR-comment Task markers; store only summaries and artifact references.
|
|
166
166
|
|
package/dist/dashboard-html.js
CHANGED
|
@@ -395,64 +395,19 @@ function creditProvider(provider){const row=element('section','usage-provider');
|
|
|
395
395
|
function shortReset(reset){if(!Number.isFinite(reset))return '';const at=new Date(reset*1000);const minutes=Math.max(0,Math.ceil((at.getTime()-Date.now())/60000));const relative=minutes<60?minutes+'m':minutes<1440?Math.floor(minutes/60)+'h '+minutes%60+'m':Math.floor(minutes/1440)+'d '+Math.floor((minutes%1440)/60)+'h';return 'Reset '+relative;}
|
|
396
396
|
function githubBucket(label,bucket){const row=element('div','usage-row');const heading=element('div','usage-label');heading.append(element('span','',text(label)),element('span','',text(bucket.remaining+' / '+bucket.limit)));const percent=bucket.limit>0?Math.max(0,Math.min(100,100*bucket.remaining/bucket.limit)):0;const track=element('div','usage-track');track.setAttribute('role','progressbar');track.setAttribute('aria-label',label+' remaining');track.setAttribute('aria-valuemin','0');track.setAttribute('aria-valuemax','100');track.setAttribute('aria-valuenow',String(Math.round(percent)));const fill=element('div','usage-fill');fill.style.width=percent+'%';track.append(fill);row.append(heading,track);const reset=shortReset(bucket.reset);if(reset){const hint=element('div','subtle',text(reset));hint.title=new Date(bucket.reset*1000).toLocaleString();row.append(hint);}return row;}
|
|
397
397
|
function githubCounterLine(label,values){const entries=Object.entries(values||{}).sort((a,b)=>Number(b[1])-Number(a[1])).slice(0,8);return entries.length?element('div','subtle',text(label+' · '+entries.map(([key,count])=>key+' '+count).join(' · '))):null;}
|
|
398
|
-
function githubObserved(label,observed){
|
|
399
|
-
if(!observed)return null;
|
|
400
|
-
if(Number.isFinite(observed.limit)&&Number.isFinite(observed.remaining)){const row=githubBucket(label,observed);if(observed.reason)row.title=observed.reason;return row;}
|
|
401
|
-
const state=observed.throttled?'Throttled':observed.available===true?'Available':observed.available===false?'Unavailable':'Unknown';
|
|
402
|
-
const parts=[label+' observed',state];
|
|
403
|
-
if(observed.status!==null)parts.push('status '+observed.status);
|
|
404
|
-
if(observed.retryAfterSeconds!==null)parts.push('retry '+observed.retryAfterSeconds+'s');
|
|
405
|
-
const row=element('div',observed.throttled?'usage-row error-state':'usage-row subtle');
|
|
406
|
-
row.textContent=parts.filter(Boolean).join(' · ');
|
|
407
|
-
if(observed.reason)row.title=observed.reason;
|
|
408
|
-
return row;
|
|
409
|
-
}
|
|
410
|
-
function githubSecondary(status){
|
|
411
|
-
const rejection=status?.runtimeRateUsage?.rejection;
|
|
412
|
-
const row=element('div','usage-row');
|
|
413
|
-
const heading=element('div','usage-label');
|
|
414
|
-
if(rejection?.kind!=='secondary'){heading.append(element('span','',text('Secondary protection')),element('span','',text('No active cooldown')));row.append(heading);return row;}
|
|
415
|
-
const start=Date.parse(rejection.observedAt);
|
|
416
|
-
const until=Date.parse(rejection.cooldownUntil||status?.cooldownUntil||'');
|
|
417
|
-
if(!Number.isFinite(start)||!Number.isFinite(until)||until<=start){heading.append(element('span','',text('Secondary protection')),element('span','',text('Throttled')));row.classList.add('error-state');row.append(heading);return row;}
|
|
418
|
-
const total=until-start;
|
|
419
|
-
const remaining=Math.max(0,until-Date.now());
|
|
420
|
-
const percent=Math.max(0,Math.min(100,100*remaining/total));
|
|
421
|
-
heading.append(element('span','',text('Secondary cooldown')),element('span','',text(Math.ceil(remaining/1000)+'s')));
|
|
422
|
-
const track=element('div','usage-track');track.setAttribute('role','progressbar');track.setAttribute('aria-label','Secondary cooldown remaining');track.setAttribute('aria-valuemin','0');track.setAttribute('aria-valuemax','100');track.setAttribute('aria-valuenow',String(Math.round(percent)));const fill=element('div','usage-fill');fill.style.width=percent+'%';track.append(fill);
|
|
423
|
-
const hint=element('div','subtle',text('Retry '+new Date(until).toLocaleString()));hint.title=new Date(until).toLocaleString();
|
|
424
|
-
row.append(heading,track,hint);
|
|
425
|
-
return row;
|
|
426
|
-
}
|
|
427
398
|
function githubDiagnostics(status){const body=element('div','usage-details-body');if(status?.calls)body.append(element('div','subtle',text('Calls · GraphQL '+(status.calls.graphql||0)+' · REST '+(status.calls.rest||0)+' · quota '+(status.calls.rateLimit||0))));const operations=githubCounterLine('GraphQL operations',status?.graphql?.operations);if(operations)body.append(operations);const contexts=githubCounterLine('GraphQL contexts',status?.graphql?.contexts);if(contexts)body.append(contexts);if(status?.readPaths){body.append(element('div','subtle',text('Reads · scans '+(status.readPaths.workspaceScans||0)+' · PR '+(status.readPaths.prRefreshes||0)+' · coalesced '+(status.readPaths.prRefreshCoalesced||0))));const triggers=githubCounterLine('Refresh triggers',status.readPaths.prRefreshesByTrigger);if(triggers)body.append(triggers);const coalesced=githubCounterLine('Coalesced',status.readPaths.prRefreshCoalescedByTrigger);if(coalesced)body.append(coalesced);}if(!body.children.length)return null;const details=element('details','usage-details');details.append(element('summary','',text('Diagnostics')),body);return details;}
|
|
428
|
-
function githubUsageCard(
|
|
399
|
+
function githubUsageCard(effective,status){
|
|
429
400
|
const card=element('section','usage-provider');
|
|
430
401
|
card.append(element('h3','',text('GitHub API')));
|
|
431
|
-
const resources=snapshot?.resources||{};
|
|
432
|
-
const observed=status?.runtimeRateUsage?.resources||{};
|
|
433
402
|
const bars=element('div','usage-bars');
|
|
434
|
-
bars.append(
|
|
435
|
-
|
|
436
|
-
for(const row of [githubObserved('REST',observed.core),githubObserved('GraphQL',observed.graphql),githubObserved('Search',observed.search)])if(row){bars.append(row);observedCount++;}
|
|
437
|
-
if(!observedCount)bars.append(element('p','empty',text('No request-time headers observed')));
|
|
438
|
-
bars.append(element('div','usage-label',text('Primary quota (/rate_limit)')));
|
|
439
|
-
let quotaCount=0;
|
|
440
|
-
for(const pair of [['REST',resources.core],['GraphQL',resources.graphql],['Search',resources.search]])if(pair[1]){bars.append(githubBucket(pair[0],pair[1]));quotaCount++;}
|
|
441
|
-
if(!quotaCount)bars.append(element('p','empty',text('Quota unavailable')));
|
|
442
|
-
if(status?.runtimeRateUsage?.rejection&&status.runtimeRateUsage.rejection.kind!=='secondary'){
|
|
443
|
-
const rejection=status.runtimeRateUsage.rejection;
|
|
444
|
-
bars.append(element('div','error-state',text('Throttled · '+(rejection.kind==='unknown'?'rate limit':rejection.kind)+' limit')));
|
|
445
|
-
}
|
|
446
|
-
if(status?.limited&&status?.runtimeRateUsage?.rejection?.kind!=='secondary'){
|
|
447
|
-
const until=status.cooldownUntil?' until '+new Date(status.cooldownUntil).toLocaleTimeString():'';
|
|
448
|
-
bars.append(element('div','error-state',text('Cooldown active'+until)));
|
|
449
|
-
}
|
|
403
|
+
if(effective)bars.append(githubBucket('Available',effective));
|
|
404
|
+
else bars.append(element('p','empty',text('Quota unavailable')));
|
|
450
405
|
const diagnostics=githubDiagnostics(status);
|
|
451
406
|
if(diagnostics)bars.append(diagnostics);
|
|
452
407
|
card.append(bars);
|
|
453
408
|
return card;
|
|
454
409
|
}
|
|
455
|
-
async function drawUsage(){try{const overview=await loadJSON('/api/usage');usageRoot.replaceChildren();usageMeta.textContent='Real account telemetry when available';const providers=Array.isArray(overview.providers)?overview.providers:[];const credits=Array.isArray(overview.credits)?overview.credits:[];const grid=element('div','usage-providers');if(overview.
|
|
410
|
+
async function drawUsage(){try{const overview=await loadJSON('/api/usage');usageRoot.replaceChildren();usageMeta.textContent='Real account telemetry when available';const providers=Array.isArray(overview.providers)?overview.providers:[];const credits=Array.isArray(overview.credits)?overview.credits:[];const grid=element('div','usage-providers');if(overview.githubEffective||overview.githubStatus)grid.append(githubUsageCard(overview.githubEffective,overview.githubStatus));for(const provider of providers)grid.append(usageProvider(provider));for(const provider of credits)grid.append(creditProvider(provider));if(!grid.children.length){usageRoot.append(element('p','empty',text('Usage telemetry is unavailable.')));return;}usageRoot.append(grid);}catch(error){usageMeta.textContent='Unavailable';usageRoot.replaceChildren(element('p','error-state',text('Usage unavailable: '+error.message)));}}
|
|
456
411
|
async function drawRules(){rulesStatus.textContent='';try{const rules=await loadJSON('/api/execution-router-rules');rulesText.value=typeof rules.text==='string'?rules.text:'';rulesMeta.textContent=rules.hash&&rules.hash!=='none'?'Loaded · '+rules.hash.slice(0,12):'No custom rules saved';}catch(error){rulesMeta.textContent='Unavailable';rulesStatus.textContent='Could not load rules: '+error.message;}}
|
|
457
412
|
async function saveRules(){rulesSave.disabled=true;rulesStatus.textContent='Saving…';try{const response=await fetch('/api/execution-router-rules',{method:'PUT',headers:{'content-type':'text/plain; charset=utf-8'},body:rulesText.value});if(!response.ok)throw new Error(response.status+' '+await response.text());const rules=await response.json();rulesMeta.textContent='Saved · '+rules.hash.slice(0,12);rulesStatus.textContent='Saved. New routing decisions will load these rules.';}catch(error){rulesStatus.textContent='Save failed: '+error.message;}finally{rulesSave.disabled=false;}}
|
|
458
413
|
async function drawShortcuts(){shortcutsStatus.textContent='';shortcutsText.value=chatShortcutText();}
|
package/dist/dashboard.js
CHANGED
|
@@ -10,6 +10,13 @@ import { summarizeRuntime } from './runtime-summary.js';
|
|
|
10
10
|
const GITHUB_REPOSITORY_PATTERN = /^([A-Za-z0-9](?:[A-Za-z0-9-]{0,36}[A-Za-z0-9])?)\/([A-Za-z0-9._-]{1,100})$/;
|
|
11
11
|
const SSE_HEARTBEAT_MS = 15000;
|
|
12
12
|
const EVENTS_HISTORY_FILE = process.env.EVENTS_BUS_DATA_DIR ? join(process.env.EVENTS_BUS_DATA_DIR, 'events.jsonl') : join(homedir(), 'Library', 'Application Support', 'EventsBus', 'events.jsonl');
|
|
13
|
+
function effectiveGitHubUsage(snapshot, runtime) {
|
|
14
|
+
const candidates = [snapshot?.resources.core, snapshot?.resources.graphql, snapshot?.resources.search, runtime?.resources.core, runtime?.resources.graphql, runtime?.resources.search]
|
|
15
|
+
.filter((candidate) => candidate !== undefined && candidate !== null)
|
|
16
|
+
.filter(candidate => Number.isFinite(candidate.limit) && Number(candidate.limit) > 0 && Number.isFinite(candidate.remaining) && Number.isFinite(candidate.reset));
|
|
17
|
+
const lowest = candidates.reduce((current, candidate) => !current || Number(candidate.remaining) / Number(candidate.limit) < Number(current.remaining) / Number(current.limit) ? candidate : current, null);
|
|
18
|
+
return lowest ? { limit: Number(lowest.limit), remaining: Number(lowest.remaining), reset: Number(lowest.reset) } : null;
|
|
19
|
+
}
|
|
13
20
|
function isPositiveInteger(candidate) {
|
|
14
21
|
return typeof candidate === 'number' && Number.isInteger(candidate) && candidate > 0;
|
|
15
22
|
}
|
|
@@ -149,7 +156,9 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
|
|
|
149
156
|
}
|
|
150
157
|
catch { /* quota telemetry is best-effort during GitHub degradation */ }
|
|
151
158
|
}
|
|
152
|
-
|
|
159
|
+
const runtime = (githubStatus.runtimeRateUsage ?? null);
|
|
160
|
+
const githubEffective = effectiveGitHubUsage(github, runtime);
|
|
161
|
+
return JSON.stringify({ ...account, githubEffective, githubStatus });
|
|
153
162
|
};
|
|
154
163
|
const server = createServer(async (req, res) => {
|
|
155
164
|
if (req.url === '/') {
|
|
@@ -192,13 +201,7 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
|
|
|
192
201
|
res.writeHead(202);
|
|
193
202
|
res.end('Accepted');
|
|
194
203
|
queueMicrotask(() => {
|
|
195
|
-
|
|
196
|
-
? webhook.refresh(event)
|
|
197
|
-
.then(jobIds => broadcastSnapshots(jobIds))
|
|
198
|
-
.catch(error => process.stderr.write(`warning: webhook dashboard snapshot unavailable: ${error instanceof Error ? error.message : String(error)}\n`))
|
|
199
|
-
: Promise.resolve();
|
|
200
|
-
void refreshDashboard
|
|
201
|
-
.then(() => webhook.wake(event))
|
|
204
|
+
void Promise.resolve(webhook.wake(event))
|
|
202
205
|
.catch(error => process.stderr.write(`warning: webhook wake unavailable: ${error instanceof Error ? error.message : String(error)}\n`));
|
|
203
206
|
});
|
|
204
207
|
return;
|
package/dist/github-webhook.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
2
|
export const PACKAGE_PUBLISHED_MARKER = 'agents-relay:package-published:v1';
|
|
3
|
-
const WAKE_EVENTS = new Set(['pull_request', 'issue_comment', 'pull_request_review', 'pull_request_review_comment', 'check_run', 'check_suite', 'workflow_run']);
|
|
4
3
|
export function parsePackagePublicationMarker(body) {
|
|
5
4
|
if (typeof body !== 'string')
|
|
6
5
|
return null;
|
|
@@ -34,10 +33,25 @@ export function verifyGitHubWebhook(secret, body, signature) {
|
|
|
34
33
|
const actual = Buffer.from(signature);
|
|
35
34
|
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
|
36
35
|
}
|
|
36
|
+
function pullRequestFacts(payload, action) {
|
|
37
|
+
if (action !== 'synchronize' && action !== 'closed')
|
|
38
|
+
return null;
|
|
39
|
+
const pr = payload.pull_request;
|
|
40
|
+
if (!pr)
|
|
41
|
+
return null;
|
|
42
|
+
const head = pr.head;
|
|
43
|
+
const ref = typeof head?.ref === 'string' ? head.ref : null;
|
|
44
|
+
const sha = typeof head?.sha === 'string' ? head.sha : null;
|
|
45
|
+
if (!ref || !sha)
|
|
46
|
+
return null;
|
|
47
|
+
if (action === 'closed')
|
|
48
|
+
return { state: pr.merged === true ? 'MERGED' : 'CLOSED', head: { ref, sha } };
|
|
49
|
+
return { state: 'OPEN', head: { ref, sha } };
|
|
50
|
+
}
|
|
37
51
|
export function parseGitHubWebhook(headers, body) {
|
|
38
52
|
const deliveryId = String(headers['x-github-delivery'] ?? '');
|
|
39
53
|
const event = String(headers['x-github-event'] ?? '');
|
|
40
|
-
if (!deliveryId ||
|
|
54
|
+
if (!deliveryId || (event !== 'pull_request' && event !== 'issue_comment'))
|
|
41
55
|
return null;
|
|
42
56
|
let payload;
|
|
43
57
|
try {
|
|
@@ -47,19 +61,24 @@ export function parseGitHubWebhook(headers, body) {
|
|
|
47
61
|
return null;
|
|
48
62
|
}
|
|
49
63
|
const repository = payload.repository;
|
|
50
|
-
const
|
|
64
|
+
const repositoryName = typeof repository?.full_name === 'string' ? repository.full_name : null;
|
|
65
|
+
const action = typeof payload.action === 'string' ? payload.action : null;
|
|
66
|
+
const pr = payload.pull_request;
|
|
51
67
|
const issue = payload.issue;
|
|
52
|
-
const number = Number(
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
68
|
+
const number = Number(pr?.number ?? (issue?.pull_request ? issue.number : 0));
|
|
69
|
+
if (!repositoryName || !Number.isInteger(number) || number <= 0)
|
|
70
|
+
return null;
|
|
71
|
+
if (event === 'issue_comment') {
|
|
72
|
+
const comment = payload.comment;
|
|
73
|
+
const publication = parsePackagePublicationMarker(comment?.body);
|
|
74
|
+
if (!publication)
|
|
75
|
+
return null;
|
|
76
|
+
return { deliveryId, event, action, repository: repositoryName, pullRequest: number, publication, pullRequestFacts: null };
|
|
77
|
+
}
|
|
78
|
+
const facts = pullRequestFacts(payload, action);
|
|
79
|
+
if (!facts)
|
|
80
|
+
return null;
|
|
81
|
+
return { deliveryId, event, action, repository: repositoryName, pullRequest: number, publication: null, pullRequestFacts: facts };
|
|
63
82
|
}
|
|
64
83
|
export class DeliveryDeduper {
|
|
65
84
|
ttlMs;
|
package/dist/pool.js
CHANGED
|
@@ -92,44 +92,8 @@ export class RepositoryWorkerPool {
|
|
|
92
92
|
this.jobTargets.set(jobId, { repository, prNumber });
|
|
93
93
|
await runScheduledBucket(this.client, repository, this.trustedAuthors, { prNumber, jobId, bucket }, { reconcile: async () => { await this.reconcileTarget(repository, prNumber, jobId, 'schedule.tick'); }, idle: async () => { } }, tickAt, () => this.makeStore(repository, prNumber));
|
|
94
94
|
}
|
|
95
|
-
async consumeGitHubSignals(
|
|
96
|
-
|
|
97
|
-
return;
|
|
98
|
-
const repository = signals.find(signal => signal.repository)?.repository;
|
|
99
|
-
const prNumber = signals.find(signal => signal.prNumber)?.prNumber;
|
|
100
|
-
if (!repository || !prNumber || !this.repositories.includes(repository))
|
|
101
|
-
return;
|
|
102
|
-
if (!this.refreshWebhookSnapshot) {
|
|
103
|
-
process.stderr.write(`warning: GitHub webhook signal refresh unavailable for ${repository}#${prNumber}\n`);
|
|
104
|
-
return;
|
|
105
|
-
}
|
|
106
|
-
const reasons = [...new Set(signals.map(signal => signal.reason))];
|
|
107
|
-
const trigger = reasons.join('+') || 'github.webhook';
|
|
108
|
-
const snapshot = await this.refreshWebhookSnapshot(repository, prNumber, trigger);
|
|
109
|
-
const sourceEvents = signals.map(signal => signal.event).filter((event) => Boolean(event));
|
|
110
|
-
for (const item of snapshot.overviews) {
|
|
111
|
-
this.jobTargets.set(item.job.id, { repository, prNumber });
|
|
112
|
-
this.seedTargetSnapshot(repository, prNumber, item.job.id, snapshot.seed);
|
|
113
|
-
if (this.watchedBus) {
|
|
114
|
-
for (const event of sourceEvents) {
|
|
115
|
-
const publication = event.data?.publication;
|
|
116
|
-
if (publication && typeof publication === 'object') {
|
|
117
|
-
const value = publication;
|
|
118
|
-
await this.watchedBus.publish(eventFor(item.job.id, 'release', null, 'package.published', 'succeeded', `Published ${String(value.package ?? 'package')}@${String(value.version ?? 'unknown')}`, 'orchestrator', {
|
|
119
|
-
deliveryId: event.data?.deliveryId,
|
|
120
|
-
repository,
|
|
121
|
-
prNumber,
|
|
122
|
-
package: value.package,
|
|
123
|
-
version: value.version,
|
|
124
|
-
registry: value.registry,
|
|
125
|
-
sha: value.sha,
|
|
126
|
-
}, event.source));
|
|
127
|
-
}
|
|
128
|
-
await this.watchedBus.publish(eventFor(item.job.id, 'github', null, 'github.webhook', 'queued', event.message, 'orchestrator', { ...(event.data ?? {}), coordinatorIngress: false, coordinatorRouted: true }, event.source));
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
await this.coordinator.accept({ jobId: item.job.id, reason: trigger, repository, prNumber });
|
|
132
|
-
}
|
|
95
|
+
async consumeGitHubSignals(_signals) {
|
|
96
|
+
// GitHub webhooks are fact-only inputs. They must never trigger a GitHub reread.
|
|
133
97
|
}
|
|
134
98
|
async consumeSignals(jobId, signals) {
|
|
135
99
|
for (const signal of signals) {
|
|
@@ -258,16 +222,8 @@ export class RepositoryWorkerPool {
|
|
|
258
222
|
await this.scheduleSignalCoordinator.acceptTransport({ jobId: event.job_id, reason: this.signalReason(event), event, repository, prNumber });
|
|
259
223
|
return;
|
|
260
224
|
}
|
|
261
|
-
if (ingressWebhook
|
|
262
|
-
await this.githubSignalCoordinator.acceptTransport({
|
|
263
|
-
jobId: `${repository}#${prNumber}`,
|
|
264
|
-
reason: this.signalReason(event),
|
|
265
|
-
event,
|
|
266
|
-
repository,
|
|
267
|
-
prNumber,
|
|
268
|
-
});
|
|
225
|
+
if (ingressWebhook)
|
|
269
226
|
return;
|
|
270
|
-
}
|
|
271
227
|
if (routedWebhook)
|
|
272
228
|
return;
|
|
273
229
|
const lifecycle = ['task.started', 'task.completed', 'task.failed', 'task.blocked', 'task.unblocked', 'task.cancelled', 'review.started', 'review.approved', 'review.changes_required', 'review.blocked'].includes(event.type);
|
|
@@ -301,30 +257,9 @@ export class RepositoryWorkerPool {
|
|
|
301
257
|
}
|
|
302
258
|
await this.scheduleSignalCoordinator.accept({ jobId: job.id, reason: this.signalReason(event), event, repository: job.repository, prNumber: job.prNumber });
|
|
303
259
|
}
|
|
304
|
-
async webhookSignal(
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
const source = { id: `github/${wake.repository}/delivery/${wake.deliveryId}`, component: 'github-webhook' };
|
|
308
|
-
const event = eventFor('github-webhook', 'github', null, 'github.webhook', 'queued', `GitHub webhook ${wake.event}${wake.action ? ':' + wake.action : ''}`, 'orchestrator', {
|
|
309
|
-
deliveryId: wake.deliveryId,
|
|
310
|
-
event: wake.event,
|
|
311
|
-
action: wake.action,
|
|
312
|
-
repository: wake.repository,
|
|
313
|
-
prNumber: wake.pullRequest,
|
|
314
|
-
publication: wake.publication ?? null,
|
|
315
|
-
coordinatorIngress: true,
|
|
316
|
-
}, source);
|
|
317
|
-
if (bus) {
|
|
318
|
-
await bus.publish(event);
|
|
319
|
-
return;
|
|
320
|
-
}
|
|
321
|
-
await this.githubSignalCoordinator.accept({
|
|
322
|
-
jobId: `${wake.repository}#${wake.pullRequest}`,
|
|
323
|
-
reason: this.signalReason(event),
|
|
324
|
-
event,
|
|
325
|
-
repository: wake.repository,
|
|
326
|
-
prNumber: wake.pullRequest,
|
|
327
|
-
});
|
|
260
|
+
async webhookSignal(_bus, _wake) {
|
|
261
|
+
// Uncorrelated webhook wakeups are intentionally ignored. A caller must first
|
|
262
|
+
// resolve the target from local cached state and then call webhookWake().
|
|
328
263
|
}
|
|
329
264
|
async webhookWake(bus, job, wake) {
|
|
330
265
|
if (bus) {
|
package/dist/projected-store.js
CHANGED
|
@@ -231,6 +231,9 @@ export class ProjectedDurableStore {
|
|
|
231
231
|
});
|
|
232
232
|
}
|
|
233
233
|
async comments() { return this.github.comments(); }
|
|
234
|
+
async applyPullRequestFacts(facts) {
|
|
235
|
+
await this.relay.savePullRequestFacts(structuredClone(facts));
|
|
236
|
+
}
|
|
234
237
|
async pullRequestState() {
|
|
235
238
|
return (await this.ensurePullRequestFacts()).state;
|
|
236
239
|
}
|
package/dist/relayd.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { once } from 'node:events';
|
|
3
|
-
import {
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { ChatGptAdapter, CodexAdapter } from './adapters.js';
|
|
@@ -134,17 +134,18 @@ export async function runDaemon(argv) {
|
|
|
134
134
|
projectionWriters.set(key, projected.projection);
|
|
135
135
|
return projected;
|
|
136
136
|
};
|
|
137
|
-
const makeReconciler = (store, maxConcurrent) => new Reconciler(store, { owner: `relayd-${process.pid}`, maxConcurrent, leaseMs: Number(value(argv, '--lease-ms', '300000')), adapters: [new CodexAdapter(value(argv, '--codex', 'codex')), new ChatGptAdapter()], continuations: [new CodexThreadContinuation(value(argv, '--codex', 'codex')), new CommandContinuation(), new WebhookContinuation()], planner: runtimePlanner(argv), usageSnapshotProvider: () => usageRegistry.snapshot(), runtimeSummaryProvider: () => ({ chatgptRunning: summarizeRuntime(cachedOverviews.map(item => item.job)).workers.chatgpt }), decisionEngine: new LayaCliDecisionEngine(), decisionMode: decisionMode(), decisionAuditStore, eventBus: bus, reviewer: runtimeReviewer(), projectPathForJob: async (job) => resolveRepositoryProjectPath(repositoryPaths.get(job.repository) ?? await resolveManagedProjectPath(job.repository, explicitProjectPath, workspaceRoot), job.projectPath) });
|
|
138
137
|
if (repositories.length === 0)
|
|
139
138
|
throw new Error(`No Git repositories with GitHub origins found under ${normalized.workspaceRoot ?? defaultWorkspaceRoot()}`);
|
|
140
|
-
const
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
139
|
+
const localSnapshots = await discoverLocalRelayStoreSnapshots(join(homedir(), '.agents-relay', 'stores'));
|
|
140
|
+
const knownRepositories = new Set(repositories);
|
|
141
|
+
cachedOverviews = dashboardManagedJobs(localSnapshots.flatMap(snapshot => {
|
|
142
|
+
if (!snapshot.facts)
|
|
143
|
+
return [];
|
|
144
|
+
return snapshot.jobs.filter(job => knownRepositories.has(job.repository)).map(job => ({
|
|
145
|
+
job, githubState: snapshot.facts.state, draft: false, mergedAt: null, closedAt: null, publication: null,
|
|
146
|
+
}));
|
|
147
|
+
}));
|
|
148
|
+
const makeReconciler = (store, maxConcurrent) => new Reconciler(store, { owner: `relayd-${process.pid}`, maxConcurrent, leaseMs: Number(value(argv, '--lease-ms', '300000')), adapters: [new CodexAdapter(value(argv, '--codex', 'codex')), new ChatGptAdapter()], continuations: [new CodexThreadContinuation(value(argv, '--codex', 'codex')), new CommandContinuation(), new WebhookContinuation()], planner: runtimePlanner(argv), usageSnapshotProvider: () => usageRegistry.snapshot(), runtimeSummaryProvider: () => ({ chatgptRunning: summarizeRuntime(cachedOverviews.map(item => item.job)).workers.chatgpt }), decisionEngine: new LayaCliDecisionEngine(), decisionMode: decisionMode(), decisionAuditStore, eventBus: bus, reviewer: runtimeReviewer(), projectPathForJob: async (job) => resolveRepositoryProjectPath(repositoryPaths.get(job.repository) ?? await resolveManagedProjectPath(job.repository, explicitProjectPath, workspaceRoot), job.projectPath) });
|
|
148
149
|
const refreshAll = async (full = false) => {
|
|
149
150
|
if (client.rateLimitStatus().limited)
|
|
150
151
|
return cachedOverviews;
|
|
@@ -157,11 +158,6 @@ export async function runDaemon(argv) {
|
|
|
157
158
|
const scanned = new Set(scanRepositories);
|
|
158
159
|
const merged = dashboardManagedJobs([...cachedOverviews.filter(item => !scanned.has(item.job.repository)), ...refreshed]);
|
|
159
160
|
cachedOverviews = await overlayOperationalOverviews(merged, (repository, prNumber) => runtimeStore(repository, prNumber));
|
|
160
|
-
try {
|
|
161
|
-
await mkdir(cacheDirectory, { recursive: true });
|
|
162
|
-
await writeFile(cacheFile, JSON.stringify(cachedOverviews), 'utf8');
|
|
163
|
-
}
|
|
164
|
-
catch { /* cache is best effort */ }
|
|
165
161
|
return cachedOverviews;
|
|
166
162
|
}
|
|
167
163
|
catch (error) {
|
|
@@ -175,13 +171,6 @@ export async function runDaemon(argv) {
|
|
|
175
171
|
cachedOverviews = await overlayOperationalOverviews(cachedOverviews, (repository, prNumber) => runtimeStore(repository, prNumber));
|
|
176
172
|
return cachedOverviews;
|
|
177
173
|
};
|
|
178
|
-
const persistCache = async () => {
|
|
179
|
-
try {
|
|
180
|
-
await mkdir(cacheDirectory, { recursive: true });
|
|
181
|
-
await writeFile(cacheFile, JSON.stringify(cachedOverviews), 'utf8');
|
|
182
|
-
}
|
|
183
|
-
catch { /* cache is best effort */ }
|
|
184
|
-
};
|
|
185
174
|
const pullRequestRefreshFlights = new Map();
|
|
186
175
|
const pullRequestRefreshCache = new Map();
|
|
187
176
|
const incrementTrigger = (counts, trigger) => { counts[trigger] = (counts[trigger] ?? 0) + 1; };
|
|
@@ -209,7 +198,6 @@ export async function runDaemon(argv) {
|
|
|
209
198
|
cachedOverviews = cachedOverviews.filter(item => item.job.repository !== repository || item.job.prNumber !== prNumber);
|
|
210
199
|
cachedOverviews.push(...operational);
|
|
211
200
|
cachedOverviews = dashboardManagedJobs(cachedOverviews);
|
|
212
|
-
await persistCache();
|
|
213
201
|
const operationalSnapshot = { ...snapshot, overviews: operational };
|
|
214
202
|
pullRequestRefreshCache.set(key, { snapshot: operationalSnapshot, completedAt: Date.now() });
|
|
215
203
|
return operationalSnapshot;
|
|
@@ -303,14 +291,18 @@ export async function runDaemon(argv) {
|
|
|
303
291
|
secret,
|
|
304
292
|
repositories: new Set(repositories),
|
|
305
293
|
wake: async (event) => {
|
|
306
|
-
|
|
294
|
+
const targets = cachedOverviews.filter(item => item.job.repository === event.repository && item.job.prNumber === event.pullRequest);
|
|
295
|
+
if (targets.length === 0)
|
|
307
296
|
return;
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
297
|
+
for (const target of targets) {
|
|
298
|
+
const store = runtimeStore(event.repository, event.pullRequest);
|
|
299
|
+
if (event.pullRequestFacts && store.applyPullRequestFacts) {
|
|
300
|
+
await store.applyPullRequestFacts({ ...event.pullRequestFacts, observedAt: new Date().toISOString() });
|
|
301
|
+
target.githubState = event.pullRequestFacts.state;
|
|
302
|
+
}
|
|
303
|
+
await pool.webhookWake(bus, target.job, event);
|
|
313
304
|
}
|
|
305
|
+
cachedOverviews = dashboardManagedJobs(cachedOverviews);
|
|
314
306
|
}
|
|
315
307
|
} : undefined;
|
|
316
308
|
const exactJob = async (jobId) => {
|
|
@@ -384,21 +376,6 @@ export async function runDaemon(argv) {
|
|
|
384
376
|
store.close(); server.close(() => process.exit(0)); }); };
|
|
385
377
|
process.once('SIGINT', stop);
|
|
386
378
|
process.once('SIGTERM', stop);
|
|
387
|
-
const localSnapshots = await discoverLocalRelayStoreSnapshots(join(homedir(), '.agents-relay', 'stores'));
|
|
388
|
-
const knownRepositories = new Set(repositories);
|
|
389
|
-
const localOverviews = localSnapshots.flatMap(snapshot => {
|
|
390
|
-
if (!snapshot.facts)
|
|
391
|
-
return [];
|
|
392
|
-
return snapshot.jobs.filter(job => knownRepositories.has(job.repository)).map(job => ({
|
|
393
|
-
job, githubState: snapshot.facts.state, draft: false, mergedAt: null, closedAt: null, publication: null,
|
|
394
|
-
}));
|
|
395
|
-
});
|
|
396
|
-
const byJob = new Map();
|
|
397
|
-
for (const item of cachedOverviews)
|
|
398
|
-
byJob.set(item.job.repository + '#' + item.job.prNumber + ':' + item.job.id, item);
|
|
399
|
-
for (const item of localOverviews)
|
|
400
|
-
byJob.set(item.job.repository + '#' + item.job.prNumber + ':' + item.job.id, item);
|
|
401
|
-
cachedOverviews = dashboardManagedJobs([...byJob.values()]);
|
|
402
379
|
await pool.reconcileKnown(cachedOverviews);
|
|
403
380
|
await scheduleHeartbeatSafely();
|
|
404
381
|
await flushProjectionsSafely();
|