agents-relay 1.0.175 → 1.0.177
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/dist/adapters.js +1 -0
- package/dist/dashboard-html.js +21 -0
- package/dist/github-client.js +276 -0
- package/dist/github-runtime-usage.js +81 -0
- package/dist/store.js +1 -246
- package/package.json +1 -1
package/dist/adapters.js
CHANGED
package/dist/dashboard-html.js
CHANGED
|
@@ -396,15 +396,36 @@ function shortReset(reset){if(!Number.isFinite(reset))return '';const at=new Dat
|
|
|
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
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;}
|
|
399
|
+
function githubObserved(label,observed){
|
|
400
|
+
if(!observed)return null;
|
|
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.remaining!==null)parts.push('remaining '+observed.remaining);
|
|
405
|
+
if(observed.headersCaptured&&observed.reset!==null)parts.push(shortReset(observed.reset));
|
|
406
|
+
if(observed.retryAfterSeconds!==null)parts.push('retry '+observed.retryAfterSeconds+'s');
|
|
407
|
+
const row=element('div',observed.throttled?'usage-row error-state':'usage-row subtle');
|
|
408
|
+
row.textContent=parts.filter(Boolean).join(' · ');
|
|
409
|
+
if(observed.reason)row.title=observed.reason;
|
|
410
|
+
return row;
|
|
411
|
+
}
|
|
399
412
|
function githubUsageCard(snapshot,status){
|
|
400
413
|
const card=element('section','usage-provider');
|
|
401
414
|
card.append(element('h3','',text('GitHub API')));
|
|
402
415
|
const resources=snapshot?.resources||{};
|
|
403
416
|
const bars=element('div','usage-bars');
|
|
417
|
+
bars.append(element('div','usage-label',text('Primary quota (/rate_limit)')));
|
|
404
418
|
if(resources.core)bars.append(githubBucket('REST',resources.core));
|
|
405
419
|
if(resources.graphql)bars.append(githubBucket('GraphQL',resources.graphql));
|
|
406
420
|
if(resources.search)bars.append(githubBucket('Search',resources.search));
|
|
407
421
|
if(!bars.children.length)bars.append(element('p','empty',text('Quota unavailable')));
|
|
422
|
+
const observed=status?.runtimeRateUsage?.resources||{};
|
|
423
|
+
for(const row of [githubObserved('REST',observed.core),githubObserved('GraphQL',observed.graphql),githubObserved('Search',observed.search)])if(row)bars.append(row);
|
|
424
|
+
if(status?.runtimeRateUsage?.rejection){
|
|
425
|
+
const rejection=status.runtimeRateUsage.rejection;
|
|
426
|
+
const cooldown=rejection.cooldownUntil?' · cooldown '+new Date(rejection.cooldownUntil).toLocaleTimeString():'';
|
|
427
|
+
bars.append(element('div','error-state',text('Throttled · '+(rejection.kind==='unknown'?'rate limit':rejection.kind)+' limit'+cooldown)));
|
|
428
|
+
}
|
|
408
429
|
if(status?.limited){
|
|
409
430
|
const until=status.cooldownUntil?' until '+new Date(status.cooldownUntil).toLocaleTimeString():'';
|
|
410
431
|
bars.append(element('div','error-state',text('Cooldown active'+until)));
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createSign } from 'node:crypto';
|
|
3
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
4
|
+
import { emptyGitHubRuntimeRateUsage, observeRuntimeRateLimit, parseGitHubCliHeaders, runtimeRejection } from './github-runtime-usage.js';
|
|
5
|
+
function base64Url(value) {
|
|
6
|
+
return Buffer.from(value).toString('base64url');
|
|
7
|
+
}
|
|
8
|
+
export function createGitHubAppJwt(options, now = Math.floor(Date.now() / 1000)) {
|
|
9
|
+
const header = base64Url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
|
|
10
|
+
const payload = base64Url(JSON.stringify({ iat: now - 60, exp: now + 540, iss: options.appId }));
|
|
11
|
+
const unsigned = `${header}.${payload}`;
|
|
12
|
+
const signer = createSign('RSA-SHA256');
|
|
13
|
+
signer.update(unsigned);
|
|
14
|
+
signer.end();
|
|
15
|
+
return `${unsigned}.${signer.sign(options.privateKey).toString('base64url')}`;
|
|
16
|
+
}
|
|
17
|
+
export class GhApiClient {
|
|
18
|
+
appAuth;
|
|
19
|
+
cooldownUntil = 0;
|
|
20
|
+
rateLimitFailures = 0;
|
|
21
|
+
installationToken = null;
|
|
22
|
+
rateLimitCache = null;
|
|
23
|
+
rateLimitFlight = null;
|
|
24
|
+
rateLimitCacheTtlMs = 5 * 60_000;
|
|
25
|
+
apiDiagnostics = { rest: 0, graphql: 0, rateLimit: 0 };
|
|
26
|
+
graphqlDiagnostics = { operations: {}, contexts: {} };
|
|
27
|
+
runtimeRateUsage = emptyGitHubRuntimeRateUsage();
|
|
28
|
+
requestContext = new AsyncLocalStorage();
|
|
29
|
+
constructor(appAuth = null) {
|
|
30
|
+
this.appAuth = appAuth;
|
|
31
|
+
}
|
|
32
|
+
async withRequestContext(context, work) { return await this.requestContext.run(context, work); }
|
|
33
|
+
graphqlOperation(query) {
|
|
34
|
+
if (/mutation\s*\([^)]*\)\s*\{addComment\(/.test(query))
|
|
35
|
+
return 'comment.add';
|
|
36
|
+
if (/updateIssueComment\(/.test(query))
|
|
37
|
+
return 'comment.update';
|
|
38
|
+
if (/deleteIssueComment\(/.test(query))
|
|
39
|
+
return 'comment.delete';
|
|
40
|
+
if (/viewer\s*\{login/.test(query))
|
|
41
|
+
return 'viewer.login';
|
|
42
|
+
if (/comments\(first:100,after:\$cursor\)/.test(query))
|
|
43
|
+
return 'pr.comments';
|
|
44
|
+
if (/comments\(last:100\)/.test(query))
|
|
45
|
+
return 'pr.snapshot';
|
|
46
|
+
if (/headRefName/.test(query) && /headRefOid/.test(query))
|
|
47
|
+
return 'pr.node';
|
|
48
|
+
if (/repository\(owner:\$owner,name:\$name\)\{id\}/.test(query))
|
|
49
|
+
return 'repository.id';
|
|
50
|
+
if (/pullRequests\(/.test(query))
|
|
51
|
+
return 'repository.pull-requests';
|
|
52
|
+
return /^\s*mutation/.test(query) ? 'mutation.other' : 'query.other';
|
|
53
|
+
}
|
|
54
|
+
rateLimitStatus() {
|
|
55
|
+
const limited = Date.now() < this.cooldownUntil;
|
|
56
|
+
return { limited, cooldownUntil: limited ? new Date(this.cooldownUntil).toISOString() : null, calls: { ...this.apiDiagnostics }, graphql: { operations: { ...this.graphqlDiagnostics.operations }, contexts: { ...this.graphqlDiagnostics.contexts } }, runtimeRateUsage: { resources: { ...this.runtimeRateUsage.resources }, rejection: this.runtimeRateUsage.rejection } };
|
|
57
|
+
}
|
|
58
|
+
async rateLimitSnapshot(force = false) {
|
|
59
|
+
const now = Date.now();
|
|
60
|
+
if (!force && this.rateLimitCache && now - this.rateLimitCache.cachedAt < this.rateLimitCacheTtlMs)
|
|
61
|
+
return this.rateLimitCache.snapshot;
|
|
62
|
+
if (this.rateLimitFlight)
|
|
63
|
+
return this.rateLimitFlight;
|
|
64
|
+
if (this.rateLimitStatus().limited && this.rateLimitCache)
|
|
65
|
+
return this.rateLimitCache.snapshot;
|
|
66
|
+
const flight = (async () => {
|
|
67
|
+
this.apiDiagnostics.rateLimit += 1;
|
|
68
|
+
const stdout = await this.spawnGh(['api', 'rate_limit'], undefined, await this.commandEnv());
|
|
69
|
+
const parsed = JSON.parse(stdout);
|
|
70
|
+
const resources = parsed.resources;
|
|
71
|
+
const bucket = (value) => {
|
|
72
|
+
const item = value;
|
|
73
|
+
const limit = Number(item?.limit), used = Number(item?.used), remaining = Number(item?.remaining), reset = Number(item?.reset);
|
|
74
|
+
return [limit, used, remaining, reset].every(Number.isFinite) ? { limit, used, remaining, reset } : undefined;
|
|
75
|
+
};
|
|
76
|
+
const snapshot = { observedAt: new Date().toISOString(), resources: { core: bucket(resources?.core), graphql: bucket(resources?.graphql), search: bucket(resources?.search) } };
|
|
77
|
+
this.rateLimitCache = { cachedAt: Date.now(), snapshot };
|
|
78
|
+
return snapshot;
|
|
79
|
+
})();
|
|
80
|
+
this.rateLimitFlight = flight;
|
|
81
|
+
try {
|
|
82
|
+
return await flight;
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
if (this.rateLimitFlight === flight)
|
|
86
|
+
this.rateLimitFlight = null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
assertAvailable() {
|
|
90
|
+
if (Date.now() < this.cooldownUntil)
|
|
91
|
+
throw new Error(`GitHub API cooldown active until ${new Date(this.cooldownUntil).toISOString()}`);
|
|
92
|
+
}
|
|
93
|
+
noteFailure(message) {
|
|
94
|
+
if (!/rate limit|secondary rate|HTTP 429/i.test(message))
|
|
95
|
+
return;
|
|
96
|
+
this.rateLimitFailures += 1;
|
|
97
|
+
const delay = Math.min(15 * 60_000, 60_000 * (2 ** Math.min(this.rateLimitFailures - 1, 4)));
|
|
98
|
+
this.cooldownUntil = Date.now() + delay;
|
|
99
|
+
}
|
|
100
|
+
noteSuccess() { if (Date.now() >= this.cooldownUntil)
|
|
101
|
+
this.rateLimitFailures = 0; }
|
|
102
|
+
appJwt(now = Math.floor(Date.now() / 1000)) {
|
|
103
|
+
if (!this.appAuth)
|
|
104
|
+
throw new Error('GitHub App authentication is not configured');
|
|
105
|
+
return createGitHubAppJwt(this.appAuth, now);
|
|
106
|
+
}
|
|
107
|
+
async appRequest(path, method = 'GET') {
|
|
108
|
+
const response = await fetch(`https://api.github.com${path}`, {
|
|
109
|
+
method,
|
|
110
|
+
headers: {
|
|
111
|
+
accept: 'application/vnd.github+json',
|
|
112
|
+
authorization: `Bearer ${this.appJwt()}`,
|
|
113
|
+
'x-github-api-version': '2026-03-10',
|
|
114
|
+
'user-agent': 'agents-relay',
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
const text = await response.text();
|
|
118
|
+
if (!response.ok)
|
|
119
|
+
throw new Error(`GitHub App API ${response.status}: ${text}`);
|
|
120
|
+
return JSON.parse(text);
|
|
121
|
+
}
|
|
122
|
+
async installationAccessToken() {
|
|
123
|
+
if (!this.appAuth)
|
|
124
|
+
throw new Error('GitHub App authentication is not configured');
|
|
125
|
+
if (this.installationToken && this.installationToken.expiresAt - Date.now() > 5 * 60_000)
|
|
126
|
+
return this.installationToken.value;
|
|
127
|
+
const parsed = await this.appRequest(`/app/installations/${this.appAuth.installationId}/access_tokens`, 'POST');
|
|
128
|
+
const token = typeof parsed.token === 'string' ? parsed.token : '';
|
|
129
|
+
const expiresAt = typeof parsed.expires_at === 'string' ? Date.parse(parsed.expires_at) : 0;
|
|
130
|
+
if (!token || !Number.isFinite(expiresAt) || expiresAt <= Date.now())
|
|
131
|
+
throw new Error('GitHub App installation token response was invalid');
|
|
132
|
+
this.installationToken = { value: token, expiresAt };
|
|
133
|
+
return token;
|
|
134
|
+
}
|
|
135
|
+
async commandEnv() {
|
|
136
|
+
if (!this.appAuth)
|
|
137
|
+
return process.env;
|
|
138
|
+
const token = await this.installationAccessToken();
|
|
139
|
+
return { ...process.env, GH_TOKEN: token, GITHUB_TOKEN: token };
|
|
140
|
+
}
|
|
141
|
+
async runGh(args, input, env, rateLimited, includeHeaders) {
|
|
142
|
+
if (rateLimited)
|
|
143
|
+
this.assertAvailable();
|
|
144
|
+
const commandArgs = includeHeaders ? [args[0] ?? '', '--include', ...args.slice(1)] : args;
|
|
145
|
+
return await new Promise((resolve) => {
|
|
146
|
+
const child = spawn('gh', commandArgs, { env });
|
|
147
|
+
let stdout = '';
|
|
148
|
+
let stderr = '';
|
|
149
|
+
child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
150
|
+
child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
151
|
+
child.on('error', error => resolve({ status: null, headers: {}, body: '', stderr: error.message }));
|
|
152
|
+
if (input !== undefined)
|
|
153
|
+
child.stdin.write(input);
|
|
154
|
+
child.stdin.end();
|
|
155
|
+
child.on('close', code => {
|
|
156
|
+
const response = parseGitHubCliHeaders(stdout, includeHeaders);
|
|
157
|
+
const failed = code !== 0;
|
|
158
|
+
const message = failed ? stderr.trim() || response.body.trim() || `gh exited ${code}` : '';
|
|
159
|
+
if (failed && rateLimited)
|
|
160
|
+
this.noteFailure(message);
|
|
161
|
+
if (!failed && rateLimited)
|
|
162
|
+
this.noteSuccess();
|
|
163
|
+
resolve({ ...response, stderr: failed ? message : undefined });
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
async spawnGh(args, input, env = process.env, rateLimited = true) {
|
|
168
|
+
return (await this.runGh(args, input, env, rateLimited, false)).body;
|
|
169
|
+
}
|
|
170
|
+
noteRuntimeRateLimit(channel, response) {
|
|
171
|
+
const observation = observeRuntimeRateLimit(channel, response, new Date().toISOString());
|
|
172
|
+
this.runtimeRateUsage.resources[observation.resource] = observation;
|
|
173
|
+
this.runtimeRateUsage.rejection = runtimeRejection(observation);
|
|
174
|
+
}
|
|
175
|
+
async graphqlTransport(query, variables) {
|
|
176
|
+
const response = await this.runGh(['api', 'graphql', '--input', '-'], JSON.stringify({ query, variables }), await this.commandEnv(), true, true);
|
|
177
|
+
this.noteRuntimeRateLimit('graphql', response);
|
|
178
|
+
return response;
|
|
179
|
+
}
|
|
180
|
+
async requestTransport(path, init) {
|
|
181
|
+
const args = ['api', path];
|
|
182
|
+
const method = String(init.method ?? 'GET');
|
|
183
|
+
if (method !== 'GET')
|
|
184
|
+
args.push('--method', method);
|
|
185
|
+
if (init.body)
|
|
186
|
+
args.push('--input', '-');
|
|
187
|
+
const response = await this.runGh(args, init.body ? String(init.body) : undefined, await this.commandEnv(), true, true);
|
|
188
|
+
this.noteRuntimeRateLimit('rest', response);
|
|
189
|
+
return response;
|
|
190
|
+
}
|
|
191
|
+
async graphql(query, variables = {}) {
|
|
192
|
+
this.assertAvailable();
|
|
193
|
+
this.apiDiagnostics.graphql += 1;
|
|
194
|
+
const operation = this.graphqlOperation(query);
|
|
195
|
+
const context = this.requestContext.getStore() ?? 'unscoped';
|
|
196
|
+
this.graphqlDiagnostics.operations[operation] = (this.graphqlDiagnostics.operations[operation] ?? 0) + 1;
|
|
197
|
+
this.graphqlDiagnostics.contexts[context] = (this.graphqlDiagnostics.contexts[context] ?? 0) + 1;
|
|
198
|
+
const payload = JSON.stringify({ query, variables });
|
|
199
|
+
const response = await this.graphqlTransport(query, variables);
|
|
200
|
+
if (response.stderr)
|
|
201
|
+
throw new Error(response.stderr);
|
|
202
|
+
const parsed = JSON.parse(response.body);
|
|
203
|
+
if (Array.isArray(parsed.errors) && parsed.errors.length > 0) {
|
|
204
|
+
const message = JSON.stringify(parsed.errors);
|
|
205
|
+
this.noteFailure(message);
|
|
206
|
+
throw new Error(message);
|
|
207
|
+
}
|
|
208
|
+
return parsed.data ?? parsed;
|
|
209
|
+
}
|
|
210
|
+
async request(path, init = {}) {
|
|
211
|
+
this.assertAvailable();
|
|
212
|
+
this.apiDiagnostics.rest += 1;
|
|
213
|
+
const args = ['api', path];
|
|
214
|
+
const method = String(init.method ?? 'GET');
|
|
215
|
+
if (method !== 'GET')
|
|
216
|
+
args.push('--method', method);
|
|
217
|
+
if (init.body)
|
|
218
|
+
args.push('--input', '-');
|
|
219
|
+
const response = await this.requestTransport(path, init);
|
|
220
|
+
if (response.stderr)
|
|
221
|
+
throw new Error(response.stderr);
|
|
222
|
+
const stdout = response.body;
|
|
223
|
+
if (!stdout.trim())
|
|
224
|
+
return null;
|
|
225
|
+
try {
|
|
226
|
+
return JSON.parse(stdout);
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
return stdout;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
async localLogin() {
|
|
233
|
+
return new Promise((resolve) => {
|
|
234
|
+
const child = spawn('gh', ['auth', 'status', '--active', '--json', 'hosts']);
|
|
235
|
+
let stdout = '';
|
|
236
|
+
child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
237
|
+
child.on('error', () => resolve(null));
|
|
238
|
+
child.on('close', () => {
|
|
239
|
+
try {
|
|
240
|
+
const value = JSON.parse(stdout);
|
|
241
|
+
const hosts = value.hosts;
|
|
242
|
+
const github = hosts?.['github.com'];
|
|
243
|
+
const active = Array.isArray(github) ? github.find(item => item.active === true) : undefined;
|
|
244
|
+
resolve(typeof active?.login === 'string' && active.login.length > 0 ? active.login : null);
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
resolve(null);
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
async currentLogin() {
|
|
253
|
+
if (this.appAuth) {
|
|
254
|
+
const app = await this.appRequest('/app');
|
|
255
|
+
if (typeof app.slug === 'string' && app.slug.length > 0)
|
|
256
|
+
return `${app.slug}[bot]`;
|
|
257
|
+
throw new Error('GitHub App identity lookup returned no slug');
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
const local = await this.localLogin();
|
|
261
|
+
if (local)
|
|
262
|
+
return local;
|
|
263
|
+
try {
|
|
264
|
+
const value = await this.graphql('query { viewer { login } }');
|
|
265
|
+
const viewer = value.viewer;
|
|
266
|
+
if (typeof viewer?.login === 'string' && viewer.login.length > 0)
|
|
267
|
+
return viewer.login;
|
|
268
|
+
}
|
|
269
|
+
catch { /* handled below */ }
|
|
270
|
+
}
|
|
271
|
+
const local = await this.localLogin();
|
|
272
|
+
if (local)
|
|
273
|
+
return local;
|
|
274
|
+
throw new Error('gh authentication did not return a login');
|
|
275
|
+
}
|
|
276
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
export function emptyGitHubRuntimeRateUsage() {
|
|
2
|
+
return { resources: {}, rejection: null };
|
|
3
|
+
}
|
|
4
|
+
function headerNumber(value) {
|
|
5
|
+
if (!value)
|
|
6
|
+
return null;
|
|
7
|
+
const parsed = Number(value);
|
|
8
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
9
|
+
}
|
|
10
|
+
function transportResource(requestChannel, headers) {
|
|
11
|
+
const resource = headers['x-ratelimit-resource'];
|
|
12
|
+
if (resource === 'core' || resource === 'graphql' || resource === 'search')
|
|
13
|
+
return resource;
|
|
14
|
+
return requestChannel === 'graphql' ? 'graphql' : 'core';
|
|
15
|
+
}
|
|
16
|
+
function rejectionKind(headers, reason) {
|
|
17
|
+
if (/secondary rate|graphql_rate_limit/i.test(reason))
|
|
18
|
+
return 'secondary';
|
|
19
|
+
if (/rate limit/i.test(reason) && headerNumber(headers['x-ratelimit-remaining']) !== 0)
|
|
20
|
+
return 'secondary';
|
|
21
|
+
if (/rate limit/i.test(reason))
|
|
22
|
+
return 'primary';
|
|
23
|
+
return 'unknown';
|
|
24
|
+
}
|
|
25
|
+
function isThrottled(status, reason) {
|
|
26
|
+
return status === 429 || /rate limit|secondary rate|graphql_rate_limit/i.test(reason);
|
|
27
|
+
}
|
|
28
|
+
export function observeRuntimeRateLimit(requestChannel, response, observedAt) {
|
|
29
|
+
const headers = response.headers;
|
|
30
|
+
const reason = [response.body, response.stderr].filter(value => typeof value === 'string' && value.length > 0).join('\n').slice(0, 2000);
|
|
31
|
+
const throttled = isThrottled(response.status, reason);
|
|
32
|
+
return {
|
|
33
|
+
resource: transportResource(requestChannel, headers),
|
|
34
|
+
observedAt,
|
|
35
|
+
status: response.status,
|
|
36
|
+
available: throttled ? false : response.status !== null ? response.status >= 200 && response.status < 300 : null,
|
|
37
|
+
throttled,
|
|
38
|
+
limit: headerNumber(headers['x-ratelimit-limit']),
|
|
39
|
+
used: headerNumber(headers['x-ratelimit-used']),
|
|
40
|
+
remaining: headerNumber(headers['x-ratelimit-remaining']),
|
|
41
|
+
reset: headerNumber(headers['x-ratelimit-reset']),
|
|
42
|
+
resetAt: headerNumber(headers['x-ratelimit-reset']) === null ? null : new Date(headerNumber(headers['x-ratelimit-reset']) * 1000).toISOString(),
|
|
43
|
+
retryAfterSeconds: headerNumber(headers['retry-after']),
|
|
44
|
+
reason: throttled ? reason : null,
|
|
45
|
+
headersCaptured: ['x-ratelimit-limit', 'x-ratelimit-remaining', 'x-ratelimit-reset', 'x-ratelimit-resource'].some(name => Object.hasOwn(headers, name)),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
export function runtimeRejection(observation) {
|
|
49
|
+
if (!observation.throttled)
|
|
50
|
+
return null;
|
|
51
|
+
const headers = {};
|
|
52
|
+
if (observation.remaining !== null)
|
|
53
|
+
headers['x-ratelimit-remaining'] = String(observation.remaining);
|
|
54
|
+
const retryAfterSeconds = observation.retryAfterSeconds;
|
|
55
|
+
return {
|
|
56
|
+
resource: observation.resource,
|
|
57
|
+
kind: rejectionKind(headers, observation.reason ?? ''),
|
|
58
|
+
observedAt: observation.observedAt,
|
|
59
|
+
status: observation.status,
|
|
60
|
+
retryAfterSeconds,
|
|
61
|
+
cooldownUntil: retryAfterSeconds === null ? null : new Date(Date.parse(observation.observedAt) + retryAfterSeconds * 1000).toISOString(),
|
|
62
|
+
reason: observation.reason ?? 'GitHub rate-limit rejection',
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
export function parseGitHubCliHeaders(output, hadHeaders) {
|
|
66
|
+
if (!hadHeaders)
|
|
67
|
+
return { status: null, headers: {}, body: output };
|
|
68
|
+
const boundary = output.search(/\r?\n\r?\n/);
|
|
69
|
+
if (boundary < 0)
|
|
70
|
+
return { status: null, headers: {}, body: output };
|
|
71
|
+
const head = output.slice(0, boundary);
|
|
72
|
+
const statusMatch = /HTTP\/[\d.]+\s+(\d{3})/.exec(head);
|
|
73
|
+
const headers = {};
|
|
74
|
+
for (const line of head.split(/\r?\n/).slice(1)) {
|
|
75
|
+
const separator = line.indexOf(':');
|
|
76
|
+
if (separator <= 0)
|
|
77
|
+
continue;
|
|
78
|
+
headers[line.slice(0, separator).trim().toLowerCase()] = line.slice(separator + 1).trim();
|
|
79
|
+
}
|
|
80
|
+
return { status: statusMatch ? Number(statusMatch[1]) : null, headers, body: output.slice(boundary).replace(/^\r?\n\r?\n/, '') };
|
|
81
|
+
}
|
package/dist/store.js
CHANGED
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
|
-
import { createSign } from 'node:crypto';
|
|
3
|
-
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
4
1
|
import { parseAgent, parseJob, parsePlan, parseTask, renderAgentComment, renderJobComment, renderPlanComment, renderTaskComment, AGENT_MARKER, JOB_MARKER, PLAN_MARKER, TASK_MARKER } from './markers.js';
|
|
5
2
|
import { isTaskForJob, sameTaskDefinition } from './store-task-definition.js';
|
|
6
3
|
import { parsePackagePublicationMarker } from './github-webhook.js';
|
|
@@ -129,6 +126,7 @@ export class InMemoryStore {
|
|
|
129
126
|
}
|
|
130
127
|
}
|
|
131
128
|
export async function withGitHubRequestContext(client, context, work) { return client.withRequestContext ? client.withRequestContext(context, work) : work(); }
|
|
129
|
+
export { createGitHubAppJwt, GhApiClient } from './github-client.js';
|
|
132
130
|
async function githubGraphql(client, query, variables) {
|
|
133
131
|
if (!client.graphql)
|
|
134
132
|
throw new Error('GitHub GraphQL client is required');
|
|
@@ -259,249 +257,6 @@ export async function resolvePullRequest(client, repository, options) {
|
|
|
259
257
|
throw error;
|
|
260
258
|
}
|
|
261
259
|
}
|
|
262
|
-
function base64Url(value) {
|
|
263
|
-
return Buffer.from(value).toString('base64url');
|
|
264
|
-
}
|
|
265
|
-
export function createGitHubAppJwt(options, now = Math.floor(Date.now() / 1000)) {
|
|
266
|
-
const header = base64Url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
|
|
267
|
-
const payload = base64Url(JSON.stringify({ iat: now - 60, exp: now + 540, iss: options.appId }));
|
|
268
|
-
const unsigned = `${header}.${payload}`;
|
|
269
|
-
const signer = createSign('RSA-SHA256');
|
|
270
|
-
signer.update(unsigned);
|
|
271
|
-
signer.end();
|
|
272
|
-
return `${unsigned}.${signer.sign(options.privateKey).toString('base64url')}`;
|
|
273
|
-
}
|
|
274
|
-
export class GhApiClient {
|
|
275
|
-
appAuth;
|
|
276
|
-
cooldownUntil = 0;
|
|
277
|
-
rateLimitFailures = 0;
|
|
278
|
-
installationToken = null;
|
|
279
|
-
rateLimitCache = null;
|
|
280
|
-
rateLimitFlight = null;
|
|
281
|
-
rateLimitCacheTtlMs = 5 * 60_000;
|
|
282
|
-
apiDiagnostics = { rest: 0, graphql: 0, rateLimit: 0 };
|
|
283
|
-
graphqlDiagnostics = { operations: {}, contexts: {} };
|
|
284
|
-
requestContext = new AsyncLocalStorage();
|
|
285
|
-
constructor(appAuth = null) {
|
|
286
|
-
this.appAuth = appAuth;
|
|
287
|
-
}
|
|
288
|
-
async withRequestContext(context, work) { return await this.requestContext.run(context, work); }
|
|
289
|
-
graphqlOperation(query) {
|
|
290
|
-
if (/mutation\s*\([^)]*\)\s*\{addComment\(/.test(query))
|
|
291
|
-
return 'comment.add';
|
|
292
|
-
if (/updateIssueComment\(/.test(query))
|
|
293
|
-
return 'comment.update';
|
|
294
|
-
if (/deleteIssueComment\(/.test(query))
|
|
295
|
-
return 'comment.delete';
|
|
296
|
-
if (/viewer\s*\{login/.test(query))
|
|
297
|
-
return 'viewer.login';
|
|
298
|
-
if (/comments\(first:100,after:\$cursor\)/.test(query))
|
|
299
|
-
return 'pr.comments';
|
|
300
|
-
if (/comments\(last:100\)/.test(query))
|
|
301
|
-
return 'pr.snapshot';
|
|
302
|
-
if (/headRefName/.test(query) && /headRefOid/.test(query))
|
|
303
|
-
return 'pr.node';
|
|
304
|
-
if (/repository\(owner:\$owner,name:\$name\)\{id\}/.test(query))
|
|
305
|
-
return 'repository.id';
|
|
306
|
-
if (/pullRequests\(/.test(query))
|
|
307
|
-
return 'repository.pull-requests';
|
|
308
|
-
return /^\s*mutation/.test(query) ? 'mutation.other' : 'query.other';
|
|
309
|
-
}
|
|
310
|
-
rateLimitStatus() {
|
|
311
|
-
const limited = Date.now() < this.cooldownUntil;
|
|
312
|
-
return { limited, cooldownUntil: limited ? new Date(this.cooldownUntil).toISOString() : null, calls: { ...this.apiDiagnostics }, graphql: { operations: { ...this.graphqlDiagnostics.operations }, contexts: { ...this.graphqlDiagnostics.contexts } } };
|
|
313
|
-
}
|
|
314
|
-
async rateLimitSnapshot(force = false) {
|
|
315
|
-
const now = Date.now();
|
|
316
|
-
if (!force && this.rateLimitCache && now - this.rateLimitCache.cachedAt < this.rateLimitCacheTtlMs)
|
|
317
|
-
return this.rateLimitCache.snapshot;
|
|
318
|
-
if (this.rateLimitFlight)
|
|
319
|
-
return this.rateLimitFlight;
|
|
320
|
-
if (this.rateLimitStatus().limited && this.rateLimitCache)
|
|
321
|
-
return this.rateLimitCache.snapshot;
|
|
322
|
-
const flight = (async () => {
|
|
323
|
-
this.apiDiagnostics.rateLimit += 1;
|
|
324
|
-
const stdout = await this.spawnGh(['api', 'rate_limit'], undefined, await this.commandEnv());
|
|
325
|
-
const parsed = JSON.parse(stdout);
|
|
326
|
-
const resources = parsed.resources;
|
|
327
|
-
const bucket = (value) => {
|
|
328
|
-
const item = value;
|
|
329
|
-
const limit = Number(item?.limit), used = Number(item?.used), remaining = Number(item?.remaining), reset = Number(item?.reset);
|
|
330
|
-
return [limit, used, remaining, reset].every(Number.isFinite) ? { limit, used, remaining, reset } : undefined;
|
|
331
|
-
};
|
|
332
|
-
const snapshot = { observedAt: new Date().toISOString(), resources: { core: bucket(resources?.core), graphql: bucket(resources?.graphql), search: bucket(resources?.search) } };
|
|
333
|
-
this.rateLimitCache = { cachedAt: Date.now(), snapshot };
|
|
334
|
-
return snapshot;
|
|
335
|
-
})();
|
|
336
|
-
this.rateLimitFlight = flight;
|
|
337
|
-
try {
|
|
338
|
-
return await flight;
|
|
339
|
-
}
|
|
340
|
-
finally {
|
|
341
|
-
if (this.rateLimitFlight === flight)
|
|
342
|
-
this.rateLimitFlight = null;
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
assertAvailable() {
|
|
346
|
-
if (Date.now() < this.cooldownUntil)
|
|
347
|
-
throw new Error(`GitHub API cooldown active until ${new Date(this.cooldownUntil).toISOString()}`);
|
|
348
|
-
}
|
|
349
|
-
noteFailure(message) {
|
|
350
|
-
if (!/rate limit|secondary rate|HTTP 429/i.test(message))
|
|
351
|
-
return;
|
|
352
|
-
this.rateLimitFailures += 1;
|
|
353
|
-
const delay = Math.min(15 * 60_000, 60_000 * (2 ** Math.min(this.rateLimitFailures - 1, 4)));
|
|
354
|
-
this.cooldownUntil = Date.now() + delay;
|
|
355
|
-
}
|
|
356
|
-
noteSuccess() { if (Date.now() >= this.cooldownUntil)
|
|
357
|
-
this.rateLimitFailures = 0; }
|
|
358
|
-
appJwt(now = Math.floor(Date.now() / 1000)) {
|
|
359
|
-
if (!this.appAuth)
|
|
360
|
-
throw new Error('GitHub App authentication is not configured');
|
|
361
|
-
return createGitHubAppJwt(this.appAuth, now);
|
|
362
|
-
}
|
|
363
|
-
async appRequest(path, method = 'GET') {
|
|
364
|
-
const response = await fetch(`https://api.github.com${path}`, {
|
|
365
|
-
method,
|
|
366
|
-
headers: {
|
|
367
|
-
accept: 'application/vnd.github+json',
|
|
368
|
-
authorization: `Bearer ${this.appJwt()}`,
|
|
369
|
-
'x-github-api-version': '2026-03-10',
|
|
370
|
-
'user-agent': 'agents-relay',
|
|
371
|
-
},
|
|
372
|
-
});
|
|
373
|
-
const text = await response.text();
|
|
374
|
-
if (!response.ok)
|
|
375
|
-
throw new Error(`GitHub App API ${response.status}: ${text}`);
|
|
376
|
-
return JSON.parse(text);
|
|
377
|
-
}
|
|
378
|
-
async installationAccessToken() {
|
|
379
|
-
if (!this.appAuth)
|
|
380
|
-
throw new Error('GitHub App authentication is not configured');
|
|
381
|
-
if (this.installationToken && this.installationToken.expiresAt - Date.now() > 5 * 60_000)
|
|
382
|
-
return this.installationToken.value;
|
|
383
|
-
const parsed = await this.appRequest(`/app/installations/${this.appAuth.installationId}/access_tokens`, 'POST');
|
|
384
|
-
const token = typeof parsed.token === 'string' ? parsed.token : '';
|
|
385
|
-
const expiresAt = typeof parsed.expires_at === 'string' ? Date.parse(parsed.expires_at) : 0;
|
|
386
|
-
if (!token || !Number.isFinite(expiresAt) || expiresAt <= Date.now())
|
|
387
|
-
throw new Error('GitHub App installation token response was invalid');
|
|
388
|
-
this.installationToken = { value: token, expiresAt };
|
|
389
|
-
return token;
|
|
390
|
-
}
|
|
391
|
-
async commandEnv() {
|
|
392
|
-
if (!this.appAuth)
|
|
393
|
-
return process.env;
|
|
394
|
-
const token = await this.installationAccessToken();
|
|
395
|
-
return { ...process.env, GH_TOKEN: token, GITHUB_TOKEN: token };
|
|
396
|
-
}
|
|
397
|
-
async spawnGh(args, input, env = process.env, rateLimited = true) {
|
|
398
|
-
if (rateLimited)
|
|
399
|
-
this.assertAvailable();
|
|
400
|
-
return new Promise((resolve, reject) => {
|
|
401
|
-
const child = spawn('gh', args, { env });
|
|
402
|
-
let stdout = '';
|
|
403
|
-
let stderr = '';
|
|
404
|
-
child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
405
|
-
child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
406
|
-
child.on('error', reject);
|
|
407
|
-
if (input !== undefined)
|
|
408
|
-
child.stdin.write(input);
|
|
409
|
-
child.stdin.end();
|
|
410
|
-
child.on('close', code => {
|
|
411
|
-
if (code !== 0) {
|
|
412
|
-
const message = stderr.trim() || `gh exited ${code}`;
|
|
413
|
-
if (rateLimited)
|
|
414
|
-
this.noteFailure(message);
|
|
415
|
-
reject(new Error(message));
|
|
416
|
-
return;
|
|
417
|
-
}
|
|
418
|
-
if (rateLimited)
|
|
419
|
-
this.noteSuccess();
|
|
420
|
-
resolve(stdout);
|
|
421
|
-
});
|
|
422
|
-
});
|
|
423
|
-
}
|
|
424
|
-
async graphql(query, variables = {}) {
|
|
425
|
-
this.assertAvailable();
|
|
426
|
-
this.apiDiagnostics.graphql += 1;
|
|
427
|
-
const operation = this.graphqlOperation(query);
|
|
428
|
-
const context = this.requestContext.getStore() ?? 'unscoped';
|
|
429
|
-
this.graphqlDiagnostics.operations[operation] = (this.graphqlDiagnostics.operations[operation] ?? 0) + 1;
|
|
430
|
-
this.graphqlDiagnostics.contexts[context] = (this.graphqlDiagnostics.contexts[context] ?? 0) + 1;
|
|
431
|
-
const payload = JSON.stringify({ query, variables });
|
|
432
|
-
const stdout = await this.spawnGh(['api', 'graphql', '--input', '-'], payload, await this.commandEnv());
|
|
433
|
-
const parsed = JSON.parse(stdout);
|
|
434
|
-
if (Array.isArray(parsed.errors) && parsed.errors.length > 0) {
|
|
435
|
-
const message = JSON.stringify(parsed.errors);
|
|
436
|
-
this.noteFailure(message);
|
|
437
|
-
throw new Error(message);
|
|
438
|
-
}
|
|
439
|
-
return parsed.data ?? parsed;
|
|
440
|
-
}
|
|
441
|
-
async request(path, init = {}) {
|
|
442
|
-
this.assertAvailable();
|
|
443
|
-
this.apiDiagnostics.rest += 1;
|
|
444
|
-
const args = ['api', path];
|
|
445
|
-
const method = String(init.method ?? 'GET');
|
|
446
|
-
if (method !== 'GET')
|
|
447
|
-
args.push('--method', method);
|
|
448
|
-
if (init.body)
|
|
449
|
-
args.push('--input', '-');
|
|
450
|
-
const stdout = await this.spawnGh(args, init.body ? String(init.body) : undefined, await this.commandEnv());
|
|
451
|
-
if (!stdout.trim())
|
|
452
|
-
return null;
|
|
453
|
-
try {
|
|
454
|
-
return JSON.parse(stdout);
|
|
455
|
-
}
|
|
456
|
-
catch {
|
|
457
|
-
return stdout;
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
async localLogin() {
|
|
461
|
-
return new Promise((resolve) => {
|
|
462
|
-
const child = spawn('gh', ['auth', 'status', '--active', '--json', 'hosts']);
|
|
463
|
-
let stdout = '';
|
|
464
|
-
child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
465
|
-
child.on('error', () => resolve(null));
|
|
466
|
-
child.on('close', () => {
|
|
467
|
-
try {
|
|
468
|
-
const value = JSON.parse(stdout);
|
|
469
|
-
const hosts = value.hosts;
|
|
470
|
-
const github = hosts?.['github.com'];
|
|
471
|
-
const active = Array.isArray(github) ? github.find(item => item.active === true) : undefined;
|
|
472
|
-
resolve(typeof active?.login === 'string' && active.login.length > 0 ? active.login : null);
|
|
473
|
-
}
|
|
474
|
-
catch {
|
|
475
|
-
resolve(null);
|
|
476
|
-
}
|
|
477
|
-
});
|
|
478
|
-
});
|
|
479
|
-
}
|
|
480
|
-
async currentLogin() {
|
|
481
|
-
if (this.appAuth) {
|
|
482
|
-
const app = await this.appRequest('/app');
|
|
483
|
-
if (typeof app.slug === 'string' && app.slug.length > 0)
|
|
484
|
-
return `${app.slug}[bot]`;
|
|
485
|
-
throw new Error('GitHub App identity lookup returned no slug');
|
|
486
|
-
}
|
|
487
|
-
else {
|
|
488
|
-
const local = await this.localLogin();
|
|
489
|
-
if (local)
|
|
490
|
-
return local;
|
|
491
|
-
try {
|
|
492
|
-
const value = await this.graphql('query { viewer { login } }');
|
|
493
|
-
const viewer = value.viewer;
|
|
494
|
-
if (typeof viewer?.login === 'string' && viewer.login.length > 0)
|
|
495
|
-
return viewer.login;
|
|
496
|
-
}
|
|
497
|
-
catch { /* handled below */ }
|
|
498
|
-
}
|
|
499
|
-
const local = await this.localLogin();
|
|
500
|
-
if (local)
|
|
501
|
-
return local;
|
|
502
|
-
throw new Error('gh authentication did not return a login');
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
260
|
export { GitHubStore } from './store-github.js';
|
|
506
261
|
export async function listManagedGitHubJobs(client, repository, trustedAuthors) {
|
|
507
262
|
if (!client.graphql)
|