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.
- package/.github/workflows/publish.yml +91 -0
- package/AGENTS.md +16 -0
- package/LICENSE +21 -0
- package/README.md +102 -0
- package/dist/adapters.js +311 -0
- package/dist/cli.js +455 -0
- package/dist/continuation.js +21 -0
- package/dist/dashboard.js +446 -0
- package/dist/events.js +36 -0
- package/dist/github-auth.js +34 -0
- package/dist/github-webhook.js +47 -0
- package/dist/markers.js +42 -0
- package/dist/planner.js +172 -0
- package/dist/pool.js +98 -0
- package/dist/reconciler.js +434 -0
- package/dist/registry.js +27 -0
- package/dist/relayd.js +177 -0
- package/dist/scheduler.js +49 -0
- package/dist/store.js +586 -0
- package/dist/types.js +6 -0
- package/dist/usage.js +370 -0
- package/dist/workspace.js +76 -0
- package/docs/agent-network.md +34 -0
- package/docs/architecture.md +120 -0
- package/docs/autonomous-objective-jobs.md +121 -0
- package/docs/example.md +30 -0
- package/docs/github-app-rate-limit.md +124 -0
- package/docs/service.md +43 -0
- package/pack.json +326 -0
- package/package.json +14 -0
- package/scripts/npm-version.mjs +11 -0
- package/skills/agents-relay/SKILL.md +77 -0
- package/skills/agents-relay/agents/planner.agent.md +28 -0
- package/src/adapters.ts +231 -0
- package/src/cli.ts +324 -0
- package/src/continuation.ts +6 -0
- package/src/dashboard.ts +421 -0
- package/src/events.ts +25 -0
- package/src/github-auth.ts +35 -0
- package/src/github-webhook.ts +37 -0
- package/src/markers.ts +33 -0
- package/src/planner.ts +150 -0
- package/src/pool.ts +87 -0
- package/src/reconciler.ts +235 -0
- package/src/registry.ts +35 -0
- package/src/relayd.ts +137 -0
- package/src/scheduler.ts +27 -0
- package/src/store.ts +526 -0
- package/src/types.ts +45 -0
- package/src/usage.ts +385 -0
- package/src/workspace.ts +62 -0
- package/test/adapters.test.js +303 -0
- package/test/autonomous.test.js +119 -0
- package/test/core.test.js +363 -0
- package/test/dashboard.test.js +178 -0
- package/test/github-auth.test.js +51 -0
- package/test/github-webhook.test.js +21 -0
- package/test/service.test.js +116 -0
- package/test/store.test.js +390 -0
- package/test/usage.test.js +88 -0
- package/test/workspace.test.js +95 -0
- package/tsconfig.json +4 -0
package/src/dashboard.ts
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
import { createServer, Server } from 'node:http';
|
|
2
|
+
import { DurableStore, ManagedJobOverview } from './store.js';
|
|
3
|
+
import { EventBus } from './events.js';
|
|
4
|
+
import { UsageRegistry } from './usage.js';
|
|
5
|
+
import { Job } from './types.js';
|
|
6
|
+
import { DeliveryDeduper, parseGitHubWebhook, verifyGitHubWebhook } from './github-webhook.js';
|
|
7
|
+
|
|
8
|
+
const GITHUB_REPOSITORY_PATTERN = /^([A-Za-z0-9](?:[A-Za-z0-9-]{0,36}[A-Za-z0-9])?)\/([A-Za-z0-9._-]{1,100})$/;
|
|
9
|
+
const SSE_HEARTBEAT_MS = 15000;
|
|
10
|
+
|
|
11
|
+
function isPositiveInteger(candidate: unknown): candidate is number {
|
|
12
|
+
return typeof candidate === 'number' && Number.isInteger(candidate) && candidate > 0;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function isSafeRepositorySegment(segment: string): boolean {
|
|
16
|
+
return segment !== '.' && segment !== '..';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function githubPrUrl(repository: unknown, prNumber: unknown): string | null {
|
|
20
|
+
if (typeof repository !== 'string' || !isPositiveInteger(prNumber)) return null;
|
|
21
|
+
const match = GITHUB_REPOSITORY_PATTERN.exec(repository);
|
|
22
|
+
if (!match) return null;
|
|
23
|
+
const [, owner, repo] = match;
|
|
24
|
+
if (!isSafeRepositorySegment(owner) || !isSafeRepositorySegment(repo)) return null;
|
|
25
|
+
return `https://github.com/${owner}/${repo}/pull/${prNumber}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const dashboardHtml = `<!doctype html>
|
|
29
|
+
<html lang="en" data-dashboard-contract="safe-dom,named-relay-events,snapshot-events,bounded-event-history">
|
|
30
|
+
<head>
|
|
31
|
+
<meta charset="utf-8">
|
|
32
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
33
|
+
<title>Agents Relay Console</title>
|
|
34
|
+
<style>
|
|
35
|
+
:root { color-scheme: light dark; --bg:#f3f5f8; --surface:#fff; --surface2:#f8fafc; --border:#d8dee7; --text:#17202b; --muted:#687587; --accent:#285f86; --accent2:#e8f1f8; --ok:#19703a; --warn:#8b5b08; --bad:#a33c3c; --shadow:0 1px 2px rgba(15,23,42,.06); }
|
|
36
|
+
* { box-sizing:border-box; }
|
|
37
|
+
[hidden] { display:none !important; }
|
|
38
|
+
body { margin:0; min-height:100vh; background:var(--bg); color:var(--text); font:14px/1.45 ui-sans-serif,system-ui,sans-serif; }
|
|
39
|
+
header { position:sticky; top:0; z-index:3; background:var(--surface); border-bottom:1px solid var(--border); padding:.8rem 1rem; }
|
|
40
|
+
.brand { display:flex; align-items:baseline; gap:.65rem; min-width:0; }
|
|
41
|
+
h1 { margin:0; font-size:1.08rem; letter-spacing:-.02em; }
|
|
42
|
+
.tagline,.subtle { color:var(--muted); font-size:.78rem; }
|
|
43
|
+
.warning-banner { margin:.65rem 0 0; padding:.5rem .65rem; border:1px solid #e6bf62; border-radius:.4rem; background:#fff4d6; color:#6f4b00; font-size:.78rem; font-weight:700; }
|
|
44
|
+
.tagline { margin:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
45
|
+
nav[role="tablist"] { display:flex; gap:.3rem; margin-top:.65rem; }
|
|
46
|
+
nav button { border:0; border-radius:.35rem; background:transparent; color:var(--muted); padding:.38rem .68rem; font:inherit; font-weight:700; cursor:pointer; }
|
|
47
|
+
nav button[aria-selected="true"] { background:var(--accent); color:#fff; }
|
|
48
|
+
button:focus-visible,a:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
|
|
49
|
+
main { max-width:1500px; margin:auto; padding:1rem; display:grid; grid-template-columns:minmax(280px,340px) minmax(0,1fr); gap:1rem; align-items:start; }
|
|
50
|
+
.panel { min-width:0; background:var(--surface); border:1px solid var(--border); border-radius:.55rem; box-shadow:var(--shadow); }
|
|
51
|
+
.panel-head { display:flex; align-items:center; justify-content:space-between; gap:.6rem; padding:.65rem .75rem; border-bottom:1px solid var(--border); }
|
|
52
|
+
.panel-head h2,.panel-head h3 { margin:0; color:var(--muted); font-size:.75rem; letter-spacing:.07em; text-transform:uppercase; }
|
|
53
|
+
.panel-actions { display:flex; align-items:center; gap:.45rem; }
|
|
54
|
+
.job-filters { display:flex; gap:.35rem; align-items:center; flex-wrap:wrap; }
|
|
55
|
+
.job-filter { max-width:145px; border:1px solid var(--border); border-radius:.35rem; background:var(--surface); color:var(--text); padding:.27rem .4rem; font:inherit; font-size:.72rem; }
|
|
56
|
+
.refresh-button { border:1px solid var(--border); border-radius:.35rem; background:var(--surface); color:var(--muted); padding:.28rem .55rem; font:inherit; font-size:.72rem; font-weight:700; cursor:pointer; }
|
|
57
|
+
.refresh-button:disabled { cursor:default; opacity:.55; }
|
|
58
|
+
.panel-body { padding:.75rem; overflow:auto; max-height:calc(100vh - 11.5rem); }
|
|
59
|
+
#jobs-workspace > .panel:first-child .panel-body { overflow:visible; max-height:none; }
|
|
60
|
+
.jobs-panel { grid-row:1/span 2; }
|
|
61
|
+
.workspace { display:grid; gap:1rem; }
|
|
62
|
+
.job-list,.tree,.event-list { list-style:none; margin:0; padding:0; }
|
|
63
|
+
.job-list { display:grid; gap:.35rem; }
|
|
64
|
+
.job-button { width:100%; display:grid; gap:.2rem; padding:.5rem; border:1px solid transparent; border-radius:.4rem; background:transparent; color:inherit; text-align:left; font:inherit; cursor:pointer; }
|
|
65
|
+
.job-button[aria-current="true"] { border-color:var(--accent); background:var(--accent2); }
|
|
66
|
+
.job-button.complete-unmerged { border-color:#d69a1b; background:#fff8df; }
|
|
67
|
+
.job-button.complete-unmerged[aria-current="true"] { border-color:#b77900; background:#fff0bd; }
|
|
68
|
+
.job-title { font-weight:750; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
69
|
+
.job-meta { display:flex; gap:.4rem; flex-wrap:wrap; color:var(--muted); font-size:.7rem; }
|
|
70
|
+
.job-meta span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
71
|
+
.status-pill,.badge { display:inline-flex; align-items:center; border-radius:999px; padding:.06rem .42rem; min-height:1.35rem; font-size:.68rem; font-weight:800; }
|
|
72
|
+
.status-pill.ok,.badge.RUNNING,.badge.SUCCEEDED,.badge.COMPLETED { background:#e5f3e9; color:var(--ok); }
|
|
73
|
+
.status-pill.warn,.badge.BLOCKED,.badge.WAITING,.badge.QUEUED,.badge.READY,.badge.OPEN { background:#fff1d2; color:var(--warn); }
|
|
74
|
+
.status-pill.bad,.badge.FAILED { background:#ffe8e8; color:var(--bad); }
|
|
75
|
+
.status-pill.neutral,.badge.CANCELLED { background:#edf0f4; color:var(--muted); }
|
|
76
|
+
.badge.DRAFT { background:#eef2ff; color:#3f51b5; }
|
|
77
|
+
.badge.MERGE_PENDING { background:#ffe3a3; color:#7a4a00; }
|
|
78
|
+
.job-summary,.detail-groups { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:.55rem; }
|
|
79
|
+
.detail-group { min-width:0; padding:.5rem; background:var(--surface2); border:1px solid var(--border); border-radius:.4rem; }
|
|
80
|
+
.detail-label { margin:0; color:var(--muted); font-size:.66rem; text-transform:uppercase; letter-spacing:.04em; }
|
|
81
|
+
.detail-value { margin:.12rem 0 0; font-weight:650; overflow-wrap:anywhere; }
|
|
82
|
+
.section-title { margin:.8rem 0 .35rem; color:var(--muted); font-size:.72rem; letter-spacing:.05em; text-transform:uppercase; }
|
|
83
|
+
ul.tree { display:grid; gap:.25rem; }
|
|
84
|
+
ul.tree ul { list-style:none; margin:.2rem 0 .1rem .6rem; padding-left:.6rem; border-left:1px solid var(--border); }
|
|
85
|
+
.task-row { width:100%; display:flex; align-items:center; gap:.4rem; min-width:0; padding:.32rem .38rem; border:0; border-radius:.35rem; background:transparent; color:inherit; font:inherit; text-align:left; cursor:pointer; }
|
|
86
|
+
.task-row[aria-current="true"] { background:var(--accent2); }
|
|
87
|
+
.task-id { min-width:0; flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font: .77rem ui-monospace,SFMono-Regular,Menlo,monospace; }
|
|
88
|
+
.event-list { display:grid; gap:.45rem; }
|
|
89
|
+
.event-item { padding-bottom:.4rem; border-bottom:1px solid var(--border); }
|
|
90
|
+
.event-meta { display:flex; flex-wrap:wrap; gap:.4rem; color:var(--muted); font-size:.68rem; }
|
|
91
|
+
.event-kind { color:var(--accent); font-weight:800; }
|
|
92
|
+
.event-message { margin:.12rem 0 0; overflow-wrap:anywhere; }
|
|
93
|
+
#usage-view { grid-column:1 / -1; }
|
|
94
|
+
.usage-providers { display:grid; gap:.55rem; }
|
|
95
|
+
.usage-provider { display:grid; grid-template-columns:minmax(130px,180px) minmax(0,1fr); gap:1rem; align-items:center; padding:.7rem .8rem; border:1px solid var(--border); border-radius:.45rem; background:var(--surface2); }
|
|
96
|
+
.usage-provider h3 { margin:0; font-size:.86rem; }
|
|
97
|
+
.usage-bars { display:grid; gap:1rem; }
|
|
98
|
+
.usage-row { display:grid; gap:.35rem; }
|
|
99
|
+
.usage-label { display:flex; justify-content:space-between; gap:1rem; font-weight:700; }
|
|
100
|
+
.usage-track { height:.72rem; overflow:hidden; border:1px solid var(--border); border-radius:999px; background:var(--surface2); }
|
|
101
|
+
.usage-fill { height:100%; width:0; border-radius:inherit; background:var(--accent); transition:width .2s ease; }
|
|
102
|
+
.empty,.error-state { padding:.85rem; border:1px dashed var(--border); border-radius:.4rem; color:var(--muted); }
|
|
103
|
+
.error-state,.error-text { color:var(--bad); }
|
|
104
|
+
.skeleton { height:3.2rem; border-radius:.4rem; background:linear-gradient(90deg,#edf0f4,#fafbfc,#edf0f4); animation:pulse 1.2s infinite; }
|
|
105
|
+
@keyframes pulse { 50% { opacity:.55; } }
|
|
106
|
+
@media (max-width:1050px) { main { grid-template-columns:1fr; } .jobs-panel { grid-row:auto; } .panel-body { max-height:24rem; } #jobs-workspace > .panel:first-child .panel-body { max-height:none; } }
|
|
107
|
+
@media (max-width:650px) { main { padding:.65rem; } .job-summary,.detail-groups { grid-template-columns:1fr; } .tagline { display:none; } }
|
|
108
|
+
@media (prefers-reduced-motion:reduce) { .skeleton { animation:none; } .usage-fill { transition:none; } }
|
|
109
|
+
</style>
|
|
110
|
+
</head>
|
|
111
|
+
<body>
|
|
112
|
+
<header>
|
|
113
|
+
<div class="brand"><h1>Agents Relay Console</h1><p class="tagline">Durable PR state is authoritative; events are observability.</p></div>
|
|
114
|
+
<div id="rate-limit-warning" class="warning-banner" role="status" hidden></div>
|
|
115
|
+
<nav role="tablist" aria-label="Console views">
|
|
116
|
+
<button id="jobs-tab" type="button" role="tab" aria-selected="true" aria-controls="jobs-view">Jobs</button>
|
|
117
|
+
<button id="usage-tab" type="button" role="tab" aria-selected="false" aria-controls="usage-view">Usage</button>
|
|
118
|
+
</nav>
|
|
119
|
+
</header>
|
|
120
|
+
<main>
|
|
121
|
+
<section id="jobs-view" role="tabpanel" aria-labelledby="jobs-tab" class="panel jobs-panel">
|
|
122
|
+
<div class="panel-head"><h2>Jobs</h2><div class="panel-actions"><div class="job-filters"><select id="repo-filter" class="job-filter" aria-label="Filter jobs by repository"><option value="">All repos</option></select></div><button id="jobs-refresh" class="refresh-button" type="button">Refresh</button></div></div>
|
|
123
|
+
<div class="panel-body" aria-live="polite"><ul id="job-list" class="job-list"><li class="skeleton" aria-hidden="true"></li></ul></div>
|
|
124
|
+
</section>
|
|
125
|
+
<div class="workspace" id="jobs-workspace">
|
|
126
|
+
<section class="panel" aria-labelledby="workspace-heading">
|
|
127
|
+
<div class="panel-head"><h2 id="workspace-heading">Selected job <span id="selected-job-title" class="subtle"></span></h2><div class="panel-actions"><button id="selected-job-refresh" class="refresh-button" type="button">Refresh</button><span id="connection" aria-live="polite">Connecting…</span></div></div>
|
|
128
|
+
<div class="panel-body">
|
|
129
|
+
<div id="job-detail"><div class="skeleton"></div></div>
|
|
130
|
+
<h3 class="section-title">Task hierarchy</h3>
|
|
131
|
+
<ul id="task-tree" class="tree"><li class="skeleton"></li></ul>
|
|
132
|
+
<h3 class="section-title">Task detail</h3>
|
|
133
|
+
<div id="task-detail"><p class="empty">Select a task to inspect execution details.</p></div>
|
|
134
|
+
</div>
|
|
135
|
+
</section>
|
|
136
|
+
<section class="panel" id="events-panel" aria-labelledby="events-heading">
|
|
137
|
+
<div class="panel-head"><h2 id="events-heading">Events</h2><span class="subtle">Newest first · 60 max</span></div>
|
|
138
|
+
<div class="panel-body"><ol id="event-feed" class="event-list"><li class="empty">Waiting for events.</li></ol></div>
|
|
139
|
+
</section>
|
|
140
|
+
</div>
|
|
141
|
+
<section id="usage-view" role="tabpanel" aria-labelledby="usage-tab" hidden>
|
|
142
|
+
<div class="panel">
|
|
143
|
+
<div class="panel-head"><h2 id="usage-heading">Usage</h2><span id="usage-meta" class="subtle"></span></div>
|
|
144
|
+
<div class="panel-body" id="usage-root" aria-live="polite"><div class="skeleton"></div></div>
|
|
145
|
+
</div>
|
|
146
|
+
</section>
|
|
147
|
+
</main>
|
|
148
|
+
<script>
|
|
149
|
+
'use strict';
|
|
150
|
+
// Compatibility contracts retained for dashboard tests:
|
|
151
|
+
// function taskDetail(
|
|
152
|
+
// location.hash = selectedJob ? '#/jobs/' + encodeURIComponent(selectedJob) : '#/jobs'
|
|
153
|
+
// const url = githubPrUrl(job.repository, job.prNumber);
|
|
154
|
+
// const link = document.createElement('a');
|
|
155
|
+
// link.href = url;
|
|
156
|
+
// link.target = '_blank';
|
|
157
|
+
// link.rel = 'noopener noreferrer';
|
|
158
|
+
// link.textContent = 'PR #' + job.prNumber;
|
|
159
|
+
// while (eventFeed.children.length > MAX_EVENTS)
|
|
160
|
+
// function usageBar(label, window)
|
|
161
|
+
|
|
162
|
+
const MAX_EVENTS=60;
|
|
163
|
+
const jobsTab=document.querySelector('#jobs-tab');
|
|
164
|
+
const usageTab=document.querySelector('#usage-tab');
|
|
165
|
+
const jobsView=document.querySelector('#jobs-view');
|
|
166
|
+
const usageView=document.querySelector('#usage-view');
|
|
167
|
+
const jobsWorkspace=document.querySelector('#jobs-workspace');
|
|
168
|
+
const jobList=document.querySelector('#job-list');
|
|
169
|
+
const jobsRefresh=document.querySelector('#jobs-refresh');
|
|
170
|
+
const repoFilter=document.querySelector('#repo-filter');
|
|
171
|
+
const selectedJobRefresh=document.querySelector('#selected-job-refresh');
|
|
172
|
+
const jobDetail=document.querySelector('#job-detail');
|
|
173
|
+
const taskTree=document.querySelector('#task-tree');
|
|
174
|
+
const taskDetailRoot=document.querySelector('#task-detail');
|
|
175
|
+
const eventFeed=document.querySelector('#event-feed');
|
|
176
|
+
const connection=document.querySelector('#connection');
|
|
177
|
+
const selectedJobTitle=document.querySelector('#selected-job-title');
|
|
178
|
+
const usageRoot=document.querySelector('#usage-root');
|
|
179
|
+
const usageMeta=document.querySelector('#usage-meta');
|
|
180
|
+
const rateLimitWarning=document.querySelector('#rate-limit-warning');
|
|
181
|
+
let jobs=[];
|
|
182
|
+
let selectedJob=sessionStorage.getItem('agents-relay:selected-job');
|
|
183
|
+
let selectedTaskId=sessionStorage.getItem('agents-relay:selected-task');
|
|
184
|
+
let selectedRepo=sessionStorage.getItem('agents-relay:repo-filter')||'';
|
|
185
|
+
|
|
186
|
+
function value(input){return input===null||input===undefined||input===''?'—':String(input);}
|
|
187
|
+
function text(input){return document.createTextNode(value(input));}
|
|
188
|
+
function element(name,className,child){const node=document.createElement(name);if(className)node.className=className;if(child)node.append(child);return node;}
|
|
189
|
+
function field(label,input){const box=element('div','detail-group');box.append(element('p','detail-label',text(label)),element('p','detail-value',text(input)));return box;}
|
|
190
|
+
function markdownField(label,input){const box=element('div','detail-group');box.append(element('p','detail-label',text(label)),markdownValue(input));return box;}
|
|
191
|
+
function rememberSelection(){if(selectedJob)sessionStorage.setItem('agents-relay:selected-job',selectedJob);else sessionStorage.removeItem('agents-relay:selected-job');if(selectedTaskId)sessionStorage.setItem('agents-relay:selected-task',selectedTaskId);else sessionStorage.removeItem('agents-relay:selected-task');}
|
|
192
|
+
function selected(){return jobs.find(job=>job.id===selectedJob)||jobs[0]||null;}
|
|
193
|
+
function counts(tasks){const out={};for(const task of Array.isArray(tasks)?tasks:[])out[task.state]=(out[task.state]||0)+1;return out;}
|
|
194
|
+
function compactCounts(input){const order=['RUNNING','BLOCKED','FAILED','QUEUED','READY','WAITING','SUCCEEDED','CANCELLED'];return order.filter(state=>input[state]).map(state=>input[state]+' '+state).join(', ')||'No tasks';}
|
|
195
|
+
function relativeTime(timestamp){if(!timestamp)return '—';const parsed=Date.parse(timestamp);if(!Number.isFinite(parsed))return value(timestamp);const seconds=Math.max(0,Math.round((Date.now()-parsed)/1000));if(seconds<60)return 'just now';if(seconds<3600)return Math.floor(seconds/60)+'m ago';if(seconds<86400)return Math.floor(seconds/3600)+'h ago';return Math.floor(seconds/86400)+'d ago';}
|
|
196
|
+
function safePrUrl(repository,prNumber){if(typeof repository!=='string'||!Number.isInteger(prNumber)||prNumber<=0)return null;const parts=repository.split('/');if(parts.length!==2)return null;const [owner,repo]=parts;if(!owner||!repo||owner==='.'||owner==='..'||repo==='.'||repo==='..')return null;if(!/^[A-Za-z0-9-]+$/.test(owner)||!/^[A-Za-z0-9._-]+$/.test(repo))return null;return 'https://github.com/'+owner+'/'+repo+'/pull/'+prNumber;}
|
|
197
|
+
function prField(job){const box=element('div','detail-group');box.append(element('p','detail-label',text('GitHub PR')));const line=element('p','detail-value');const url=safePrUrl(job.repository,job.prNumber);if(url){const link=document.createElement('a');link.href=url;link.target='_blank';link.rel='noopener noreferrer';link.textContent='PR #'+job.prNumber;line.append(link);}else line.append(text('unavailable'));box.append(line);return box;}
|
|
198
|
+
function chatField(task){const box=element('div','detail-group');box.append(element('p','detail-label',text('Chat')));const line=element('p','detail-value');if(task.adapter==='chatgpt'&&typeof task.threadId==='string'&&task.threadId){const link=document.createElement('a');link.href='https://chatgpt.com/c/'+encodeURIComponent(task.threadId);link.target='_blank';link.rel='noopener noreferrer';link.textContent='Open chat';line.append(link);}else line.append(text('—'));box.append(line);return box;}
|
|
199
|
+
function selectJob(id){selectedJob=id;selectedTaskId=null;rememberSelection();location.hash=id?'#/jobs/'+encodeURIComponent(id):'#/jobs';renderAll();}
|
|
200
|
+
function filteredJobs(){return jobs.filter(job=>!selectedRepo||job.repository===selectedRepo);}
|
|
201
|
+
function syncFilters(){
|
|
202
|
+
const repos=[...new Set(jobs.map(job=>job.repository).filter(Boolean))].sort();
|
|
203
|
+
const previousRepo=selectedRepo;
|
|
204
|
+
repoFilter.replaceChildren(new Option('All repos',''),...repos.map(repo=>new Option(repo,repo)));
|
|
205
|
+
selectedRepo=repos.includes(previousRepo)?previousRepo:'';
|
|
206
|
+
repoFilter.value=selectedRepo;
|
|
207
|
+
}
|
|
208
|
+
function renderJobList(){jobList.replaceChildren();const visible=filteredJobs();if(!jobs.length){jobList.append(element('li','empty',text('No durable jobs discovered.')));return;}if(!visible.length){jobList.append(element('li','empty',text('No jobs match the selected repo.')));return;}for(const job of visible){const button=document.createElement('button');button.type='button';button.className='job-button'+(isCompleteUnmerged(job)?' complete-unmerged':'');button.setAttribute('aria-current',String(job.id===selectedJob));button.title=job.title||job.id;button.addEventListener('click',()=>selectJob(job.id));const meta=element('span','job-meta');meta.append(element('span','badge '+value(job.state),text(job.state)),element('span','badge mode-'+value(job.executionMode||'fixed'),text(value(job.executionMode||'fixed').toUpperCase())),element('span',job.draft?'badge DRAFT':'',text(job.draft?'DRAFT':value(job.githubState||'UNKNOWN'))));if(isCompleteUnmerged(job))meta.append(element('span','badge MERGE_PENDING',text('MERGE PENDING')));meta.append(element('span','',text(job.repository||'unknown repo')),element('span','',text(relativeTime(job.updatedAt))));button.append(element('span','job-title',text('PR#'+value(job.prNumber)+': '+value(job.title||job.id))),meta,element('span','subtle',text(compactCounts(counts(job.tasks)))));jobList.append(element('li','',button));}}
|
|
209
|
+
function taskNode(task,children,depth){const li=element('li');const row=document.createElement('button');row.type='button';row.className='task-row';row.setAttribute('aria-current',String(task.id===selectedTaskId));row.title=task.id;row.addEventListener('click',()=>{selectedTaskId=task.id;rememberSelection();renderAll();});const id=element('span','task-id',text(task.id));id.style.paddingLeft=Math.min(depth,4)*6+'px';row.append(id,element('span','badge '+value(task.state),text(task.state)));li.append(row);const nested=children.get(task.id)||[];if(nested.length){const ul=element('ul','tree');for(const child of nested)ul.append(taskNode(child,children,depth+1));li.append(ul);}return li;}
|
|
210
|
+
function buildTree(tasks){taskTree.replaceChildren();if(!Array.isArray(tasks)||!tasks.length){taskTree.append(element('li','empty',text('No tasks in durable state.')));return;}const known=new Set(tasks.map(task=>task.id));const children=new Map();const roots=[];for(const task of tasks){if(!task.parentTaskId||!known.has(task.parentTaskId))roots.push(task);else{const siblings=children.get(task.parentTaskId)||[];siblings.push(task);children.set(task.parentTaskId,siblings);}}for(const task of roots)taskTree.append(taskNode(task,children,0));}
|
|
211
|
+
function attemptBudget(task){const attempt=Number(task?.attempt)||0;const max=Number(task?.maxAttempts)||0;if(max>=3&&max%3===0)return attempt+' / 3×'+(max/3);return attempt+' / '+max;}
|
|
212
|
+
function renderTaskDetail(task){taskDetailRoot.replaceChildren();if(!task){taskDetailRoot.append(element('p','empty',text('Select a task to inspect execution details.')));return;}const routing=task.routing||{};const groups=element('div','detail-groups');groups.append(field('Task',task.id),field('Kind',task.kind||'work'),field('State',task.state),field('Project / agent',value(task.projectName)+' / '+value(task.agentName)),field('Parent task',task.parentTaskId||'root'),field('Adapter',task.adapter),field('Provider / model',value(routing.provider)+' / '+value(routing.model)),field('Reasoning / profile',value(routing.reasoning||routing.profile)),field('Attempt',attemptBudget(task)),field('Dependencies',Array.isArray(task.dependencies)&&task.dependencies.length?task.dependencies.join(', '):'none'),chatField(task));if(task.plannerResult)groups.append(field('Objective status',task.plannerResult.objective_status),markdownField('Assessment',task.plannerResult.assessment));if(task.error)groups.append(field('Error',task.error));taskDetailRoot.append(groups);}
|
|
213
|
+
function renderSelectedJob(){jobDetail.replaceChildren();const job=selected();if(!job){selectedJobTitle.textContent='';jobDetail.append(element('div','empty',text('No durable jobs discovered.')));buildTree([]);renderTaskDetail(null);return;}selectedJobTitle.textContent='— '+value(job.title||job.id);if(job.id!==selectedJob){selectedJob=job.id;selectedTaskId=null;rememberSelection();}const summary=element('div','job-summary');summary.append(field('GitHub state',job.githubState||'UNKNOWN'),field('Durable state',job.state),field('Execution mode',job.executionMode||'fixed'),field('Objective',job.objective||job.description||job.title),prField(job),field('Repository',job.repository||'—'),field('Last activity',relativeTime(job.updatedAt)),field('Task states',compactCounts(counts(job.tasks))));jobDetail.append(summary);const selectedTask=(job.tasks||[]).find(task=>task.id===selectedTaskId)||null;if(selectedTaskId&&!selectedTask){selectedTaskId=null;rememberSelection();}buildTree(job.tasks||[]);renderTaskDetail(selectedTask);}
|
|
214
|
+
function renderAll(){jobs.sort(compareJobs);syncFilters();renderJobList();renderSelectedJob();}
|
|
215
|
+
function isCompleteUnmerged(job){return job.state==='COMPLETED'&&job.githubState==='OPEN';}
|
|
216
|
+
function isTerminalJob(job){return ['COMPLETED','CANCELLED'].includes(job.state)||['MERGED','CLOSED'].includes(job.githubState);}
|
|
217
|
+
function compareJobs(a,b){const mergePendingDelta=Number(isCompleteUnmerged(b))-Number(isCompleteUnmerged(a));if(mergePendingDelta)return mergePendingDelta;const terminalDelta=Number(isTerminalJob(a))-Number(isTerminalJob(b));return terminalDelta||String(b.updatedAt).localeCompare(String(a.updatedAt));}
|
|
218
|
+
async function loadJSON(url){const response=await fetch(url);if(!response.ok)throw new Error(response.status+' '+response.statusText);return response.json();}
|
|
219
|
+
function renderRateLimitStatus(status){
|
|
220
|
+
if(!status?.limited){rateLimitWarning.hidden=true;rateLimitWarning.textContent='';return;}
|
|
221
|
+
const until=status.cooldownUntil?new Date(status.cooldownUntil):null;
|
|
222
|
+
const suffix=until&&!Number.isNaN(until.getTime())?' Live GitHub refresh will retry after '+until.toLocaleTimeString()+'.':' Live GitHub refresh is temporarily paused.';
|
|
223
|
+
rateLimitWarning.textContent='GitHub API rate limit reached. Showing cached job data.'+suffix;
|
|
224
|
+
rateLimitWarning.hidden=false;
|
|
225
|
+
}
|
|
226
|
+
async function refreshStatus(){try{renderRateLimitStatus(await loadJSON('/api/status'));}catch{renderRateLimitStatus(null);}}
|
|
227
|
+
async function refresh(force=false){jobsRefresh.disabled=true;jobsRefresh.textContent='Refreshing…';try{const discovered=await loadJSON('/api/jobs'+(force?'?refresh=1':''));jobs=Array.isArray(discovered)?discovered:[];jobs.sort(compareJobs);if(selectedJob&&!jobs.some(job=>job.id===selectedJob)){selectedJob=null;selectedTaskId=null;}if(!selectedJob){const match=location.hash.match(/^#\\/jobs\\/(.+)$/);const fromHash=match?decodeURIComponent(match[1]):null;selectedJob=jobs.some(job=>job.id===fromHash)?fromHash:(jobs[0]?.id||null);}rememberSelection();renderAll();connection.textContent='Live · durable state connected';connection.className='status-pill ok';}catch(error){connection.textContent='Error · durable state unavailable';connection.className='status-pill bad';jobList.replaceChildren(element('li','error-state',text('Could not load jobs: '+error.message)));}finally{jobsRefresh.disabled=false;jobsRefresh.textContent='Refresh';}}
|
|
228
|
+
function addEvent(event,level){if(!event||selectedJob&&event.job_id&&event.job_id!==selectedJob)return;const item=element('li','event-item');const meta=element('div','event-meta');meta.append(element('span','event-kind',text(event.type||'event')),element('span','',text(relativeTime(event.timestamp))),element('span','',text('task: '+value(event.task_id))));item.append(meta);const message=element('p','event-message',text(event.message||'Event received'));if(level==='error'||event.level==='error')message.classList.add('error-text');item.append(message);eventFeed.querySelector('.empty')?.remove();eventFeed.prepend(item);while(eventFeed.children.length>MAX_EVENTS)eventFeed.lastElementChild?.remove();}
|
|
229
|
+
function usageBar(label,windowData){const percent=Math.max(0,Math.min(100,100-windowData.usedPercent));const row=element('div','usage-row');const heading=element('div','usage-label');heading.append(element('span','',text(label)),element('span','',text(Math.round(percent)+'%')));const track=element('div','usage-track');track.setAttribute('role','progressbar');track.setAttribute('aria-label',label+' available');track.setAttribute('aria-valuemin','0');track.setAttribute('aria-valuemax','100');track.setAttribute('aria-valuenow',String(percent));const fill=element('div','usage-fill');fill.style.width=percent+'%';track.append(fill);row.append(heading,track);return row;}
|
|
230
|
+
function usageProvider(provider){const card=element('section','usage-provider');card.append(element('h3','',text(provider.label)));const usage=provider.usage||{};if(!usage.available||!usage.fiveHour||!usage.weekly){card.append(element('p','empty',text(usage.reason||'Account usage telemetry is unavailable.')));return card;}const bars=element('div','usage-bars');bars.append(usageBar('5-hour remaining',usage.fiveHour),usageBar('Weekly remaining',usage.weekly));card.append(bars);return card;}
|
|
231
|
+
function formatCredit(value,currency){if(value===null||value===undefined||!Number.isFinite(Number(value)))return '—';const number=Number(value);if(currency==='USD')return '$'+number.toFixed(2);if(currency==='CNY')return '¥'+number.toFixed(2);return number.toFixed(2)+' '+currency;}
|
|
232
|
+
function creditProvider(provider){const row=element('section','usage-provider');row.append(element('h3','',text(provider.label)));const credits=provider.credits||{};if(!credits.available||!Array.isArray(credits.balances)||!credits.balances.length){row.append(element('p','empty',text(credits.reason||'Credit balance is unavailable.')));return row;}const balance=credits.balances[0];row.append(element('div','detail-value',text(formatCredit(balance.remaining,balance.currency))));return row;}
|
|
233
|
+
async function drawUsage(){try{const overview=await loadJSON('/api/usage');usageRoot.replaceChildren();usageMeta.textContent='Real account telemetry when available';const providers=Array.isArray(overview.providers)?overview.providers:[];const credits=Array.isArray(overview.credits)?overview.credits:[];if(!providers.length&&!credits.length){usageRoot.append(element('p','empty',text('Usage telemetry is unavailable.')));return;}const grid=element('div','usage-providers');for(const provider of providers)grid.append(usageProvider(provider));for(const provider of credits)grid.append(creditProvider(provider));usageRoot.append(grid);}catch(error){usageMeta.textContent='Unavailable';usageRoot.replaceChildren(element('p','error-state',text('Usage unavailable: '+error.message)));}}
|
|
234
|
+
function showView(view){const isJobs=view==='jobs';jobsView.hidden=!isJobs;jobsWorkspace.hidden=!isJobs;usageView.hidden=isJobs;jobsTab.setAttribute('aria-selected',String(isJobs));usageTab.setAttribute('aria-selected',String(!isJobs));if(isJobs)void refresh();else void drawUsage();}
|
|
235
|
+
jobsTab.addEventListener('click',()=>{location.hash=selectedJob?'#/jobs/'+encodeURIComponent(selectedJob):'#/jobs';showView('jobs');});
|
|
236
|
+
jobsRefresh.addEventListener('click',()=>void refresh(true));
|
|
237
|
+
repoFilter.addEventListener('change',()=>{selectedRepo=repoFilter.value;sessionStorage.setItem('agents-relay:repo-filter',selectedRepo);renderJobList();});
|
|
238
|
+
selectedJobRefresh.addEventListener('click',()=>void refreshSelected());
|
|
239
|
+
usageTab.addEventListener('click',()=>{location.hash='#/usage';showView('usage');});
|
|
240
|
+
window.addEventListener('keydown',event=>{if(event.key==='Tab'&&event.altKey){event.preventDefault();showView(location.hash==='#/usage'?'jobs':'usage');}});
|
|
241
|
+
function parseMessage(message){try{return JSON.parse(message.data);}catch{return null;}}
|
|
242
|
+
const stream=new EventSource('/events');
|
|
243
|
+
stream.onopen=()=>{connection.textContent='Live · event stream connected';connection.className='status-pill ok';};
|
|
244
|
+
stream.onerror=()=>{connection.textContent='Degraded · event stream reconnecting';connection.className='status-pill warn';};
|
|
245
|
+
stream.onmessage=message=>{const payload=parseMessage(message);if(payload?.job){const index=jobs.findIndex(job=>job.id===payload.job.id);if(index>=0)jobs[index]=payload.job;else jobs.push(payload.job);renderAll();}};
|
|
246
|
+
stream.addEventListener('relay',message=>{const payload=parseMessage(message);if(payload?.job){const index=jobs.findIndex(job=>job.id===payload.job.id);if(index>=0)jobs[index]=payload.job;else jobs.push(payload.job);}if(payload?.event)addEvent(payload.event);renderAll();});
|
|
247
|
+
if(location.hash==='#/usage')showView('usage');else showView('jobs');
|
|
248
|
+
setInterval(()=>{if(location.hash!=='#/usage')void refresh();},300000);
|
|
249
|
+
setInterval(()=>{if(location.hash==='#/usage')void drawUsage();},30000);
|
|
250
|
+
async function refreshSelected(){if(!selectedJob){await refresh();return;}selectedJobRefresh.disabled=true;selectedJobRefresh.textContent='Refreshing…';try{const updated=await loadJSON('/api/job?id='+encodeURIComponent(selectedJob));const index=jobs.findIndex(job=>job.id===selectedJob);if(index>=0)jobs[index]=updated;else jobs.push(updated);rememberSelection();renderAll();connection.textContent='Live · durable state connected';connection.className='status-pill ok';}catch(error){connection.textContent='Error · durable state unavailable';connection.className='status-pill bad';}finally{selectedJobRefresh.disabled=false;selectedJobRefresh.textContent='Refresh';}}
|
|
251
|
+
</script>
|
|
252
|
+
</body>
|
|
253
|
+
</html>`;
|
|
254
|
+
|
|
255
|
+
export function serveDashboard(
|
|
256
|
+
store: DurableStore,
|
|
257
|
+
port = 8787,
|
|
258
|
+
bus?: EventBus,
|
|
259
|
+
refreshMs = 300000,
|
|
260
|
+
jobId?: string,
|
|
261
|
+
usageRegistry?: UsageRegistry,
|
|
262
|
+
overviewProvider?: (forceRefresh?: boolean) => Promise<ManagedJobOverview[]>,
|
|
263
|
+
webhook?: { secret: string; repositories: ReadonlySet<string>; pullRequest?: number; wake: (event: import('./github-webhook.js').GitHubWebhookWake) => void | Promise<void> },
|
|
264
|
+
statusProvider?: () => { limited: boolean; cooldownUntil: string | null },
|
|
265
|
+
exactJobProvider?: (jobId: string) => Promise<Job | null>,
|
|
266
|
+
): Server {
|
|
267
|
+
const webhookDeliveries = new DeliveryDeduper();
|
|
268
|
+
const clients = new Set<import('node:http').ServerResponse>();
|
|
269
|
+
const subscribedJobs = new Set<string>();
|
|
270
|
+
|
|
271
|
+
let lastKnownJobs: Array<Job & { githubState?: string; mergedAt?: string | null; closedAt?: string | null; draft?: boolean }> | null = null;
|
|
272
|
+
const discoveredJobs = async (forceRefresh = false) => {
|
|
273
|
+
try {
|
|
274
|
+
const jobs = overviewProvider
|
|
275
|
+
? (await overviewProvider(forceRefresh)).map(item => ({ ...item.job, githubState: item.githubState, mergedAt: item.mergedAt, closedAt: item.closedAt, draft: item.draft }))
|
|
276
|
+
: jobId ? [await store.load(jobId)] : [];
|
|
277
|
+
lastKnownJobs = jobs;
|
|
278
|
+
return jobs;
|
|
279
|
+
} catch (error) {
|
|
280
|
+
if (lastKnownJobs) return lastKnownJobs;
|
|
281
|
+
if (jobId) {
|
|
282
|
+
try {
|
|
283
|
+
const job = await store.load(jobId);
|
|
284
|
+
lastKnownJobs = [job];
|
|
285
|
+
return lastKnownJobs;
|
|
286
|
+
} catch { /* durable backend unavailable too */ }
|
|
287
|
+
}
|
|
288
|
+
throw error;
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const subscribeDiscovered = async (): Promise<void> => {
|
|
293
|
+
if (!bus) return;
|
|
294
|
+
const jobs = await discoveredJobs().catch(() => []);
|
|
295
|
+
for (const job of jobs) {
|
|
296
|
+
if (subscribedJobs.has(job.id)) continue;
|
|
297
|
+
subscribedJobs.add(job.id);
|
|
298
|
+
try {
|
|
299
|
+
await bus.subscribe(job.id, async event => {
|
|
300
|
+
const current = (await discoveredJobs()).find(candidate => candidate.id === job.id) ?? job;
|
|
301
|
+
const payload = JSON.stringify({ event, job: current });
|
|
302
|
+
for (const client of clients) client.write(`event: relay\ndata: ${payload}\n\n`);
|
|
303
|
+
});
|
|
304
|
+
} catch {
|
|
305
|
+
subscribedJobs.delete(job.id);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
const snapshot = async (requestedJobId = jobId, forceRefresh = false): Promise<{ id: string; json: string }> => {
|
|
311
|
+
await subscribeDiscovered();
|
|
312
|
+
if (overviewProvider) {
|
|
313
|
+
if (forceRefresh && requestedJobId && exactJobProvider) {
|
|
314
|
+
const exact = await exactJobProvider(requestedJobId);
|
|
315
|
+
if (exact) return { id: exact.id, json: JSON.stringify(exact) };
|
|
316
|
+
}
|
|
317
|
+
const candidates = await discoveredJobs();
|
|
318
|
+
const job = requestedJobId ? candidates.find(candidate => candidate.id === requestedJobId) : candidates[0];
|
|
319
|
+
if (!job) throw new Error(`Managed job ${requestedJobId ?? ''} not found`);
|
|
320
|
+
return { id: job.id, json: JSON.stringify(job) };
|
|
321
|
+
}
|
|
322
|
+
if (!jobId) throw new Error('Dashboard requires a durable job id');
|
|
323
|
+
if (requestedJobId !== jobId) throw new Error(`Managed job ${requestedJobId ?? ''} not found`);
|
|
324
|
+
return { id: jobId, json: JSON.stringify(await store.load(jobId)) };
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const usage = async (): Promise<string> => {
|
|
328
|
+
if (!usageRegistry) throw new Error('Account usage telemetry is unavailable');
|
|
329
|
+
return JSON.stringify(await usageRegistry.snapshot());
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
const server = createServer(async (req, res) => {
|
|
333
|
+
if (req.url === '/') {
|
|
334
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
|
335
|
+
res.end(dashboardHtml);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
if (req.url === '/webhooks/github' && req.method === 'POST') {
|
|
339
|
+
const chunks: Buffer[] = [];
|
|
340
|
+
for await (const chunk of req) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
341
|
+
const body = Buffer.concat(chunks);
|
|
342
|
+
const signature = typeof req.headers['x-hub-signature-256'] === 'string' ? req.headers['x-hub-signature-256'] : undefined;
|
|
343
|
+
if (!webhook || !verifyGitHubWebhook(webhook.secret, body, signature)) { res.writeHead(401); res.end('Invalid signature'); return; }
|
|
344
|
+
const event = parseGitHubWebhook(req.headers, body);
|
|
345
|
+
if (!event) { res.writeHead(202); res.end('Ignored'); return; }
|
|
346
|
+
if (!event.repository || !webhook.repositories.has(event.repository) || (webhook.pullRequest !== undefined && event.pullRequest !== null && event.pullRequest !== webhook.pullRequest)) { res.writeHead(202); res.end('Ignored'); return; }
|
|
347
|
+
if (!webhookDeliveries.accept(event.deliveryId)) { res.writeHead(202); res.end('Duplicate'); return; }
|
|
348
|
+
res.writeHead(202); res.end('Accepted');
|
|
349
|
+
queueMicrotask(() => { void webhook.wake(event); });
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
if (new URL(req.url ?? '/', 'http://127.0.0.1').pathname === '/api/jobs') {
|
|
353
|
+
try {
|
|
354
|
+
await subscribeDiscovered();
|
|
355
|
+
const forceRefresh = new URL(req.url ?? '/', 'http://127.0.0.1').searchParams.get('refresh') === '1';
|
|
356
|
+
const jobs = await discoveredJobs(forceRefresh);
|
|
357
|
+
if (!res.headersSent) res.writeHead(200, { 'content-type': 'application/json' });
|
|
358
|
+
if (!res.writableEnded) res.end(JSON.stringify(jobs));
|
|
359
|
+
} catch (error) {
|
|
360
|
+
if (!res.headersSent) res.writeHead(502);
|
|
361
|
+
if (!res.writableEnded) res.end(error instanceof Error ? error.message : String(error));
|
|
362
|
+
}
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
if (new URL(req.url ?? '/', 'http://127.0.0.1').pathname === '/api/job') {
|
|
366
|
+
try {
|
|
367
|
+
const requestedJobId = new URL(req.url ?? '/', 'http://127.0.0.1').searchParams.get('id') ?? undefined;
|
|
368
|
+
const body = (await snapshot(requestedJobId, true)).json;
|
|
369
|
+
if (!res.headersSent) res.writeHead(200, { 'content-type': 'application/json' });
|
|
370
|
+
if (!res.writableEnded) res.end(body);
|
|
371
|
+
} catch (error) {
|
|
372
|
+
if (!res.headersSent) res.writeHead(404);
|
|
373
|
+
if (!res.writableEnded) res.end(error instanceof Error ? error.message : String(error));
|
|
374
|
+
}
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
if (req.url === '/api/status') {
|
|
378
|
+
const status = statusProvider ? statusProvider() : { limited: false, cooldownUntil: null };
|
|
379
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
380
|
+
res.end(JSON.stringify(status));
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
if (req.url === '/api/usage') {
|
|
384
|
+
try {
|
|
385
|
+
const body = await usage();
|
|
386
|
+
if (!res.headersSent) res.writeHead(200, { 'content-type': 'application/json' });
|
|
387
|
+
if (!res.writableEnded) res.end(body);
|
|
388
|
+
} catch (error) {
|
|
389
|
+
if (!res.headersSent) res.writeHead(503);
|
|
390
|
+
if (!res.writableEnded) res.end(error instanceof Error ? error.message : String(error));
|
|
391
|
+
}
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
if (req.url === '/events') {
|
|
395
|
+
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
|
|
396
|
+
clients.add(res);
|
|
397
|
+
res.flushHeaders();
|
|
398
|
+
res.write(': connected\n\n');
|
|
399
|
+
req.on('close', () => clients.delete(res));
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
res.writeHead(404);
|
|
403
|
+
res.end('Not found');
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
const interval = setInterval(() => {
|
|
407
|
+
if (clients.size === 0) return;
|
|
408
|
+
void snapshot().then(value => {
|
|
409
|
+
for (const client of clients) client.write(`data: ${value.json}\n\n`);
|
|
410
|
+
}).catch(() => undefined);
|
|
411
|
+
}, Math.max(10000, refreshMs));
|
|
412
|
+
interval.unref();
|
|
413
|
+
const heartbeat = setInterval(() => {
|
|
414
|
+
if (clients.size === 0) return;
|
|
415
|
+
for (const client of clients) client.write(': heartbeat\n\n');
|
|
416
|
+
}, SSE_HEARTBEAT_MS);
|
|
417
|
+
heartbeat.unref();
|
|
418
|
+
server.on('close', () => { clearInterval(interval); clearInterval(heartbeat); clients.clear(); });
|
|
419
|
+
server.listen(port, '127.0.0.1');
|
|
420
|
+
return server;
|
|
421
|
+
}
|
package/src/events.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { EventRecord } from './types.js';
|
|
3
|
+
import type { NatsConnection, Subscription } from 'nats';
|
|
4
|
+
type NatsModule = typeof import('nats');
|
|
5
|
+
|
|
6
|
+
export interface EventBus { publish(event: EventRecord): Promise<void>; subscribe(jobId: string, wake: (event: EventRecord) => Promise<void>): Promise<() => Promise<void>>; subscribeAll(wake: (event: EventRecord) => Promise<void>): Promise<() => Promise<void>>; }
|
|
7
|
+
export function subjectToken(value: string): string { if (!/^[A-Za-z0-9_-]+$/.test(value)) throw new Error(`Unsafe event subject token: ${value}`); return value; }
|
|
8
|
+
export class MemoryEventBus implements EventBus {
|
|
9
|
+
private readonly handlers = new Map<string, Set<(event: EventRecord) => Promise<void>>>();
|
|
10
|
+
async publish(event: EventRecord): Promise<void> { for (const handler of [...(this.handlers.get(event.job_id) ?? []), ...(this.handlers.get('*') ?? [])]) await handler(event); }
|
|
11
|
+
async subscribe(jobId: string, wake: (event: EventRecord) => Promise<void>): Promise<() => Promise<void>> { const set = this.handlers.get(jobId) ?? new Set(); set.add(wake); this.handlers.set(jobId, set); return async () => { set.delete(wake); }; }
|
|
12
|
+
async subscribeAll(wake: (event: EventRecord) => Promise<void>): Promise<() => Promise<void>> { const key = '*'; const set = this.handlers.get(key) ?? new Set(); set.add(wake); this.handlers.set(key, set); return async () => { set.delete(wake); }; }
|
|
13
|
+
}
|
|
14
|
+
export class NatsEventBus implements EventBus {
|
|
15
|
+
private connection: NatsConnection | null = null;
|
|
16
|
+
private module: NatsModule | null = null;
|
|
17
|
+
constructor(private readonly url = 'nats://127.0.0.1:4222', private readonly subjectPrefix = 'agents-relay.events.job') { if (!/^[A-Za-z0-9_.-]+$/.test(subjectPrefix)) throw new Error(`Unsafe event subject prefix: ${subjectPrefix}`); }
|
|
18
|
+
private async moduleLoaded(): Promise<NatsModule> { this.module ??= await import('nats'); return this.module; }
|
|
19
|
+
private async client(): Promise<NatsConnection> { const nats = await this.moduleLoaded(); this.connection ??= await nats.connect({ servers: this.url }); return this.connection; }
|
|
20
|
+
async publish(event: EventRecord): Promise<void> { const nats = await this.moduleLoaded(); const subject = `${this.subjectPrefix}.${subjectToken(event.job_id)}.${event.type}`; (await this.client()).publish(subject, nats.StringCodec().encode(JSON.stringify(event))); }
|
|
21
|
+
async subscribe(jobId: string, wake: (event: EventRecord) => Promise<void>): Promise<() => Promise<void>> { return this.subscribeSubject(`${this.subjectPrefix}.${subjectToken(jobId)}.>`, wake); }
|
|
22
|
+
async subscribeAll(wake: (event: EventRecord) => Promise<void>): Promise<() => Promise<void>> { return this.subscribeSubject(`${this.subjectPrefix}.>`, wake); }
|
|
23
|
+
private async subscribeSubject(subject: string, wake: (event: EventRecord) => Promise<void>): Promise<() => Promise<void>> { const nats = await this.moduleLoaded(); const codec = nats.StringCodec(); const subscription: Subscription = (await this.client()).subscribe(subject); let active = true; void (async () => { for await (const message of subscription) { if (!active) break; try { await wake(JSON.parse(codec.decode(message.data)) as EventRecord); } catch { /* malformed events never become durable state */ } } })(); return async () => { active = false; subscription.unsubscribe(); }; }
|
|
24
|
+
}
|
|
25
|
+
export function eventFor(jobId: string, taskId: string, parentTaskId: string | null, type: string, status: string, message: string, visibility: string = 'orchestrator', data?: Record<string, unknown>): EventRecord { return { version: 1, event_id: randomUUID(), job_id: jobId, task_id: taskId, parent_task_id: parentTaskId, type, status, timestamp: new Date().toISOString(), visibility, level: status === 'failed' || status === 'blocked' ? 'error' : 'info', message, data }; }
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { GhApiClient, GitHubAppAuthOptions } from './store.js';
|
|
3
|
+
|
|
4
|
+
function value(args: readonly string[], name: string): string {
|
|
5
|
+
const index = args.indexOf(name);
|
|
6
|
+
return index >= 0 ? args[index + 1] ?? '' : '';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type GitHubAuthContext = { client: GhApiClient; trustedAuthors: Set<string>; appAuth: boolean };
|
|
10
|
+
export type GitHubAppConfig = { appId: string; installationId: string; privateKeyFile: string };
|
|
11
|
+
|
|
12
|
+
export function githubAppConfig(args: readonly string[], env: NodeJS.ProcessEnv = process.env): GitHubAppConfig | null {
|
|
13
|
+
const appId = value(args, '--github-app-id') || env.AGENTS_RELAY_GITHUB_APP_ID || '';
|
|
14
|
+
const installationId = value(args, '--github-app-installation-id') || env.AGENTS_RELAY_GITHUB_APP_INSTALLATION_ID || '';
|
|
15
|
+
const privateKeyFile = value(args, '--github-app-private-key-file') || env.AGENTS_RELAY_GITHUB_APP_PRIVATE_KEY_FILE || '';
|
|
16
|
+
const configured = [appId, installationId, privateKeyFile].filter(Boolean).length;
|
|
17
|
+
if (configured === 0) return null;
|
|
18
|
+
if (configured !== 3) throw new Error('GitHub App auth requires app id, installation id, and private key file');
|
|
19
|
+
return { appId, installationId, privateKeyFile };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function githubAuthContext(args: readonly string[], env: NodeJS.ProcessEnv = process.env): Promise<GitHubAuthContext> {
|
|
23
|
+
const config = githubAppConfig(args, env);
|
|
24
|
+
let options: GitHubAppAuthOptions | null = null;
|
|
25
|
+
if (config) options = { appId: config.appId, installationId: config.installationId, privateKey: await readFile(config.privateKeyFile, 'utf8') };
|
|
26
|
+
const client = new GhApiClient(options);
|
|
27
|
+
const trustedAuthors = new Set<string>();
|
|
28
|
+
trustedAuthors.add(await client.currentLogin());
|
|
29
|
+
for (const author of (value(args, '--trusted-authors') || env.AGENTS_RELAY_TRUSTED_AUTHORS || '').split(',').map(item => item.trim()).filter(Boolean)) trustedAuthors.add(author);
|
|
30
|
+
if (options) {
|
|
31
|
+
const local = await client.localLogin();
|
|
32
|
+
if (local) trustedAuthors.add(local);
|
|
33
|
+
}
|
|
34
|
+
return { client, trustedAuthors, appAuth: options !== null };
|
|
35
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { IncomingHttpHeaders } from 'node:http';
|
|
3
|
+
|
|
4
|
+
export type GitHubWebhookWake = { deliveryId: string; event: string; action: string | null; repository: string | null; pullRequest: number | null };
|
|
5
|
+
const WAKE_EVENTS = new Set(['pull_request', 'issue_comment', 'pull_request_review', 'pull_request_review_comment', 'check_run', 'check_suite', 'workflow_run']);
|
|
6
|
+
|
|
7
|
+
export function verifyGitHubWebhook(secret: string, body: Buffer, signature: string | undefined): boolean {
|
|
8
|
+
if (!secret || !signature?.startsWith('sha256=')) return false;
|
|
9
|
+
const expected = Buffer.from('sha256=' + createHmac('sha256', secret).update(body).digest('hex'));
|
|
10
|
+
const actual = Buffer.from(signature);
|
|
11
|
+
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function parseGitHubWebhook(headers: IncomingHttpHeaders, body: Buffer): GitHubWebhookWake | null {
|
|
15
|
+
const deliveryId = String(headers['x-github-delivery'] ?? '');
|
|
16
|
+
const event = String(headers['x-github-event'] ?? '');
|
|
17
|
+
if (!deliveryId || !WAKE_EVENTS.has(event)) return null;
|
|
18
|
+
let payload: Record<string, unknown>;
|
|
19
|
+
try { payload = JSON.parse(body.toString('utf8')) as Record<string, unknown>; } catch { return null; }
|
|
20
|
+
const repository = payload.repository as Record<string, unknown> | undefined;
|
|
21
|
+
const pullRequest = payload.pull_request as Record<string, unknown> | undefined;
|
|
22
|
+
const issue = payload.issue as Record<string, unknown> | undefined;
|
|
23
|
+
const number = Number(pullRequest?.number ?? (issue?.pull_request ? issue.number : 0));
|
|
24
|
+
return { deliveryId, event, action: typeof payload.action === 'string' ? payload.action : null, repository: typeof repository?.full_name === 'string' ? repository.full_name : null, pullRequest: Number.isInteger(number) && number > 0 ? number : null };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class DeliveryDeduper {
|
|
28
|
+
private readonly seen = new Map<string, number>();
|
|
29
|
+
constructor(private readonly ttlMs = 86400000, private readonly maxEntries = 10000) {}
|
|
30
|
+
accept(id: string, now = Date.now()): boolean {
|
|
31
|
+
for (const [key, expires] of this.seen) if (expires <= now) this.seen.delete(key);
|
|
32
|
+
if (this.seen.has(id)) return false;
|
|
33
|
+
this.seen.set(id, now + this.ttlMs);
|
|
34
|
+
while (this.seen.size > this.maxEntries) this.seen.delete(this.seen.keys().next().value as string);
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/markers.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { AgentRegistration, Job, Task } from './types.js';
|
|
2
|
+
export const JOB_MARKER = 'agents-relay:job:v1';
|
|
3
|
+
export const TASK_MARKER = 'agents-relay:task:v1';
|
|
4
|
+
export const AGENT_MARKER = 'agents-relay:agent:v1';
|
|
5
|
+
export function marker(label: string, value: unknown): string { return `<!-- ${label} ${JSON.stringify(value)} -->`; }
|
|
6
|
+
export function parseMarker<T>(body: string, label: string): T | null {
|
|
7
|
+
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
8
|
+
const startMatch = body.match(new RegExp(`<!--\\s*${escaped}\\s+`));
|
|
9
|
+
if (!startMatch || startMatch.index === undefined) return null;
|
|
10
|
+
const payloadStart = startMatch.index + startMatch[0].length;
|
|
11
|
+
let end = body.indexOf('-->', payloadStart);
|
|
12
|
+
while (end !== -1) {
|
|
13
|
+
const candidate = body.slice(payloadStart, end).trim();
|
|
14
|
+
try { return JSON.parse(candidate) as T; } catch { end = body.indexOf('-->', end + 3); }
|
|
15
|
+
}
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
export function renderJobComment(job: Job): string { return `${marker(JOB_MARKER, { ...job, tasks: undefined })}\n# Agents Relay job: ${job.title}\nState: ${job.state}\nPR: #${job.prNumber}`; }
|
|
19
|
+
export function renderTaskComment(task: Task): string { return `${marker(TASK_MARKER, task)}\n## Task ${task.id}\nState: ${task.state}\n${task.result?.summary ?? task.input}`; }
|
|
20
|
+
export function parseJob(body: string): Job | null {
|
|
21
|
+
const job = parseMarker<Job>(body, JOB_MARKER);
|
|
22
|
+
if (!job) return null;
|
|
23
|
+
if (job.executionMode === undefined) return { ...job, executionMode: 'fixed' };
|
|
24
|
+
if (job.executionMode !== 'fixed' && job.executionMode !== 'autonomous') return null;
|
|
25
|
+
return job;
|
|
26
|
+
}
|
|
27
|
+
export function parseTask(body: string): Task | null {
|
|
28
|
+
const task = parseMarker<Task>(body, TASK_MARKER);
|
|
29
|
+
if (!task) return null;
|
|
30
|
+
return { ...task, kind: task.kind === 'planner' ? 'planner' : 'work' };
|
|
31
|
+
}
|
|
32
|
+
export function renderAgentComment(agent: AgentRegistration): string { return `${marker(AGENT_MARKER, agent)}\n## Registered agent ${agent.name}\nRole: ${agent.role}\nAvailability: ${agent.availability}`; }
|
|
33
|
+
export function parseAgent(body: string): AgentRegistration | null { return parseMarker<AgentRegistration>(body, AGENT_MARKER); }
|