agents-relay 2.0.176 → 2.0.178

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/adapters.js CHANGED
@@ -389,7 +389,7 @@ export class ChatGptAdapter {
389
389
  const referencePrompt = task.chatgptThreadDelivery && references.length
390
390
  ? `\n\n[Inline immutable local reference snapshots]\nThese complete verified snapshots are included in this message because this thread delivery does not attach local files. Follow their contents according to their declared roles.\n${references.map(reference => `\n--- ${reference.role}: ${reference.path} (sha256:${reference.sha256}) ---\n${reference.content}\n--- end snapshot ---`).join('\n')}`
391
391
  : references.length
392
- ? `\n\n[Attached immutable Agent Contract]\nAfter publishing the required task.started event, read the attached contract file and verify its SHA-256 before following it. If it is unreadable or mismatched, report a concrete failure.\n${references.map(reference => `- ${reference.path} (sha256:${reference.sha256})`).join('\n')}`
392
+ ? `\n\n[Attached immutable Agent Contract]\nAfter publishing the required task.started event, read the attached immutable contract snapshot and follow it. Relay owns snapshot integrity; do not hash or compare the original .agent.md. If the attached snapshot is unreadable, report a concrete failure.\n${references.map(reference => `- ${reference.path} (sha256:${reference.sha256})`).join('\n')}`
393
393
  : '';
394
394
  if (task.chatgptThreadDelivery && declaredPromptReferences(task.input).length) {
395
395
  const unresolved = declaredPromptReferences(task.input)
@@ -72,7 +72,7 @@ export async function buildReferenceFirstPrompt(input) {
72
72
  return buildAgentPrompt({
73
73
  role: input.role,
74
74
  system: (input.bootstrap ? input.bootstrap.trim() + '\n' : '') +
75
- 'After the required execution startup action, read the referenced immutable Agent Contract. Verify its SHA-256 before following it. If unreadable or mismatched, report a concrete failure; never guess its instructions.',
75
+ 'After the required execution startup action, read the referenced immutable Agent Contract snapshot and follow it. Relay owns snapshot integrity and version identity; do not compute a hash of the source .agent.md or compare it to the snapshot. If the snapshot is unreadable, report a concrete failure; never guess its instructions.',
76
76
  user: input.user,
77
77
  references: [reference],
78
78
  });
@@ -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"><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
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,13 @@ 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 statusFilter=document.querySelector('#status-filter');
148
152
  const selectedJobRefresh=document.querySelector('#selected-job-refresh');
149
153
  const mobileJobsBack=document.querySelector('#mobile-jobs-back');
150
154
  const mobileJobs=window.matchMedia('(max-width:650px)');
@@ -176,9 +180,12 @@ const runtimeSummary=document.querySelector('#runtime-summary');
176
180
  let jobs=[];
177
181
  let selectedJob=sessionStorage.getItem('agents-relay:selected-job');
178
182
  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')||'';
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;
188
+ let jobRepositories=[];
182
189
  let searchQuery=sessionStorage.getItem('agents-relay:job-search')||'';
183
190
  let workflowGraphs=[];
184
191
  let selectedGraphId='entry';
@@ -188,6 +195,7 @@ const collapsedTasks=new Set();
188
195
  const expandedTasks=new Set();
189
196
  const displayedEventIds=new Set();
190
197
  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 [];}
191
199
  function text(input){return document.createTextNode(value(input));}
192
200
  function element(name,className,child){const node=document.createElement(name);if(className)node.className=className;if(child)node.append(child);return node;}
193
201
  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 +222,22 @@ function clearEvents(){displayedEventIds.clear();eventFeed.replaceChildren(eleme
214
222
  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
223
  function copySelectedJobIdentity(job){if(!job||!navigator.clipboard?.writeText)return;void navigator.clipboard.writeText(repoName(job.repository)+'/'+value(job.id)).catch(()=>{});}
216
224
  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;}
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);}
218
226
  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);}
227
+ function filteredJobs(){return jobs.filter(job=>(!selectedRepos.length||selectedRepos.includes(job.repository))&&matchesQuickFilter(job)&&matchesSearch(job));}
222
228
  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'));
229
+ 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);
235
237
  jobSearch.value=searchQuery;
238
+ jobsPageLabel.textContent='Page '+jobsPage+' of '+jobsTotalPages+(jobsTotal?' · '+jobsTotal+' jobs':'');
239
+ jobsPrev.disabled=jobsPage<=1;
240
+ jobsNext.disabled=jobsPage>=jobsTotalPages;
236
241
  }
237
242
  function repoName(repository){if(typeof repository!=='string'||!repository)return 'unknown-repo';const parts=repository.split('/').filter(Boolean);return parts.at(-1)||repository;}
238
243
  const CHAT_SHORTCUTS_KEY='agents-relay:chatgpt-shortcuts';
@@ -247,7 +252,7 @@ function jobChatRef(job){const pr=Number(job?.prNumber||0);return repoName(job?.
247
252
  function chatShortcutUrl(prompt){return 'https://chatgpt.com/?prompt='+encodeURIComponent(prompt);}
248
253
  const CHAT_SHORTCUT_TARGET='agents-relay-shortcut';
249
254
  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));}}
