agents-relay 1.0.0 → 1.0.2

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.
Files changed (45) hide show
  1. package/README.md +1 -1
  2. package/dist/cli.js +2 -1
  3. package/dist/dashboard.js +1 -1
  4. package/dist/relayd.js +2 -2
  5. package/dist/store.js +24 -9
  6. package/package.json +19 -4
  7. package/.github/workflows/publish.yml +0 -91
  8. package/AGENTS.md +0 -16
  9. package/docs/agent-network.md +0 -34
  10. package/docs/architecture.md +0 -120
  11. package/docs/autonomous-objective-jobs.md +0 -121
  12. package/docs/example.md +0 -30
  13. package/docs/github-app-rate-limit.md +0 -124
  14. package/docs/service.md +0 -43
  15. package/pack.json +0 -326
  16. package/scripts/npm-version.mjs +0 -11
  17. package/src/adapters.ts +0 -231
  18. package/src/cli.ts +0 -324
  19. package/src/continuation.ts +0 -6
  20. package/src/dashboard.ts +0 -421
  21. package/src/events.ts +0 -25
  22. package/src/github-auth.ts +0 -35
  23. package/src/github-webhook.ts +0 -37
  24. package/src/markers.ts +0 -33
  25. package/src/planner.ts +0 -150
  26. package/src/pool.ts +0 -87
  27. package/src/reconciler.ts +0 -235
  28. package/src/registry.ts +0 -35
  29. package/src/relayd.ts +0 -137
  30. package/src/scheduler.ts +0 -27
  31. package/src/store.ts +0 -526
  32. package/src/types.ts +0 -45
  33. package/src/usage.ts +0 -385
  34. package/src/workspace.ts +0 -62
  35. package/test/adapters.test.js +0 -303
  36. package/test/autonomous.test.js +0 -119
  37. package/test/core.test.js +0 -363
  38. package/test/dashboard.test.js +0 -178
  39. package/test/github-auth.test.js +0 -51
  40. package/test/github-webhook.test.js +0 -21
  41. package/test/service.test.js +0 -116
  42. package/test/store.test.js +0 -390
  43. package/test/usage.test.js +0 -88
  44. package/test/workspace.test.js +0 -95
  45. package/tsconfig.json +0 -4
