agents-relay 2.0.177 → 2.0.179

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,6 +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;
8
+ export const DEFAULT_DASHBOARD_JOB_STATUSES = ['DRAFT', 'ACTIVE', 'COMPLETED'];
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 ?? ''));
19
+ }
7
20
  export async function dashboardWorkflowGraphs() {
8
21
  const names = (await readdir(workflowDirectory)).filter(name => name.endsWith('.mmd')).sort();
9
22
  return Promise.all(names.map(async (name) => {
@@ -43,3 +56,37 @@ export async function eventHistory(jobId, taskId, limit) {
43
56
  return [];
44
57
  }
45
58
  }
59
+ 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))];
63
+ 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'
69
+ ? Boolean(job.draft) || job.state === 'DRAFT'
70
+ : job.state === status);
71
+ const matchesRepository = (job) => repositories.length === 0 || repositories.includes(job.repository);
72
+ const normalizedPr = search.replace(/^pr\s*#?/, '').replace(/^#/, '');
73
+ const matchesSearch = (job) => {
74
+ if (!search)
75
+ return true;
76
+ return String(job.title || job.id || '').toLowerCase().includes(search)
77
+ || String(job.id || '').toLowerCase().includes(search)
78
+ || Boolean(normalizedPr) && String(job.prNumber || '').includes(normalizedPr);
79
+ };
80
+ 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
+ return {
85
+ jobs: matching.slice((safePage - 1) * pageSize, safePage * pageSize),
86
+ repositories: [...new Set(jobs.map(job => job.repository).filter(Boolean))].sort(),
87
+ total,
88
+ page: safePage,
89
+ pageSize,
90
+ totalPages,
91
+ };
92
+ }
@@ -1,4 +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
3
  import { createRequire } from 'node:module';
3
4
  import { execFileSync } from 'node:child_process';
4
5
  import { fileURLToPath } from 'node:url';
@@ -41,7 +42,7 @@ ${dashboardStyles}
41
42
  </header>
42
43
  <main>
43
44
  <section id="jobs-view" role="tabpanel" aria-labelledby="jobs-tab" class="panel jobs-panel">
44
- <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="quick-status" class="job-filter" aria-label="Filter jobs by derived status"><option value="">STATUS</option></select><button id="quick-draft" class="quick-filter" type="button" aria-pressed="false">DRAFT</button><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>
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"><div class="filter-dropdown"><button id="status-filter" class="filter-trigger" type="button" aria-haspopup="true" aria-expanded="false" aria-controls="status-filter-menu"><span class="filter-label">Status</span><span id="status-filter-summary" class="filter-value">Default</span></button><div id="status-filter-menu" class="filter-menu" role="group" aria-label="Status options" hidden><label class="filter-option"><input type="checkbox" name="status" value="DRAFT"><span>DRAFT</span></label><label class="filter-option"><input type="checkbox" name="status" value="ACTIVE"><span>ACTIVE</span></label><label class="filter-option"><input type="checkbox" name="status" value="COMPLETED"><span>COMPLETED</span></label><label class="filter-option"><input type="checkbox" name="status" value="CANCELLED"><span>CANCELLED</span></label></div></div><div class="filter-dropdown"><button id="repo-filter" class="filter-trigger" type="button" aria-haspopup="true" aria-expanded="false" aria-controls="repo-filter-menu"><span class="filter-label">Repositories</span><span id="repo-filter-summary" class="filter-value">All</span></button><div id="repo-filter-menu" class="filter-menu" role="group" aria-label="Repository options" hidden></div></div></div><div class="jobs-actions"><button id="jobs-reset" class="refresh-button" type="button">Reset</button><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></div>
45
46
  <div class="panel-body" aria-live="polite"><ul id="job-list" class="job-list"><li class="skeleton" aria-hidden="true"></li></ul></div>
46
47
  </section>
47
48
  <div class="workspace" id="jobs-workspace">
@@ -141,10 +142,17 @@ const shortcutsView=document.querySelector('#shortcuts-view');
141
142
  const jobsWorkspace=document.querySelector('#jobs-workspace');