255
+ 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
256
  function taskTagDisplay(job,task){const display=task.tag&&job?.tag?.values?.[task.tag];return typeof display==='string'&&display?display:null;}
252
257
  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
258
  function taskStateLabel(task){const resolution=task?.resolution?.state;return task?.state==='FAILED'&&typeof resolution==='string'&&resolution.trim()?task.state+' / '+resolution:task.state;}
@@ -279,7 +284,10 @@ function renderRateLimitStatus(status){
279
284
  function runtimePill(label,value){const pill=element('span','runtime-stat');pill.append(document.createTextNode(label+' '),element('strong','',text(value)));return pill;}
280
285
  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
286
  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';}}
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();}
289
+ 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();}}}
283
291
  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
292
  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
293
  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 +345,13 @@ async function saveQa(){qaSave.disabled=true;qaStatus.textContent='Saving…';tr
337
345
  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
346
  jobsTab.addEventListener('click',()=>{location.hash=selectedJob?'#/jobs/'+encodeURIComponent(selectedJob):'#/jobs';showView('jobs');});
339
347
  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);}});
340
351
  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));
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);});
345
355
  selectedJobRefresh.addEventListener('click',()=>void refreshSelected());
346
356
  mobileJobsBack.addEventListener('click',()=>{location.hash='#/jobs';syncMobileJobsView();window.scrollTo({top:0,behavior:'instant'});});
347
357
  mobileJobs.addEventListener('change',syncMobileJobsView);
@@ -24,6 +24,8 @@ export const dashboardStyles = ` <style>
24
24
  .panel-actions { display:flex; align-items:center; gap:.45rem; }
25
25
  .job-filters { display:flex; gap:.35rem; align-items:center; flex-wrap:wrap; }
26
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; }
27
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; }
28
30
  .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
31
  .quick-filter[aria-pressed="true"] { border-color:var(--accent); background:var(--accent2); color:var(--accent); }
@@ -147,9 +149,8 @@ export const dashboardStyles = ` <style>
147
149
  #jobs-view .panel-actions { width:100%; flex-wrap:wrap; }
148
150
  .runtime-summary { display:none; }
149
151
  #jobs-view .job-filters { width:auto; }
150
- #jobs-view .job-search,
151
- #jobs-view #quick-status,
152
- #jobs-view #quick-draft { display:none; }
152
+ #jobs-view .job-search { display:none; }
153
+ #jobs-view #status-filter { display:block; max-width:145px; }
153
154
  #jobs-view .panel-body { max-height:none; }
154
155
  #jobs-workspace { gap:.65rem; }
155
156
  #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)
@@ -242,11 +242,10 @@ export async function resolveAgentContract(context, agentName) {
242
242
  return { agent, definition, hash: resolved.hash, sources: resolved.sources };
243
243
  }
244
244
  export async function agentContractPrompt(context, agentName) {
245
- const { agent, definition, hash, sources } = await resolveAgentContract(context, agentName);
245
+ const { agent, definition, sources } = await resolveAgentContract(context, agentName);
246
246
  return `[Selected task agent contract]
247
247
  Agent: ${agent.name}
248
248
  Source: ${agent.path ?? agent.source}
249
- Effective contract SHA-256: ${hash}
250
249
  Contract layers: ${sources.join(' -> ')}
251
250
 
252
251
  The following Markdown is the authoritative behavioral contract for this Task agent.
package/dist/planner.js CHANGED
@@ -498,7 +498,7 @@ export class AgentObjectivePlanner {
498
498
  const instructions = effectivePlanner?.content ?? await readFile(this.agentFile, 'utf8');
499
499
  const contract = await readFile(DEFAULT_TASK_JOB_CONTRACT, 'utf8');
500
500
  const resolvedContext = context.executionContext ? executionContextPrompt(context.executionContext) + '\n\n' : '';
501
- const prompt = renderAgentPrompt(await buildReferenceFirstPrompt({ role: 'planner', adapter: 'codex', system: (effectivePlanner ? `[Effective planner contract SHA-256: ${effectivePlanner.hash}]\n\n` : '') + instructions.trim() + '\n\n## Shared Task / Job Contract\n\n' + contract.trim(), user: resolvedContext + '## Durable planner input\n\n' + plannerInput(context) }), 'codex');
501
+ const prompt = renderAgentPrompt(await buildReferenceFirstPrompt({ role: 'planner', adapter: 'codex', system: instructions.trim() + '\n\n## Shared Task / Job Contract\n\n' + contract.trim(), user: resolvedContext + '## Durable planner input\n\n' + plannerInput(context) }), 'codex');
502
502
  const runAgent = (input) => new Promise((resolve, reject) => {
503
503
  // Plan apply writes unified relay.sqlite beside AGENTS_RELAY_STORE_DIR.
504
504
  const child = spawnOwnedProcess(this.command, buildPlannerCodexArgs(relayStoreDirectory(), input), { invocation: 'planner.agent', signal, correlation: { jobId: context.job.id }, spawn: { cwd: context.executionContext?.project.path, stdio: ['ignore', 'pipe', 'pipe'] } });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "2.0.176",
3
+ "version": "2.0.178",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "repository": {
6
6
  "type": "git",