agents-relay 1.0.0

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 (62) hide show
  1. package/.github/workflows/publish.yml +91 -0
  2. package/AGENTS.md +16 -0
  3. package/LICENSE +21 -0
  4. package/README.md +102 -0
  5. package/dist/adapters.js +311 -0
  6. package/dist/cli.js +455 -0
  7. package/dist/continuation.js +21 -0
  8. package/dist/dashboard.js +446 -0
  9. package/dist/events.js +36 -0
  10. package/dist/github-auth.js +34 -0
  11. package/dist/github-webhook.js +47 -0
  12. package/dist/markers.js +42 -0
  13. package/dist/planner.js +172 -0
  14. package/dist/pool.js +98 -0
  15. package/dist/reconciler.js +434 -0
  16. package/dist/registry.js +27 -0
  17. package/dist/relayd.js +177 -0
  18. package/dist/scheduler.js +49 -0
  19. package/dist/store.js +586 -0
  20. package/dist/types.js +6 -0
  21. package/dist/usage.js +370 -0
  22. package/dist/workspace.js +76 -0
  23. package/docs/agent-network.md +34 -0
  24. package/docs/architecture.md +120 -0
  25. package/docs/autonomous-objective-jobs.md +121 -0
  26. package/docs/example.md +30 -0
  27. package/docs/github-app-rate-limit.md +124 -0
  28. package/docs/service.md +43 -0
  29. package/pack.json +326 -0
  30. package/package.json +14 -0
  31. package/scripts/npm-version.mjs +11 -0
  32. package/skills/agents-relay/SKILL.md +77 -0
  33. package/skills/agents-relay/agents/planner.agent.md +28 -0
  34. package/src/adapters.ts +231 -0
  35. package/src/cli.ts +324 -0
  36. package/src/continuation.ts +6 -0
  37. package/src/dashboard.ts +421 -0
  38. package/src/events.ts +25 -0
  39. package/src/github-auth.ts +35 -0
  40. package/src/github-webhook.ts +37 -0
  41. package/src/markers.ts +33 -0
  42. package/src/planner.ts +150 -0
  43. package/src/pool.ts +87 -0
  44. package/src/reconciler.ts +235 -0
  45. package/src/registry.ts +35 -0
  46. package/src/relayd.ts +137 -0
  47. package/src/scheduler.ts +27 -0
  48. package/src/store.ts +526 -0
  49. package/src/types.ts +45 -0
  50. package/src/usage.ts +385 -0
  51. package/src/workspace.ts +62 -0
  52. package/test/adapters.test.js +303 -0
  53. package/test/autonomous.test.js +119 -0
  54. package/test/core.test.js +363 -0
  55. package/test/dashboard.test.js +178 -0
  56. package/test/github-auth.test.js +51 -0
  57. package/test/github-webhook.test.js +21 -0
  58. package/test/service.test.js +116 -0
  59. package/test/store.test.js +390 -0
  60. package/test/usage.test.js +88 -0
  61. package/test/workspace.test.js +95 -0
  62. package/tsconfig.json +4 -0