package/src/store.ts DELETED
@@ -1,526 +0,0 @@
1
- import { Job, Task } from './types.js';
2
- import { spawn } from 'node:child_process';
3
- import { createSign } from 'node:crypto';
4
- import { parseAgent, parseJob, parseTask, renderAgentComment, renderJobComment, renderTaskComment, AGENT_MARKER, JOB_MARKER, TASK_MARKER } from './markers.js';
5
- import { AgentRegistration } from './types.js';
6
- export type Comment = { id: number; nodeId?: string; body: string; updatedAt: string; authorLogin: string | null };
7
- export type PullRequestState = 'OPEN' | 'MERGED' | 'CLOSED';
8
- export type ManagedJobOverview = { job: Job; githubState: PullRequestState; draft: boolean; mergedAt: string | null; closedAt: string | null };
9
- export async function loadManagedGitHubPullRequestGraphql(client: GitHubClient, repository: string, prNumber: number, trustedAuthors: ReadonlySet<string>): Promise<ManagedJobOverview[]> {
10
- const [owner, name] = repository.split('/', 2);
11
- if (!owner || !name || !Number.isInteger(prNumber) || prNumber <= 0 || !client.graphql) return [];
12
- 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{author{login} body}}}}}';
13
- const data = await client.graphql(query, { owner, name, number: prNumber }) as Record<string, unknown>;
14
- const repo = data.repository as Record<string, unknown> | undefined;
15
- const pr = repo?.pullRequest as Record<string, unknown> | undefined;
16
- if (!pr) return [];
17
- const comments = (pr.comments as Record<string, unknown> | undefined)?.nodes;
18
- const trusted = (Array.isArray(comments) ? comments : []).filter(item => {
19
- const row = item as Record<string, unknown>; const author = row.author as Record<string, unknown> | undefined;
20
- return typeof author?.login === 'string' && trustedAuthors.has(author.login);
21
- });
22
- const jobs = trusted.map(item => parseJob(String((item as Record<string, unknown>).body ?? ''))).filter((item): item is Job => item !== null);
23
- const tasks = trusted.map(item => parseTask(String((item as Record<string, unknown>).body ?? ''))).filter((item): item is Task => item !== null);
24
- const state = String(pr.state ?? '').toUpperCase();
25
- const githubState: PullRequestState = pr.merged === true ? 'MERGED' : state === 'CLOSED' ? 'CLOSED' : 'OPEN';
26
- return jobs.map(job => {
27
- job.tasks = tasks.filter(task => task.jobId === job.id || (!task.jobId && jobs.length === 1));
28
- if (!job.description && typeof pr.body === 'string' && pr.body) job.description = pr.body;
29
- return { job, githubState, draft: pr.isDraft === true, mergedAt: typeof pr.mergedAt === 'string' ? pr.mergedAt : null, closedAt: typeof pr.closedAt === 'string' ? pr.closedAt : null };
30
- });
31
- }
32
- export async function loadManagedGitHubJobGraphql(client: GitHubClient, repository: string, prNumber: number, jobId: string, trustedAuthors: ReadonlySet<string>): Promise<ManagedJobOverview | null> {
33
- return (await loadManagedGitHubPullRequestGraphql(client, repository, prNumber, trustedAuthors)).find(item => item.job.id === jobId) ?? null;
34
- }
35
- const COMPLETED_JOB_HISTORY_LIMIT = 10;
36
- function isCompletedManagedJob(item: ManagedJobOverview): boolean { return ['COMPLETED', 'CANCELLED'].includes(item.job.state) || item.githubState !== 'OPEN'; }
37
- function completedAt(item: ManagedJobOverview): string { return item.mergedAt ?? item.closedAt ?? item.job.updatedAt; }
38
- export function dashboardManagedJobs(items: readonly ManagedJobOverview[]): ManagedJobOverview[] {
39
- const unfinished = items.filter(item => !isCompletedManagedJob(item));
40
- const completed = items.filter(isCompletedManagedJob).sort((a, b) => completedAt(b).localeCompare(completedAt(a)) || b.job.prNumber - a.job.prNumber).slice(0, COMPLETED_JOB_HISTORY_LIMIT);
41
- return [...unfinished, ...completed];
42
- }
43
- export interface DurableStore { load(jobId: string): Promise<Job>; saveJob(job: Job): Promise<void>; appendTask(task: Task): Promise<void>; saveTask(task: Task): Promise<void>; listAgents(): Promise<AgentRegistration[]>; saveAgent(agent: AgentRegistration): Promise<void>; comments(): Promise<Comment[]>; pullRequestState?(): Promise<PullRequestState>; }
44
- export class InMemoryStore implements DurableStore {
45
- private readonly values = new Map<string, string>();
46
- constructor(job?: Job) {
47
- if (job) {
48
- this.values.set(`${JOB_MARKER}:${job.id}`, renderJobComment(job));
49
- for (const task of job.tasks) this.values.set(`${TASK_MARKER}:${task.jobId ?? ''}:${task.id}`, renderTaskComment(task));
50
- }
51
- }
52
- async load(jobId: string): Promise<Job> { const job = await this.findJob(jobId); if (!job) throw new Error(`Job ${jobId} not found`); job.tasks = this.tasksForJob(jobId); return job; }
53
- async saveJob(job: Job): Promise<void> { this.values.set(`${JOB_MARKER}:${job.id}`, renderJobComment(job)); }
54
- async appendTask(task: Task): Promise<void> {
55
- const key = `${TASK_MARKER}:${task.jobId ?? ''}:${task.id}`;
56
- const existing = this.values.get(key);
57
- if (existing) {
58
- const current = parseTask(existing);
59
- if (current && sameTaskDefinition(current, task)) return;
60
- throw new Error(`Task ${task.id} already exists with a different definition`);
61
- }
62
- this.values.set(key, renderTaskComment(task));
63
- }
64
- async saveTask(task: Task): Promise<void> { this.values.set(`${TASK_MARKER}:${task.jobId ?? ''}:${task.id}`, renderTaskComment(task)); }
65
- async listAgents(): Promise<AgentRegistration[]> { return [...this.values.values()].map(parseAgent).filter((x): x is AgentRegistration => x !== null); }
66
- async saveAgent(agent: AgentRegistration): Promise<void> { this.values.set(`${AGENT_MARKER}:${agent.id}`, renderAgentComment(agent)); }
67
- async comments(): Promise<Comment[]> { return [...this.values.values()].map((body, i) => ({ id: i + 1, body, updatedAt: new Date().toISOString(), authorLogin: 'local' })); }
68
- private findJob(jobId: string): Job | null { return [...this.values.values()].map(parseJob).find((x): x is Job => x !== null && x.id === jobId) ?? null; }
69
- private tasksForJob(jobId: string): Task[] {
70
- const jobs = [...this.values.values()].map(parseJob).filter((x): x is Job => x !== null && typeof x.id === 'string' && x.id.length > 0);
71
- return [...this.values.values()].map(parseTask).filter((x): x is Task => x !== null).filter(task => isTaskForJob(task, jobId, jobs.length === 1));
72
- }
73
- }
74
- export type GitHubClient = { request(path: string, init?: RequestInit): Promise<unknown>; graphql?(query: string, variables?: Record<string, unknown>): Promise<unknown> };
75
- export type PullRequest = { number: number; title: string; body: string; state: string; head: string; base: string };
76
-
77
- function immutableTaskDefinition(task: Task): unknown {
78
- const routing = task.routing ? {
79
- provider: task.routing.provider, model: task.routing.model, profile: task.routing.profile,
80
- reasoning: task.routing.reasoning, cwd: task.routing.cwd, projectId: task.routing.projectId
81
- } : undefined;
82
- return {
83
- jobId: task.jobId, id: task.id, priority: task.priority, projectName: task.projectName, agentName: task.agentName,
84
- kind: task.kind ?? 'work', parentTaskId: task.parentTaskId, dependencies: task.dependencies, capabilities: task.capabilities, adapter: task.adapter,
85
- input: task.input, routing, continuation: task.continuation ?? null, maxAttempts: task.maxAttempts, timeoutMs: task.timeoutMs
86
- };
87
- }
88
- function sameTaskDefinition(left: Task, right: Task): boolean {
89
- return JSON.stringify(immutableTaskDefinition(left)) === JSON.stringify(immutableTaskDefinition(right));
90
- }
91
- async function githubGraphql(client: GitHubClient, query: string, variables: Record<string, unknown>): Promise<Record<string, unknown>> {
92
- if (!client.graphql) throw new Error('GitHub GraphQL client is required');
93
- return await client.graphql(query, variables) as Record<string, unknown>;
94
- }
95
- function repositoryParts(repository: string): { owner: string; name: string } {
96
- const [owner, name] = repository.split('/', 2);
97
- if (!owner || !name) throw new Error(`Invalid GitHub repository ${repository}`);
98
- return { owner, name };
99
- }
100
- async function headHasNoAheadCommits(client: GitHubClient, repository: string, base: string, head: string): Promise<boolean> {
101
- const path = `repos/${repository}/compare/${encodeURIComponent(base)}...${encodeURIComponent(head)}`;
102
- const comparison = await client.request(path) as Record<string, unknown>;
103
- return Number(comparison.ahead_by) === 0;
104
- }
105
-
106
- async function bootstrapEmptyHeadCommit(client: GitHubClient, repository: string, head: string): Promise<void> {
107
- const { owner, name } = repositoryParts(repository);
108
- const data = await githubGraphql(
109
- client,
110
- 'query($owner:String!,$name:String!,$qualified:String!){repository(owner:$owner,name:$name){ref(qualifiedName:$qualified){target{... on Commit{oid tree{oid}}}}}}',
111
- { owner, name, qualified: `refs/heads/${head}` }
112
- );
113
- const repo = data.repository as Record<string, unknown> | undefined;
114
- const ref = repo?.ref as Record<string, unknown> | undefined;
115
- const target = ref?.target as Record<string, unknown> | undefined;
116
- const tree = target?.tree as Record<string, unknown> | undefined;
117
- const expectedHeadOid = typeof target?.oid === 'string' ? target.oid : '';
118
- const treeOid = typeof tree?.oid === 'string' ? tree.oid : '';
119
- if (!expectedHeadOid || !treeOid) throw new Error(`Unable to resolve head branch ${head}`);
120
-
121
- const created = await client.request(`repos/${repository}/git/commits`, {
122
- method: 'POST',
123
- body: JSON.stringify({
124
- message: 'chore: bootstrap Agents Relay job',
125
- tree: treeOid,
126
- parents: [expectedHeadOid],
127
- }),
128
- }) as Record<string, unknown>;
129
- const commitOid = typeof created.sha === 'string' ? created.sha : '';
130
- if (!commitOid) throw new Error(`Unable to create bootstrap commit for ${head}`);
131
-
132
- await client.request(`repos/${repository}/git/refs/heads/${head}`, {
133
- method: 'PATCH',
134
- body: JSON.stringify({ sha: commitOid, force: false }),
135
- });
136
- }
137
- function pullRequest(value: unknown): PullRequest {
138
- const item = value as Record<string, unknown>;
139
- const head = item.head as Record<string, unknown> | undefined;
140
- const base = item.base as Record<string, unknown> | undefined;
141
- const number = Number(item.number);
142
- const headRef = typeof item.headRefName === 'string' ? item.headRefName : typeof head?.ref === 'string' ? head.ref : '';
143
- const baseRef = typeof item.baseRefName === 'string' ? item.baseRefName : typeof base?.ref === 'string' ? base.ref : '';
144
- if (!Number.isInteger(number) || number <= 0 || !headRef || !baseRef) throw new Error('Invalid GitHub pull request response');
145
- return { number, title: String(item.title ?? ''), body: String(item.body ?? ''), state: String(item.state ?? ''), head: headRef, base: baseRef };
146
- }
147
-
148
- export async function resolvePullRequest(client: GitHubClient, repository: string, options: { prNumber?: number; head?: string; base?: string; title?: string; body?: string }): Promise<PullRequest> {
149
- const { owner, name } = repositoryParts(repository);
150
- if (options.prNumber) {
151
- const data = await githubGraphql(
152
- client,
153
- 'query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){number title body state headRefName baseRefName}}}',
154
- { owner, name, number: options.prNumber }
155
- );
156
- const repo = data.repository as Record<string, unknown> | undefined;
157
- const pr = repo?.pullRequest;
158
- if (!pr) throw new Error(`Pull request #${options.prNumber} not found`);
159
- return pullRequest(pr);
160
- }
161
- if (!options.head) throw new Error('--head is required when --pr is not provided');
162
- const base = options.base || 'main';
163
- const find = async (): Promise<PullRequest | null> => {
164
- const data = await githubGraphql(
165
- client,
166
- 'query($owner:String!,$name:String!,$head:String!,$base:String!){repository(owner:$owner,name:$name){pullRequests(first:20,states:OPEN,headRefName:$head,baseRefName:$base){nodes{number title body state headRefName baseRefName}}}}',
167
- { owner, name, head: options.head, base }
168
- );
169
- const repo = data.repository as Record<string, unknown> | undefined;
170
- const connection = repo?.pullRequests as Record<string, unknown> | undefined;
171
- const nodes = Array.isArray(connection?.nodes) ? connection.nodes : [];
172
- const match = nodes.map(pullRequest).find(pr => pr.head === options.head && pr.base === base);
173
- return match ?? null;
174
- };
175
- const existing = await find();
176
- if (existing) return existing;
177
- if (!options.title) throw new Error('--title is required when creating a pull request');
178
- if (await headHasNoAheadCommits(client, repository, base, options.head)) {
179
- await bootstrapEmptyHeadCommit(client, repository, options.head);
180
- const afterBootstrap = await find();
181
- if (afterBootstrap) return afterBootstrap;
182
- }
183
- const repositoryData = await githubGraphql(client, 'query($owner:String!,$name:String!){repository(owner:$owner,name:$name){id}}', { owner, name });
184
- const repo = repositoryData.repository as Record<string, unknown> | undefined;
185
- if (typeof repo?.id !== 'string') throw new Error(`Repository ${repository} not found`);
186
- const create = async (): Promise<PullRequest> => {
187
- const data = await githubGraphql(
188
- client,
189
- 'mutation($repositoryId:ID!,$base:String!,$head:String!,$title:String!,$body:String!){createPullRequest(input:{repositoryId:$repositoryId,baseRefName:$base,headRefName:$head,title:$title,body:$body}){pullRequest{number title body state headRefName baseRefName}}}',
190
- { repositoryId: repo.id, base, head: options.head, title: options.title, body: options.body ?? '' }
191
- );
192
- const result = data.createPullRequest as Record<string, unknown> | undefined;
193
- if (!result?.pullRequest) throw new Error('GitHub createPullRequest returned no pull request');
194
- return pullRequest(result.pullRequest);
195
- };
196
- try {
197
- return await create();
198
- } catch (error) {
199
- const raced = await find();
200
- if (raced) return raced;
201
- const message = error instanceof Error ? error.message : String(error);
202
- if (/No commits between|no commits between/i.test(message)) {
203
- await bootstrapEmptyHeadCommit(client, repository, options.head);
204
- const afterBootstrap = await find();
205
- if (afterBootstrap) return afterBootstrap;
206
- return create();
207
- }
208
- throw error;
209
- }
210
- }
211
- export type GitHubAppAuthOptions = { appId: string; installationId: string; privateKey: string };
212
-
213
- function base64Url(value: string | Buffer): string {
214
- return Buffer.from(value).toString('base64url');
215
- }
216
-
217
- export function createGitHubAppJwt(options: GitHubAppAuthOptions, now = Math.floor(Date.now() / 1000)): string {
218
- const header = base64Url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
219
- const payload = base64Url(JSON.stringify({ iat: now - 60, exp: now + 540, iss: options.appId }));
220
- const unsigned = `${header}.${payload}`;
221
- const signer = createSign('RSA-SHA256'); signer.update(unsigned); signer.end();
222
- return `${unsigned}.${signer.sign(options.privateKey).toString('base64url')}`;
223
- }
224
-
225
- export class GhApiClient implements GitHubClient {
226
- private cooldownUntil = 0;
227
- private rateLimitFailures = 0;
228
- private installationToken: { value: string; expiresAt: number } | null = null;
229
- constructor(private readonly appAuth: GitHubAppAuthOptions | null = null) {}
230
- rateLimitStatus(): { limited: boolean; cooldownUntil: string | null } {
231
- const limited = Date.now() < this.cooldownUntil;
232
- return { limited, cooldownUntil: limited ? new Date(this.cooldownUntil).toISOString() : null };
233
- }
234
- private assertAvailable(): void {
235
- if (Date.now() < this.cooldownUntil) throw new Error(`GitHub API cooldown active until ${new Date(this.cooldownUntil).toISOString()}`);
236
- }
237
- private noteFailure(message: string): void {
238
- if (!/rate limit|secondary rate|HTTP 429/i.test(message)) return;
239
- this.rateLimitFailures += 1;
240
- const delay = Math.min(15 * 60_000, 60_000 * (2 ** Math.min(this.rateLimitFailures - 1, 4)));
241
- this.cooldownUntil = Date.now() + delay;
242
- }
243
- private noteSuccess(): void { if (Date.now() >= this.cooldownUntil) this.rateLimitFailures = 0; }
244
- private appJwt(now = Math.floor(Date.now() / 1000)): string {
245
- if (!this.appAuth) throw new Error('GitHub App authentication is not configured');
246
- return createGitHubAppJwt(this.appAuth, now);
247
- }
248
- private async appRequest(path: string, method: 'GET' | 'POST' = 'GET'): Promise<Record<string, unknown>> {
249
- const response = await fetch(`https://api.github.com${path}`, {
250
- method,
251
- headers: {
252
- accept: 'application/vnd.github+json',
253
- authorization: `Bearer ${this.appJwt()}`,
254
- 'x-github-api-version': '2026-03-10',
255
- 'user-agent': 'agents-relay',
256
- },
257
- });
258
- const text = await response.text();
259
- if (!response.ok) throw new Error(`GitHub App API ${response.status}: ${text}`);
260
- return JSON.parse(text) as Record<string, unknown>;
261
- }
262
- private async installationAccessToken(): Promise<string> {
263
- if (!this.appAuth) throw new Error('GitHub App authentication is not configured');
264
- if (this.installationToken && this.installationToken.expiresAt - Date.now() > 5 * 60_000) return this.installationToken.value;
265
- const parsed = await this.appRequest(`/app/installations/${this.appAuth.installationId}/access_tokens`, 'POST');
266
- const token = typeof parsed.token === 'string' ? parsed.token : '';
267
- const expiresAt = typeof parsed.expires_at === 'string' ? Date.parse(parsed.expires_at) : 0;
268
- if (!token || !Number.isFinite(expiresAt) || expiresAt <= Date.now()) throw new Error('GitHub App installation token response was invalid');
269
- this.installationToken = { value: token, expiresAt };
270
- return token;
271
- }
272
- private async commandEnv(): Promise<NodeJS.ProcessEnv> {
273
- if (!this.appAuth) return process.env;
274
- const token = await this.installationAccessToken();
275
- return { ...process.env, GH_TOKEN: token, GITHUB_TOKEN: token };
276
- }
277
- private async spawnGh(args: string[], input?: string, env: NodeJS.ProcessEnv = process.env, rateLimited = true): Promise<string> {
278
- if (rateLimited) this.assertAvailable();
279
- return new Promise<string>((resolve, reject) => {
280
- const child = spawn('gh', args, { env }); let stdout = ''; let stderr = '';
281
- child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString(); });
282
- child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString(); });
283
- child.on('error', reject); if (input !== undefined) child.stdin.write(input); child.stdin.end();
284
- child.on('close', code => {
285
- if (code !== 0) { const message = stderr.trim() || `gh exited ${code}`; if (rateLimited) this.noteFailure(message); reject(new Error(message)); return; }
286
- if (rateLimited) this.noteSuccess(); resolve(stdout);
287
- });
288
- });
289
- }
290
- async graphql(query: string, variables: Record<string, unknown> = {}): Promise<unknown> {
291
- this.assertAvailable();
292
- const payload = JSON.stringify({ query, variables });
293
- const stdout = await this.spawnGh(['api', 'graphql', '--input', '-'], payload, await this.commandEnv());
294
- const parsed = JSON.parse(stdout) as Record<string, unknown>;
295
- if (Array.isArray(parsed.errors) && parsed.errors.length > 0) {
296
- const message = JSON.stringify(parsed.errors); this.noteFailure(message); throw new Error(message);
297
- }
298
- return parsed.data ?? parsed;
299
- }
300
- async request(path: string, init: RequestInit = {}): Promise<unknown> {
301
- this.assertAvailable();
302
- const args = ['api', path]; const method = String(init.method ?? 'GET');
303
- if (method !== 'GET') args.push('--method', method); if (init.body) args.push('--input', '-');
304
- const stdout = await this.spawnGh(args, init.body ? String(init.body) : undefined, await this.commandEnv());
305
- if (!stdout.trim()) return null;
306
- try { return JSON.parse(stdout) as unknown; } catch { return stdout; }
307
- }
308
- async localLogin(): Promise<string | null> {
309
- return new Promise<string | null>((resolve) => {
310
- const child = spawn('gh', ['auth', 'status', '--active', '--json', 'hosts']); let stdout = '';
311
- child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString(); });
312
- child.on('error', () => resolve(null));
313
- child.on('close', () => {
314
- try {
315
- const value = JSON.parse(stdout) as Record<string, unknown>; const hosts = value.hosts as Record<string, unknown[]> | undefined;
316
- const github = hosts?.['github.com']; const active = Array.isArray(github) ? github.find(item => (item as Record<string, unknown>).active === true) as Record<string, unknown> | undefined : undefined;
317
- resolve(typeof active?.login === 'string' && active.login.length > 0 ? active.login : null);
318
- } catch { resolve(null); }
319
- });
320
- });
321
- }
322
- async currentLogin(): Promise<string> {
323
- if (this.appAuth) {
324
- const app = await this.appRequest('/app');
325
- if (typeof app.slug === 'string' && app.slug.length > 0) return `${app.slug}[bot]`;
326
- throw new Error('GitHub App identity lookup returned no slug');
327
- } else {
328
- const local = await this.localLogin();
329
- if (local) return local;
330
- try {
331
- const value = await this.graphql('query { viewer { login } }') as Record<string, unknown>;
332
- const viewer = value.viewer as Record<string, unknown> | undefined;
333
- if (typeof viewer?.login === 'string' && viewer.login.length > 0) return viewer.login;
334
- } catch { /* handled below */ }
335
- }
336
- const local = await this.localLogin();
337
- if (local) return local;
338
- throw new Error('gh authentication did not return a login');
339
- }
340
- }
341
-
342
- export class GitHubStore implements DurableStore {
343
- constructor(private readonly client: GitHubClient, private readonly repository: string, private readonly prNumber: number, private readonly trustedAuthors: ReadonlySet<string> = new Set()) {}
344
- private isTrusted(comment: Comment): boolean { return comment.authorLogin !== null && this.trustedAuthors.has(comment.authorLogin); }
345
- private repoParts(): { owner: string; name: string } {
346
- const [owner, name] = this.repository.split('/', 2);
347
- if (!owner || !name) throw new Error(`Invalid GitHub repository ${this.repository}`);
348
- return { owner, name };
349
- }
350
- private async graphql(query: string, variables: Record<string, unknown>): Promise<Record<string, unknown>> {
351
- if (!this.client.graphql) throw new Error('GitHub GraphQL client is required');
352
- return await this.client.graphql(query, variables) as Record<string, unknown>;
353
- }
354
- private async prNode(): Promise<Record<string, unknown>> {
355
- const { owner, name } = this.repoParts();
356
- const data = await this.graphql(
357
- 'query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){id state merged mergedAt closedAt}}}',
358
- { owner, name, number: this.prNumber }
359
- );
360
- const repository = data.repository as Record<string, unknown> | undefined;
361
- const pr = repository?.pullRequest as Record<string, unknown> | undefined;
362
- if (!pr) throw new Error(`Pull request #${this.prNumber} not found in ${this.repository}`);
363
- return pr;
364
- }
365
- private async list(): Promise<Comment[]> {
366
- const { owner, name } = this.repoParts();
367
- const all: Comment[] = [];
368
- let cursor: string | null = null;
369
- do {
370
- const data = await this.graphql(
371
- '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 body updatedAt author{login}} pageInfo{hasNextPage endCursor}}}}}',
372
- { owner, name, number: this.prNumber, cursor }
373
- );
374
- const repository = data.repository as Record<string, unknown> | undefined;
375
- const pr = repository?.pullRequest as Record<string, unknown> | undefined;
376
- const comments = pr?.comments as Record<string, unknown> | undefined;
377
- const nodes = Array.isArray(comments?.nodes) ? comments.nodes as Array<Record<string, unknown>> : [];
378
- for (const value of nodes) {
379
- const author = value.author as Record<string, unknown> | undefined;
380
- all.push({
381
- id: Number(value.databaseId ?? 0),
382
- nodeId: typeof value.id === 'string' ? value.id : undefined,
383
- body: String(value.body ?? ''),
384
- updatedAt: String(value.updatedAt ?? ''),
385
- authorLogin: typeof author?.login === 'string' ? author.login : null
386
- });
387
- }
388
- const pageInfo = comments?.pageInfo as Record<string, unknown> | undefined;
389
- cursor = pageInfo?.hasNextPage === true && typeof pageInfo.endCursor === 'string' ? pageInfo.endCursor : null;
390
- } while (cursor);
391
- return all;
392
- }
393
- async comments(): Promise<Comment[]> { return this.list(); }
394
- async pullRequestState(): Promise<PullRequestState> {
395
- const pr = await this.prNode();
396
- if (pr.merged === true || typeof pr.mergedAt === 'string') return 'MERGED';
397
- return String(pr.state ?? '').toUpperCase() === 'CLOSED' ? 'CLOSED' : 'OPEN';
398
- }
399
- async load(jobId: string): Promise<Job> {
400
- const comments = (await this.list()).filter(comment => this.isTrusted(comment));
401
- const jobs = comments.map(x => parseJob(x.body)).filter((x): x is Job => x !== null && typeof x.id === 'string' && x.id.length > 0);
402
- const job = jobs.find(x => x.id === jobId);
403
- if (!job) throw new Error(`Managed GitHub job ${jobId} has no durable ${JOB_MARKER} marker in PR #${this.prNumber}; run agents-relay job adopt --repo ${this.repository} --pr ${this.prNumber} --id ${jobId}`);
404
- const soleTrustedJob = jobs.length === 1;
405
- job.tasks = comments.map(x => parseTask(x.body)).filter((x): x is Task => x !== null).filter(task => isTaskForJob(task, jobId, soleTrustedJob));
406
- return job;
407
- }
408
- async listAgents(): Promise<AgentRegistration[]> { return (await this.list()).filter(comment => this.isTrusted(comment)).map(x => parseAgent(x.body)).filter((x): x is AgentRegistration => x !== null); }
409
- private async comment(body: string): Promise<void> {
410
- const pr = await this.prNode();
411
- await this.graphql(
412
- 'mutation($subjectId:ID!,$body:String!){addComment(input:{subjectId:$subjectId,body:$body}){commentEdge{node{id}}}}',
413
- { subjectId: pr.id, body }
414
- );
415
- }
416
- private async updateComment(comment: Comment, body: string): Promise<void> {
417
- if (!comment.nodeId) throw new Error('GitHub comment node id is required for GraphQL update');
418
- await this.graphql(
419
- 'mutation($id:ID!,$body:String!){updateIssueComment(input:{id:$id,body:$body}){issueComment{id}}}',
420
- { id: comment.nodeId, body }
421
- );
422
- }
423
- private async deleteComment(comment: Comment): Promise<void> {
424
- if (!comment.nodeId) throw new Error('GitHub comment node id is required for GraphQL delete');
425
- await this.graphql(
426
- 'mutation($id:ID!){deleteIssueComment(input:{id:$id}){clientMutationId}}',
427
- { id: comment.nodeId }
428
- );
429
- }
430
- async saveJob(job: Job): Promise<void> {
431
- const comments = (await this.list()).filter(comment => this.isTrusted(comment));
432
- const jobComments = comments.filter(comment => parseJob(comment.body) !== null);
433
- const conflicts = jobComments.filter(comment => parseJob(comment.body)?.id !== job.id);
434
- if (conflicts.length > 0) throw new Error(`PR #${this.prNumber} already has a different ${JOB_MARKER} marker`);
435
- const matches = jobComments.filter(comment => parseJob(comment.body)?.id === job.id);
436
- if (matches.length === 0) await this.comment(renderJobComment(job));
437
- else {
438
- await this.updateComment(matches[0], renderJobComment(job));
439
- for (const duplicate of matches.slice(1)) await this.deleteComment(duplicate);
440
- }
441
- const durable = (await this.list()).filter(comment => this.isTrusted(comment)).filter(comment => parseJob(comment.body)?.id === job.id);
442
- if (durable.length !== 1) throw new Error(`Failed to persist exactly one ${JOB_MARKER} marker for job ${job.id}`);
443
- }
444
- async appendTask(task: Task): Promise<void> {
445
- if (!task.jobId) throw new Error('GitHub task requires jobId');
446
- await this.load(task.jobId);
447
- const comments = (await this.list()).filter(comment => this.isTrusted(comment));
448
- const matches = comments.filter(comment => {
449
- const existing = parseTask(comment.body);
450
- return existing !== null && existing.jobId === task.jobId && existing.id === task.id;
451
- });
452
- if (matches.length === 0) { await this.comment(renderTaskComment(task)); return; }
453
- const parsed = matches.map(comment => ({ comment, task: parseTask(comment.body) as Task }));
454
- if (parsed.some(item => !sameTaskDefinition(item.task, task))) throw new Error(`Task ${task.id} already exists with a different definition`);
455
- for (const duplicate of parsed.slice(1)) await this.deleteComment(duplicate.comment);
456
- }
457
- async saveTask(task: Task): Promise<void> {
458
- const comments = (await this.list()).filter(comment => this.isTrusted(comment));
459
- const existing = comments.filter(comment => { const markerTask = parseTask(comment.body); return markerTask !== null && markerTask.jobId === task.jobId && markerTask.id === task.id; });
460
- if (existing.length > 0) {
461
- await this.updateComment(existing[0], renderTaskComment(task));
462
- for (const duplicate of existing.slice(1)) await this.deleteComment(duplicate);
463
- } else await this.appendTask(task);
464
- }
465
- async saveAgent(agent: AgentRegistration): Promise<void> {
466
- const comments = (await this.list()).filter(comment => this.isTrusted(comment));
467
- const existing = comments.find(comment => parseAgent(comment.body)?.id === agent.id);
468
- if (existing) await this.updateComment(existing, renderAgentComment(agent));
469
- else await this.comment(renderAgentComment(agent));
470
- }
471
- }
472
-
473
- export async function listManagedGitHubJobs(client: GitHubClient, repository: string, trustedAuthors: ReadonlySet<string>): Promise<ManagedJobOverview[]> {
474
- if (!client.graphql) throw new Error('GitHub GraphQL client is required');
475
- const [owner, name] = repository.split('/', 2);
476
- if (!owner || !name) throw new Error(`Invalid GitHub repository ${repository}`);
477
- const output: ManagedJobOverview[] = [];
478
- const seen = new Set<number>();
479
- const collect = (nodes: Array<Record<string, unknown>>): void => {
480
- for (const pr of nodes) {
481
- const number = Number(pr.number);
482
- if (!Number.isInteger(number) || number <= 0 || seen.has(number)) continue;
483
- seen.add(number);
484
- const comments = pr.comments as Record<string, unknown> | undefined;
485
- const rows = Array.isArray(comments?.nodes) ? comments.nodes as Array<Record<string, unknown>> : [];
486
- const trusted = rows.filter(item => {
487
- const author = item.author as Record<string, unknown> | undefined;
488
- return typeof author?.login === 'string' && trustedAuthors.has(author.login);
489
- });
490
- const jobs = trusted.map(item => parseJob(String(item.body ?? ''))).filter((item): item is Job => item !== null);
491
- const tasks = trusted.map(item => parseTask(String(item.body ?? ''))).filter((item): item is Task => item !== null);
492
- const state = String(pr.state ?? '').toUpperCase();
493
- const githubState: PullRequestState = pr.merged === true || state === 'MERGED' ? 'MERGED' : state === 'CLOSED' ? 'CLOSED' : 'OPEN';
494
- for (const job of jobs) {
495
- if (!job.description && typeof pr.body === 'string' && pr.body) job.description = pr.body;
496
- job.prNumber = number; job.repository = repository;
497
- job.tasks = tasks.filter(task => task.jobId === job.id || (!task.jobId && jobs.length === 1));
498
- output.push({ job, githubState, draft: pr.isDraft === true, mergedAt: typeof pr.mergedAt === 'string' ? pr.mergedAt : null, closedAt: typeof pr.closedAt === 'string' ? pr.closedAt : null });
499
- }
500
- }
501
- };
502
- let cursor: string | null = null;
503
- let firstPage = true;
504
- do {
505
- const query = firstPage
506
- ? 'query($owner:String!,$name:String!,$cursor:String){repository(owner:$owner,name:$name){open:pullRequests(first:50,after:$cursor,states:OPEN,orderBy:{field:UPDATED_AT,direction:DESC}){nodes{number body state isDraft merged mergedAt closedAt comments(first:100){nodes{author{login} body}}} pageInfo{hasNextPage endCursor}} recentClosed:pullRequests(first:20,states:CLOSED,orderBy:{field:UPDATED_AT,direction:DESC}){nodes{number body state isDraft merged mergedAt closedAt comments(first:100){nodes{author{login} body}}}}}}'
507
- : 'query($owner:String!,$name:String!,$cursor:String){repository(owner:$owner,name:$name){open:pullRequests(first:50,after:$cursor,states:OPEN,orderBy:{field:UPDATED_AT,direction:DESC}){nodes{number body state isDraft merged mergedAt closedAt comments(first:100){nodes{author{login} body}}} pageInfo{hasNextPage endCursor}}}}';
508
- const data = await client.graphql(query, { owner, name, cursor }) as Record<string, unknown>;
509
- const repo = data.repository as Record<string, unknown> | undefined;
510
- const open = repo?.open as Record<string, unknown> | undefined;
511
- collect(Array.isArray(open?.nodes) ? open.nodes as Array<Record<string, unknown>> : []);
512
- if (firstPage) {
513
- const recentClosed = repo?.recentClosed as Record<string, unknown> | undefined;
514
- collect(Array.isArray(recentClosed?.nodes) ? recentClosed.nodes as Array<Record<string, unknown>> : []);
515
- }
516
- const pageInfo = open?.pageInfo as Record<string, unknown> | undefined;
517
- cursor = pageInfo?.hasNextPage === true && typeof pageInfo.endCursor === 'string' ? pageInfo.endCursor : null;
518
- firstPage = false;
519
- } while (cursor);
520
- return output.sort((a, b) => b.job.prNumber - a.job.prNumber);
521
- }
522
-
523
- function isTaskForJob(task: Task, jobId: string, soleTrustedJob: boolean): boolean {
524
- if (task.jobId === jobId) return true;
525
- return !task.jobId && soleTrustedJob;
526
- }
package/src/types.ts DELETED
@@ -1,45 +0,0 @@
1
- export type JobState = 'OPEN' | 'RUNNING' | 'WAITING' | 'BLOCKED' | 'COMPLETED' | 'FAILED' | 'CANCELLED';
2
- export type TaskState = 'QUEUED' | 'READY' | 'RUNNING' | 'WAITING' | 'BLOCKED' | 'SUCCEEDED' | 'FAILED' | 'CANCELLED';
3
- export type Priority = 'P0' | 'P1' | 'P2' | 'P3';
4
- export type AdapterName = 'shell' | 'codex' | 'chatgpt' | 'orchestrator';
5
- export type ExecutionMode = 'fixed' | 'autonomous';
6
- export type TaskKind = 'work' | 'planner';
7
- export type AgentAvailability = 'available' | 'degraded' | 'offline';
8
- export type AgentEndpoint = { kind: 'adapter' | 'command' | 'http'; target: string };
9
- export type AgentEvidence = {
10
- evaluations: Array<{ taskKind: string; outcome: 'success' | 'failure' | 'timeout'; observedAt: string; summary?: string; latencyMs?: number; cost?: number }>;
11
- outcomes: { succeeded: number; failed: number; timedOut: number };
12
- lastObservedAt: string | null;
13
- };
14
- export type AgentRegistration = {
15
- id: string;
16
- name: string;
17
- responsibility: string;
18
- role: string;
19
- capabilities: string[];
20
- boundaries: string[];
21
- endpoint: AgentEndpoint;
22
- runtime: { adapter: string; provider?: string; model?: string };
23
- availability: AgentAvailability;
24
- routing: { labels?: Record<string, string>; trustDomains?: string[]; regions?: string[] };
25
- evidence: AgentEvidence;
26
- registeredAt: string;
27
- updatedAt: string;
28
- };
29
- export type RoutingDecision = { provider: string; model: string; profile?: string; reasoning?: string; cwd?: string; projectId?: string; decidedBy: string; decidedAt: string };
30
- export type Result = { summary: string; artifacts?: string[]; data?: Record<string, unknown> };
31
- export type Task = {
32
- jobId?: string; id: string; kind?: TaskKind; priority?: Priority; projectName?: string; agentName?: string; parentTaskId: string | null; dependencies: string[]; capabilities: string[]; adapter: AdapterName;
33
- input: string; routing?: RoutingDecision; continuation?: Continuation | null; continuationDeliveredAt?: string | null; state: TaskState; attempt: number; maxAttempts: number;
34
- leaseOwner: string | null; leaseExpiresAt: string | null; executionId: string | null; threadId: string | null;
35
- threadDeletedAt?: string | null; threadCleanupError?: string | null;
36
- result: Result | null; plannerResult?: import('./planner.js').PlannerResult | null; error: string | null; timeoutMs: number; createdAt: string; updatedAt: string;
37
- };
38
- export type Job = { id: string; title: string; objective?: string; priority?: Priority; description?: string; executionMode: ExecutionMode; prNumber: number; repository: string; state: JobState; continuation: Continuation | null; createdAt: string; updatedAt: string; tasks: Task[] };
39
- export type Continuation = { kind: 'codex-thread' | 'command' | 'webhook'; target: string; input?: string };
40
- export type EventRecord = { version: 1; event_id: string; job_id: string; task_id: string; parent_task_id: string | null; type: string; status: string; timestamp: string; visibility: string; level: string; message: string; data?: Record<string, unknown> };
41
-
42
- const jobTransitions: Record<JobState, JobState[]> = { OPEN: ['RUNNING', 'WAITING', 'BLOCKED', 'COMPLETED', 'CANCELLED'], RUNNING: ['OPEN', 'WAITING', 'BLOCKED', 'COMPLETED', 'FAILED', 'CANCELLED'], WAITING: ['RUNNING', 'BLOCKED', 'COMPLETED', 'CANCELLED'], BLOCKED: ['RUNNING', 'CANCELLED'], COMPLETED: ['RUNNING', 'CANCELLED'], FAILED: ['RUNNING', 'CANCELLED'], CANCELLED: ['RUNNING'] };
43
- const taskTransitions: Record<TaskState, TaskState[]> = { QUEUED: ['READY', 'BLOCKED', 'CANCELLED'], READY: ['RUNNING', 'BLOCKED', 'FAILED', 'CANCELLED'], RUNNING: ['WAITING', 'BLOCKED', 'SUCCEEDED', 'FAILED', 'CANCELLED', 'READY'], WAITING: ['READY', 'BLOCKED', 'CANCELLED'], BLOCKED: ['READY', 'CANCELLED'], SUCCEEDED: [], FAILED: ['READY', 'CANCELLED'], CANCELLED: [] };
44
- export function assertJobTransition(from: JobState, to: JobState): void { if (from !== to && !jobTransitions[from].includes(to)) throw new Error(`Invalid job transition ${from} -> ${to}`); }
45
- export function assertTaskTransition(from: TaskState, to: TaskState): void { if (from !== to && !taskTransitions[from].includes(to)) throw new Error(`Invalid task transition ${from} -> ${to}`); }