142
143
  const jobList=document.querySelector('#job-list');
143
144
  const jobsRefresh=document.querySelector('#jobs-refresh');
145
+ const jobsReset=document.querySelector('#jobs-reset');
144
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');
145
150
  const repoFilter=document.querySelector('#repo-filter');
146
- const quickStatus=document.querySelector('#quick-status');
147
- const quickDraft=document.querySelector('#quick-draft');
151
+ const repoFilterMenu=document.querySelector('#repo-filter-menu');
152
+ const repoFilterSummary=document.querySelector('#repo-filter-summary');
153
+ const statusFilter=document.querySelector('#status-filter');
154
+ const statusFilterMenu=document.querySelector('#status-filter-menu');
155
+ const statusFilterSummary=document.querySelector('#status-filter-summary');
148
156
  const selectedJobRefresh=document.querySelector('#selected-job-refresh');
149
157
  const mobileJobsBack=document.querySelector('#mobile-jobs-back');
150
158
  const mobileJobs=window.matchMedia('(max-width:650px)');
@@ -176,9 +184,12 @@ const runtimeSummary=document.querySelector('#runtime-summary');
176
184
  let jobs=[];
177
185
  let selectedJob=sessionStorage.getItem('agents-relay:selected-job');
178
186
  let selectedTaskId=sessionStorage.getItem('agents-relay:selected-task');
179
- let selectedRepo=sessionStorage.getItem('agents-relay:repo-filter')||'';
180
- let quickMode=sessionStorage.getItem('agents-relay:quick-filter')||'jobs';
181
- let selectedRuntimeStatus=sessionStorage.getItem('agents-relay:runtime-status-filter')||'';
187
+ let selectedRepos=storedValues('agents-relay:repo-filter');
188
+ let selectedStatuses=sessionStorage.getItem('agents-relay:status-filter')===null?[...${JSON.stringify(DEFAULT_DASHBOARD_JOB_STATUSES)}]:storedValues('agents-relay:status-filter');
189
+ let jobsPage=1;
190
+ let jobsTotal=0;
191
+ let jobsTotalPages=1;
192
+ let jobRepositories=[];
182
193
  let searchQuery=sessionStorage.getItem('agents-relay:job-search')||'';
183
194
  let workflowGraphs=[];
184
195
  let selectedGraphId='entry';
@@ -188,6 +199,7 @@ const collapsedTasks=new Set();
188
199
  const expandedTasks=new Set();
189
200
  const displayedEventIds=new Set();
190
201
  function value(input){return input===null||input===undefined||input===''?'—':String(input);}
202
+ 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 [];}
191
203
  function text(input){return document.createTextNode(value(input));}
192
204
  function element(name,className,child){const node=document.createElement(name);if(className)node.className=className;if(child)node.append(child);return node;}
193
205
  function yamlScalar(input){if(input===null)return 'null';if(typeof input==='boolean'||typeof input==='number')return String(input);return JSON.stringify(String(input));}
@@ -214,25 +226,35 @@ function clearEvents(){displayedEventIds.clear();eventFeed.replaceChildren(eleme
214
226
  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();}}
215
227
  function copySelectedJobIdentity(job){if(!job||!navigator.clipboard?.writeText)return;void navigator.clipboard.writeText(repoName(job.repository)+'/'+value(job.id)).catch(()=>{});}
216
228
  function selectJob(job){selectedJob=jobSelectionKey(job);selectedTaskId=null;eventTaskId=null;rememberSelection();clearEvents();location.hash=selectedJob?'#/jobs/'+encodeURIComponent(selectedJob):'#/jobs';copySelectedJobIdentity(job);renderAll();void loadEventHistory();}
217
- function matchesQuickFilter(job){if(quickMode==='draft')return job.draft||job.state==='DRAFT';if(quickMode==='status')return !!selectedRuntimeStatus&&(job.runtimeStatus||'IDLE')===selectedRuntimeStatus;return true;}
229
+ 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);}
218
230
  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);}
219
- function filteredJobs(){return jobs.filter(job=>(!selectedRepo||job.repository===selectedRepo)&&matchesQuickFilter(job)&&matchesSearch(job));}
220
- function setQuickMode(mode,status=''){quickMode=mode;selectedRuntimeStatus=mode==='status'?status:'';sessionStorage.setItem('agents-relay:quick-filter',quickMode);if(selectedRuntimeStatus)sessionStorage.setItem('agents-relay:runtime-status-filter',selectedRuntimeStatus);else sessionStorage.removeItem('agents-relay:runtime-status-filter');syncFilters();renderJobList();}
221
- function toggleQuickMode(mode){setQuickMode(quickMode===mode?'jobs':mode);}
231
+ function filteredJobs(){return jobs.filter(job=>(!selectedRepos.length||selectedRepos.includes(job.repository))&&matchesQuickFilter(job)&&matchesSearch(job));}
232
+ function selectedNames(inputs,selected){const selectedSet=new Set(selected);return [...inputs].filter(input=>selectedSet.has(input.value)).map(input=>input.value);}
233
+ function syncFilterSummary(trigger,summary,inputs,selected,emptyLabel){const names=selectedNames(inputs,selected);summary.textContent=names.length===0?emptyLabel:names.length<=2?names.join(', '):names.length+' selected';trigger.title=names.length===0?emptyLabel:names.join(', ');}
234
+ function closeFilterMenus(exceptMenu){for(const [trigger,menu] of [[statusFilter,statusFilterMenu],[repoFilter,repoFilterMenu]]){if(menu===exceptMenu)continue;menu.hidden=true;trigger.setAttribute('aria-expanded','false');}}
235
+ function toggleFilterMenu(trigger,menu){const open=menu.hidden;closeFilterMenus(open?menu:null);menu.hidden=!open;trigger.setAttribute('aria-expanded',String(open));}
222
236
  function syncFilters(){
223
- const repos=[...new Set(jobs.map(job=>job.repository).filter(Boolean))].sort();
224
- const previousRepo=selectedRepo;
225
- repoFilter.replaceChildren(new Option('All repos',''),...repos.map(repo=>new Option(repo,repo)));
226
- selectedRepo=repos.includes(previousRepo)?previousRepo:'';
227
- repoFilter.value=selectedRepo;
228
- const statuses=[...new Set(jobs.map(job=>job.runtimeStatus||'IDLE'))].sort();
229
- const previousStatus=selectedRuntimeStatus;
230
- quickStatus.replaceChildren(new Option('STATUS',''),...statuses.map(status=>new Option(status,status)));
231
- selectedRuntimeStatus=statuses.includes(previousStatus)?previousStatus:'';
232
- if(quickMode==='status'&&!selectedRuntimeStatus)quickMode='jobs';
233
- quickStatus.value=quickMode==='status'?selectedRuntimeStatus:'';
234
- quickDraft.setAttribute('aria-pressed',String(quickMode==='draft'));
237
+ const repos=jobRepositories.length?jobRepositories:[...new Set(jobs.map(job=>job.repository).filter(Boolean))].sort();
238
+ const validRepos=new Set(repos);
239
+ const existingInputs=[...repoFilterMenu.querySelectorAll('input')];
240
+ // Preserve checkbox nodes while their options are unchanged so keyboard focus survives selection.
241
+ if(existingInputs.length!==repos.length||existingInputs.some((input,index)=>input.value!==repos[index])){
242
+ const focusedRepo=repoFilterMenu.contains(document.activeElement)?document.activeElement.value:null;
243
+ repoFilterMenu.replaceChildren(...repos.map(repo=>{const label=document.createElement('label');label.className='filter-option';const input=document.createElement('input');input.type='checkbox';input.name='repo';input.value=repo;label.append(input,element('span','',text(repo)));return label;}));
244
+ if(focusedRepo)for(const input of repoFilterMenu.querySelectorAll('input'))if(input.value===focusedRepo){input.focus();break;}
245
+ }
246
+ selectedRepos=selectedRepos.filter(repo=>validRepos.has(repo));
247
+ for(const input of repoFilterMenu.querySelectorAll('input'))input.checked=selectedRepos.includes(input.value);
248
+ const statusInputs=[...statusFilterMenu.querySelectorAll('input')];
249
+ const validStatuses=new Set(statusInputs.map(input=>input.value));
250
+ selectedStatuses=selectedStatuses.filter(status=>validStatuses.has(status));
251
+ for(const input of statusInputs)input.checked=selectedStatuses.includes(input.value);
252
+ syncFilterSummary(statusFilter,statusFilterSummary,statusInputs,selectedStatuses,'Default');
253
+ syncFilterSummary(repoFilter,repoFilterSummary,[...repoFilterMenu.querySelectorAll('input')],selectedRepos,'All');
235
254
  jobSearch.value=searchQuery;
255
+ jobsPageLabel.textContent='Page '+jobsPage+' of '+jobsTotalPages+(jobsTotal?' · '+jobsTotal+' jobs':'');
256
+ jobsPrev.disabled=jobsPage<=1;
257
+ jobsNext.disabled=jobsPage>=jobsTotalPages;
236
258
  }
237
259
  function repoName(repository){if(typeof repository!=='string'||!repository)return 'unknown-repo';const parts=repository.split('/').filter(Boolean);return parts.at(-1)||repository;}
238
260
  const CHAT_SHORTCUTS_KEY='agents-relay:chatgpt-shortcuts';
@@ -247,7 +269,7 @@ function jobChatRef(job){const pr=Number(job?.prNumber||0);return repoName(job?.
247
269
  function chatShortcutUrl(prompt){return 'https://chatgpt.com/?prompt='+encodeURIComponent(prompt);}
248
270
  const CHAT_SHORTCUT_TARGET='agents-relay-shortcut';
249
271
  function chatShortcutField(job){const box=element('div','detail-group');box.append(element('p','detail-label',text('ChatGPT shortcut')));const select=document.createElement('select');select.className='job-filter job-chat-shortcut-select';select.setAttribute('aria-label','ChatGPT shortcut');select.append(new Option('',''));for(const item of parseChatShortcuts(chatShortcutText()))select.append(new Option(item.name,item.prompt));select.value='';select.addEventListener('change',()=>{if(!select.value)return;const prompt=select.value.replaceAll('%1',jobChatRef(job));window.open(chatShortcutUrl(prompt),CHAT_SHORTCUT_TARGET,'noopener');select.value='';});box.append(select);return box;}
250
- 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 row=element('div','job-row'+(isCompleteUnmerged(job)?' complete-unmerged':''));row.setAttribute('aria-current',String(jobSelectionKey(job)===selectedJob));row.addEventListener('click',()=>selectJob(job));const content=element('div','job-content');const select=document.createElement('button');select.type='button';select.className='job-button';select.setAttribute('aria-current',String(jobSelectionKey(job)===selectedJob));select.title=job.title||job.id;select.addEventListener('click',event=>{event.stopPropagation();selectJob(job);});select.append(element('span','job-title',text(repoName(job.repository)+'/'+value(job.id)+(Number(job.prNumber||0)>0?'/PR#'+value(job.prNumber):'')+': '+value(job.title||job.id))));const states=element('span','job-status-line');states.append(element('span','badge '+value(job.state),text(value(job.state))),element('span','badge '+value(job.runtimeStatus||'IDLE'),text(value(job.runtimeStatus||'IDLE'))));if(job.publicationFailure)states.append(element('span','badge FAILED',text('PUBLISH FAILED: '+value(job.publicationFailure.stage))));else if(job.publication?.version)states.append(element('span','badge SUCCEEDED',text('v'+job.publication.version)));else if(Number(job.prNumber||0)>0&&job.githubState==='MERGED')states.append(element('span','badge MERGED',text('PR Merged')));else if(job.state==='COMPLETED'&&job.githubState==='CLOSED')states.append(element('span','badge SUCCEEDED',text('PR Archived')));if(job.state==='ACTIVE'&&job.reviewEnabled!==false)states.append(element('span','badge flag-review',text('REVIEW REQUIRED')));if(job.localMerge?.status==='failed')states.append(element('span','badge FAILED',text('MERGE FAILED: '+value(job.localMerge.error||'Unknown error'))));else if(isCompleteUnmerged(job))states.append(element('span','badge MERGE_PENDING',text('MERGE PENDING')));const primary=element('div','job-primary');primary.append(states);const lastLine=element('div','job-last-line');lastLine.append(element('span','subtle',text(compactCounts(counts(job.tasks)))),element('span','subtle',text(relativeTime(job.updatedAt))));content.append(select,primary,lastLine);row.append(content);jobList.append(element('li','',row));}}
272
+ 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 filters.')));return;}for(const job of visible){const row=element('div','job-row'+(isCompleteUnmerged(job)?' complete-unmerged':''));row.setAttribute('aria-current',String(jobSelectionKey(job)===selectedJob));row.addEventListener('click',()=>selectJob(job));const content=element('div','job-content');const select=document.createElement('button');select.type='button';select.className='job-button';select.setAttribute('aria-current',String(jobSelectionKey(job)===selectedJob));select.title=job.title||job.id;select.addEventListener('click',event=>{event.stopPropagation();selectJob(job);});select.append(element('span','job-title',text(repoName(job.repository)+'/'+value(job.id)+(Number(job.prNumber||0)>0?'/PR#'+value(job.prNumber):'')+': '+value(job.title||job.id))));const states=element('span','job-status-line');states.append(element('span','badge '+value(job.state),text(value(job.state))),element('span','badge '+value(job.runtimeStatus||'IDLE'),text(value(job.runtimeStatus||'IDLE'))));if(job.publicationFailure)states.append(element('span','badge FAILED',text('PUBLISH FAILED: '+value(job.publicationFailure.stage))));else if(job.publication?.version)states.append(element('span','badge SUCCEEDED',text('v'+job.publication.version)));else if(Number(job.prNumber||0)>0&&job.githubState==='MERGED')states.append(element('span','badge MERGED',text('PR Merged')));else if(job.state==='COMPLETED'&&job.githubState==='CLOSED')states.append(element('span','badge SUCCEEDED',text('PR Archived')));if(job.state==='ACTIVE'&&job.reviewEnabled!==false)states.append(element('span','badge flag-review',text('REVIEW REQUIRED')));if(job.localMerge?.status==='failed')states.append(element('span','badge FAILED',text('MERGE FAILED: '+value(job.localMerge.error||'Unknown error'))));else if(isCompleteUnmerged(job))states.append(element('span','badge MERGE_PENDING',text('MERGE PENDING')));const primary=element('div','job-primary');primary.append(states);const lastLine=element('div','job-last-line');lastLine.append(element('span','subtle',text(compactCounts(counts(job.tasks)))),element('span','subtle',text(relativeTime(job.updatedAt))));content.append(select,primary,lastLine);row.append(content);jobList.append(element('li','',row));}}
251
273
  function taskTagDisplay(job,task){const display=task.tag&&job?.tag?.values?.[task.tag];return typeof display==='string'&&display?display:null;}
252
274
  function taskVerdict(job,task){if(task.state!=="SUCCEEDED")return null;const data=task.result?.data||{};if(task.agentName==="reviewer"){const verdict=data.verdict||((job?.review?.taskId===task.id)?job.review.outcome:null);return ({approved:"APPROVED",pass:"APPROVED","changes-required":"CHANGES REQUIRED",changes_required:"CHANGES REQUIRED"})[verdict]||null;}if(task.agentName==="troubleshooter"){const original=job?.tasks?.find(t=>t.id===task.parentTaskId);const resolution=original?.resolution;if(resolution?.state!=="resolved"||resolution.owner!=="troubleshooter")return null;return ({exact_original_retry:"RETRY VERIFIED",same_job_dependency:"DEPENDENCY REGISTERED",blocker_job_dependency:"BLOCKER REGISTERED",cancel_supersede:"SUPERSEDED"})[resolution.path]||null;}return null;}function taskSubtreeComplete(task,children){return ['SUCCEEDED','CANCELLED'].includes(task.state)&&(children.get(task.id)||[]).every(child=>taskSubtreeComplete(child,children));}
253
275
  function taskStateLabel(task){const resolution=task?.resolution?.state;return task?.state==='FAILED'&&typeof resolution==='string'&&resolution.trim()?task.state+' / '+resolution:task.state;}
@@ -279,7 +301,10 @@ function renderRateLimitStatus(status){
279
301
  function runtimePill(label,value){const pill=element('span','runtime-stat');pill.append(document.createTextNode(label+' '),element('strong','',text(value)));return pill;}
280
302
  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();}}
281
303
  async function refreshStatus(){try{renderRateLimitStatus(await loadJSON('/api/status'));}catch{renderRateLimitStatus(null);}}
282
- async function refresh(force=false){jobsRefresh.disabled=true;jobsRefresh.textContent='Refreshing…';try{const discovered=await loadJSON('/api/jobs'+(force?'?refresh=1':''));jobs=dedupeJobs(Array.isArray(discovered)?discovered:[]);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();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';}}
304
+ 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);}
305
+ 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();}
306
+ let jobsRequestSequence=0;
307
+ 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();}}}
283
308
  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';}
284
309
  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';}
285
310
  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';}
@@ -337,11 +362,17 @@ async function saveQa(){qaSave.disabled=true;qaStatus.textContent='Saving…';tr
337
362
  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();}
338
363
  jobsTab.addEventListener('click',()=>{location.hash=selectedJob?'#/jobs/'+encodeURIComponent(selectedJob):'#/jobs';showView('jobs');});
339
364
  jobsRefresh.addEventListener('click',()=>void refresh(true));
365
+ jobsReset.addEventListener('click',()=>{searchQuery='';selectedStatuses=[...${JSON.stringify(DEFAULT_DASHBOARD_JOB_STATUSES)}];selectedRepos=[];jobsPage=1;closeFilterMenus();void refresh(false);});
366
+ jobsPrev.addEventListener('click',()=>{if(jobsPage>1){jobsPage-=1;void refresh(false);}});
367
+ jobsNext.addEventListener('click',()=>{if(jobsPage<jobsTotalPages){jobsPage+=1;void refresh(false);}});
340
368
  eventsAllTasks.addEventListener('click',()=>{eventTaskId=null;clearEvents();renderEventScope();void loadEventHistory();});
341
- repoFilter.addEventListener('change',()=>{selectedRepo=repoFilter.value;sessionStorage.setItem('agents-relay:repo-filter',selectedRepo);renderJobList();});
342
- jobSearch.addEventListener('input',()=>{searchQuery=jobSearch.value;sessionStorage.setItem('agents-relay:job-search',searchQuery);renderJobList();});
343
- quickDraft.addEventListener('click',()=>toggleQuickMode('draft'));
344
- quickStatus.addEventListener('change',()=>setQuickMode(quickStatus.value?'status':'jobs',quickStatus.value));
369
+ repoFilter.addEventListener('click',()=>toggleFilterMenu(repoFilter,repoFilterMenu));
370
+ repoFilterMenu.addEventListener('change',()=>{selectedRepos=[...repoFilterMenu.querySelectorAll('input:checked')].map(input=>input.value);jobsPage=1;syncFilters();void refresh(false);});
371
+ jobSearch.addEventListener('input',()=>{searchQuery=jobSearch.value;jobsPage=1;void refresh(false);});
372
+ statusFilter.addEventListener('click',()=>toggleFilterMenu(statusFilter,statusFilterMenu));
373
+ statusFilterMenu.addEventListener('change',()=>{selectedStatuses=[...statusFilterMenu.querySelectorAll('input:checked')].map(input=>input.value);jobsPage=1;syncFilters();void refresh(false);});
374
+ document.addEventListener('click',event=>{const target=event.target instanceof Node?event.target:null;const inStatus=statusFilter.contains(target)||statusFilterMenu.contains(target);const inRepo=repoFilter.contains(target)||repoFilterMenu.contains(target);if(!inStatus&&!inRepo)closeFilterMenus(null);});
375
+ document.addEventListener('keydown',event=>{if(event.key!=='Escape')return;const active=document.activeElement;if(statusFilterMenu.hidden===false&&(statusFilterMenu.contains(active)||active===statusFilter)){statusFilterMenu.hidden=true;statusFilter.setAttribute('aria-expanded','false');statusFilter.focus();}if(repoFilterMenu.hidden===false&&(repoFilterMenu.contains(active)||active===repoFilter)){repoFilterMenu.hidden=true;repoFilter.setAttribute('aria-expanded','false');repoFilter.focus();}});
345
376
  selectedJobRefresh.addEventListener('click',()=>void refreshSelected());
346
377
  mobileJobsBack.addEventListener('click',()=>{location.hash='#/jobs';syncMobileJobsView();window.scrollTo({top:0,behavior:'instant'});});
347
378
  mobileJobs.addEventListener('change',syncMobileJobsView);
@@ -21,10 +21,25 @@ 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-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:grid; grid-template-columns:minmax(0,1fr); align-items:start; gap:.45rem; }
26
+ .job-filters,.jobs-actions { display:flex; align-items:center; gap:.4rem; flex-wrap:wrap; min-width:0; }
27
+ .jobs-actions { justify-content:flex-end; }
28
+ .filter-trigger,.refresh-button { min-height:2rem; border:1px solid var(--border); border-radius:.45rem; background:var(--surface); color:var(--muted); padding:.3rem .55rem; font:inherit; font-size:.72rem; font-weight:700; cursor:pointer; transition:border-color .15s ease,background .15s ease; }
29
+ .filter-trigger { display:inline-flex; align-items:center; gap:.4rem; max-width:17rem; }
30
+ .filter-trigger:hover,.refresh-button:hover { background:#ffffff10; color:var(--text); }
31
+ .filter-trigger:focus-visible,.refresh-button:focus-visible,.filter-option input:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
32
+ .filter-trigger[aria-expanded="true"] { border-color:var(--accent); color:var(--text); }
33
+ .filter-label { flex:0 0 auto; }
34
+ .filter-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--text); font-weight:600; }
35
+ .filter-dropdown { position:relative; min-width:0; }
36
+ .filter-menu { position:absolute; top:calc(100% + .3rem); left:0; z-index:30; width:max-content; min-width:11rem; max-width:calc(100vw - 1.5rem); max-height:17rem; overflow:auto; border:1px solid var(--border); border-radius:.55rem; background:var(--surface); box-shadow:0 12px 34px #00000026; padding:.3rem; }
37
+ .filter-option { display:grid; grid-template-columns:auto minmax(0,1fr); align-items:center; gap:.45rem; padding:.4rem .5rem; border-radius:.4rem; font-size:.74rem; font-weight:600; color:var(--text); cursor:pointer; }
38
+ .filter-option:hover { background:#ffffff10; }
39
+ .filter-option input { width:.95rem; height:.95rem; margin:0; accent-color:var(--accent); }
40
+ .jobs-pagination { display:flex; gap:.3rem; align-items:center; white-space:nowrap; }
41
+ .job-search { flex:1 1 10rem; width:12rem; min-width:0; max-width:20rem; border:1px solid var(--border); border-radius:.45rem; background:var(--surface); color:var(--text); padding:.3rem .5rem; font:inherit; font-size:.72rem; }
42
+ .job-search:focus-visible { outline:2px solid var(--accent); outline-offset:1px; }
28
43
  .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; }
29
44
  .quick-filter[aria-pressed="true"] { border-color:var(--accent); background:var(--accent2); color:var(--accent); }
30
45
  .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; }
