agents-relay 1.0.137 → 1.0.139

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); }
@@ -28,6 +28,7 @@ export class RepositoryWorkerPool {
28
28
  reconcileLocks = new Map();
29
29
  targetedReconcileFlights = new Map();
30
30
  jobTargets = new Map();
31
+ targetSeeds = new Map();
31
32
  constructor(client, repository, trustedAuthors, concurrency, makeReconciler, isRateLimited = () => false) {
32
33
  this.client = client;
33
34
  this.trustedAuthors = trustedAuthors;
@@ -60,7 +61,7 @@ export class RepositoryWorkerPool {
60
61
  for (const job of terminalManagedJobs(discovered)) {
61
62
  await this.reconcileExclusive(job.id, async () => {
62
63
  const store = new GitHubStore(this.client, job.repository, job.prNumber, this.trustedAuthors);
63
- await this.makeReconciler(store, 0).reconcile(job.id);
64
+ await withGitHubRequestContext(this.client, 'pool.terminal-reconcile', () => this.makeReconciler(store, 0).reconcile(job.id));
64
65
  });
65
66
  }
66
67
  const jobs = runnableManagedJobs(discovered);
@@ -75,7 +76,7 @@ export class RepositoryWorkerPool {
75
76
  continue;
76
77
  const allowance = Math.max(1, Math.min(capacity, ready || (expiredRunning ? 1 : 0) || 1));
77
78
  await this.reconcileExclusive(job.id, async () => {
78
- await this.makeReconciler(store, allowance).reconcile(job.id);
79
+ await withGitHubRequestContext(this.client, 'pool.discovered-reconcile', () => this.makeReconciler(store, allowance).reconcile(job.id));
79
80
  });
80
81
  capacity -= allowance;
81
82
  }
@@ -92,13 +93,18 @@ export class RepositoryWorkerPool {
92
93
  try {
93
94
  do {
94
95
  this.pending = false;
95
- await this.reconcileDiscovered(await aggregateManagedGitHubJobs(this.client, this.repositories, this.trustedAuthors));
96
+ await this.reconcileDiscovered(await withGitHubRequestContext(this.client, 'pool.workspace-scan', () => aggregateManagedGitHubJobs(this.client, this.repositories, this.trustedAuthors)));
96
97
  } while (this.pending);
97
98
  }
98
99
  finally {
99
100
  this.running = false;
100
101
  }
101
102
  }
103
+ seedTargetSnapshot(repository, prNumber, jobId, seed) {
104
+ if (!this.repositories.includes(repository))
105
+ return;
106
+ this.targetSeeds.set(`${repository}#${prNumber}:${jobId}`, seed);
107
+ }
102
108
  async reconcileTarget(repository, prNumber, jobId) {
103
109
  if (this.isRateLimited() || !this.repositories.includes(repository))
104
110
  return;
@@ -108,8 +114,11 @@ export class RepositoryWorkerPool {
108
114
  if (existing)
109
115
  return existing;
110
116
  const flight = this.reconcileExclusive(jobId, async () => {
111
- const store = new GitHubStore(this.client, repository, prNumber, this.trustedAuthors);
112
- await this.makeReconciler(store, Math.max(1, this.concurrency)).reconcile(jobId);
117
+ const seedKey = `${repository}#${prNumber}:${jobId}`;
118
+ const seed = this.targetSeeds.get(seedKey) ?? null;
119
+ this.targetSeeds.delete(seedKey);
120
+ const store = new GitHubStore(this.client, repository, prNumber, this.trustedAuthors, seed);
121
+ await withGitHubRequestContext(this.client, seed ? 'pool.targeted-reconcile.seeded' : 'pool.targeted-reconcile', () => this.makeReconciler(store, Math.max(1, this.concurrency)).reconcile(jobId));
113
122
  });
114
123
  this.targetedReconcileFlights.set(key, flight);
115
124
  try {
@@ -130,9 +139,9 @@ export class RepositoryWorkerPool {
130
139
  }
131
140
  const store = new GitHubStore(this.client, target.repository, target.prNumber, this.trustedAuthors);
132
141
  const reconciler = this.makeReconciler(store, Math.max(1, this.concurrency));
133
- await reconciler.handleEvent(event);
142
+ await withGitHubRequestContext(this.client, `pool.event.${event.type}`, () => reconciler.handleEvent(event));
134
143
  if (['task.completed', 'task.failed', 'task.blocked', 'task.cancelled'].includes(event.type))
135
- await reconciler.reconcile(event.job_id);
144
+ await withGitHubRequestContext(this.client, `pool.event-reconcile.${event.type}`, () => reconciler.reconcile(event.job_id));
136
145
  }
137
146
  async watch(bus) {
138
147
  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, loadManagedGitHubPullRequestSnapshotGraphql, 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 {
@@ -143,21 +143,21 @@ export async function runDaemon(argv) {
143
143
  catch { /* cache is best effort */ }
144
144
  };
145
145
  const pullRequestRefreshFlights = new Map();
146
- const refreshPullRequest = async (repository, prNumber) => {
146
+ const refreshPullRequestSnapshot = async (repository, prNumber) => {
147
147
  if (client.rateLimitStatus().limited)
148
- return cachedOverviews.filter(item => item.job.repository === repository && item.job.prNumber === prNumber);
148
+ return { overviews: cachedOverviews.filter(item => item.job.repository === repository && item.job.prNumber === prNumber), seed: { prNode: {}, comments: null } };
149
149
  const key = `${repository}#${prNumber}`;
150
150
  const existing = pullRequestRefreshFlights.get(key);
151
151
  if (existing)
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 snapshot = await withGitHubRequestContext(client, 'relayd.pr-refresh', () => loadManagedGitHubPullRequestSnapshotGraphql(client, repository, prNumber, trusted));
156
156
  cachedOverviews = cachedOverviews.filter(item => item.job.repository !== repository || item.job.prNumber !== prNumber);
157
- cachedOverviews.push(...refreshed);
157
+ cachedOverviews.push(...snapshot.overviews);
158
158
  cachedOverviews = dashboardManagedJobs(cachedOverviews);
159
159
  await persistCache();
160
- return refreshed;
160
+ return snapshot;
161
161
  })();
162
162
  pullRequestRefreshFlights.set(key, flight);
163
163
  try {
@@ -168,6 +168,7 @@ export async function runDaemon(argv) {
168
168
  pullRequestRefreshFlights.delete(key);
169
169
  }
170
170
  };
171
+ const refreshPullRequest = async (repository, prNumber) => (await refreshPullRequestSnapshot(repository, prNumber)).overviews;
171
172
  const initial = cachedOverviews;
172
173
  const fallback = initial[0]?.job;
173
174
  const dashboardStore = fallback ? new GitHubStore(client, fallback.repository, fallback.prNumber, trusted) : new InMemoryStore();
@@ -206,8 +207,10 @@ export async function runDaemon(argv) {
206
207
  refresh: async (event) => {
207
208
  if (!event.repository || !event.pullRequest)
208
209
  return [];
209
- const refreshed = await refreshPullRequest(event.repository, event.pullRequest);
210
- return refreshed.map(item => item.job.id);
210
+ const snapshot = await refreshPullRequestSnapshot(event.repository, event.pullRequest);
211
+ for (const item of snapshot.overviews)
212
+ pool.seedTargetSnapshot(event.repository, event.pullRequest, item.job.id, snapshot.seed);
213
+ return snapshot.overviews.map(item => item.job.id);
211
214
  },
212
215
  wake: async (event) => {
213
216
  if (!event.repository || !event.pullRequest)
@@ -7,11 +7,21 @@ export class GitHubStore {
7
7
  repository;
8
8
  prNumber;
9
9
  trustedAuthors;
10
- constructor(client, repository, prNumber, trustedAuthors = new Set()) {
10
+ prNodeCache = null;
11
+ commentsCache = null;
12
+ commentsFlight = null;
13
+ clearReadSnapshot() { this.prNodeCache = null; this.commentsCache = null; this.commentsFlight = null; }
14
+ constructor(client, repository, prNumber, trustedAuthors = new Set(), seed = null) {
11
15
  this.client = client;
12
16
  this.repository = repository;
13
17
  this.prNumber = prNumber;
14
18
  this.trustedAuthors = trustedAuthors;
19
+ if (seed) {
20
+ if (Object.keys(seed.prNode).length > 0)
21
+ this.prNodeCache = Promise.resolve(seed.prNode);
22
+ if (seed.comments)
23
+ this.commentsCache = [...seed.comments];
24
+ }
15
25
  }
16
26
  taskKey(task) { return `${this.repository}#${this.prNumber}:${task.jobId ?? ''}/${task.id}`; }
17
27
  taskKeyFromBody(body) { const task = parseTask(body); return task ? this.taskKey(task) : null; }
@@ -49,43 +59,64 @@ export class GitHubStore {
49
59
  return await this.client.graphql(query, variables);
50
60
  }
51
61
  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;
62
+ if (this.prNodeCache)
63
+ return this.prNodeCache;
64
+ const flight = (async () => {
65
+ const { owner, name } = this.repoParts();
66
+ 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 });
67
+ const repository = data.repository;
68
+ const pr = repository?.pullRequest;
69
+ if (!pr)
70
+ throw new Error(`Pull request #${this.prNumber} not found in ${this.repository}`);
71
+ return pr;
72
+ })();
73
+ this.prNodeCache = flight;
74
+ try {
75
+ return await flight;
76
+ }
77
+ catch (error) {
78
+ if (this.prNodeCache === flight)
79
+ this.prNodeCache = null;
80
+ throw error;
81
+ }
59
82
  }
60
83
  async pullRequestBody() {
61
84
  const pr = await this.prNode();
62
85
  return typeof pr.body === 'string' ? pr.body : '';
63
86
  }
64
87
  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);
88
+ if (this.commentsCache)
89
+ return this.overlayTaskWrites(this.commentsCache);
90
+ if (this.commentsFlight)
91
+ return this.overlayTaskWrites(await this.commentsFlight);
92
+ const flight = (async () => {
93
+ const { owner, name } = this.repoParts();
94
+ const all = [];
95
+ let cursor = null;
96
+ do {
97
+ 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 });
98
+ const repository = data.repository;
99
+ const pr = repository?.pullRequest;
100
+ const comments = pr?.comments;
101
+ const nodes = Array.isArray(comments?.nodes) ? comments.nodes : [];
102
+ for (const value of nodes) {
103
+ const author = value.author;
104
+ 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 });
105
+ }
106
+ const pageInfo = comments?.pageInfo;
107
+ cursor = pageInfo?.hasNextPage === true && typeof pageInfo.endCursor === 'string' ? pageInfo.endCursor : null;
108
+ } while (cursor);
109
+ return all;
110
+ })();
111
+ this.commentsFlight = flight;
112
+ try {
113
+ this.commentsCache = await flight;
114
+ return this.overlayTaskWrites(this.commentsCache);
115
+ }
116
+ finally {
117
+ if (this.commentsFlight === flight)
118
+ this.commentsFlight = null;
119
+ }
89
120
  }
90
121
  async comments() { return this.list(); }
91
122
  async pullRequestState() {
@@ -104,12 +135,18 @@ export class GitHubStore {
104
135
  });
105
136
  if (result.merged !== true)
106
137
  throw new Error(typeof result.message === 'string' ? result.message : 'Pull request #' + this.prNumber + ' was not merged');
138
+ this.prNodeCache = null;
107
139
  }
108
140
  async closePullRequest() {
109
141
  await this.client.request('repos/' + this.repository + '/pulls/' + this.prNumber, { method: 'PATCH', body: JSON.stringify({ state: 'closed' }) });
142
+ this.prNodeCache = null;
110
143
  }
111
144
  async updatePullRequestBody(body) {
112
145
  await this.client.request('repos/' + this.repository + '/pulls/' + this.prNumber, { method: 'PATCH', body: JSON.stringify({ body }) });
146
+ if (this.prNodeCache) {
147
+ const current = await this.prNodeCache;
148
+ this.prNodeCache = Promise.resolve({ ...current, body });
149
+ }
113
150
  }
114
151
  async pullRequestHead() {
115
152
  const pr = await this.prNode();
@@ -139,6 +176,7 @@ export class GitHubStore {
139
176
  const job = jobs.find(x => x.id === jobId);
140
177
  if (!job) {
141
178
  const conflicting = bodyJob ?? commentJobs[0];
179
+ this.clearReadSnapshot();
142
180
  if (conflicting)
143
181
  throw new Error(`JOB_ID_MISMATCH: PR #${this.prNumber} is bound to ${conflicting.id}, not ${jobId}`);
144
182
  throw new Error(`JOB_MARKER_MISSING: Managed GitHub job ${jobId} has no durable ${JOB_MARKER} marker in PR #${this.prNumber}`);
@@ -188,7 +226,7 @@ export class GitHubStore {
188
226
  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
227
  const edge = data.addComment?.commentEdge;
190
228
  const node = edge?.node;
191
- return {
229
+ const persisted = {
192
230
  id: Number(node?.databaseId ?? 0),
193
231
  nodeId: typeof node?.id === 'string' ? node.id : undefined,
194
232
  url: typeof node?.url === 'string' ? node.url : undefined,
@@ -196,13 +234,16 @@ export class GitHubStore {
196
234
  updatedAt: typeof node?.updatedAt === 'string' ? node.updatedAt : new Date().toISOString(),
197
235
  authorLogin: typeof node?.author?.login === 'string' ? String((node?.author).login) : [...this.trustedAuthors][0] ?? null,
198
236
  };
237
+ if (this.commentsCache)
238
+ this.commentsCache.push(persisted);
239
+ return persisted;
199
240
  }
200
241
  async updateComment(comment, body) {
201
242
  if (!comment.nodeId)
202
243
  throw new Error('GitHub comment node id is required for GraphQL update');
203
244
  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
245
  const node = data.updateIssueComment?.issueComment;
205
- return {
246
+ const persisted = {
206
247
  id: Number(node?.databaseId ?? comment.id),
207
248
  nodeId: typeof node?.id === 'string' ? node.id : comment.nodeId,
208
249
  url: typeof node?.url === 'string' ? node.url : comment.url,
@@ -210,11 +251,19 @@ export class GitHubStore {
210
251
  updatedAt: typeof node?.updatedAt === 'string' ? node.updatedAt : new Date().toISOString(),
211
252
  authorLogin: typeof node?.author?.login === 'string' ? String((node?.author).login) : comment.authorLogin,
212
253
  };
254
+ if (this.commentsCache) {
255
+ const index = this.commentsCache.findIndex(item => item.nodeId === comment.nodeId || (item.id > 0 && item.id === comment.id));
256
+ if (index >= 0)
257
+ this.commentsCache[index] = persisted;
258
+ }
259
+ return persisted;
213
260
  }
214
261
  async deleteComment(comment) {
215
262
  if (!comment.nodeId)
216
263
  throw new Error('GitHub comment node id is required for GraphQL delete');
217
264
  await this.graphql('mutation($id:ID!){deleteIssueComment(input:{id:$id}){clientMutationId}}', { id: comment.nodeId });
265
+ if (this.commentsCache)
266
+ this.commentsCache = this.commentsCache.filter(item => item.nodeId !== comment.nodeId && !(item.id > 0 && item.id === comment.id));
218
267
  }
219
268
  async saveJob(job) {
220
269
  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';
@@ -11,28 +12,39 @@ function publicationFromComments(rows, trustedAuthors) {
11
12
  });
12
13
  return allowed.map(item => parsePackagePublicationMarker(String(item.body ?? ''))).filter((item) => item !== null).at(-1) ?? null;
13
14
  }
14
- export async function loadManagedGitHubPullRequestGraphql(client, repository, prNumber, trustedAuthors) {
15
+ export async function loadManagedGitHubPullRequestSnapshotGraphql(client, repository, prNumber, trustedAuthors) {
15
16
  const [owner, name] = repository.split('/', 2);
16
17
  if (!owner || !name || !Number.isInteger(prNumber) || prNumber <= 0 || !client.graphql)
17
- return [];
18
- const query = 'query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){state isDraft merged mergedAt closedAt body comments(last:100){nodes{databaseId url author{login} body}}}}}';
18
+ return { overviews: [], seed: { prNode: {}, comments: null } };
19
+ const query = 'query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){id state isDraft merged mergedAt closedAt body headRefName headRefOid comments(last:100){totalCount nodes{id databaseId url updatedAt author{login} body}}}}}';
19
20
  const data = await client.graphql(query, { owner, name, number: prNumber });
20
21
  const repo = data.repository;
21
22
  const pr = repo?.pullRequest;
22
23
  if (!pr)
23
- return [];
24
- const comments = pr.comments?.nodes;
25
- const trusted = (Array.isArray(comments) ? comments : []).filter(item => {
26
- const row = item;
24
+ return { overviews: [], seed: { prNode: {}, comments: null } };
25
+ const commentConnection = pr.comments;
26
+ const commentRows = (Array.isArray(commentConnection?.nodes) ? commentConnection.nodes : []);
27
+ const totalCount = Number(commentConnection?.totalCount ?? commentRows.length);
28
+ const seedComments = Number.isFinite(totalCount) && totalCount <= commentRows.length ? commentRows.map(row => {
27
29
  const author = row.author;
30
+ return {
31
+ id: Number(row.databaseId ?? 0),
32
+ nodeId: typeof row.id === 'string' ? row.id : undefined,
33
+ url: typeof row.url === 'string' ? row.url : undefined,
34
+ body: String(row.body ?? ''),
35
+ updatedAt: String(row.updatedAt ?? ''),
36
+ authorLogin: typeof author?.login === 'string' ? author.login : null,
37
+ };
38
+ }) : null;
39
+ const trusted = commentRows.filter(item => {
40
+ const author = item.author;
28
41
  return typeof author?.login === 'string' && trustedAuthors.has(author.login);
29
42
  });
30
43
  const bodyJob = parseJob(typeof pr.body === 'string' ? pr.body : '');
31
44
  const commentJobs = trusted.map(item => parseJob(String(item.body ?? ''))).filter((item) => item !== null);
32
45
  const jobs = bodyJob ? [bodyJob] : commentJobs;
33
- const publication = publicationFromComments((Array.isArray(comments) ? comments : []), trustedAuthors);
34
- const tasks = trusted.map(item => {
35
- const row = item;
46
+ const publication = publicationFromComments(commentRows, trustedAuthors);
47
+ const tasks = trusted.map(row => {
36
48
  const task = parseTask(String(row.body ?? ''));
37
49
  if (!task)
38
50
  return null;
@@ -41,12 +53,18 @@ export async function loadManagedGitHubPullRequestGraphql(client, repository, pr
41
53
  }).filter((item) => item !== null);
42
54
  const state = String(pr.state ?? '').toUpperCase();
43
55
  const githubState = pr.merged === true ? 'MERGED' : state === 'CLOSED' ? 'CLOSED' : 'OPEN';
44
- return jobs.map(job => {
56
+ const overviews = jobs.map(job => {
45
57
  job.tasks = tasks.filter(task => task.jobId === job.id || (!task.jobId && jobs.length === 1));
46
58
  if (!job.description && typeof pr.body === 'string' && pr.body)
47
59
  job.description = pr.body;
60
+ if (typeof pr.headRefOid === 'string' && pr.headRefOid)
61
+ job.headSha = pr.headRefOid;
48
62
  return { job, githubState, draft: pr.isDraft === true, mergedAt: typeof pr.mergedAt === 'string' ? pr.mergedAt : null, closedAt: typeof pr.closedAt === 'string' ? pr.closedAt : null, publication };
49
63
  });
64
+ return { overviews, seed: { prNode: pr, comments: seedComments } };
65
+ }
66
+ export async function loadManagedGitHubPullRequestGraphql(client, repository, prNumber, trustedAuthors) {
67
+ return (await loadManagedGitHubPullRequestSnapshotGraphql(client, repository, prNumber, trustedAuthors)).overviews;
50
68
  }
51
69
  export async function loadManagedGitHubJobGraphql(client, repository, prNumber, jobId, trustedAuthors) {
52
70
  return (await loadManagedGitHubPullRequestGraphql(client, repository, prNumber, trustedAuthors)).find(item => item.job.id === jobId) ?? null;
@@ -97,6 +115,7 @@ export class InMemoryStore {
97
115
  return [...this.values.values()].map(parseTask).filter((x) => x !== null).filter(task => isTaskForJob(task, jobId, jobs.length === 1));
98
116
  }
99
117
  }
118
+ export async function withGitHubRequestContext(client, context, work) { return client.withRequestContext ? client.withRequestContext(context, work) : work(); }
100
119
  async function githubGraphql(client, query, variables) {
101
120
  if (!client.graphql)
102
121
  throw new Error('GitHub GraphQL client is required');
@@ -248,12 +267,36 @@ export class GhApiClient {
248
267
  rateLimitFlight = null;
249
268
  rateLimitCacheTtlMs = 5 * 60_000;
250
269
  apiDiagnostics = { rest: 0, graphql: 0, rateLimit: 0 };
270
+ graphqlDiagnostics = { operations: {}, contexts: {} };
271
+ requestContext = new AsyncLocalStorage();
251
272
  constructor(appAuth = null) {
252
273
  this.appAuth = appAuth;
253
274
  }
275
+ async withRequestContext(context, work) { return await this.requestContext.run(context, work); }
276
+ graphqlOperation(query) {
277
+ if (/mutation\s*\([^)]*\)\s*\{addComment\(/.test(query))
278
+ return 'comment.add';
279
+ if (/updateIssueComment\(/.test(query))
280
+ return 'comment.update';
281
+ if (/deleteIssueComment\(/.test(query))
282
+ return 'comment.delete';
283
+ if (/viewer\s*\{login/.test(query))
284
+ return 'viewer.login';
285
+ if (/comments\(first:100,after:\$cursor\)/.test(query))
286
+ return 'pr.comments';
287
+ if (/comments\(last:100\)/.test(query))
288
+ return 'pr.snapshot';
289
+ if (/headRefName/.test(query) && /headRefOid/.test(query))
290
+ return 'pr.node';
291
+ if (/repository\(owner:\$owner,name:\$name\)\{id\}/.test(query))
292
+ return 'repository.id';
293
+ if (/pullRequests\(/.test(query))
294
+ return 'repository.pull-requests';
295
+ return /^\s*mutation/.test(query) ? 'mutation.other' : 'query.other';
296
+ }
254
297
  rateLimitStatus() {
255
298
  const limited = Date.now() < this.cooldownUntil;
256
- return { limited, cooldownUntil: limited ? new Date(this.cooldownUntil).toISOString() : null, calls: { ...this.apiDiagnostics } };
299
+ return { limited, cooldownUntil: limited ? new Date(this.cooldownUntil).toISOString() : null, calls: { ...this.apiDiagnostics }, graphql: { operations: { ...this.graphqlDiagnostics.operations }, contexts: { ...this.graphqlDiagnostics.contexts } } };
257
300
  }
258
301
  async rateLimitSnapshot(force = false) {
259
302
  const now = Date.now();
@@ -368,6 +411,10 @@ export class GhApiClient {
368
411
  async graphql(query, variables = {}) {
369
412
  this.assertAvailable();
370
413
  this.apiDiagnostics.graphql += 1;
414
+ const operation = this.graphqlOperation(query);
415
+ const context = this.requestContext.getStore() ?? 'unscoped';
416
+ this.graphqlDiagnostics.operations[operation] = (this.graphqlDiagnostics.operations[operation] ?? 0) + 1;
417
+ this.graphqlDiagnostics.contexts[context] = (this.graphqlDiagnostics.contexts[context] ?? 0) + 1;
371
418
  const payload = JSON.stringify({ query, variables });
372
419
  const stdout = await this.spawnGh(['api', 'graphql', '--input', '-'], payload, await this.commandEnv());
373
420
  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.139",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {