agents-relay 1.0.137 → 1.0.138

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.
@@ -372,7 +372,8 @@ function usageProvider(provider){const card=element('section','usage-provider');
372
372
  function formatCredit(value,currency){if(value===null||value===undefined||!Number.isFinite(Number(value)))return '—';const number=Number(value);if(currency==='USD')return '$'+number.toFixed(2);if(currency==='CNY')return '¥'+number.toFixed(2);return number.toFixed(2)+' '+currency;}
373
373
  function creditProvider(provider){const row=element('section','usage-provider');row.append(element('h3','',text(provider.label)));const credits=provider.credits||{};if(!credits.available||!Array.isArray(credits.balances)||!credits.balances.length){row.append(element('p','empty',text(credits.reason||'Credit balance is unavailable.')));return row;}const balance=credits.balances[0];row.append(element('div','detail-value',text(formatCredit(balance.remaining,balance.currency))));return row;}
374
374
  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+' remaining')));row.append(heading);row.append(element('div','subtle',text(bucket.used+' used / '+bucket.limit+' limit')));const reset=resetText({resetsAt:bucket.reset});if(reset)row.append(element('div','subtle',text(reset)));return row;}
375
- function githubUsageCard(snapshot,status){const card=element('section','usage-provider');card.append(element('h3','',text('GitHub API')));const resources=snapshot?.resources||{};const bars=element('div','usage-bars');if(resources.core)bars.append(githubBucket('REST core',resources.core));if(resources.graphql)bars.append(githubBucket('GraphQL',resources.graphql));if(resources.search)bars.append(githubBucket('Search',resources.search));if(!bars.children.length)bars.append(element('p','empty',text('GitHub quota telemetry is unavailable.')));if(status?.limited)bars.append(element('div','error-state',text('Relay cooldown active'+(status.cooldownUntil?' until '+new Date(status.cooldownUntil).toLocaleString():''))));else bars.append(element('div','subtle',text('Relay cooldown inactive')));if(status?.calls)bars.append(element('div','subtle',text('Calls since start · GraphQL '+(status.calls.graphql||0)+' · REST '+(status.calls.rest||0)+' · quota '+(status.calls.rateLimit||0))));if(status?.readPaths)bars.append(element('div','subtle',text('Read paths · workspace scans '+(status.readPaths.workspaceScans||0)+' · PR refreshes '+(status.readPaths.prRefreshes||0))));card.append(bars);return card;}
375
+ 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;}
376
+ function githubUsageCard(snapshot,status){const card=element('section','usage-provider');card.append(element('h3','',text('GitHub API')));const resources=snapshot?.resources||{};const bars=element('div','usage-bars');if(resources.core)bars.append(githubBucket('REST core',resources.core));if(resources.graphql)bars.append(githubBucket('GraphQL',resources.graphql));if(resources.search)bars.append(githubBucket('Search',resources.search));if(!bars.children.length)bars.append(element('p','empty',text('GitHub quota telemetry is unavailable.')));if(status?.limited)bars.append(element('div','error-state',text('Relay cooldown active'+(status.cooldownUntil?' until '+new Date(status.cooldownUntil).toLocaleString():''))));else bars.append(element('div','subtle',text('Relay cooldown inactive')));if(status?.calls)bars.append(element('div','subtle',text('Calls since start · 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)bars.append(operations);const contexts=githubCounterLine('GraphQL contexts',status?.graphql?.contexts);if(contexts)bars.append(contexts);if(status?.readPaths)bars.append(element('div','subtle',text('Read paths · workspace scans '+(status.readPaths.workspaceScans||0)+' · PR refreshes '+(status.readPaths.prRefreshes||0))));card.append(bars);return card;}
376
377
  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)));}}