@@ -144,12 +159,18 @@ export const dashboardStyles = ` <style>
144
159
  body[data-mobile-jobs-view="detail"] #jobs-view { display:none; }
145
160
  .mobile-only { display:inline-flex; }
146
161
  #jobs-view .panel-head { display:grid; align-items:start; }
147
- #jobs-view .panel-actions { width:100%; flex-wrap:wrap; }
162
+ #jobs-view .panel-actions { display:grid; grid-template-columns:1fr; width:100%; gap:.5rem; }
148
163
  .runtime-summary { display:none; }
149
- #jobs-view .job-filters { width:auto; }
150
- #jobs-view .job-search,
151
- #jobs-view #quick-status,
152
- #jobs-view #quick-draft { display:none; }
164
+ #jobs-view .job-filters { width:100%; display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:.4rem; }
165
+ #jobs-view .job-search { grid-column:1 / -1; width:100%; max-width:none; }
166
+ #jobs-view .filter-trigger { width:100%; max-width:none; min-height:2.5rem; }
167
+ #jobs-view .filter-trigger { display:grid; align-content:center; justify-items:start; gap:.1rem; }
168
+ #jobs-view .filter-label { font-size:.6rem; letter-spacing:.04em; text-transform:uppercase; color:var(--muted); }
169
+ #jobs-view .filter-value { overflow:visible; text-overflow:clip; white-space:normal; font-size:.72rem; line-height:1.2; }
170
+ #jobs-view .jobs-actions { width:100%; display:grid; grid-template-columns:auto minmax(0,1fr) auto; align-items:center; }
171
+ #jobs-view .refresh-button { min-height:2.5rem; }
172
+ #jobs-view .jobs-pagination { min-width:0; justify-content:center; }
173
+ #jobs-view #jobs-page { overflow:hidden; text-overflow:ellipsis; }
153
174
  #jobs-view .panel-body { max-height:none; }
154
175
  #jobs-workspace { gap:.65rem; }
155
176
  #jobs-workspace .panel-body { max-height:none; }
package/dist/dashboard.js CHANGED
@@ -7,8 +7,8 @@ import { deriveJobRuntimeStatus } from './runtime-status.js';
7
7
  import { DeliveryDeduper, parseGitHubWebhook, verifyGitHubWebhook } from './github-webhook.js';
8
8
  import { loadExecutionRouterRules, saveExecutionRouterRules } from './routing-policy.js';
9
9
  import { summarizeRuntime } from './runtime-summary.js';
10
- import { dashboardWorkflowGraphs, effectiveGitHubUsage, eventHistory } from './dashboard-data.js';
11
- export { dashboardWorkflowGraphs, githubPrUrl } from './dashboard-data.js';
10
+ import { dashboardWorkflowGraphs, effectiveGitHubUsage, eventHistory, searchDashboardJobs } from './dashboard-data.js';
11
+ export { dashboardWorkflowGraphs, githubPrUrl, searchDashboardJobs } from './dashboard-data.js';
12
12
  const SSE_HEARTBEAT_MS = 15000;
13
13
  function isTaskId(candidate) {
14
14
  return candidate.length > 0;
@@ -34,6 +34,8 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
34
34
  return jobs;
35
35
  }
36
36
  catch (error) {
37
+ if (forceRefresh)
38
+ throw error;
37
39
  if (lastKnownJobs)
38
40
  return lastKnownJobs;
39
41
  if (jobId) {
@@ -204,12 +206,15 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
204
206
  if (new URL(req.url ?? '/', 'http://127.0.0.1').pathname === '/api/jobs') {
205
207
  try {
206
208
  await subscribeDiscovered();
207
- const forceRefresh = new URL(req.url ?? '/', 'http://127.0.0.1').searchParams.get('refresh') === '1';
209
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1');
210
+ const forceRefresh = url.searchParams.get('refresh') === '1';
208
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;
209
214
  if (!res.headersSent)
210
- res.writeHead(200, { 'content-type': 'application/json' });
215
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
211
216
  if (!res.writableEnded)
212
- res.end(JSON.stringify(jobs));
217
+ res.end(JSON.stringify(body));
213
218
  }
214
219
  catch (error) {
215
220
  if (!res.headersSent)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "2.0.177",
3
+ "version": "2.0.179",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "repository": {
6
6
  "type": "git",