package/src/relayd.ts ADDED
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env node
2
+ import { once } from 'node:events';
3
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
+ import { homedir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { ChatGptAdapter, CodexAdapter, ShellAdapter } from './adapters.js';
7
+ import { CodexThreadContinuation, CommandContinuation, WebhookContinuation } from './continuation.js';
8
+ import { serveDashboard } from './dashboard.js';
9
+ import { NatsEventBus } from './events.js';
10
+ import { RepositoryWorkerPool, aggregateManagedGitHubJobs } from './pool.js';
11
+ import { Reconciler } from './reconciler.js';
12
+ import { dashboardManagedJobs, GitHubStore, InMemoryStore, ManagedJobOverview, loadManagedGitHubPullRequestGraphql } from './store.js';
13
+ import { githubAuthContext } from './github-auth.js';
14
+ import { isEntrypoint, startService, SERVICE_DEFAULTS } from './cli.js';
15
+ import { codexAndZaiUsageRegistry } from './usage.js';
16
+ import { defaultWorkspaceRoot, discoverWorkspaceRepositories } from './workspace.js';
17
+ import { GitHubWebhookWake } from './github-webhook.js';
18
+
19
+ export interface DaemonArguments { repository: string | null; workspaceRoot: string | null; pullRequest: number | null; jobId: string | null; serviceArguments: string[]; }
20
+ export const daemonHelp = `Agents Relay daemon runs the dashboard and worker pool.
21
+
22
+ Usage:
23
+ npx agents-relayd [options]
24
+
25
+ Options:
26
+ --repo OWNER/REPO Watch one repository
27
+ --workspace PATH Discover repositories below PATH (default: ~/Workspace)
28
+ --port PORT Dashboard port (default: 8787)
29
+ --concurrency N Maximum concurrent workers (default: 4)
30
+ --interval MS Recovery interval
31
+ --events nats Enable event wakeups
32
+
33
+ Legacy single-job mode also accepts --repo OWNER/REPO --pr NUMBER --id JOB_ID.
34
+
35
+ Examples:
36
+ npx agents-relayd --repo OWNER/REPO
37
+ npx agents-relayd --workspace ~/Workspace`;
38
+ function value(args: readonly string[], name: string, fallback = ''): string { const index = args.indexOf(name); return index >= 0 ? args[index + 1] ?? fallback : fallback; }
39
+ export function normalizeDaemonArguments(argv: readonly string[]): DaemonArguments {
40
+ const repository = value(argv, '--repo') || null;
41
+ const workspaceRoot = value(argv, '--workspace', defaultWorkspaceRoot()) || null;
42
+ const prValue = value(argv, '--pr'); const id = value(argv, '--id');
43
+ if ((prValue && !id) || (!prValue && id)) throw new Error('--pr and --id must be supplied together for legacy single-job mode');
44
+ if ((prValue || id) && !repository) throw new Error('--repo is required for legacy single-job mode');
45
+ if (repository && argv.includes('--workspace')) throw new Error('--repo and --workspace are mutually exclusive');
46
+ const pullRequest = prValue ? Number(prValue) : null; if (pullRequest !== null && (!Number.isInteger(pullRequest) || pullRequest <= 0)) throw new Error('--pr must be positive');
47
+ return { repository, workspaceRoot: repository ? null : workspaceRoot, pullRequest, jobId: id || null, serviceArguments: [...argv] };
48
+ }
49
+ export async function runDaemon(argv: readonly string[]): Promise<void> {
50
+ if (argv.includes('--help') || argv.includes('-h')) { console.log(daemonHelp); return; }
51
+ const normalized = normalizeDaemonArguments(argv);
52
+ if (normalized.pullRequest && normalized.jobId) { await startService([...argv]); return; }
53
+ const auth = await githubAuthContext(argv); const client = auth.client;
54
+ const trusted = auth.trustedAuthors;
55
+ const concurrency = Number(value(argv,'--concurrency','4'));
56
+ const bus = value(argv,'--events') === 'nats' ? new NatsEventBus(value(argv,'--nats-url','nats://127.0.0.1:4222'), value(argv,'--subject-prefix','agents-relay.events.job')) : undefined;
57
+ const makeReconciler = (store: GitHubStore, maxConcurrent: number) => new Reconciler(store,{ owner:`relayd-${process.pid}`, maxConcurrent, leaseMs:Number(value(argv,'--lease-ms','300000')), adapters:[new ShellAdapter(),new CodexAdapter(value(argv,'--codex','codex')),new ChatGptAdapter({endpoint:value(argv,'--macbridge-url')||undefined,tokenFile:value(argv,'--macbridge-token-file')||undefined})], continuations:[new CodexThreadContinuation(value(argv,'--codex','codex')),new CommandContinuation(),new WebhookContinuation()], eventBus:bus });
58
+ const repositories = normalized.repository
59
+ ? [normalized.repository]
60
+ : (await discoverWorkspaceRepositories(normalized.workspaceRoot ?? defaultWorkspaceRoot())).map(item => item.repository);
61
+ if (repositories.length === 0) throw new Error(`No Git repositories with GitHub origins found under ${normalized.workspaceRoot ?? defaultWorkspaceRoot()}`);
62
+ const pool = new RepositoryWorkerPool(client, repositories, trusted, concurrency, makeReconciler);
63
+ const cacheDirectory = join(homedir(), '.agents-relay');
64
+ const cacheFile = join(cacheDirectory, 'dashboard-jobs.json');
65
+ let cachedOverviews: ManagedJobOverview[] = [];
66
+ try {
67
+ const parsed = JSON.parse(await readFile(cacheFile, 'utf8')) as ManagedJobOverview[];
68
+ if (Array.isArray(parsed)) cachedOverviews = parsed;
69
+ } catch { /* first run or invalid cache */ }
70
+ const refreshAll = async (): Promise<ManagedJobOverview[]> => {
71
+ if (client.rateLimitStatus().limited) return cachedOverviews;
72
+ try {
73
+ cachedOverviews = dashboardManagedJobs(await aggregateManagedGitHubJobs(client, repositories, trusted));
74
+ try { await mkdir(cacheDirectory, { recursive: true }); await writeFile(cacheFile, JSON.stringify(cachedOverviews), 'utf8'); } catch { /* cache is best effort */ }
75
+ return cachedOverviews;
76
+ } catch (error) {
77
+ process.stderr.write(`warning: workspace job refresh unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
78
+ return cachedOverviews;
79
+ }
80
+ };
81
+ const overviews = async (forceRefresh = false): Promise<ManagedJobOverview[]> => forceRefresh ? refreshAll() : cachedOverviews;
82
+ const persistCache = async (): Promise<void> => {
83
+ try { await mkdir(cacheDirectory, { recursive: true }); await writeFile(cacheFile, JSON.stringify(cachedOverviews), 'utf8'); } catch { /* cache is best effort */ }
84
+ };
85
+ const refreshPullRequest = async (repository: string, prNumber: number): Promise<ManagedJobOverview[]> => {
86
+ const refreshed = await loadManagedGitHubPullRequestGraphql(client, repository, prNumber, trusted);
87
+ cachedOverviews = cachedOverviews.filter(item => item.job.repository !== repository || item.job.prNumber !== prNumber);
88
+ cachedOverviews.push(...refreshed);
89
+ cachedOverviews = dashboardManagedJobs(cachedOverviews);
90
+ await persistCache();
91
+ return refreshed;
92
+ };
93
+ const initial = await refreshAll(); const fallback = initial[0]?.job;
94
+ const dashboardStore = fallback ? new GitHubStore(client, fallback.repository, fallback.prNumber, trusted) : new InMemoryStore();
95
+ const secretFile = value(argv,'--github-webhook-secret-file');
96
+ const secret = value(argv,'--github-webhook-secret') || process.env.AGENTS_RELAY_GITHUB_WEBHOOK_SECRET || (secretFile ? (await readFile(secretFile,'utf8')).trim() : '');
97
+ const webhook = secret ? {
98
+ secret,
99
+ repositories: new Set(repositories),
100
+ wake: async (event: GitHubWebhookWake): Promise<void> => {
101
+ if (!event.repository || !event.pullRequest) return;
102
+ try {
103
+ const refreshed = await refreshPullRequest(event.repository, event.pullRequest);
104
+ for (const item of refreshed) await pool.webhookWake(bus, item.job, event.deliveryId, event.event, event.action);
105
+ } catch (error) {
106
+ process.stderr.write(`warning: webhook PR refresh unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
107
+ }
108
+ }
109
+ } : undefined;
110
+ const exactJob = async (jobId: string) => {
111
+ const cached = cachedOverviews.find(item => item.job.id === jobId);
112
+ if (!cached) return null;
113
+ try {
114
+ const refreshed = (await refreshPullRequest(cached.job.repository, cached.job.prNumber)).find(item => item.job.id === jobId);
115
+ if (!refreshed) return null;
116
+ return { ...refreshed.job, githubState: refreshed.githubState, mergedAt: refreshed.mergedAt, closedAt: refreshed.closedAt, draft: refreshed.draft };
117
+ } catch (error) {
118
+ process.stderr.write(`warning: selected job refresh unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
119
+ return null;
120
+ }
121
+ };
122
+ const usageRegistry = codexAndZaiUsageRegistry();
123
+ const server = serveDashboard(dashboardStore,Number(value(argv,'--port',String(SERVICE_DEFAULTS.port))),bus,Number(value(argv,'--refresh-ms',String(SERVICE_DEFAULTS.dashboardRefreshMs))),fallback?.id,usageRegistry,overviews,webhook,()=>client.rateLimitStatus(),exactJob);
124
+ await once(server,'listening');
125
+ const reconcileSafely = async (): Promise<void> => {
126
+ if (client.rateLimitStatus().limited) return;
127
+ try { await pool.reconcile(); }
128
+ catch (error) { process.stderr.write(`warning: workspace reconcile unavailable: ${error instanceof Error ? error.message : String(error)}\n`); }
129
+ };
130
+ const watchdogDefault = webhook ? SERVICE_DEFAULTS.webhookWatchdogMs : SERVICE_DEFAULTS.watchdogMs;
131
+ const interval=setInterval(()=>void reconcileSafely(),Number(value(argv,'--interval',String(watchdogDefault)))); interval.unref();
132
+ const stopWatch = bus ? await pool.watch(bus) : async()=>{};
133
+ const stop=()=>{clearInterval(interval);void stopWatch().finally(()=>server.close(()=>process.exit(0)));}; process.once('SIGINT',stop);process.once('SIGTERM',stop);
134
+ console.log('Dashboard listening; repository worker pool active');
135
+ if (!client.rateLimitStatus().limited) void pool.reconcileKnown(initial).catch(error=>process.stderr.write(`warning: startup reconciliation unavailable: ${error instanceof Error ? error.message : String(error)}\n`));
136
+ }
137
+ if (isEntrypoint(import.meta.url)) runDaemon(process.argv.slice(2)).catch(error=>{console.error(error instanceof Error?error.message:String(error));process.exitCode=1;});
@@ -0,0 +1,27 @@
1
+ import { Job, Task, TaskState, assertJobTransition, assertTaskTransition } from './types.js';
2
+ export function dependencyReady(task: Task, tasks: Map<string, Task>): boolean { return task.dependencies.every(id => tasks.get(id)?.state === 'SUCCEEDED'); }
3
+ export function detectCycles(tasks: Task[]): void { const map = new Map(tasks.map(t => [t.id, t])); const visiting = new Set<string>(); const done = new Set<string>(); const visit = (id: string): void => { if (visiting.has(id)) throw new Error(`Dependency cycle at ${id}`); if (done.has(id)) return; visiting.add(id); for (const dep of map.get(id)?.dependencies ?? []) visit(dep); visiting.delete(id); done.add(id); }; for (const task of tasks) visit(task.id); }
4
+ export function completionGateSatisfied(job: Job): boolean {
5
+ if (job.executionMode !== 'autonomous') return true;
6
+ const planners = job.tasks.filter(task => task.kind === 'planner');
7
+ return planners.some(task => task.state === 'SUCCEEDED' && task.plannerResult?.objective_status === 'satisfied') && job.tasks.every(task => task.state === 'SUCCEEDED');
8
+ }
9
+ export function schedule(job: Job): Job {
10
+ detectCycles(job.tasks);
11
+ if (job.tasks.length === 0) {
12
+ if (job.executionMode === 'autonomous' && job.state !== 'WAITING') { assertJobTransition(job.state, 'WAITING'); job.state = 'WAITING'; job.updatedAt = new Date().toISOString(); }
13
+ return job;
14
+ }
15
+ const map = new Map(job.tasks.map(t => [t.id, t]));
16
+ for (const task of job.tasks) { if (['QUEUED', 'WAITING'].includes(task.state) && dependencyReady(task, map)) transitionTask(task, 'READY'); }
17
+ const active = job.tasks.some(t => ['READY', 'RUNNING'].includes(t.state)); const blocked = job.tasks.some(t => t.state === 'BLOCKED'); const waiting = job.tasks.some(t => ['QUEUED', 'WAITING'].includes(t.state)); const failed = job.tasks.some(t => t.state === 'FAILED'); const cancelled = job.tasks.every(t => t.state === 'CANCELLED');
18
+ const next = failed ? 'FAILED' : active ? 'RUNNING' : blocked ? 'BLOCKED' : waiting ? 'WAITING' : cancelled ? 'CANCELLED' : completionGateSatisfied(job) ? 'COMPLETED' : 'WAITING';
19
+ if (job.state !== next) { assertJobTransition(job.state, next); job.state = next; job.updatedAt = new Date().toISOString(); }
20
+ return job;
21
+ }
22
+ export function extendRetryBudget(task: Pick<Task, 'attempt' | 'maxAttempts'>, windowSize = 3): number {
23
+ if (!Number.isInteger(windowSize) || windowSize < 1) throw new Error('Retry window size must be a positive integer');
24
+ const usedOrBudgeted = Math.max(task.maxAttempts, task.attempt);
25
+ return Math.max(windowSize, (Math.floor(usedOrBudgeted / windowSize) + 1) * windowSize);
26
+ }
27
+ export function transitionTask(task: Task, state: TaskState): void { assertTaskTransition(task.state, state); task.state = state; if (['READY', 'RUNNING', 'SUCCEEDED'].includes(state)) task.error = null; task.updatedAt = new Date().toISOString(); }