agents-relay 2.0.5 → 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.
@@ -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(snapshot,status){
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(githubSecondary(status),element('div','usage-label',text('Request-time X-RateLimit headers')));
435
- let observedCount=0;
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.github||overview.githubStatus)grid.append(githubUsageCard(overview.github,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)));}}
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
- return JSON.stringify({ ...account, github, githubStatus });
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
- const refreshDashboard = webhook.refresh
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;
@@ -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 || !WAKE_EVENTS.has(event))
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 pullRequest = payload.pull_request;
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(pullRequest?.number ?? (issue?.pull_request ? issue.number : 0));
53
- const comment = payload.comment;
54
- const publication = event === 'issue_comment' ? parsePackagePublicationMarker(comment?.body) : null;
55
- return {
56
- deliveryId,
57
- event,
58
- action: typeof payload.action === 'string' ? payload.action : null,
59
- repository: typeof repository?.full_name === 'string' ? repository.full_name : null,
60
- pullRequest: Number.isInteger(number) && number > 0 ? number : null,
61
- publication,
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(signals) {
96
- if (this.isRateLimited() || signals.length === 0)
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 && repository && prNumber) {
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(bus, wake) {
305
- if (!wake.repository || !wake.pullRequest)
306
- return;
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) {
@@ -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
@@ -291,14 +291,18 @@ export async function runDaemon(argv) {
291
291
  secret,
292
292
  repositories: new Set(repositories),
293
293
  wake: async (event) => {
294
- if (!event.repository || !event.pullRequest)
294
+ const targets = cachedOverviews.filter(item => item.job.repository === event.repository && item.job.prNumber === event.pullRequest);
295
+ if (targets.length === 0)
295
296
  return;
296
- try {
297
- await pool.webhookSignal(bus, event);
298
- }
299
- catch (error) {
300
- process.stderr.write(`warning: webhook wake unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
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);
301
304
  }
305
+ cachedOverviews = dashboardManagedJobs(cachedOverviews);
302
306
  }
303
307
  } : undefined;
304
308
  const exactJob = async (jobId) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "2.0.5",
3
+ "version": "2.0.6",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {