agents-relay 1.0.105 → 1.0.107
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/dashboard-html.js +67 -11
- package/dist/dashboard.js +48 -1
- package/dist/reconciler-review.js +2 -0
- package/dist/relayd.js +3 -2
- package/dist/runtime-summary.js +38 -0
- package/dist/worker-routing.js +12 -3
- package/package.json +1 -1
package/dist/dashboard-html.js
CHANGED
|
@@ -14,6 +14,9 @@ export const dashboardHtml = `<!doctype html>
|
|
|
14
14
|
header { position:sticky; top:0; z-index:3; background:var(--surface); border-bottom:1px solid var(--border); padding:.8rem 1rem; }
|
|
15
15
|
.brand { display:flex; align-items:baseline; gap:.65rem; min-width:0; }
|
|
16
16
|
.version { margin-left:auto; color:var(--muted); font-size:.72rem; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; white-space:nowrap; }
|
|
17
|
+
.runtime-summary { display:flex; gap:.4rem; align-items:center; flex-wrap:wrap; margin-top:.55rem; }
|
|
18
|
+
.runtime-stat { padding:.18rem .45rem; border:1px solid var(--border); border-radius:999px; background:var(--surface2); color:var(--muted); font-size:.72rem; white-space:nowrap; }
|
|
19
|
+
.runtime-stat strong { color:var(--text); }
|
|
17
20
|
h1 { margin:0; font-size:1.08rem; letter-spacing:-.02em; }
|
|
18
21
|
.tagline,.subtle { color:var(--muted); font-size:.78rem; }
|
|
19
22
|
.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; }
|
|
@@ -34,6 +37,7 @@ export const dashboardHtml = `<!doctype html>
|
|
|
34
37
|
.quick-filter[aria-pressed="true"] { border-color:var(--accent); background:var(--accent2); color:var(--accent); }
|
|
35
38
|
.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; }
|
|
36
39
|
.refresh-button:disabled { cursor:default; opacity:.55; }
|
|
40
|
+
.mobile-only { display:none; }
|
|
37
41
|
.panel-body { padding:.75rem; overflow:auto; max-height:calc(100vh - 11.5rem); }
|
|
38
42
|
#jobs-workspace > .panel:first-child .panel-body { overflow:visible; max-height:none; }
|
|
39
43
|
.jobs-panel { grid-row:1/span 2; }
|
|
@@ -86,7 +90,7 @@ export const dashboardHtml = `<!doctype html>
|
|
|
86
90
|
.event-meta { display:flex; flex-wrap:wrap; gap:.4rem; color:var(--muted); font-size:.68rem; }
|
|
87
91
|
.event-kind { color:var(--accent); font-weight:800; }
|
|
88
92
|
.event-message { margin:.12rem 0 0; overflow-wrap:anywhere; }
|
|
89
|
-
#usage-view,#rules-view { grid-column:1 / -1; }
|
|
93
|
+
#usage-view,#rules-view,#worker-rules-view { grid-column:1 / -1; }
|
|
90
94
|
.rules-editor { display:grid; gap:.65rem; }
|
|
91
95
|
.rules-help { margin:0; color:var(--muted); font-size:.8rem; }
|
|
92
96
|
.rules-textarea { width:100%; min-height:55vh; resize:vertical; border:1px solid var(--border); border-radius:.45rem; background:var(--surface2); color:var(--text); padding:.8rem; font:13px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace; }
|
|
@@ -106,18 +110,39 @@ export const dashboardHtml = `<!doctype html>
|
|
|
106
110
|
.skeleton { height:3.2rem; border-radius:.4rem; background:linear-gradient(90deg,#edf0f4,#fafbfc,#edf0f4); animation:pulse 1.2s infinite; }
|
|
107
111
|
@keyframes pulse { 50% { opacity:.55; } }
|
|
108
112
|
@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; } }
|
|
109
|
-
@media (max-width:650px) {
|
|
113
|
+
@media (max-width:650px) {
|
|
114
|
+
header { padding:.65rem .7rem; }
|
|
115
|
+
nav[role="tablist"] { overflow-x:auto; padding-bottom:.1rem; }
|
|
116
|
+
nav button { flex:0 0 auto; white-space:nowrap; }
|
|
117
|
+
main { padding:.5rem; gap:.65rem; }
|
|
118
|
+
body[data-mobile-jobs-view="list"] #jobs-workspace { display:none; }
|
|
119
|
+
body[data-mobile-jobs-view="detail"] #jobs-view { display:none; }
|
|
120
|
+
.mobile-only { display:inline-flex; }
|
|
121
|
+
#jobs-view .panel-head { display:grid; align-items:start; }
|
|
122
|
+
#jobs-view .panel-actions { width:100%; flex-wrap:wrap; }
|
|
123
|
+
#jobs-view .job-filters { width:100%; }
|
|
124
|
+
#jobs-view .job-search { width:100%; max-width:none; flex:1 1 100%; }
|
|
125
|
+
#jobs-view .panel-body { max-height:none; }
|
|
126
|
+
#jobs-workspace { gap:.65rem; }
|
|
127
|
+
#jobs-workspace .panel-body { max-height:none; }
|
|
128
|
+
.job-summary,.detail-groups { grid-template-columns:1fr; }
|
|
129
|
+
.panel-head { padding:.55rem .6rem; }
|
|
130
|
+
.panel-body { padding:.6rem; }
|
|
131
|
+
.tagline { display:none; }
|
|
132
|
+
}
|
|
110
133
|
@media (prefers-reduced-motion:reduce) { .skeleton { animation:none; } .usage-fill { transition:none; } }
|
|
111
134
|
</style>
|
|
112
135
|
</head>
|
|
113
136
|
<body>
|
|
114
137
|
<header>
|
|
115
138
|
<div class="brand"><h1>Agents Relay Console</h1><p class="tagline">Durable PR state is authoritative; events are observability.</p><span class="version">v${packageVersion}</span></div>
|
|
139
|
+
<div id="runtime-summary" class="runtime-summary" aria-label="Runtime summary"></div>
|
|
116
140
|
<div id="rate-limit-warning" class="warning-banner" role="status" hidden></div>
|
|
117
141
|
<nav role="tablist" aria-label="Console views">
|
|
118
142
|
<button id="jobs-tab" type="button" role="tab" aria-selected="true" aria-controls="jobs-view">Jobs</button>
|
|
119
143
|
<button id="usage-tab" type="button" role="tab" aria-selected="false" aria-controls="usage-view">Usage</button>
|
|
120
144
|
<button id="rules-tab" type="button" role="tab" aria-selected="false" aria-controls="rules-view">Model Router Rules</button>
|
|
145
|
+
<button id="worker-rules-tab" type="button" role="tab" aria-selected="false" aria-controls="worker-rules-view">Worker Router Rules</button>
|
|
121
146
|
</nav>
|
|
122
147
|
</header>
|
|
123
148
|
<main>
|
|
@@ -127,7 +152,7 @@ export const dashboardHtml = `<!doctype html>
|
|
|
127
152
|
</section>
|
|
128
153
|
<div class="workspace" id="jobs-workspace">
|
|
129
154
|
<section class="panel" aria-labelledby="workspace-heading">
|
|
130
|
-
<div class="panel-head"><h2 id="workspace-heading"><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" class="status-pill neutral" aria-live="polite" aria-label="Connecting"></span></div></div>
|
|
155
|
+
<div class="panel-head"><button id="mobile-jobs-back" class="refresh-button mobile-only" type="button">← Jobs</button><h2 id="workspace-heading"><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" class="status-pill neutral" aria-live="polite" aria-label="Connecting"></span></div></div>
|
|
131
156
|
<div class="panel-body">
|
|
132
157
|
<div id="job-detail"><div class="skeleton"></div></div>
|
|
133
158
|
<h3 class="section-title">Task hierarchy</h3>
|
|
@@ -159,6 +184,18 @@ export const dashboardHtml = `<!doctype html>
|
|
|
159
184
|
</div>
|
|
160
185
|
</div>
|
|
161
186
|
</section>
|
|
187
|
+
<section id="worker-rules-view" role="tabpanel" aria-labelledby="worker-rules-tab" hidden>
|
|
188
|
+
<div class="panel">
|
|
189
|
+
<div class="panel-head"><h2>Worker Router Rules</h2><span id="worker-rules-meta" class="subtle"></span></div>
|
|
190
|
+
<div class="panel-body">
|
|
191
|
+
<div class="rules-editor">
|
|
192
|
+
<p class="rules-help">Free-text guidance loaded by the worker decider when choosing ChatGPT versus Codex. Use this for local preferences; worker legality and Model Router remain separate.</p>
|
|
193
|
+
<textarea id="worker-rules-text" class="rules-textarea" spellcheck="true" aria-label="Worker router rules" placeholder="Example: Prefer ChatGPT unless the task requires native local execution or computer-use that ChatGPT plus MacBridge cannot perform."></textarea>
|
|
194
|
+
<div class="rules-footer"><span id="worker-rules-status" class="subtle" aria-live="polite"></span><button id="worker-rules-save" class="rules-save" type="button">Save rules</button></div>
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
</section>
|
|
162
199
|
</main>
|
|
163
200
|
<script>
|
|
164
201
|
'use strict';
|
|
@@ -178,9 +215,11 @@ const MAX_EVENTS=60;
|
|
|
178
215
|
const jobsTab=document.querySelector('#jobs-tab');
|
|
179
216
|
const usageTab=document.querySelector('#usage-tab');
|
|
180
217
|
const rulesTab=document.querySelector('#rules-tab');
|
|
218
|
+
const workerRulesTab=document.querySelector('#worker-rules-tab');
|
|
181
219
|
const jobsView=document.querySelector('#jobs-view');
|
|
182
220
|
const usageView=document.querySelector('#usage-view');
|
|
183
221
|
const rulesView=document.querySelector('#rules-view');
|
|
222
|
+
const workerRulesView=document.querySelector('#worker-rules-view');
|
|
184
223
|
const jobsWorkspace=document.querySelector('#jobs-workspace');
|
|
185
224
|
const jobList=document.querySelector('#job-list');
|
|
186
225
|
const jobsRefresh=document.querySelector('#jobs-refresh');
|
|
@@ -190,6 +229,8 @@ const quickStatus=document.querySelector('#quick-status');
|
|
|
190
229
|
const quickAutonomous=document.querySelector('#quick-autonomous');
|
|
191
230
|
const quickDraft=document.querySelector('#quick-draft');
|
|
192
231
|
const selectedJobRefresh=document.querySelector('#selected-job-refresh');
|
|
232
|
+
const mobileJobsBack=document.querySelector('#mobile-jobs-back');
|
|
233
|
+
const mobileJobs=window.matchMedia('(max-width:650px)');
|
|
193
234
|
const jobDetail=document.querySelector('#job-detail');
|
|
194
235
|
const taskTree=document.querySelector('#task-tree');
|
|
195
236
|
const taskDetailRoot=document.querySelector('#task-detail');
|
|
@@ -204,7 +245,12 @@ const rulesText=document.querySelector('#rules-text');
|
|
|
204
245
|
const rulesMeta=document.querySelector('#rules-meta');
|
|
205
246
|
const rulesStatus=document.querySelector('#rules-status');
|
|
206
247
|
const rulesSave=document.querySelector('#rules-save');
|
|
248
|
+
const workerRulesText=document.querySelector('#worker-rules-text');
|
|
249
|
+
const workerRulesMeta=document.querySelector('#worker-rules-meta');
|
|
250
|
+
const workerRulesStatus=document.querySelector('#worker-rules-status');
|
|
251
|
+
const workerRulesSave=document.querySelector('#worker-rules-save');
|
|
207
252
|
const rateLimitWarning=document.querySelector('#rate-limit-warning');
|
|
253
|
+
const runtimeSummary=document.querySelector('#runtime-summary');
|
|
208
254
|
let jobs=[];
|
|
209
255
|
let selectedJob=sessionStorage.getItem('agents-relay:selected-job');
|
|
210
256
|
let selectedTaskId=sessionStorage.getItem('agents-relay:selected-task');
|
|
@@ -221,6 +267,7 @@ function element(name,className,child){const node=document.createElement(name);i
|
|
|
221
267
|
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;}
|
|
222
268
|
function markdownField(label,input){const box=element('div','detail-group');box.append(element('p','detail-label',text(label)),markdownValue(input));return box;}
|
|
223
269
|
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');}
|
|
270
|
+
function syncMobileJobsView(){const detail=mobileJobs.matches&&Boolean(selectedJob)&&/^#\/jobs\/.+/.test(location.hash);document.body.dataset.mobileJobsView=detail?'detail':'list';}
|
|
224
271
|
function selected(){return jobs.find(job=>job.id===selectedJob)||jobs[0]||null;}
|
|
225
272
|
function counts(tasks){const out={};for(const task of Array.isArray(tasks)?tasks:[])out[task.state]=(out[task.state]||0)+1;return out;}
|
|
226
273
|
function compactCounts(input){const order=['RUNNING','BLOCKED','FAILED','QUEUED','DRAFT','SUCCEEDED','CANCELLED'];return order.filter(state=>input[state]).map(state=>input[state]+' '+state).join(', ')||'No tasks';}
|
|
@@ -270,7 +317,7 @@ async function runTaskAction(task,action,button){const job=selected();if(!job)re
|
|
|
270
317
|
function renderTaskDetail(task){taskDetailRoot.replaceChildren();if(!task){taskDetailRoot.append(element('p','empty',text('Select a task to inspect execution details.')));return;}const actions=element('div','task-actions');actions.append(taskActionButton(task,'retry','Restart task','↻'),taskActionButton(task,'cancel','Cancel task','×'));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('Planning decision',task.planningDecisionId||'—'),field('Planning source',task.planningSource||'—'),field('Planning round',task.planningRound===undefined?'—':String(task.planningRound)),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'),taskCommentField(task),chatField(task));const graph=agentGraphField(task);if(graph)groups.append(graph);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(actions,groups);}
|
|
271
318
|
function reviewStatus(job){const review=job?.review;if(!review)return 'Not started';if(review.outcome==='approved'&&review.headSha===job.headSha)return 'MERGE READY · approved';const stale=Array.isArray(job?.tasks)&&job.tasks.some(task=>task.agentName==='reviewer'&&task.reviewHeadSha&&job.headSha&&task.reviewHeadSha!==job.headSha);if(stale)return 'STALE · awaiting review';return String(review.outcome||'pending').replace('_',' ').toUpperCase();}
|
|
272
319
|
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;eventTaskId=null;clearEvents();rememberSelection();}const summary=element('div','job-summary');summary.append(field('PR state',job.githubState||'UNKNOWN'),field('Lifecycle state',job.state),field('Runtime status',job.runtimeStatus||'IDLE'),field('Execution mode',job.executionMode||'fixed'),field('Review',reviewStatus(job)),field('Review head SHA',job.review?.headSha||job.headSha||'—'),prField(job),field('Repository',job.repository||'—'),field('Last activity',relativeTime(job.updatedAt)),field('Task states',compactCounts(job.tasks)));jobDetail.append(summary);const selectedTask=(job.tasks||[]).find(task=>task.id===selectedTaskId)||null;if(selectedTaskId&&!selectedTask){selectedTaskId=null;eventTaskId=null;clearEvents();rememberSelection();}buildTree(job.tasks||[]);renderTaskDetail(selectedTask);renderEventScope();}
|
|
273
|
-
function renderAll(){jobs.sort(compareJobs);syncFilters();renderJobList();renderSelectedJob();}
|
|
320
|
+
function renderAll(){jobs.sort(compareJobs);syncFilters();renderJobList();renderSelectedJob();syncMobileJobsView();}
|
|
274
321
|
function isCompleteUnmerged(job){return job.runtimeStatus==='MERGE_PENDING';}
|
|
275
322
|
function isTerminalJob(job){return ['COMPLETED','CANCELLED'].includes(job.state)||['MERGED','CLOSED'].includes(job.githubState);}
|
|
276
323
|
function jobGroup(job){if(isTerminalJob(job))return 3;if(job.executionMode==='autonomous')return 1;if(job.draft||job.state==='DRAFT')return 2;return 0;}
|
|
@@ -283,8 +330,10 @@ function renderRateLimitStatus(status){
|
|
|
283
330
|
rateLimitWarning.textContent='GitHub API rate limit reached. Showing cached job data.'+suffix;
|
|
284
331
|
rateLimitWarning.hidden=false;
|
|
285
332
|
}
|
|
333
|
+
function runtimePill(label,value){const pill=element('span','runtime-stat');pill.append(document.createTextNode(label+' '),element('strong','',text(value)));return pill;}
|
|
334
|
+
async function drawRuntimeSummary(){try{const summary=await loadJSON('/api/runtime-summary');runtimeSummary.replaceChildren(runtimePill('Active Jobs',summary.activeJobs||0),runtimePill('Running Tasks',summary.runningTasks||0),runtimePill('ChatGPT',summary.workers?.chatgpt||0),runtimePill('Codex',summary.workers?.codex||0));for(const item of Array.isArray(summary.models)?summary.models:[])runtimeSummary.append(runtimePill(item.model,item.count));}catch{runtimeSummary.replaceChildren();}}
|
|
286
335
|
async function refreshStatus(){try{renderRateLimitStatus(await loadJSON('/api/status'));}catch{renderRateLimitStatus(null);}}
|
|
287
|
-
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();void loadEventHistory();connection.textContent='';connection.setAttribute('aria-label','Live durable state connected');connection.className='status-pill ok';}catch(error){connection.textContent='';connection.setAttribute('aria-label','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';}}
|
|
336
|
+
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:[];void drawRuntimeSummary();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();void loadEventHistory();connection.textContent='';connection.setAttribute('aria-label','Live durable state connected');connection.className='status-pill ok';}catch(error){connection.textContent='';connection.setAttribute('aria-label','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';}}
|
|
288
337
|
function renderEventScope(){const task=eventTaskId&&selected()?.tasks?.find(candidate=>candidate.id===eventTaskId);eventsAllTasks.disabled=!task;eventsAllTasks.setAttribute('aria-pressed',String(!task));eventsScope.textContent=task?'Task '+task.id+' · newest first · 60 max':'Job-wide · newest first · 60 max';}
|
|
289
338
|
function eventLabel(type){return ({'task.process.launched':'Process launched','task.started':'Worker started','task.process.exited':'Sync process exited','task.process.async_exited':'Async process exited','task.completed':'Worker completed','task.failed':'Worker failed','task.blocked':'Worker blocked','review.started':'Review started','review.approved':'Review approved','review.changes_required':'Review changes required','review.blocked':'Review blocked'})[type]||type||'Event';}
|
|
290
339
|
function addEvent(event,level){if(!event||selectedJob&&event.job_id&&event.job_id!==selectedJob||eventTaskId&&event.task_id!==eventTaskId)return;if(event.event_id&&displayedEventIds.has(event.event_id))return;if(event.event_id)displayedEventIds.add(event.event_id);const item=element('li','event-item');const meta=element('div','event-meta');meta.append(element('span','event-kind',text(eventLabel(event.type))),element('span','event-type',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();}
|
|
@@ -296,7 +345,9 @@ function creditProvider(provider){const row=element('section','usage-provider');
|
|
|
296
345
|
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)));}}
|
|
297
346
|
async function drawRules(){rulesStatus.textContent='';try{const rules=await loadJSON('/api/model-router-rules');rulesText.value=typeof rules.text==='string'?rules.text:'';rulesMeta.textContent=rules.hash&&rules.hash!=='none'?'Loaded · '+rules.hash.slice(0,12):'No custom rules saved';}catch(error){rulesMeta.textContent='Unavailable';rulesStatus.textContent='Could not load rules: '+error.message;}}
|
|
298
347
|
async function saveRules(){rulesSave.disabled=true;rulesStatus.textContent='Saving…';try{const response=await fetch('/api/model-router-rules',{method:'PUT',headers:{'content-type':'text/plain; charset=utf-8'},body:rulesText.value});if(!response.ok)throw new Error(response.status+' '+await response.text());const rules=await response.json();rulesMeta.textContent='Saved · '+rules.hash.slice(0,12);rulesStatus.textContent='Saved. New routing decisions will load these rules.';}catch(error){rulesStatus.textContent='Save failed: '+error.message;}finally{rulesSave.disabled=false;}}
|
|
299
|
-
function
|
|
348
|
+
async function drawWorkerRules(){workerRulesStatus.textContent='';try{const rules=await loadJSON('/api/worker-router-rules');workerRulesText.value=typeof rules.text==='string'?rules.text:'';workerRulesMeta.textContent=rules.hash&&rules.hash!=='none'?'Loaded · '+rules.hash.slice(0,12):'No custom rules saved';}catch(error){workerRulesMeta.textContent='Unavailable';workerRulesStatus.textContent='Could not load rules: '+error.message;}}
|
|
349
|
+
async function saveWorkerRules(){workerRulesSave.disabled=true;workerRulesStatus.textContent='Saving…';try{const response=await fetch('/api/worker-router-rules',{method:'PUT',headers:{'content-type':'text/plain; charset=utf-8'},body:workerRulesText.value});if(!response.ok)throw new Error(response.status+' '+await response.text());const rules=await response.json();workerRulesMeta.textContent='Saved · '+rules.hash.slice(0,12);workerRulesStatus.textContent='Saved. New worker decisions will load these rules.';}catch(error){workerRulesStatus.textContent='Save failed: '+error.message;}finally{workerRulesSave.disabled=false;}}
|
|
350
|
+
function showView(view){const isJobs=view==='jobs';const isUsage=view==='usage';const isRules=view==='rules';const isWorkerRules=view==='worker-rules';jobsView.hidden=!isJobs;jobsWorkspace.hidden=!isJobs;usageView.hidden=!isUsage;rulesView.hidden=!isRules;workerRulesView.hidden=!isWorkerRules;jobsTab.setAttribute('aria-selected',String(isJobs));usageTab.setAttribute('aria-selected',String(isUsage));rulesTab.setAttribute('aria-selected',String(isRules));workerRulesTab.setAttribute('aria-selected',String(isWorkerRules));if(isJobs)void refresh();else if(isUsage)void drawUsage();else if(isRules)void drawRules();else void drawWorkerRules();}
|
|
300
351
|
jobsTab.addEventListener('click',()=>{location.hash=selectedJob?'#/jobs/'+encodeURIComponent(selectedJob):'#/jobs';showView('jobs');});
|
|
301
352
|
jobsRefresh.addEventListener('click',()=>void refresh(true));
|
|
302
353
|
eventsAllTasks.addEventListener('click',()=>{eventTaskId=null;clearEvents();renderEventScope();void loadEventHistory();});
|
|
@@ -306,18 +357,23 @@ quickAutonomous.addEventListener('click',()=>toggleQuickMode('autonomous'));
|
|
|
306
357
|
quickDraft.addEventListener('click',()=>toggleQuickMode('draft'));
|
|
307
358
|
quickStatus.addEventListener('change',()=>setQuickMode(quickStatus.value?'status':'jobs',quickStatus.value));
|
|
308
359
|
selectedJobRefresh.addEventListener('click',()=>void refreshSelected());
|
|
360
|
+
mobileJobsBack.addEventListener('click',()=>{location.hash='#/jobs';syncMobileJobsView();window.scrollTo({top:0,behavior:'instant'});});
|
|
361
|
+
mobileJobs.addEventListener('change',syncMobileJobsView);
|
|
362
|
+
window.addEventListener('hashchange',syncMobileJobsView);
|
|
309
363
|
usageTab.addEventListener('click',()=>{location.hash='#/usage';showView('usage');});
|
|
310
364
|
rulesTab.addEventListener('click',()=>{location.hash='#/model-router-rules';showView('rules');});
|
|
365
|
+
workerRulesTab.addEventListener('click',()=>{location.hash='#/worker-router-rules';showView('worker-rules');});
|
|
311
366
|
rulesSave.addEventListener('click',()=>void saveRules());
|
|
312
|
-
|
|
367
|
+
workerRulesSave.addEventListener('click',()=>void saveWorkerRules());
|
|
368
|
+
window.addEventListener('keydown',event=>{if(event.key==='Tab'&&event.altKey){event.preventDefault();const next=location.hash==='#/usage'?'rules':location.hash==='#/model-router-rules'?'worker-rules':location.hash==='#/worker-router-rules'?'jobs':'usage';location.hash=next==='usage'?'#/usage':next==='rules'?'#/model-router-rules':next==='worker-rules'?'#/worker-router-rules':(selectedJob?'#/jobs/'+encodeURIComponent(selectedJob):'#/jobs');showView(next);}});
|
|
313
369
|
function parseMessage(message){try{return JSON.parse(message.data);}catch{return null;}}
|
|
314
370
|
const stream=new EventSource('/events');
|
|
315
371
|
stream.onopen=()=>{connection.textContent='';connection.setAttribute('aria-label','Event stream connected');connection.className='status-pill ok';};
|
|
316
372
|
stream.onerror=()=>{connection.textContent='';connection.setAttribute('aria-label','Event stream reconnecting');connection.className='status-pill warn';};
|
|
317
|
-
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();}};
|
|
318
|
-
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();});
|
|
319
|
-
if(location.hash==='#/usage')showView('usage');else if(location.hash==='#/model-router-rules')showView('rules');else showView('jobs');
|
|
320
|
-
setInterval(()=>{if(location.hash!=='#/usage'&&location.hash!=='#/model-router-rules')void refresh();},300000);
|
|
373
|
+
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();void drawRuntimeSummary();}};
|
|
374
|
+
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();void drawRuntimeSummary();});
|
|
375
|
+
if(location.hash==='#/usage')showView('usage');else if(location.hash==='#/model-router-rules')showView('rules');else if(location.hash==='#/worker-router-rules')showView('worker-rules');else showView('jobs');
|
|
376
|
+
setInterval(()=>{if(location.hash!=='#/usage'&&location.hash!=='#/model-router-rules'&&location.hash!=='#/worker-router-rules')void refresh();},300000);
|
|
321
377
|
setInterval(()=>{if(location.hash==='#/usage')void drawUsage();},30000);
|
|
322
378
|
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='';connection.setAttribute('aria-label','Live durable state connected');connection.className='status-pill ok';}catch(error){connection.textContent='';connection.setAttribute('aria-label','Durable state unavailable');connection.className='status-pill bad';}finally{selectedJobRefresh.disabled=false;selectedJobRefresh.textContent='Refresh';}}
|
|
323
379
|
</script>
|
package/dist/dashboard.js
CHANGED
|
@@ -5,7 +5,8 @@ import { join } from 'node:path';
|
|
|
5
5
|
import { readUsageSnapshot } from './usage.js';
|
|
6
6
|
import { deriveJobRuntimeStatus } from './runtime-status.js';
|
|
7
7
|
import { DeliveryDeduper, parseGitHubWebhook, verifyGitHubWebhook } from './github-webhook.js';
|
|
8
|
-
import { loadModelRouterRules, saveModelRouterRules } from './routing-policy.js';
|
|
8
|
+
import { loadModelRouterRules, loadWorkerRouterRules, saveModelRouterRules, saveWorkerRouterRules } from './routing-policy.js';
|
|
9
|
+
import { summarizeRuntime } from './runtime-summary.js';
|
|
9
10
|
const GITHUB_REPOSITORY_PATTERN = /^([A-Za-z0-9](?:[A-Za-z0-9-]{0,36}[A-Za-z0-9])?)\/([A-Za-z0-9._-]{1,100})$/;
|
|
10
11
|
const SSE_HEARTBEAT_MS = 15000;
|
|
11
12
|
const EVENTS_HISTORY_FILE = process.env.EVENTS_BUS_DATA_DIR ? join(process.env.EVENTS_BUS_DATA_DIR, 'events.jsonl') : join(homedir(), 'Library', 'Application Support', 'EventsBus', 'events.jsonl');
|
|
@@ -309,6 +310,52 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
|
|
|
309
310
|
res.end('Method not allowed');
|
|
310
311
|
return;
|
|
311
312
|
}
|
|
313
|
+
if (req.url === '/api/worker-router-rules') {
|
|
314
|
+
if (req.method === 'GET') {
|
|
315
|
+
try {
|
|
316
|
+
const rules = await loadWorkerRouterRules();
|
|
317
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
318
|
+
res.end(JSON.stringify(rules));
|
|
319
|
+
}
|
|
320
|
+
catch (error) {
|
|
321
|
+
res.writeHead(500);
|
|
322
|
+
res.end(error instanceof Error ? error.message : String(error));
|
|
323
|
+
}
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (req.method === 'PUT') {
|
|
327
|
+
const chunks = [];
|
|
328
|
+
let size = 0;
|
|
329
|
+
for await (const chunk of req) {
|
|
330
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
331
|
+
size += buffer.length;
|
|
332
|
+
if (size > 64 * 1024) {
|
|
333
|
+
res.writeHead(413);
|
|
334
|
+
res.end('Worker router rules exceed 64 KiB');
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
chunks.push(buffer);
|
|
338
|
+
}
|
|
339
|
+
try {
|
|
340
|
+
const rules = await saveWorkerRouterRules(Buffer.concat(chunks).toString('utf8'));
|
|
341
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
342
|
+
res.end(JSON.stringify(rules));
|
|
343
|
+
}
|
|
344
|
+
catch (error) {
|
|
345
|
+
res.writeHead(500);
|
|
346
|
+
res.end(error instanceof Error ? error.message : String(error));
|
|
347
|
+
}
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
res.writeHead(405, { allow: 'GET, PUT' });
|
|
351
|
+
res.end('Method not allowed');
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (req.url === '/api/runtime-summary') {
|
|
355
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
356
|
+
res.end(JSON.stringify(summarizeRuntime(lastKnownJobs ?? [])));
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
312
359
|
if (req.url === '/api/status') {
|
|
313
360
|
const status = statusProvider ? statusProvider() : { limited: false, cooldownUntil: null };
|
|
314
361
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
@@ -108,6 +108,7 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
|
|
|
108
108
|
const usage = this.options.usageSnapshotProvider ? await this.options.usageSnapshotProvider() : undefined;
|
|
109
109
|
const registered = await this.store.listAgents();
|
|
110
110
|
const candidates = workerCandidates(this.options.adapters, registered, usage);
|
|
111
|
+
const runtimeSummary = this.options.runtimeSummaryProvider?.();
|
|
111
112
|
for (const task of job.tasks.filter(item => item.kind !== 'planner' && item.agentName !== 'reviewer' && item.adapter !== 'orchestrator' && ['QUEUED', 'BLOCKED'].includes(item.state) && item.output && !item.workerSelection && (item.workerRequirements || item.planningSource === 'model-planner'))) {
|
|
112
113
|
const requirements = task.workerRequirements ?? { capabilities: [] };
|
|
113
114
|
const taskFacts = {
|
|
@@ -124,6 +125,7 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
|
|
|
124
125
|
decisionMode: this.options.decisionMode,
|
|
125
126
|
taskFacts,
|
|
126
127
|
usage,
|
|
128
|
+
chatgptRunning: runtimeSummary?.chatgptRunning ?? 0,
|
|
127
129
|
});
|
|
128
130
|
const models = await discoverModelCandidates(routedWorker.candidate, registered, usage);
|
|
129
131
|
const routingConstraints = task.routing ? { provider: task.routing.provider, model: task.routing.model, profile: task.routing.profile, reasoning: task.routing.reasoning } : undefined;
|
package/dist/relayd.js
CHANGED
|
@@ -17,6 +17,7 @@ import { defaultWorkspaceRoot, discoverWorkspaceRepositories, resolveManagedProj
|
|
|
17
17
|
import { deriveJobRuntimeStatus } from './runtime-status.js';
|
|
18
18
|
import { decisionMode, LayaCliDecisionEngine } from './decision.js';
|
|
19
19
|
import { JsonlDecisionAuditStore } from './decision-audit.js';
|
|
20
|
+
import { summarizeRuntime } from './runtime-summary.js';
|
|
20
21
|
export const daemonHelp = `Agents Relay daemon runs the dashboard and worker pool.
|
|
21
22
|
|
|
22
23
|
Usage:
|
|
@@ -74,13 +75,13 @@ export async function runDaemon(argv) {
|
|
|
74
75
|
const explicitProjectPath = value(argv, '--project-path');
|
|
75
76
|
const usageRegistry = codexAndZaiUsageRegistry();
|
|
76
77
|
const decisionAuditStore = new JsonlDecisionAuditStore(process.env.AGENTS_RELAY_DECISION_AUDIT_PATH ?? join(homedir(), '.agents-relay', 'decision-audit.jsonl'));
|
|
77
|
-
|
|
78
|
+
let cachedOverviews = [];
|
|
79
|
+
const makeReconciler = (store, maxConcurrent) => new Reconciler(store, { owner: `relayd-${process.pid}`, maxConcurrent, leaseMs: Number(value(argv, '--lease-ms', '300000')), adapters: [new CodexAdapter(value(argv, '--codex', 'codex')), new ChatGptAdapter()], continuations: [new CodexThreadContinuation(value(argv, '--codex', 'codex')), new CommandContinuation(), new WebhookContinuation()], planner: runtimePlanner(argv), usageSnapshotProvider: () => usageRegistry.snapshot(), runtimeSummaryProvider: () => ({ chatgptRunning: summarizeRuntime(cachedOverviews.map(item => item.job)).workers.chatgpt }), decisionEngine: new LayaCliDecisionEngine(), decisionMode: decisionMode(), decisionAuditStore, eventBus: bus, reviewer: runtimeReviewer(), projectPathForJob: job => repositoryPaths.get(job.repository) ?? resolveManagedProjectPath(job.repository, explicitProjectPath, workspaceRoot) });
|
|
78
80
|
if (repositories.length === 0)
|
|
79
81
|
throw new Error(`No Git repositories with GitHub origins found under ${normalized.workspaceRoot ?? defaultWorkspaceRoot()}`);
|
|
80
82
|
const pool = new RepositoryWorkerPool(client, repositories, trusted, concurrency, makeReconciler);
|
|
81
83
|
const cacheDirectory = join(homedir(), '.agents-relay');
|
|
82
84
|
const cacheFile = join(cacheDirectory, 'dashboard-jobs.json');
|
|
83
|
-
let cachedOverviews = [];
|
|
84
85
|
try {
|
|
85
86
|
const parsed = JSON.parse(await readFile(cacheFile, 'utf8'));
|
|
86
87
|
if (Array.isArray(parsed))
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
function runningWorker(task) {
|
|
2
|
+
if (task.state !== 'RUNNING')
|
|
3
|
+
return null;
|
|
4
|
+
const adapter = task.workerSelection?.adapter ?? task.adapter;
|
|
5
|
+
return adapter === 'chatgpt' || adapter === 'codex' ? adapter : null;
|
|
6
|
+
}
|
|
7
|
+
export function summarizeRuntime(jobs) {
|
|
8
|
+
let activeJobs = 0;
|
|
9
|
+
let runningTasks = 0;
|
|
10
|
+
let chatgpt = 0;
|
|
11
|
+
let codex = 0;
|
|
12
|
+
const models = new Map();
|
|
13
|
+
for (const job of jobs) {
|
|
14
|
+
if (job.state === 'ACTIVE')
|
|
15
|
+
activeJobs += 1;
|
|
16
|
+
for (const task of job.tasks) {
|
|
17
|
+
if (task.state !== 'RUNNING')
|
|
18
|
+
continue;
|
|
19
|
+
runningTasks += 1;
|
|
20
|
+
const worker = runningWorker(task);
|
|
21
|
+
if (worker === 'chatgpt')
|
|
22
|
+
chatgpt += 1;
|
|
23
|
+
if (worker === 'codex')
|
|
24
|
+
codex += 1;
|
|
25
|
+
const model = task.routing?.model;
|
|
26
|
+
if (model)
|
|
27
|
+
models.set(model, (models.get(model) ?? 0) + 1);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
activeJobs,
|
|
32
|
+
runningTasks,
|
|
33
|
+
workers: { chatgpt, codex },
|
|
34
|
+
models: [...models.entries()]
|
|
35
|
+
.map(([model, count]) => ({ model, count }))
|
|
36
|
+
.sort((left, right) => right.count - left.count || left.model.localeCompare(right.model)),
|
|
37
|
+
};
|
|
38
|
+
}
|
package/dist/worker-routing.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { chooseWithDecision } from './decision.js';
|
|
2
2
|
import { loadWorkerRouterRules } from './routing-policy.js';
|
|
3
|
+
export const DEFAULT_CHATGPT_PREFERRED_CONCURRENCY = 10;
|
|
3
4
|
export const DEFAULT_WORKER_ROUTER_PRINCIPLES = [
|
|
4
5
|
'Worker Router chooses only the execution worker. Model Router chooses provider/model/profile after worker selection.',
|
|
5
6
|
'Prefer ChatGPT when both workers can execute the task because it does not consume Codex coding-plan tokens and normally has access to a stronger ChatGPT Sol model.',
|
|
6
7
|
'Prefer Codex when the task requires native/local execution or computer-use abilities that are unavailable through ChatGPT plus MacBridge.',
|
|
7
8
|
'Treat coding-plan/provider quota as decision context, not worker legality. Codex may still use another provider selected later by Model Router.',
|
|
8
9
|
'When Codex/OpenAI coding quota is scarce, prefer ChatGPT unless the Codex execution environment is necessary.',
|
|
10
|
+
'When current ChatGPT running load reaches the preferred ChatGPT concurrency, strongly prefer Codex unless ChatGPT is materially required or better suited for the task.',
|
|
9
11
|
].join('\n');
|
|
10
12
|
export function usageForProvider(snapshot, provider, now = Date.now()) {
|
|
11
13
|
const usage = snapshot?.providers.find(item => item.id === provider)?.usage;
|
|
@@ -84,9 +86,10 @@ export function satisfiesWorker(candidate, requirements) {
|
|
|
84
86
|
export function eligibleWorkerCandidates(candidates, requirements) {
|
|
85
87
|
return candidates.filter(candidate => satisfiesWorker(candidate, requirements));
|
|
86
88
|
}
|
|
87
|
-
function deterministicWorker(eligible) {
|
|
89
|
+
function deterministicWorker(eligible, chatgptRunning = 0, preferredChatgptConcurrency = DEFAULT_CHATGPT_PREFERRED_CONCURRENCY) {
|
|
90
|
+
const chatgptAtPreferenceLimit = chatgptRunning >= preferredChatgptConcurrency;
|
|
88
91
|
return [...eligible].map((candidate, index) => ({ candidate, index })).sort((left, right) => {
|
|
89
|
-
const preference = (item) => item.adapter === 'chatgpt' ? 0 : 1;
|
|
92
|
+
const preference = (item) => chatgptAtPreferenceLimit ? (item.adapter === 'codex' ? 0 : 1) : (item.adapter === 'chatgpt' ? 0 : 1);
|
|
90
93
|
return preference(left.candidate) - preference(right.candidate) || left.index - right.index;
|
|
91
94
|
})[0].candidate;
|
|
92
95
|
}
|
|
@@ -120,7 +123,9 @@ export async function routeWorker(candidates, requirements, options = {}) {
|
|
|
120
123
|
const eligible = eligibleWorkerCandidates(candidates, requirements);
|
|
121
124
|
if (eligible.length === 0)
|
|
122
125
|
throw new Error('No eligible worker satisfies the authoritative worker constraints');
|
|
123
|
-
const
|
|
126
|
+
const preferredChatgptConcurrency = options.preferredChatgptConcurrency ?? DEFAULT_CHATGPT_PREFERRED_CONCURRENCY;
|
|
127
|
+
const chatgptRunning = options.chatgptRunning ?? 0;
|
|
128
|
+
const deterministic = deterministicWorker(eligible, chatgptRunning, preferredChatgptConcurrency);
|
|
124
129
|
if (eligible.length === 1) {
|
|
125
130
|
const evidence = { mode: options.decisionMode ?? 'off', fallback: true };
|
|
126
131
|
return { candidate: deterministic, selection: selection(deterministic, 'worker-router:constraint', evidence) };
|
|
@@ -132,6 +137,10 @@ export async function routeWorker(candidates, requirements, options = {}) {
|
|
|
132
137
|
routing_principles: DEFAULT_WORKER_ROUTER_PRINCIPLES,
|
|
133
138
|
environment_rules: rules.text,
|
|
134
139
|
usage_snapshot: options.usage ?? null,
|
|
140
|
+
runtime_load: {
|
|
141
|
+
chatgpt_running: chatgptRunning,
|
|
142
|
+
preferred_chatgpt_concurrency: preferredChatgptConcurrency,
|
|
143
|
+
},
|
|
135
144
|
candidates: eligible.map(item => ({
|
|
136
145
|
id: item.workerId,
|
|
137
146
|
adapter: item.adapter,
|