377
378
  async function drawRules(){rulesStatus.textContent='';try{const rules=await loadJSON('/api/model-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;}}
378
379
  async function saveRules(){rulesSave.disabled=true;rulesStatus.textContent='Saving…';try{const response=await fetch('/api/model-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;}}
package/dist/pool.js CHANGED
@@ -1,4 +1,4 @@
1
- import { GitHubStore, listManagedGitHubJobs } from './store.js';
1
+ import { GitHubStore, listManagedGitHubJobs, withGitHubRequestContext } from './store.js';
2
2
  import { eventFor } from './events.js';
3
3
  const RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
4
4
  export function compareJobPriority(a, b) { return RANK[a.priority ?? 'P2'] - RANK[b.priority ?? 'P2'] || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id); }
@@ -60,7 +60,7 @@ export class RepositoryWorkerPool {
60
60
  for (const job of terminalManagedJobs(discovered)) {
61
61
  await this.reconcileExclusive(job.id, async () => {
62
62
  const store = new GitHubStore(this.client, job.repository, job.prNumber, this.trustedAuthors);
63
- await this.makeReconciler(store, 0).reconcile(job.id);
63
+ await withGitHubRequestContext(this.client, 'pool.terminal-reconcile', () => this.makeReconciler(store, 0).reconcile(job.id));
64
64
  });
65
65
  }
66
66
  const jobs = runnableManagedJobs(discovered);
@@ -75,7 +75,7 @@ export class RepositoryWorkerPool {
75
75
  continue;
76
76
  const allowance = Math.max(1, Math.min(capacity, ready || (expiredRunning ? 1 : 0) || 1));
77
77
  await this.reconcileExclusive(job.id, async () => {
78
- await this.makeReconciler(store, allowance).reconcile(job.id);
78
+ await withGitHubRequestContext(this.client, 'pool.discovered-reconcile', () => this.makeReconciler(store, allowance).reconcile(job.id));
79
79
  });
80
80
  capacity -= allowance;
81
81
  }
@@ -92,7 +92,7 @@ export class RepositoryWorkerPool {
92
92
  try {
93
93
  do {
94
94
  this.pending = false;
95
- await this.reconcileDiscovered(await aggregateManagedGitHubJobs(this.client, this.repositories, this.trustedAuthors));
95
+ await this.reconcileDiscovered(await withGitHubRequestContext(this.client, 'pool.workspace-scan', () => aggregateManagedGitHubJobs(this.client, this.repositories, this.trustedAuthors)));
96
96
  } while (this.pending);
97
97
  }
98
98
  finally {
@@ -109,7 +109,7 @@ export class RepositoryWorkerPool {
109
109
  return existing;
110
110
  const flight = this.reconcileExclusive(jobId, async () => {
111
111
  const store = new GitHubStore(this.client, repository, prNumber, this.trustedAuthors);
112
- await this.makeReconciler(store, Math.max(1, this.concurrency)).reconcile(jobId);
112
+ await withGitHubRequestContext(this.client, 'pool.targeted-reconcile', () => this.makeReconciler(store, Math.max(1, this.concurrency)).reconcile(jobId));
113
113
  });
114
114
  this.targetedReconcileFlights.set(key, flight);
115
115
  try {
@@ -130,9 +130,9 @@ export class RepositoryWorkerPool {
130
130
  }
131
131
  const store = new GitHubStore(this.client, target.repository, target.prNumber, this.trustedAuthors);
132
132
  const reconciler = this.makeReconciler(store, Math.max(1, this.concurrency));
133
- await reconciler.handleEvent(event);
133
+ await withGitHubRequestContext(this.client, `pool.event.${event.type}`, () => reconciler.handleEvent(event));
134
134
  if (['task.completed', 'task.failed', 'task.blocked', 'task.cancelled'].includes(event.type))
135
- await reconciler.reconcile(event.job_id);
135
+ await withGitHubRequestContext(this.client, `pool.event-reconcile.${event.type}`, () => reconciler.reconcile(event.job_id));
136
136
  }
137
137
  async watch(bus) {
138
138
  return bus.subscribeAll(async (event) => {
package/dist/relayd.js CHANGED
@@ -9,7 +9,7 @@ import { serveDashboard } from './dashboard.js';
9
9
  import { NatsEventBus } from './events.js';
10
10
  import { RepositoryWorkerPool, aggregateManagedGitHubJobs } from './pool.js';
11
11
  import { Reconciler } from './reconciler.js';
12
- import { dashboardManagedJobs, GitHubStore, InMemoryStore, loadManagedGitHubPullRequestGraphql } from './store.js';
12
+ import { dashboardManagedJobs, GitHubStore, InMemoryStore, loadManagedGitHubPullRequestGraphql, withGitHubRequestContext } from './store.js';
13
13
  import { githubAuthContext } from './github-auth.js';
14
14
  import { dashboardJobAction, isEntrypoint, runtimePlanner, runtimeReviewer, startService, SERVICE_DEFAULTS } from './cli.js';
15
15
  import { dashboardTaskAction, dashboardTaskSetState } from './dashboard-actions.js';
@@ -119,7 +119,7 @@ export async function runDaemon(argv) {
119
119
  return cachedOverviews;
120
120
  try {
121
121
  githubReadPaths.workspaceScans += 1;
122
- const refreshed = await aggregateManagedGitHubJobs(client, scanRepositories, trusted);
122
+ const refreshed = await withGitHubRequestContext(client, 'relayd.workspace-refresh', () => aggregateManagedGitHubJobs(client, scanRepositories, trusted));
123
123
  const scanned = new Set(scanRepositories);
124
124
  cachedOverviews = dashboardManagedJobs([...cachedOverviews.filter(item => !scanned.has(item.job.repository)), ...refreshed]);
125
125
  try {
@@ -152,7 +152,7 @@ export async function runDaemon(argv) {
152
152
  return existing;
153
153
  const flight = (async () => {
154
154
  githubReadPaths.prRefreshes += 1;
155
- const refreshed = await loadManagedGitHubPullRequestGraphql(client, repository, prNumber, trusted);
155
+ const refreshed = await withGitHubRequestContext(client, 'relayd.pr-refresh', () => loadManagedGitHubPullRequestGraphql(client, repository, prNumber, trusted));
156
156
  cachedOverviews = cachedOverviews.filter(item => item.job.repository !== repository || item.job.prNumber !== prNumber);
157
157
  cachedOverviews.push(...refreshed);
158
158
  cachedOverviews = dashboardManagedJobs(cachedOverviews);
@@ -7,6 +7,10 @@ export class GitHubStore {
7
7
  repository;
8
8
  prNumber;
9
9
  trustedAuthors;
10
+ prNodeCache = null;
11
+ commentsCache = null;
12
+ commentsFlight = null;
13
+ clearReadSnapshot() { this.prNodeCache = null; this.commentsCache = null; this.commentsFlight = null; }
10
14
  constructor(client, repository, prNumber, trustedAuthors = new Set()) {
11
15
  this.client = client;
12
16
  this.repository = repository;
@@ -49,43 +53,64 @@ export class GitHubStore {
49
53
  return await this.client.graphql(query, variables);
50
54
  }
51
55
  async prNode() {
52
- const { owner, name } = this.repoParts();
53
- const data = await this.graphql('query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){id state merged mergedAt closedAt body headRefName headRefOid}}}', { owner, name, number: this.prNumber });
54
- const repository = data.repository;
55
- const pr = repository?.pullRequest;
56
- if (!pr)
57
- throw new Error(`Pull request #${this.prNumber} not found in ${this.repository}`);
58
- return pr;
56
+ if (this.prNodeCache)
57
+ return this.prNodeCache;
58
+ const flight = (async () => {
59
+ const { owner, name } = this.repoParts();
60
+ const data = await this.graphql('query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){id state merged mergedAt closedAt body headRefName headRefOid}}}', { owner, name, number: this.prNumber });
61
+ const repository = data.repository;
62
+ const pr = repository?.pullRequest;
63
+ if (!pr)
64
+ throw new Error(`Pull request #${this.prNumber} not found in ${this.repository}`);
65
+ return pr;
66
+ })();
67
+ this.prNodeCache = flight;
68
+ try {
69
+ return await flight;
70
+ }
71
+ catch (error) {
72
+ if (this.prNodeCache === flight)
73
+ this.prNodeCache = null;
74
+ throw error;
75
+ }
59
76
  }
60
77
  async pullRequestBody() {
61
78
  const pr = await this.prNode();
62
79
  return typeof pr.body === 'string' ? pr.body : '';
63
80
  }
64
81
  async list() {
65
- const { owner, name } = this.repoParts();
66
- const all = [];
67
- let cursor = null;
68
- do {
69
- const data = await this.graphql('query($owner:String!,$name:String!,$number:Int!,$cursor:String){repository(owner:$owner,name:$name){pullRequest(number:$number){comments(first:100,after:$cursor){nodes{id databaseId url body updatedAt author{login}} pageInfo{hasNextPage endCursor}}}}}', { owner, name, number: this.prNumber, cursor });
70
- const repository = data.repository;
71
- const pr = repository?.pullRequest;
72
- const comments = pr?.comments;
73
- const nodes = Array.isArray(comments?.nodes) ? comments.nodes : [];
74
- for (const value of nodes) {
75
- const author = value.author;
76
- all.push({
77
- id: Number(value.databaseId ?? 0),
78
- nodeId: typeof value.id === 'string' ? value.id : undefined,
79
- url: typeof value.url === 'string' ? value.url : undefined,
80
- body: String(value.body ?? ''),
81
- updatedAt: String(value.updatedAt ?? ''),
82
- authorLogin: typeof author?.login === 'string' ? author.login : null
83
- });
84
- }
85
- const pageInfo = comments?.pageInfo;
86
- cursor = pageInfo?.hasNextPage === true && typeof pageInfo.endCursor === 'string' ? pageInfo.endCursor : null;
87
- } while (cursor);
88
- return this.overlayTaskWrites(all);
82
+ if (this.commentsCache)
83
+ return this.overlayTaskWrites(this.commentsCache);
84
+ if (this.commentsFlight)
85
+ return this.overlayTaskWrites(await this.commentsFlight);
86
+ const flight = (async () => {
87
+ const { owner, name } = this.repoParts();
88
+ const all = [];
89
+ let cursor = null;
90
+ do {
91
+ const data = await this.graphql('query($owner:String!,$name:String!,$number:Int!,$cursor:String){repository(owner:$owner,name:$name){pullRequest(number:$number){comments(first:100,after:$cursor){nodes{id databaseId url body updatedAt author{login}} pageInfo{hasNextPage endCursor}}}}}', { owner, name, number: this.prNumber, cursor });
92
+ const repository = data.repository;
93
+ const pr = repository?.pullRequest;
94
+ const comments = pr?.comments;
95
+ const nodes = Array.isArray(comments?.nodes) ? comments.nodes : [];
96
+ for (const value of nodes) {
97
+ const author = value.author;
98
+ all.push({ id: Number(value.databaseId ?? 0), nodeId: typeof value.id === 'string' ? value.id : undefined, url: typeof value.url === 'string' ? value.url : undefined, body: String(value.body ?? ''), updatedAt: String(value.updatedAt ?? ''), authorLogin: typeof author?.login === 'string' ? author.login : null });
99
+ }
100
+ const pageInfo = comments?.pageInfo;
101
+ cursor = pageInfo?.hasNextPage === true && typeof pageInfo.endCursor === 'string' ? pageInfo.endCursor : null;
102
+ } while (cursor);
103
+ return all;
104
+ })();
105
+ this.commentsFlight = flight;
106
+ try {
107
+ this.commentsCache = await flight;
108
+ return this.overlayTaskWrites(this.commentsCache);
109
+ }
110
+ finally {
111
+ if (this.commentsFlight === flight)
112
+ this.commentsFlight = null;
113
+ }
89
114
  }
90
115
  async comments() { return this.list(); }
91
116
  async pullRequestState() {
@@ -104,12 +129,18 @@ export class GitHubStore {
104
129
  });
105
130
  if (result.merged !== true)
106
131
  throw new Error(typeof result.message === 'string' ? result.message : 'Pull request #' + this.prNumber + ' was not merged');
132
+ this.prNodeCache = null;
107
133
  }
108
134
  async closePullRequest() {
109
135
  await this.client.request('repos/' + this.repository + '/pulls/' + this.prNumber, { method: 'PATCH', body: JSON.stringify({ state: 'closed' }) });
136
+ this.prNodeCache = null;
110
137
  }
111
138
  async updatePullRequestBody(body) {
112
139
  await this.client.request('repos/' + this.repository + '/pulls/' + this.prNumber, { method: 'PATCH', body: JSON.stringify({ body }) });
140
+ if (this.prNodeCache) {
141
+ const current = await this.prNodeCache;
142
+ this.prNodeCache = Promise.resolve({ ...current, body });
143
+ }
113
144
  }
114
145
  async pullRequestHead() {
115
146
  const pr = await this.prNode();
@@ -139,6 +170,7 @@ export class GitHubStore {
139
170
  const job = jobs.find(x => x.id === jobId);
140
171
  if (!job) {
141
172
  const conflicting = bodyJob ?? commentJobs[0];
173
+ this.clearReadSnapshot();
142
174
  if (conflicting)
143
175
  throw new Error(`JOB_ID_MISMATCH: PR #${this.prNumber} is bound to ${conflicting.id}, not ${jobId}`);
144
176
  throw new Error(`JOB_MARKER_MISSING: Managed GitHub job ${jobId} has no durable ${JOB_MARKER} marker in PR #${this.prNumber}`);
@@ -188,7 +220,7 @@ export class GitHubStore {
188
220
  const data = await this.graphql('mutation($subjectId:ID!,$body:String!){addComment(input:{subjectId:$subjectId,body:$body}){commentEdge{node{id databaseId url body updatedAt author{login}}}}}', { subjectId: pr.id, body });
189
221
  const edge = data.addComment?.commentEdge;
190
222
  const node = edge?.node;
191
- return {
223
+ const persisted = {
192
224
  id: Number(node?.databaseId ?? 0),
193
225
  nodeId: typeof node?.id === 'string' ? node.id : undefined,
194
226
  url: typeof node?.url === 'string' ? node.url : undefined,
@@ -196,13 +228,16 @@ export class GitHubStore {
196
228
  updatedAt: typeof node?.updatedAt === 'string' ? node.updatedAt : new Date().toISOString(),
197
229
  authorLogin: typeof node?.author?.login === 'string' ? String((node?.author).login) : [...this.trustedAuthors][0] ?? null,
198
230
  };
231
+ if (this.commentsCache)
232
+ this.commentsCache.push(persisted);
233
+ return persisted;
199
234
  }
200
235
  async updateComment(comment, body) {
201
236
  if (!comment.nodeId)
202
237
  throw new Error('GitHub comment node id is required for GraphQL update');
203
238
  const data = await this.graphql('mutation($id:ID!,$body:String!){updateIssueComment(input:{id:$id,body:$body}){issueComment{id databaseId url body updatedAt author{login}}}}', { id: comment.nodeId, body });
204
239
  const node = data.updateIssueComment?.issueComment;
205
- return {
240
+ const persisted = {
206
241
  id: Number(node?.databaseId ?? comment.id),
207
242
  nodeId: typeof node?.id === 'string' ? node.id : comment.nodeId,
208
243
  url: typeof node?.url === 'string' ? node.url : comment.url,
@@ -210,11 +245,19 @@ export class GitHubStore {
210
245
  updatedAt: typeof node?.updatedAt === 'string' ? node.updatedAt : new Date().toISOString(),
211
246
  authorLogin: typeof node?.author?.login === 'string' ? String((node?.author).login) : comment.authorLogin,
212
247
  };
248
+ if (this.commentsCache) {
249
+ const index = this.commentsCache.findIndex(item => item.nodeId === comment.nodeId || (item.id > 0 && item.id === comment.id));
250
+ if (index >= 0)
251
+ this.commentsCache[index] = persisted;
252
+ }
253
+ return persisted;
213
254
  }
214
255
  async deleteComment(comment) {
215
256
  if (!comment.nodeId)
216
257
  throw new Error('GitHub comment node id is required for GraphQL delete');
217
258
  await this.graphql('mutation($id:ID!){deleteIssueComment(input:{id:$id}){clientMutationId}}', { id: comment.nodeId });
259
+ if (this.commentsCache)
260
+ this.commentsCache = this.commentsCache.filter(item => item.nodeId !== comment.nodeId && !(item.id > 0 && item.id === comment.id));
218
261
  }
219
262
  async saveJob(job) {
220
263
  const currentBody = await this.pullRequestBody();
package/dist/store.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { createSign } from 'node:crypto';
3
+ import { AsyncLocalStorage } from 'node:async_hooks';
3
4
  import { parseAgent, parseJob, parseTask, renderAgentComment, renderJobComment, renderTaskComment, AGENT_MARKER, JOB_MARKER, TASK_MARKER } from './markers.js';
4
5
  import { isTaskForJob, sameTaskDefinition } from './store-task-definition.js';
5
6
  import { parsePackagePublicationMarker } from './github-webhook.js';
@@ -97,6 +98,7 @@ export class InMemoryStore {
97
98
  return [...this.values.values()].map(parseTask).filter((x) => x !== null).filter(task => isTaskForJob(task, jobId, jobs.length === 1));
98
99
  }
99
100
  }
101
+ export async function withGitHubRequestContext(client, context, work) { return client.withRequestContext ? client.withRequestContext(context, work) : work(); }
100
102
  async function githubGraphql(client, query, variables) {
101
103
  if (!client.graphql)
102
104
  throw new Error('GitHub GraphQL client is required');
@@ -248,12 +250,36 @@ export class GhApiClient {
248
250
  rateLimitFlight = null;
249
251
  rateLimitCacheTtlMs = 5 * 60_000;
250
252
  apiDiagnostics = { rest: 0, graphql: 0, rateLimit: 0 };
253
+ graphqlDiagnostics = { operations: {}, contexts: {} };
254
+ requestContext = new AsyncLocalStorage();
251
255
  constructor(appAuth = null) {
252
256
  this.appAuth = appAuth;
253
257
  }
258
+ async withRequestContext(context, work) { return await this.requestContext.run(context, work); }
259
+ graphqlOperation(query) {
260
+ if (/mutation\s*\([^)]*\)\s*\{addComment\(/.test(query))
261
+ return 'comment.add';
262
+ if (/updateIssueComment\(/.test(query))
263
+ return 'comment.update';
264
+ if (/deleteIssueComment\(/.test(query))
265
+ return 'comment.delete';
266
+ if (/viewer\s*\{login/.test(query))
267
+ return 'viewer.login';
268
+ if (/comments\(first:100,after:\$cursor\)/.test(query))
269
+ return 'pr.comments';
270
+ if (/comments\(last:100\)/.test(query))
271
+ return 'pr.snapshot';
272
+ if (/headRefName/.test(query) && /headRefOid/.test(query))
273
+ return 'pr.node';
274
+ if (/repository\(owner:\$owner,name:\$name\)\{id\}/.test(query))
275
+ return 'repository.id';
276
+ if (/pullRequests\(/.test(query))
277
+ return 'repository.pull-requests';
278
+ return /^\s*mutation/.test(query) ? 'mutation.other' : 'query.other';
279
+ }
254
280
  rateLimitStatus() {
255
281
  const limited = Date.now() < this.cooldownUntil;
256
- return { limited, cooldownUntil: limited ? new Date(this.cooldownUntil).toISOString() : null, calls: { ...this.apiDiagnostics } };
282
+ return { limited, cooldownUntil: limited ? new Date(this.cooldownUntil).toISOString() : null, calls: { ...this.apiDiagnostics }, graphql: { operations: { ...this.graphqlDiagnostics.operations }, contexts: { ...this.graphqlDiagnostics.contexts } } };
257
283
  }
258
284
  async rateLimitSnapshot(force = false) {
259
285
  const now = Date.now();
@@ -368,6 +394,10 @@ export class GhApiClient {
368
394
  async graphql(query, variables = {}) {
369
395
  this.assertAvailable();
370
396
  this.apiDiagnostics.graphql += 1;
397
+ const operation = this.graphqlOperation(query);
398
+ const context = this.requestContext.getStore() ?? 'unscoped';
399
+ this.graphqlDiagnostics.operations[operation] = (this.graphqlDiagnostics.operations[operation] ?? 0) + 1;
400
+ this.graphqlDiagnostics.contexts[context] = (this.graphqlDiagnostics.contexts[context] ?? 0) + 1;
371
401
  const payload = JSON.stringify({ query, variables });
372
402
  const stdout = await this.spawnGh(['api', 'graphql', '--input', '-'], payload, await this.commandEnv());
373
403
  const parsed = JSON.parse(stdout);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "1.0.137",
3
+ "version": "1.0.138",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {