agents-relay 2.0.178 → 2.0.180

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.
@@ -4,18 +4,19 @@ import { join } from 'node:path';
4
4
  import { eventsBusHistory } from './events-bus-runtime.js';
5
5
  const repositoryPattern = /^([A-Za-z0-9](?:[A-Za-z0-9-]{0,36}[A-Za-z0-9])?)\/([A-Za-z0-9._-]{1,100})$/;
6
6
  const workflowDirectory = fileURLToPath(new URL('../workflow/', import.meta.url));
7
- export const DASHBOARD_JOB_PAGE_SIZE = 20;
7
+ export const DASHBOARD_JOB_RESULT_LIMIT = 20;
8
8
  export const DEFAULT_DASHBOARD_JOB_STATUSES = ['DRAFT', 'ACTIVE', 'COMPLETED'];
9
9
  function compareDashboardJobs(a, b) {
10
- const terminal = (job) => ['COMPLETED', 'CANCELLED'].includes(job.state) || ['MERGED', 'CLOSED'].includes(job.githubState ?? '');
11
- const group = (job) => terminal(job) ? 3 : job.draft || job.state === 'DRAFT' ? 2 : 0;
12
- const groupDelta = group(a) - group(b);
13
- if (groupDelta)
14
- return groupDelta;
15
- const mergePendingDelta = Number(b.runtimeStatus === 'MERGE_PENDING') - Number(a.runtimeStatus === 'MERGE_PENDING');
16
- if (mergePendingDelta)
17
- return mergePendingDelta;
18
- return String(b.updatedAt ?? '').localeCompare(String(a.updatedAt ?? ''));
10
+ const timestamp = (job) => {
11
+ const updated = Date.parse(job.updatedAt ?? '');
12
+ if (Number.isFinite(updated))
13
+ return updated;
14
+ const created = Date.parse(job.createdAt ?? '');
15
+ return Number.isFinite(created) ? created : 0;
16
+ };
17
+ return timestamp(b) - timestamp(a)
18
+ || a.repository.localeCompare(b.repository)
19
+ || a.id.localeCompare(b.id);
19
20
  }
20
21
  export async function dashboardWorkflowGraphs() {
21
22
  const names = (await readdir(workflowDirectory)).filter(name => name.endsWith('.mmd')).sort();
@@ -57,18 +58,16 @@ export async function eventHistory(jobId, taskId, limit) {
57
58
  }
58
59
  }
59
60
  export function searchDashboardJobs(jobs, query) {
60
- const requestedStatuses = [...new Set([...query.getAll('status'), ...query.getAll('statuses')].flatMap(value => value.split(',')).map(value => value.trim().toUpperCase()).filter(Boolean))];
61
- const statuses = requestedStatuses.length > 0 ? requestedStatuses : [...DEFAULT_DASHBOARD_JOB_STATUSES];
62
- const repositories = [...new Set([...query.getAll('repo'), ...query.getAll('repositories'), ...query.getAll('repos')].map(value => value.trim()).filter(Boolean))];
61
+ const requestedStatus = (query.get('status') ?? query.get('statuses') ?? '').trim().toUpperCase();
62
+ const status = requestedStatus && ['DRAFT', 'ACTIVE', 'COMPLETED', 'CANCELLED'].includes(requestedStatus) ? requestedStatus : '';
63
+ const repository = (query.get('repo') ?? query.get('repositories') ?? query.get('repos') ?? '').trim();
63
64
  const search = query.get('q')?.trim().toLowerCase() ?? '';
64
- const parsedPage = Number(query.get('page'));
65
- const page = Number.isInteger(parsedPage) && parsedPage > 0 ? parsedPage : 1;
66
- const parsedPageSize = Number(query.get('pageSize'));
67
- const pageSize = Number.isInteger(parsedPageSize) && parsedPageSize > 0 ? Math.min(parsedPageSize, DASHBOARD_JOB_PAGE_SIZE) : DASHBOARD_JOB_PAGE_SIZE;
68
- const matchesStatus = (job) => statuses.some(status => status === 'DRAFT'
65
+ const matchesStatus = (job) => status ? status === 'DRAFT'
66
+ ? Boolean(job.draft) || job.state === 'DRAFT'
67
+ : job.state === status : DEFAULT_DASHBOARD_JOB_STATUSES.some(defaultStatus => defaultStatus === 'DRAFT'
69
68
  ? Boolean(job.draft) || job.state === 'DRAFT'
70
- : job.state === status);
71
- const matchesRepository = (job) => repositories.length === 0 || repositories.includes(job.repository);
69
+ : job.state === defaultStatus);
70
+ const matchesRepository = (job) => !repository || repository === job.repository;
72
71
  const normalizedPr = search.replace(/^pr\s*#?/, '').replace(/^#/, '');
73
72
  const matchesSearch = (job) => {
74
73
  if (!search)
@@ -78,15 +77,9 @@ export function searchDashboardJobs(jobs, query) {
78
77
  || Boolean(normalizedPr) && String(job.prNumber || '').includes(normalizedPr);
79
78
  };
80
79
  const matching = jobs.filter(job => matchesStatus(job) && matchesRepository(job) && matchesSearch(job)).sort(compareDashboardJobs);
81
- const total = matching.length;
82
- const totalPages = Math.max(1, Math.ceil(total / pageSize));
83
- const safePage = Math.min(page, totalPages);
84
80
  return {
85
- jobs: matching.slice((safePage - 1) * pageSize, safePage * pageSize),
81
+ jobs: matching.slice(0, DASHBOARD_JOB_RESULT_LIMIT),
86
82
  repositories: [...new Set(jobs.map(job => job.repository).filter(Boolean))].sort(),
87
- total,
88
- page: safePage,
89
- pageSize,
90
- totalPages,
83
+ total: matching.length,
91
84
  };
92
85
  }
@@ -1,5 +1,5 @@
1
1
  import { dashboardStyles } from './dashboard-style.js';
2
- import { DASHBOARD_JOB_PAGE_SIZE, DEFAULT_DASHBOARD_JOB_STATUSES } from './dashboard-data.js';
2
+ import { DEFAULT_DASHBOARD_JOB_STATUSES } from './dashboard-data.js';
3
3
  import { createRequire } from 'node:module';
4
4
  import { execFileSync } from 'node:child_process';
5
5
  import { fileURLToPath } from 'node:url';
@@ -42,7 +42,7 @@ ${dashboardStyles}
42
42
  </header>
43
43
  <main>
44
44
  <section id="jobs-view" role="tabpanel" aria-labelledby="jobs-tab" class="panel jobs-panel">
45
- <div class="panel-head"><h2>Jobs</h2><div class="panel-actions"><div class="job-filters"><input id="job-search" class="job-search" type="search" placeholder="Search title or PR #" aria-label="Search jobs by title or PR number"><select id="status-filter" class="job-filter" multiple aria-label="Filter jobs by status (choose at least one; empty uses DRAFT, ACTIVE, and COMPLETED)"><option value="DRAFT">DRAFT</option><option value="ACTIVE">ACTIVE</option><option value="COMPLETED">COMPLETED</option><option value="CANCELLED">CANCELLED</option></select><select id="repo-filter" class="job-filter" multiple aria-label="Filter jobs by repository (empty shows all repositories)"></select><button id="jobs-reset" class="refresh-button" type="button">Reset</button></div><div class="jobs-pagination"><button id="jobs-prev" class="refresh-button" type="button">Previous</button><span id="jobs-page" class="subtle">Page 1</span><button id="jobs-next" class="refresh-button" type="button">Next</button></div><button id="jobs-refresh" class="refresh-button" type="button">Refresh</button></div></div>
45
+ <div class="panel-head"><h2>Jobs</h2><div class="panel-actions"><div class="job-filters"><input id="job-search" class="job-search" type="search" placeholder="Search title or PR #" aria-label="Search jobs by title or PR number"><select id="status-filter" class="job-filter" aria-label="Filter jobs by status"><option value="">All (no CANCELLED)</option><option value="DRAFT">DRAFT</option><option value="ACTIVE">ACTIVE</option><option value="COMPLETED">COMPLETED</option><option value="CANCELLED">CANCELLED</option></select><select id="repo-filter" class="job-filter" aria-label="Filter jobs by repository"><option value="">All repositories</option></select></div><div class="jobs-actions"><button id="jobs-reset" class="refresh-button" type="button">Reset</button><button id="jobs-refresh" class="refresh-button" type="button">Refresh</button></div><span id="jobs-feedback" class="jobs-feedback subtle" role="status" aria-live="polite"></span></div></div>
46
46
  <div class="panel-body" aria-live="polite"><ul id="job-list" class="job-list"><li class="skeleton" aria-hidden="true"></li></ul></div>
47
47
  </section>
48
48
  <div class="workspace" id="jobs-workspace">
@@ -144,11 +144,9 @@ const jobList=document.querySelector('#job-list');
144
144
  const jobsRefresh=document.querySelector('#jobs-refresh');
145
145
  const jobsReset=document.querySelector('#jobs-reset');
146
146
  const jobSearch=document.querySelector('#job-search');
147
- const jobsPrev=document.querySelector('#jobs-prev');
148
- const jobsNext=document.querySelector('#jobs-next');
149
- const jobsPageLabel=document.querySelector('#jobs-page');
150
147
  const repoFilter=document.querySelector('#repo-filter');
151
148
  const statusFilter=document.querySelector('#status-filter');
149
+ const jobsFeedback=document.querySelector('#jobs-feedback');
152
150
  const selectedJobRefresh=document.querySelector('#selected-job-refresh');
153
151
  const mobileJobsBack=document.querySelector('#mobile-jobs-back');
154
152
  const mobileJobs=window.matchMedia('(max-width:650px)');
@@ -180,11 +178,8 @@ const runtimeSummary=document.querySelector('#runtime-summary');
180
178
  let jobs=[];
181
179
  let selectedJob=sessionStorage.getItem('agents-relay:selected-job');
182
180
  let selectedTaskId=sessionStorage.getItem('agents-relay:selected-task');
183
- let selectedRepos=storedValues('agents-relay:repo-filter');
184
- let selectedStatuses=sessionStorage.getItem('agents-relay:status-filter')===null?[...${JSON.stringify(DEFAULT_DASHBOARD_JOB_STATUSES)}]:storedValues('agents-relay:status-filter');
185
- let jobsPage=1;
186
- let jobsTotal=0;
187
- let jobsTotalPages=1;
181
+ let selectedRepo=storedFilterValue('agents-relay:repo-filter');
182
+ let selectedStatus=storedFilterValue('agents-relay:status-filter');
188
183
  let jobRepositories=[];
189
184
  let searchQuery=sessionStorage.getItem('agents-relay:job-search')||'';
190
185
  let workflowGraphs=[];
@@ -195,7 +190,7 @@ const collapsedTasks=new Set();
195
190
  const expandedTasks=new Set();
196
191
  const displayedEventIds=new Set();
197
192
  function value(input){return input===null||input===undefined||input===''?'—':String(input);}
198
- function storedValues(key){const stored=sessionStorage.getItem(key);if(!stored)return [];try{const parsed=JSON.parse(stored);if(Array.isArray(parsed))return parsed.filter(value=>typeof value==='string'&&value);if(typeof parsed==='string'&&parsed)return [parsed];}catch{return [stored];}return [];}
193
+ function storedFilterValue(key){const stored=sessionStorage.getItem(key);if(!stored)return '';try{const parsed=JSON.parse(stored);if(Array.isArray(parsed))return parsed.length===1&&typeof parsed[0]==='string'?parsed[0]:'';if(typeof parsed==='string')return parsed;}catch{return stored;}return '';}
199
194
  function text(input){return document.createTextNode(value(input));}
200
195
  function element(name,className,child){const node=document.createElement(name);if(className)node.className=className;if(child)node.append(child);return node;}
201
196
  function yamlScalar(input){if(input===null)return 'null';if(typeof input==='boolean'||typeof input==='number')return String(input);return JSON.stringify(String(input));}
@@ -222,22 +217,18 @@ function clearEvents(){displayedEventIds.clear();eventFeed.replaceChildren(eleme
222
217
  async function loadEventHistory(){const job=selected();if(!job){clearEvents();return;}const params=new URLSearchParams({job:job.id,limit:String(MAX_EVENTS)});if(eventTaskId)params.set('task',eventTaskId);try{const history=await loadJSON('/api/events?'+params.toString());clearEvents();for(const event of Array.isArray(history)?history.slice().reverse():[])addEvent(event);}catch{clearEvents();}}
223
218
  function copySelectedJobIdentity(job){if(!job||!navigator.clipboard?.writeText)return;void navigator.clipboard.writeText(repoName(job.repository)+'/'+value(job.id)).catch(()=>{});}
224
219
  function selectJob(job){selectedJob=jobSelectionKey(job);selectedTaskId=null;eventTaskId=null;rememberSelection();clearEvents();location.hash=selectedJob?'#/jobs/'+encodeURIComponent(selectedJob):'#/jobs';copySelectedJobIdentity(job);renderAll();void loadEventHistory();}
225
- function matchesQuickFilter(job){const statuses=selectedStatuses.length?selectedStatuses:${JSON.stringify(DEFAULT_DASHBOARD_JOB_STATUSES)};return statuses.some(status=>status==='DRAFT'?job.draft||job.state==='DRAFT':job.state===status);}
220
+ function matchesQuickFilter(job){return !selectedStatus?${JSON.stringify(DEFAULT_DASHBOARD_JOB_STATUSES)}.some(status=>status==='DRAFT'?job.draft||job.state==='DRAFT':job.state===status):selectedStatus==='DRAFT'?job.draft||job.state==='DRAFT':job.state===selectedStatus;}
226
221
  function matchesSearch(job){const query=searchQuery.trim().toLowerCase();if(!query)return true;const pr=String(job.prNumber||'');const normalized=query.replace(/^pr\s*#?/,'').replace(/^#/,'');return String(job.title||job.id||'').toLowerCase().includes(query)||String(job.id||'').toLowerCase().includes(query)||shortManagedId(job.id,job.repository,'j').toLowerCase().includes(query)||pr.includes(normalized);}
227
- function filteredJobs(){return jobs.filter(job=>(!selectedRepos.length||selectedRepos.includes(job.repository))&&matchesQuickFilter(job)&&matchesSearch(job));}
222
+ function filteredJobs(){return jobs.filter(job=>(!selectedRepo||selectedRepo===job.repository)&&matchesQuickFilter(job)&&matchesSearch(job));}
228
223
  function syncFilters(){
229
224
  const repos=jobRepositories.length?jobRepositories:[...new Set(jobs.map(job=>job.repository).filter(Boolean))].sort();
230
- const validRepos=new Set(repos);
231
- repoFilter.replaceChildren(...repos.map(repo=>new Option(repo,repo)));
232
- selectedRepos=selectedRepos.filter(repo=>validRepos.has(repo));
233
- for(const option of repoFilter.options)option.selected=selectedRepos.includes(option.value);
234
- const validStatuses=new Set([...statusFilter.options].map(option=>option.value));
235
- selectedStatuses=selectedStatuses.filter(status=>validStatuses.has(status));
236
- for(const option of statusFilter.options)option.selected=selectedStatuses.includes(option.value);
225
+ const repoOptions=[new Option('All repositories',''),...repos.map(repo=>new Option(repo,repo))];
226
+ if(repoFilter.options.length!==repoOptions.length||[...repoFilter.options].some((option,index)=>option.value!==repoOptions[index].value))repoFilter.replaceChildren(...repoOptions);
227
+ if(selectedRepo&&!repos.includes(selectedRepo))selectedRepo='';
228
+ if(!['','DRAFT','ACTIVE','COMPLETED','CANCELLED'].includes(selectedStatus))selectedStatus='';
229
+ statusFilter.value=selectedStatus;
230
+ repoFilter.value=selectedRepo;
237
231
  jobSearch.value=searchQuery;
238
- jobsPageLabel.textContent='Page '+jobsPage+' of '+jobsTotalPages+(jobsTotal?' · '+jobsTotal+' jobs':'');
239
- jobsPrev.disabled=jobsPage<=1;
240
- jobsNext.disabled=jobsPage>=jobsTotalPages;
241
232
  }
242
233
  function repoName(repository){if(typeof repository!=='string'||!repository)return 'unknown-repo';const parts=repository.split('/').filter(Boolean);return parts.at(-1)||repository;}
243
234
  const CHAT_SHORTCUTS_KEY='agents-relay:chatgpt-shortcuts';
@@ -269,9 +260,7 @@ function manualOverrideField(task){const rows=Array.isArray(task.manualStateOver
269
260
  function showDetailsDialog(title,sections){const dialog=element('dialog','details-dialog');dialog.setAttribute('aria-label',title);const head=element('div','details-dialog-head');const name=element('strong','',text(title));const close=element('button','refresh-button',text('Close'));close.type='button';close.addEventListener('click',()=>dialog.close());head.append(name,close);dialog.append(head);for(const [label,value] of sections){if(value===undefined||value===null||value==='')continue;dialog.append(element('h3','',text(label)));const content=element('pre');content.textContent=typeof value==='string'?value:JSON.stringify(value,null,2);dialog.append(content);}dialog.addEventListener('close',()=>dialog.remove());document.body.append(dialog);dialog.showModal();}function openJobDetails(){const job=selected();if(!job)return;showDetailsDialog('Job '+job.id+' · '+value(job.title),[['Objective',job.objective],['Description',job.description],['State',job.state],['Plans',job.plans?.map(p=>({id:p.id,status:p.status,assessment:p.assessment,error:p.error,generatedTaskIds:p.generatedTaskIds}))],['Review',job.review],['Outcome',job.result],['Publication',job.publication],['Failure',job.error]]);}function openTaskDetails(task){const result=task.result?.data??task.result;showDetailsDialog('Task '+task.id+' · '+value(task.title),[['Input / task objective',task.input],['State',task.state],['Conclusion',result],['Result summary',task.result?.summary],['Planner assessment',task.plannerResult?.assessment],['Resolution',task.resolution],['Resolution classification',task.resolutionClassification],['Failure',task.failureReason||task.error],['Dependencies',task.dependencies]]);}document.querySelector('#job-content-open').addEventListener('click',openJobDetails);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('Title',task.title||'—'),field('Task ID',task.id),field('Kind',task.kind||'work'),taskStateField(task),...(task.state==='FAILED'&&task.resolution?.state?[field('Resolution state',task.resolution.state),field('Resolution path',task.resolution.path||'—'),field('Resolution target',task.resolution.targetTaskId||'—')]:[]),manualOverrideField(task),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'),...(task.resolutionClassification?[field('Resolution classification',task.resolutionClassification.kind),field('Resolution target',task.resolutionClassification.targetTaskId),field('Resolution evidence',task.resolutionClassification.evidence),field('Resolution reason',task.resolutionClassification.reason)]:[]),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 job=selected();if(task.reviewHeadSha&&job)groups.append(commitField('HEAD commit',job,task.reviewHeadSha));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));const result=task.result?.data??task.result;if(result){groups.append(field('Conclusion',result.verdict||result.outcome?.verdict||task.result?.summary||'Available'));}taskDetailRoot.append(groups);const details=element('button','refresh-button',text('Task details / conclusion'));details.type='button';details.addEventListener('click',()=>openTaskDetails(task));taskDetailRoot.append(details);}
270
261
  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();}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(jobSelectionKey(job)!==selectedJob){selectedJob=jobSelectionKey(job);selectedTaskId=null;eventTaskId=null;clearEvents();rememberSelection();}const summary=element('div','job-summary');summary.append(field('PR state',job.githubState||'UNKNOWN'),jobStateField(job),field('Runtime status',job.runtimeStatus||'IDLE'),field('Review',reviewStatus(job)),field('Creator thread',job.creatorThreadId||'—'),threadField('Implementation thread',job.implementationThreadId),threadField('Reviewer thread',job.reviewerThreadId),commitField('Review head SHA',job,job.review?.headSha||job.headSha||''),prField(job),field('Repository',job.repository||'—'),field('Last activity',relativeTime(job.updatedAt)),field('Task states',compactCounts(job.tasks)),chatShortcutField(job));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();}function renderAll(){jobs.sort(compareJobs);syncFilters();renderJobList();renderSelectedJob();syncMobileJobsView();}
271
262
  function isCompleteUnmerged(job){return job.runtimeStatus==='MERGE_PENDING';}
272
- function isTerminalJob(job){return ['COMPLETED','CANCELLED'].includes(job.state)||['MERGED','CLOSED'].includes(job.githubState);}
273
- function jobGroup(job){if(isTerminalJob(job))return 3;if(job.draft||job.state==='DRAFT')return 2;return 0;}
274
- function compareJobs(a,b){const groupDelta=jobGroup(a)-jobGroup(b);if(groupDelta)return groupDelta;const mergePendingDelta=Number(isCompleteUnmerged(b))-Number(isCompleteUnmerged(a));return mergePendingDelta||String(b.updatedAt).localeCompare(String(a.updatedAt));}
263
+ function compareJobs(a,b){const timestamp=job=>{const updated=Date.parse(job.updatedAt||'');if(Number.isFinite(updated))return updated;const created=Date.parse(job.createdAt||'');return Number.isFinite(created)?created:0;};return timestamp(b)-timestamp(a)||String(a.repository||'').localeCompare(String(b.repository||''))||String(a.id||'').localeCompare(String(b.id||''));}
275
264
  async function loadJSON(url){const response=await fetch(url);if(!response.ok)throw new Error(response.status+' '+response.statusText);return response.json();}
276
265
  async function refreshVersion(){try{const live=await loadJSON('/api/version');if(live?.version&&live.version!==DASHBOARD_VERSION)location.reload();}catch{}}
277
266
  function renderRateLimitStatus(status){
@@ -284,10 +273,10 @@ function renderRateLimitStatus(status){
284
273
  function runtimePill(label,value){const pill=element('span','runtime-stat');pill.append(document.createTextNode(label+' '),element('strong','',text(value)));return pill;}
285
274
  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
275
  async function refreshStatus(){try{renderRateLimitStatus(await loadJSON('/api/status'));}catch{renderRateLimitStatus(null);}}
287
- function rememberJobFilters(){sessionStorage.setItem('agents-relay:repo-filter',JSON.stringify(selectedRepos));sessionStorage.setItem('agents-relay:status-filter',JSON.stringify(selectedStatuses));sessionStorage.setItem('agents-relay:job-search',searchQuery);}
288
- function jobsQuery(force=false){const params=new URLSearchParams();if(searchQuery.trim())params.set('q',searchQuery.trim());for(const status of selectedStatuses)params.append('status',status);for(const repository of selectedRepos)params.append('repo',repository);params.set('page',String(jobsPage));params.set('pageSize',String(${DASHBOARD_JOB_PAGE_SIZE}));if(force)params.set('refresh','1');return params.toString();}
276
+ function rememberJobFilters(){sessionStorage.setItem('agents-relay:repo-filter',selectedRepo);sessionStorage.setItem('agents-relay:status-filter',selectedStatus);sessionStorage.setItem('agents-relay:job-search',searchQuery);}
277
+ function jobsQuery(force=false){const params=new URLSearchParams();if(searchQuery.trim())params.set('q',searchQuery.trim());if(selectedStatus)params.set('status',selectedStatus);if(selectedRepo)params.set('repo',selectedRepo);if(force)params.set('refresh','1');return params.toString();}
289
278
  let jobsRequestSequence=0;
290
- async function refresh(force=false){const requestSequence=++jobsRequestSequence;jobsRefresh.disabled=true;jobsRefresh.textContent='Refreshing…';jobsPrev.disabled=true;jobsNext.disabled=true;try{const params=jobsQuery(force);const discovered=await loadJSON('/api/jobs'+(params?'?'+params:''));if(requestSequence!==jobsRequestSequence)return;const result=Array.isArray(discovered)?{jobs:discovered,repositories:[...new Set(discovered.map(job=>job.repository).filter(Boolean))].sort(),total:discovered.length,page:1,totalPages:1}:discovered;jobs=dedupeJobs(Array.isArray(result.jobs)?result.jobs:[]);jobRepositories=result.repositories||[];jobsTotal=result.total||0;jobsPage=result.page||1;jobsTotalPages=result.totalPages||1;void drawRuntimeSummary();jobs.sort(compareJobs);if(selectedJob&&!jobForSelection(selectedJob)){selectedJob=null;selectedTaskId=null;}if(!selectedJob){const match=location.hash.match(/^#\\/jobs\\/(.+)$/);const fromHash=match?decodeURIComponent(match[1]):null;const fromRoute=jobForSelection(fromHash);selectedJob=jobSelectionKey(fromRoute||jobs[0]);}rememberSelection();rememberJobFilters();renderAll();void loadEventHistory();connection.textContent='';connection.setAttribute('aria-label','Live durable state connected');connection.className='status-pill ok';}catch(error){if(requestSequence!==jobsRequestSequence)return;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{if(requestSequence===jobsRequestSequence){jobsRefresh.disabled=false;jobsRefresh.textContent='Refresh';syncFilters();}}}
279
+ async function refresh(force=true){const requestSequence=++jobsRequestSequence;jobsRefresh.disabled=true;jobsRefresh.textContent='Refreshing…';jobsFeedback.textContent='Loading…';try{const params=jobsQuery(force);const discovered=await loadJSON('/api/jobs'+(params?'?'+params:''));if(requestSequence!==jobsRequestSequence)return;const result=Array.isArray(discovered)?{jobs:discovered,repositories:[...new Set(discovered.map(job=>job.repository).filter(Boolean))].sort()}:discovered;jobs=dedupeJobs(Array.isArray(result.jobs)?result.jobs:[]);jobRepositories=result.repositories||[];void drawRuntimeSummary();jobs.sort(compareJobs);if(selectedJob&&!jobForSelection(selectedJob)){selectedJob=null;selectedTaskId=null;}if(!selectedJob){const match=location.hash.match(/^#\\/jobs\\/(.+)$/);const fromHash=match?decodeURIComponent(match[1]):null;const fromRoute=jobForSelection(fromHash);selectedJob=jobSelectionKey(fromRoute||jobs[0]);}rememberSelection();rememberJobFilters();renderAll();void loadEventHistory();jobsFeedback.textContent='';connection.textContent='';connection.setAttribute('aria-label','Live durable state connected');connection.className='status-pill ok';}catch(error){if(requestSequence!==jobsRequestSequence)return;jobsFeedback.textContent='Refresh failed: '+error.message;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{if(requestSequence===jobsRequestSequence){jobsRefresh.disabled=false;jobsRefresh.textContent='Refresh';syncFilters();}}}
291
280
  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';}
292
281
  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';}
293
282
  function eventSeverity(event,level){const type=String(event?.type||'').toLowerCase(),eventLevel=String(event?.level||level||'').toLowerCase(),outcome=String(event?.outcome||event?.status||'').toLowerCase();if(eventLevel==='error'||outcome==='failed'||type.endsWith('.failed')||type==='task.failed')return 'error';if(eventLevel==='warn'||outcome==='blocked'||type.endsWith('.blocked')||type==='task.blocked'||type==='review.blocked')return 'warn';return 'normal';}
@@ -345,13 +334,11 @@ async function saveQa(){qaSave.disabled=true;qaStatus.textContent='Saving…';tr
345
334
  function showView(view){const isJobs=view==='jobs';const isUsage=view==='usage';const isGraphs=view==='graphs';const isRules=view==='rules';const isShortcuts=view==='shortcuts';const isQa=view==='qa';qaView.hidden=!isQa;qaTab.setAttribute('aria-selected',String(isQa));jobsView.hidden=!isJobs;jobsWorkspace.hidden=!isJobs;usageView.hidden=!isUsage;graphsView.hidden=!isGraphs;rulesView.hidden=!isRules;shortcutsView.hidden=!isShortcuts;jobsTab.setAttribute('aria-selected',String(isJobs));usageTab.setAttribute('aria-selected',String(isUsage));graphsTab.setAttribute('aria-selected',String(isGraphs));rulesTab.setAttribute('aria-selected',String(isRules));shortcutsTab.setAttribute('aria-selected',String(isShortcuts));if(isJobs)void refresh();else if(isUsage)void drawUsage();else if(isGraphs)void drawGraphs();else if(isRules)void drawRules();else if(isQa)void drawQa();else void drawShortcuts();}
346
335
  jobsTab.addEventListener('click',()=>{location.hash=selectedJob?'#/jobs/'+encodeURIComponent(selectedJob):'#/jobs';showView('jobs');});
347
336
  jobsRefresh.addEventListener('click',()=>void refresh(true));
348
- jobsReset.addEventListener('click',()=>{searchQuery='';selectedStatuses=[...${JSON.stringify(DEFAULT_DASHBOARD_JOB_STATUSES)}];selectedRepos=[];jobsPage=1;void refresh(false);});
349
- jobsPrev.addEventListener('click',()=>{if(jobsPage>1){jobsPage-=1;void refresh(false);}});
350
- jobsNext.addEventListener('click',()=>{if(jobsPage<jobsTotalPages){jobsPage+=1;void refresh(false);}});
351
337
  eventsAllTasks.addEventListener('click',()=>{eventTaskId=null;clearEvents();renderEventScope();void loadEventHistory();});
352
- repoFilter.addEventListener('change',()=>{selectedRepos=[...repoFilter.selectedOptions].map(option=>option.value);jobsPage=1;void refresh(false);});
353
- jobSearch.addEventListener('input',()=>{searchQuery=jobSearch.value;jobsPage=1;void refresh(false);});
354
- statusFilter.addEventListener('change',()=>{selectedStatuses=[...statusFilter.selectedOptions].map(option=>option.value);jobsPage=1;void refresh(false);});
338
+ jobsReset.addEventListener('click',()=>{searchQuery='';selectedStatus='';selectedRepo='';void refresh(true);});
339
+ repoFilter.addEventListener('change',()=>{selectedRepo=repoFilter.value;void refresh(true);});
340
+ jobSearch.addEventListener('input',()=>{searchQuery=jobSearch.value;void refresh(true);});
341
+ statusFilter.addEventListener('change',()=>{selectedStatus=statusFilter.value;void refresh(true);});
355
342
  selectedJobRefresh.addEventListener('click',()=>void refreshSelected());
356
343
  mobileJobsBack.addEventListener('click',()=>{location.hash='#/jobs';syncMobileJobsView();window.scrollTo({top:0,behavior:'instant'});});
357
344
  mobileJobs.addEventListener('change',syncMobileJobsView);
@@ -21,12 +21,24 @@ export const dashboardStyles = ` <style>
21
21
  .panel { min-width:0; background:var(--surface); border:1px solid var(--border); border-radius:.55rem; box-shadow:var(--shadow); }
22
22
  .panel-head { display:flex; align-items:center; justify-content:space-between; gap:.6rem; padding:.65rem .75rem; border-bottom:1px solid var(--border); }
23
23
  .panel-head h2,.panel-head h3 { margin:0; color:var(--muted); font-size:.75rem; letter-spacing:.07em; text-transform:uppercase; min-width:0; }\n #workspace-heading { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }\n #selected-job-title { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
24
- .panel-actions { display:flex; align-items:center; gap:.45rem; }
25
- .job-filters { display:flex; gap:.35rem; align-items:center; flex-wrap:wrap; }
26
- .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; }
27
- .job-filter[multiple] { height:5.6rem; }
28
- .jobs-pagination { display:flex; gap:.3rem; align-items:center; }
29
- .job-search { width:95px; max-width:14vw; border:1px solid var(--border); border-radius:.35rem; background:var(--surface); color:var(--text); padding:.27rem .45rem; font:inherit; font-size:.72rem; }
24
+ .panel-actions { display:flex; align-items:center; gap:.55rem; flex-wrap:wrap; min-width:0; }
25
+ #jobs-view .panel-head { display:flex; align-items:center; justify-content:space-between; gap:.65rem; }
26
+ #jobs-view .panel-actions { display:flex; align-items:center; justify-content:flex-end; gap:.4rem; flex-wrap:wrap; min-width:0; }
27
+ .job-filters,.jobs-actions { display:flex; align-items:center; gap:.35rem; min-width:0; }
28
+ .job-filters { flex:1 1 auto; }
29
+ .jobs-actions { flex:none; }
30
+ .job-filter,.refresh-button { min-height:2rem; border:1px solid var(--border); border-radius:.45rem; background:var(--surface); color:var(--text); padding:.3rem .5rem; font:inherit; font-size:.72rem; font-weight:650; }
31
+ .job-filter { width:8rem; max-width:8rem; flex:none; }
32
+ #status-filter { width:9.5rem; max-width:9.5rem; font-size:.66rem; }
33
+ #repo-filter { width:8rem; max-width:8rem; }
34
+ .refresh-button { color:var(--muted); font-weight:700; cursor:pointer; transition:border-color .15s ease,background .15s ease; }
35
+ .refresh-button:hover { background:#ffffff10; color:var(--text); }
36
+ .job-filter:focus-visible,.refresh-button:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
37
+ .jobs-feedback { flex:none; min-width:0; max-width:12rem; font-size:.67rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
38
+ .jobs-feedback:empty { display:none; }
39
+ .jobs-feedback:not(:empty) { flex-basis:100%; max-width:none; text-align:right; white-space:normal; }
40
+ .job-search { flex:1 1 11rem; width:12rem; min-width:8rem; max-width:18rem; border:1px solid var(--border); border-radius:.45rem; background:var(--surface); color:var(--text); padding:.3rem .5rem; font:inherit; font-size:.72rem; }
41
+ .job-search:focus-visible { outline:2px solid var(--accent); outline-offset:1px; }
30
42
  .quick-filter { border:1px solid var(--border); border-radius:.35rem; background:var(--surface); color:var(--muted); padding:.27rem .42rem; font:inherit; font-size:.7rem; font-weight:750; cursor:pointer; }
31
43
  .quick-filter[aria-pressed="true"] { border-color:var(--accent); background:var(--accent2); color:var(--accent); }
32
44
  .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; }
@@ -146,11 +158,15 @@ export const dashboardStyles = ` <style>
146
158
  body[data-mobile-jobs-view="detail"] #jobs-view { display:none; }
147
159
  .mobile-only { display:inline-flex; }
148
160
  #jobs-view .panel-head { display:grid; align-items:start; }
149
- #jobs-view .panel-actions { width:100%; flex-wrap:wrap; }
161
+ #jobs-view .panel-actions { display:grid; grid-template-columns:minmax(0,1fr) auto; width:100%; gap:.5rem; }
150
162
  .runtime-summary { display:none; }
151
- #jobs-view .job-filters { width:auto; }
152
- #jobs-view .job-search { display:none; }
153
- #jobs-view #status-filter { display:block; max-width:145px; }
163
+ #jobs-view .job-filters { width:100%; display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:.4rem; }
164
+ #jobs-view .job-filters { grid-column:1 / -1; }
165
+ #jobs-view .job-search { grid-column:1 / -1; width:100%; max-width:none; }
166
+ #jobs-view .job-filter { width:100%; max-width:none; min-width:0; text-transform:none; font-size:.72rem; }
167
+ #jobs-view .jobs-actions { grid-column:1 / -1; justify-content:flex-end; }
168
+ #jobs-view .jobs-feedback { grid-column:1 / -1; text-align:right; }
169
+ #jobs-view .refresh-button { min-height:2.5rem; }
154
170
  #jobs-view .panel-body { max-height:none; }
155
171
  #jobs-workspace { gap:.65rem; }
156
172
  #jobs-workspace .panel-body { max-height:none; }
package/dist/dashboard.js CHANGED
@@ -209,8 +209,7 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
209
209
  const url = new URL(req.url ?? '/', 'http://127.0.0.1');
210
210
  const forceRefresh = url.searchParams.get('refresh') === '1';
211
211
  const jobs = await discoveredJobs(forceRefresh);
212
- const usesSearchContract = ['q', 'status', 'statuses', 'repo', 'repositories', 'repos', 'page', 'pageSize'].some(name => url.searchParams.has(name));
213
- const body = usesSearchContract ? searchDashboardJobs(jobs, url.searchParams) : jobs;
212
+ const body = searchDashboardJobs(jobs, url.searchParams);
214
213
  if (!res.headersSent)
215
214
  res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
216
215
  if (!res.writableEnded)
package/dist/relayd.js CHANGED
@@ -172,11 +172,11 @@ export async function runDaemon(argv) {
172
172
  }
173
173
  return prepareLocalJobExecutionWorktree(canonical, await store.load(job.id), workspaceRoot);
174
174
  } });
175
- const refreshAll = async (_full = false) => {
175
+ const refreshAll = async (_forceRefresh = false) => {
176
176
  cachedOverviews = await inventoryFromLocal();
177
177
  return cachedOverviews;
178
178
  };
179
- const overviews = async (_forceRefresh = false, _trigger = 'internal.unknown') => refreshAll();
179
+ const overviews = async (forceRefresh = false, _trigger = 'internal.unknown') => refreshAll(forceRefresh);
180
180
  const refreshPullRequestSnapshot = async (repository, prNumber, _trigger = 'internal', _options = {}) => {
181
181
  const inventory = await refreshAll();
182
182
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "2.0.178",
3
+ "version": "2.0.180",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "repository": {
6
6
  "type": "git",