agents-relay 2.0.67 → 2.0.69

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.
Files changed (72) hide show
  1. package/README.md +56 -20
  2. package/dist/adapters.js +39 -3
  3. package/dist/blockers.js +17 -4
  4. package/dist/cli-blocker.js +12 -30
  5. package/dist/cli-help.js +22 -17
  6. package/dist/cli-job-create.js +77 -0
  7. package/dist/cli-local-merge-config.js +11 -0
  8. package/dist/cli-projection.js +23 -55
  9. package/dist/cli-review.js +7 -24
  10. package/dist/cli-support.js +81 -8
  11. package/dist/cli-worker-task.js +63 -0
  12. package/dist/cli.js +223 -185
  13. package/dist/dashboard-html.js +15 -12
  14. package/dist/dashboard.js +27 -2
  15. package/dist/e2e-injection.js +76 -0
  16. package/dist/github-auth.js +4 -3
  17. package/dist/github-binding-order.js +15 -0
  18. package/dist/github-client.js +7 -2
  19. package/dist/github-pr-archive.js +22 -0
  20. package/dist/github-webhook.js +43 -10
  21. package/dist/job-actions.js +29 -0
  22. package/dist/local-authority-store.js +288 -0
  23. package/dist/local-commit-events.js +2 -0
  24. package/dist/local-git-merge.js +124 -0
  25. package/dist/local-job-path.js +7 -0
  26. package/dist/local-job-registry.js +29 -0
  27. package/dist/local-managed-store.js +21 -0
  28. package/dist/local-merge-delivery.js +53 -0
  29. package/dist/local-merge-lock.js +103 -0
  30. package/dist/local-merge-projection.js +60 -0
  31. package/dist/managed-worker-input.js +13 -6
  32. package/dist/manual-plan.js +29 -7
  33. package/dist/no-github.js +11 -0
  34. package/dist/planned-review-head.js +7 -8
  35. package/dist/planner-runtime.js +182 -41
  36. package/dist/planner.js +35 -2
  37. package/dist/pool.js +89 -136
  38. package/dist/projected-store.js +20 -3
  39. package/dist/projection-outbox.js +3 -1
  40. package/dist/publication-store.js +37 -0
  41. package/dist/reconciler-cancellation.js +21 -0
  42. package/dist/reconciler-review.js +99 -29
  43. package/dist/reconciler-runtime.js +72 -35
  44. package/dist/reconciler.js +124 -85
  45. package/dist/relay-store.js +108 -6
  46. package/dist/relayd-control.js +184 -0
  47. package/dist/relayd-dashboard-actions.js +108 -0
  48. package/dist/relayd-utilities.js +107 -0
  49. package/dist/relayd.js +304 -299
  50. package/dist/review-generation-fence.js +12 -0
  51. package/dist/runtime-status-events.js +21 -0
  52. package/dist/runtime-status.js +21 -7
  53. package/dist/scheduled-jobs.js +43 -36
  54. package/dist/service-external-actions.js +27 -0
  55. package/dist/service-publication.js +29 -0
  56. package/dist/store-github.js +17 -6
  57. package/dist/task-actions.js +59 -1
  58. package/dist/task-update.js +1 -1
  59. package/dist/troubleshooter.js +21 -2
  60. package/dist/unblock.js +47 -15
  61. package/dist/worker-rejection.js +11 -0
  62. package/dist/workflow/contracts.js +10 -1
  63. package/dist/workflow/definitions-v2.js +2 -0
  64. package/dist/workflow/job-reconcile-v2.js +9 -60
  65. package/dist/workflow/runtime.js +18 -3
  66. package/dist/workflow-effect-failures.js +30 -0
  67. package/dist/workspace.js +95 -6
  68. package/package.json +6 -3
  69. package/skills/agents-relay/agents/planner.agent.md +3 -0
  70. package/skills/agents-relay/contracts/orchestrator-workflows.md +4 -7
  71. package/workflow/job-lifecycle.mmd +19 -60
  72. package/workflow/review-lifecycle.mmd +5 -8
@@ -1,6 +1,5 @@
1
1
  import { createRequire } from 'node:module';
2
- const packageVersion = createRequire(import.meta.url)('../package.json').version;
3
- export const dashboardVersion = packageVersion;
2
+ export const dashboardVersion = createRequire(import.meta.url)('../package.json').version;
4
3
  export const dashboardHtml = `<!doctype html>
5
4
  <html lang="en" data-dashboard-contract="safe-dom,named-relay-events,snapshot-events,bounded-event-history">
6
5
  <head>
@@ -87,7 +86,11 @@ export const dashboardHtml = `<!doctype html>
87
86
  .agent-arrow { color:var(--muted); font-weight:800; }
88
87
  .agent-graph-source { margin:.1rem 0 0; padding:.45rem; border-radius:.35rem; background:var(--surface); color:var(--muted); font:.7rem/1.45 ui-monospace,SFMono-Regular,Menlo,monospace; white-space:pre; overflow:auto; }
89
88
  .event-list { display:grid; gap:.45rem; }
90
- .event-item { padding-bottom:.4rem; border-bottom:1px solid var(--border); }
89
+ .event-item { padding:.34rem .42rem .42rem; border-left:3px solid transparent; border-bottom:1px solid var(--border); border-radius:.28rem; }
90
+ .event-item.event-error { border-left-color:var(--bad); background:#fff4f4; }
91
+ .event-item.event-error .event-kind,.event-item.event-error .event-message { color:var(--bad); }
92
+ .event-item.event-warn { border-left-color:var(--warn); background:#fff9eb; }
93
+ .event-item.event-warn .event-kind { color:var(--warn); }
91
94
  .event-meta { display:flex; flex-wrap:wrap; gap:.4rem; color:var(--muted); font-size:.68rem; }
92
95
  .event-kind { color:var(--accent); font-weight:800; }
93
96
  .event-message { margin:.12rem 0 0; overflow-wrap:anywhere; }
@@ -176,7 +179,6 @@ export const dashboardHtml = `<!doctype html>
176
179
  .tagline { display:none; }
177
180
  }
178
181
  @media (prefers-reduced-motion:reduce) { .skeleton { animation:none; } .usage-fill { transition:none; } }
179
-
180
182
  .router-rules-workspace{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:16px;align-items:start;min-width:0}.router-rules-pane{min-width:0;max-width:100%;overflow:hidden}.rules-editor .rules-textarea{min-height:420px}.audit-log{display:flex;flex-direction:column;gap:8px;max-height:500px;overflow:auto;min-width:0}.audit-entry{border:1px solid var(--border);border-radius:10px;padding:10px;background:var(--surface)}.audit-entry-head{display:flex;justify-content:space-between;gap:8px;align-items:center}.audit-entry pre{white-space:pre-wrap;word-break:break-word;margin:8px 0 0;font-size:11px}.router-test-panel{margin-top:16px;border-top:1px solid var(--border);padding-top:12px}.router-test-results{margin-top:8px;display:flex;flex-direction:column;gap:6px;max-height:480px;overflow:auto;min-width:0}.router-test-results pre{white-space:pre;overflow:auto;max-width:100%;font-size:11px;line-height:1.45;background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:12px}.router-test-row{display:grid;grid-template-columns:minmax(0,1fr) auto auto;gap:8px;padding:7px 0;border-bottom:1px solid var(--border)}@media(max-width:900px){.router-rules-workspace{grid-template-columns:1fr}.router-rules-pane{max-height:none}.router-test-results{max-height:420px}}@media(max-width:650px){#rules-view .panel-head .subtle{display:none}#rules-view .panel-body{max-height:none;overflow:visible;padding:.6rem}#rules-view .router-rules-workspace{gap:.9rem}#rules-view .rules-help{font-size:.74rem;line-height:1.4}#rules-view .rules-textarea{min-height:52vh;padding:.65rem;font-size:12px}#rules-view .rules-footer{align-items:stretch}#rules-view .rules-footer>.subtle{width:100%}#rules-view .rules-footer>div{width:100%;display:grid;grid-template-columns:1fr 1fr;gap:.5rem}#rules-view .rules-save{min-height:44px;width:100%;padding:.55rem .7rem}#rules-view .router-rules-pane>.panel-head{padding:.55rem 0}#rules-view .audit-log{max-height:none}#rules-view .audit-entry-head{align-items:flex-start}#rules-view .audit-entry-head .subtle{display:block;flex:0 0 auto}#rules-view .router-test-panel{min-width:0;max-width:100%;overflow:hidden}#rules-view .router-test-results{max-height:none;width:100%;max-width:100%;overflow-x:hidden}#rules-view .router-test-results>*{min-width:0;max-width:100%}#rules-view .router-test-results pre{width:100%;box-sizing:border-box;white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word;font-size:11px;padding:.65rem}#rules-view .router-test-row{grid-template-columns:1fr}}
181
183
  </style>
182
184
  </head>
@@ -263,7 +265,6 @@ export const dashboardHtml = `<!doctype html>
263
265
  // link.textContent = 'PR #' + job.prNumber;
264
266
  // while (eventFeed.children.length > MAX_EVENTS)
265
267
  // function usageBar(label, window)
266
-
267
268
  const MAX_EVENTS=60;
268
269
  const DASHBOARD_VERSION=${JSON.stringify(dashboardVersion)};
269
270
  const jobsTab=document.querySelector('#jobs-tab');
@@ -325,7 +326,6 @@ let eventTaskId=selectedTaskId;
325
326
  const collapsedTasks=new Set();
326
327
  const expandedTasks=new Set();
327
328
  const displayedEventIds=new Set();
328
-
329
329
  function value(input){return input===null||input===undefined||input===''?'—':String(input);}
330
330
  function text(input){return document.createTextNode(value(input));}
331
331
  function element(name,className,child){const node=document.createElement(name);if(className)node.className=className;if(child)node.append(child);return node;}
@@ -345,7 +345,7 @@ function cadence(job){const schedule=job?.schedule;if(!schedule?.cron)return '
345
345
  function safePrUrl(repository,prNumber){if(typeof repository!=='string'||!Number.isInteger(prNumber)||prNumber<=0)return null;const parts=repository.split('/');if(parts.length!==2)return null;const [owner,repo]=parts;if(!owner||!repo||owner==='.'||owner==='..'||repo==='.'||repo==='..')return null;if(!/^[A-Za-z0-9-]+$/.test(owner)||!/^[A-Za-z0-9._-]+$/.test(repo))return null;return 'https://github.com/'+owner+'/'+repo+'/pull/'+prNumber;}
346
346
  function safeCommitUrl(repository,sha){if(typeof repository!=='string'||typeof sha!=='string'||!/^[0-9a-f]{7,64}$/i.test(sha))return null;const parts=repository.split('/');if(parts.length!==2||!parts[0]||!parts[1])return null;return 'https://github.com/'+parts[0]+'/'+parts[1]+'/commit/'+sha;}
347
347
  function commitField(label,job,sha){const box=element('div','detail-group');box.append(element('p','detail-label',text(label)));const line=element('p','detail-value');const url=safeCommitUrl(job?.repository,sha);if(url){const link=document.createElement('a');link.href=url;link.target='_blank';link.rel='noopener noreferrer';link.textContent=sha.slice(0,7);line.append(link);}else line.append(text(sha||'—'));box.append(line);return box;}
348
- function prField(job){const box=element('div','detail-group');box.append(element('p','detail-label',text('GitHub PR')));const line=element('p','detail-value');const url=safePrUrl(job.repository,job.prNumber);if(url){const link=document.createElement('a');link.href=url;link.target='_blank';link.rel='noopener noreferrer';link.textContent='PR #'+job.prNumber;line.append(link);}else line.append(text('unavailable'));box.append(line);return box;}
348
+ function prField(job){const box=element('div','detail-group');box.append(element('p','detail-label',text('GitHub PR')));const line=element('p','detail-value');const prNumber=Number(job.externalPrNumber||job.prNumber||0);const url=safePrUrl(job.repository,prNumber);if(url){const link=document.createElement('a');link.href=url;link.target='_blank';link.rel='noopener noreferrer';link.textContent='PR #'+prNumber;line.append(link);}else line.append(text('unavailable'));box.append(line);return box;}
349
349
  function chatField(task){const box=element('div','detail-group');box.append(element('p','detail-label',text('Chat')));const line=element('p','detail-value');if(task.adapter==='chatgpt'&&typeof task.threadId==='string'&&task.threadId){const link=document.createElement('a');link.href='https://chatgpt.com/c/'+encodeURIComponent(task.threadId);link.target='_blank';link.rel='noopener noreferrer';link.textContent='Open chat';line.append(link);}else line.append(text('—'));box.append(line);return box;}
350
350
  function taskCommentField(task){const box=element('div','detail-group');box.append(element('p','detail-label',text('PR comment')));const line=element('p','detail-value');if(typeof task.taskUrl==='string'&&task.taskUrl.startsWith('https://github.com/')){const link=document.createElement('a');link.className='task-link';link.href=task.taskUrl;link.target='_blank';link.rel='noopener noreferrer';link.textContent='Open task comment';line.append(link);}else line.append(text('—'));box.append(line);return box;}
351
351
  function parseAgentGraphSource(source){if(typeof source!=='string'||!source.trim())return null;const agents=new Map(),edges=[];for(const raw of source.split(/\\r?\\n/)){const line=raw.trim();if(!line||line.startsWith('%%')||line.startsWith('flowchart '))continue;let match=line.match(/^([A-Za-z_][A-Za-z0-9_.-]*)(?:\\[agent:([A-Za-z_][A-Za-z0-9_.-]*)\\])?\\s*-->\\s*([A-Za-z_][A-Za-z0-9_.-]*)(?:\\[agent:([A-Za-z_][A-Za-z0-9_.-]*)\\])?$/);if(match){agents.set(match[1],match[2]||agents.get(match[1])||match[1]);agents.set(match[3],match[4]||agents.get(match[3])||match[3]);edges.push([match[1],match[3]]);continue;}match=line.match(/^([A-Za-z_][A-Za-z0-9_.-]*)\\[agent:([A-Za-z_][A-Za-z0-9_.-]*)\\]$/);if(match){agents.set(match[1],match[2]);continue;}return null;}const incoming=new Map([...agents.keys()].map(id=>[id,0]));for(const [,to] of edges)incoming.set(to,(incoming.get(to)||0)+1);const remaining=new Map(incoming),waves=[];let ready=[...remaining].filter(([,degree])=>degree===0).map(([id])=>id).sort();while(ready.length){waves.push(ready);const next=[];for(const id of ready)for(const [from,to] of edges)if(from===id){const degree=(remaining.get(to)||0)-1;remaining.set(to,degree);if(degree===0)next.push(to);}ready=[...new Set(next)].sort();}return {agents,waves};}
@@ -388,7 +388,7 @@ function jobChatRef(job){return repoName(job?.repository)+'/pr#'+value(job?.prNu
388
388
  function chatShortcutUrl(prompt){return 'https://chatgpt.com/?prompt='+encodeURIComponent(prompt);}
389
389
  const CHAT_SHORTCUT_TARGET='agents-relay-shortcut';
390
390
  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;}
391
- 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)+'/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.executionMode==='autonomous')states.append(element('span','badge flag-autonomous',text('AUTONOMOUS')));if(job.reviewEnabled!==false&&job.githubState!=='MERGED')states.append(element('span','badge flag-review',text('REVIEW REQUIRED')));if(job.githubState==='MERGED'&&job.publication?.version)states.append(element('span','badge SUCCEEDED',text('v'+job.publication.version)));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));}}
391
+ 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)+'/'+(Number(job.externalPrNumber||job.prNumber||0)>0?'PR#'+value(job.externalPrNumber||job.prNumber):'Job '+value(job.id))+': '+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.externalPrNumber||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.executionMode==='autonomous')states.append(element('span','badge flag-autonomous',text('AUTONOMOUS')));if(job.state==='ACTIVE'&&job.reviewEnabled!==false)states.append(element('span','badge flag-review',text('REVIEW REQUIRED')));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));}}
392
392
  function taskTagDisplay(job,task){const display=task.tag&&job?.tag?.values?.[task.tag];return typeof display==='string'&&display?display:null;}
393
393
  function taskSubtreeComplete(task,children){return ['SUCCEEDED','CANCELLED'].includes(task.state)&&(children.get(task.id)||[]).every(child=>taskSubtreeComplete(child,children));}
394
394
  function taskNode(task,children,depth){const li=element('li');const nested=children.get(task.id)||[];const line=element('div','task-line');if(nested.length){const key=(selectedJob||'')+':'+task.id;const collapsed=collapsedTasks.has(key)||(!expandedTasks.has(key)&&taskSubtreeComplete(task,children));const fold=document.createElement('button');fold.type='button';fold.className='task-fold';fold.setAttribute('aria-expanded',String(!collapsed));fold.setAttribute('aria-label',(collapsed?'Expand ':'Collapse ')+task.id);fold.textContent=collapsed?'▸':'▾';line.append(fold);const ul=element('ul','tree');ul.hidden=collapsed;for(const child of nested)ul.append(taskNode(child,children,depth+1));fold.addEventListener('click',()=>{const next=!ul.hidden;ul.hidden=next;fold.setAttribute('aria-expanded',String(!next));fold.setAttribute('aria-label',(next?'Expand ':'Collapse ')+task.id);fold.textContent=next?'▸':'▾';if(next){collapsedTasks.add(key);expandedTasks.delete(key);}else{collapsedTasks.delete(key);expandedTasks.add(key);}});li.append(line,ul);}else li.append(line);const row=document.createElement('button');row.type='button';row.className='task-row';row.setAttribute('aria-current',String(task.id===selectedTaskId));row.title=task.id;row.addEventListener('click',()=>{const deselect=selectedTaskId===task.id;selectedTaskId=deselect?null:task.id;eventTaskId=deselect?null:task.id;rememberSelection();clearEvents();renderAll();void loadEventHistory();});const id=element('span','task-id',text(task.id));id.style.paddingLeft=Math.min(depth,4)*6+'px';row.append(id,element('span','badge '+value(task.state),text(task.state)));const tag=taskTagDisplay(selected(),task);if(tag){const badge=element('span','task-tag',text(tag));badge.title=(selected()?.tag?.description||'Task tag')+': '+task.tag;row.append(badge);}line.append(row);return li;}
@@ -424,7 +424,8 @@ async function refreshStatus(){try{renderRateLimitStatus(await loadJSON('/api/st
424
424
  async function refresh(force=false){jobsRefresh.disabled=true;jobsRefresh.textContent='Refreshing…';try{const discovered=await loadJSON('/api/jobs'+(force?'?refresh=1':''));jobs=Array.isArray(discovered)?discovered:[];void drawRuntimeSummary();jobs.sort(compareJobs);if(selectedJob&&!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';}}
425
425
  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';}
426
426
  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';}
427
- function addEvent(event,level){const current=selected();if(!event||current&&event.job_id&&event.job_id!==current.id||eventTaskId&&event.task_id!==eventTaskId)return;if(event.event_id&&displayedEventIds.has(event.event_id))return;if(event.event_id)displayedEventIds.add(event.event_id);const item=element('li','event-item');const meta=element('div','event-meta');meta.append(element('span','event-kind',text(eventLabel(event.type))),element('span','event-type',text(event.type||'event')),element('span','',text(relativeTime(event.timestamp))),element('span','',text('task: '+value(event.task_id))));item.append(meta);const message=element('p','event-message',text(event.message||'Event received'));if(level==='error'||event.level==='error')message.classList.add('error-text');item.append(message);eventFeed.querySelector('.empty')?.remove();eventFeed.prepend(item);while(eventFeed.children.length>MAX_EVENTS)eventFeed.lastElementChild?.remove();}
427
+ 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';}
428
+ function addEvent(event,level){const current=selected();if(!event||current&&event.job_id&&event.job_id!==current.id||eventTaskId&&event.task_id!==eventTaskId)return;if(event.event_id&&displayedEventIds.has(event.event_id))return;if(event.event_id)displayedEventIds.add(event.event_id);const severity=eventSeverity(event,level);const item=element('li','event-item'+(severity==='error'?' event-error':severity==='warn'?' event-warn':''));const meta=element('div','event-meta');meta.append(element('span','event-kind',text(eventLabel(event.type))),element('span','event-type',text(event.type||'event')),element('span','',text(relativeTime(event.timestamp))),element('span','',text('task: '+value(event.task_id))));item.append(meta);const message=element('p','event-message',text(event.message||'Event received'));item.append(message);eventFeed.querySelector('.empty')?.remove();eventFeed.prepend(item);while(eventFeed.children.length>MAX_EVENTS)eventFeed.lastElementChild?.remove();}
428
429
  function resetText(windowData){if(!Number.isFinite(windowData.resetsAt))return '';const at=new Date(windowData.resetsAt*1000);const minutes=Math.max(0,Math.ceil((at.getTime()-Date.now())/60000));const relative=minutes<60?minutes+'m':minutes<1440?Math.floor(minutes/60)+'h '+minutes%60+'m':Math.floor(minutes/1440)+'d '+Math.floor((minutes%1440)/60)+'h';return 'Resets '+at.toLocaleString()+' · in '+relative;}
429
430
  function usageBar(label,windowData){const percent=Math.max(0,Math.min(100,100-windowData.usedPercent));const row=element('div','usage-row');const heading=element('div','usage-label');heading.append(element('span','',text(label)),element('span','',text(Math.round(percent)+'%')));const track=element('div','usage-track');track.setAttribute('role','progressbar');track.setAttribute('aria-label',label+' available');track.setAttribute('aria-valuemin','0');track.setAttribute('aria-valuemax','100');track.setAttribute('aria-valuenow',String(percent));const fill=element('div','usage-fill');fill.style.width=percent+'%';track.append(fill);row.append(heading,track);const reset=resetText(windowData);if(reset)row.append(element('div','subtle',text(reset)));return row;}
430
431
  function usageProvider(provider){const card=element('section','usage-provider');card.append(element('h3','',text(provider.label)));const usage=provider.usage||{};if(!usage.available||!usage.fiveHour||!usage.weekly){card.append(element('p','empty',text(usage.reason||'Account usage telemetry is unavailable.')));return card;}const bars=element('div','usage-bars');bars.append(usageBar('5-hour remaining',usage.fiveHour),usageBar('Weekly remaining',usage.weekly));card.append(bars);return card;}
@@ -481,12 +482,14 @@ auditRefresh.addEventListener('click',()=>void drawAudit());
481
482
  shortcutsSave.addEventListener('click',saveShortcuts);
482
483
  window.addEventListener('keydown',event=>{if(event.key==='Tab'&&event.altKey){event.preventDefault();const views=['jobs','usage','graphs','rules','shortcuts'];const current=location.hash==='#/usage'?'usage':location.hash==='#/graphs'?'graphs':location.hash==='#/execution-router-rules'?'rules':location.hash==='#/shortcuts'?'shortcuts':'jobs';const next=views[(views.indexOf(current)+1)%views.length];location.hash=next==='usage'?'#/usage':next==='graphs'?'#/graphs':next==='rules'?'#/execution-router-rules':next==='shortcuts'?'#/shortcuts':(selectedJob?'#/jobs/'+encodeURIComponent(selectedJob):'#/jobs');showView(next);}});
483
484
  function parseMessage(message){try{return JSON.parse(message.data);}catch{return null;}}
484
- function applyStatePatch(patch){if(!patch?.jobId)return false;const job=jobs.find(candidate=>candidate.id===patch.jobId);if(!job)return false;if(patch.jobState)job['state']=patch.jobState;if(patch.jobUpdatedAt)job.updatedAt=patch.jobUpdatedAt;if(patch.taskId&&patch.taskState){const task=(job.tasks||[]).find(candidate=>candidate.id===patch.taskId);if(task){task['state']=patch.taskState;if(patch.taskUpdatedAt)task.updatedAt=patch.taskUpdatedAt;}}return true;}
485
+ function isFreshEnough(incoming,current){const a=Date.parse(incoming||''),b=Date.parse(current||'');return !Number.isFinite(b)||!Number.isFinite(a)||a>=b;}
486
+ function applyStatePatch(patch){if(!patch?.jobId)return false;const job=jobs.find(candidate=>candidate.id===patch.jobId);if(!job)return false;if(patch.jobState&&isFreshEnough(patch.jobUpdatedAt,job.updatedAt)){job['state']=patch.jobState;if(patch.jobUpdatedAt)job.updatedAt=patch.jobUpdatedAt;}if(patch.taskId&&patch.taskState){const task=(job.tasks||[]).find(candidate=>candidate.id===patch.taskId);if(task&&isFreshEnough(patch.taskUpdatedAt,task.updatedAt)){task['state']=patch.taskState;if(patch.taskUpdatedAt)task.updatedAt=patch.taskUpdatedAt;}}return true;}
487
+ function upsertFreshJob(incoming){if(!incoming)return;const index=jobs.findIndex(job=>jobSelectionKey(job)===jobSelectionKey(incoming));if(index<0){jobs.push(incoming);return;}if(isFreshEnough(incoming.updatedAt,jobs[index].updatedAt))jobs[index]=incoming;}
485
488
  const stream=new EventSource('/events');
486
489
  stream.onopen=()=>{connection.textContent='';connection.setAttribute('aria-label','Event stream connected');connection.className='status-pill ok';};
487
490
  stream.onerror=()=>{connection.textContent='';connection.setAttribute('aria-label','Event stream reconnecting');connection.className='status-pill warn';};
488
- stream.onmessage=message=>{const payload=parseMessage(message);if(payload?.job){const index=jobs.findIndex(job=>jobSelectionKey(job)===jobSelectionKey(payload.job));if(index>=0)jobs[index]=payload.job;else jobs.push(payload.job);renderAll();void drawRuntimeSummary();}};
489
- stream.addEventListener('relay',message=>{const payload=parseMessage(message);if(payload?.job){const index=jobs.findIndex(job=>jobSelectionKey(job)===jobSelectionKey(payload.job));if(index>=0)jobs[index]=payload.job;else jobs.push(payload.job);}else if(payload?.statePatch)applyStatePatch(payload.statePatch);if(payload?.event)addEvent(payload.event);renderAll();void drawRuntimeSummary();});
491
+ stream.onmessage=message=>{const payload=parseMessage(message);if(payload?.job){upsertFreshJob(payload.job);renderAll();void drawRuntimeSummary();}};
492
+ stream.addEventListener('relay',message=>{const payload=parseMessage(message);if(payload?.job)upsertFreshJob(payload.job);else if(payload?.statePatch)applyStatePatch(payload.statePatch);if(payload?.event)addEvent(payload.event);renderAll();void drawRuntimeSummary();});
490
493
  if(location.hash==='#/usage')showView('usage');else if(location.hash==='#/graphs')showView('graphs');else if(location.hash==='#/execution-router-rules')showView('rules');else if(location.hash==='#/shortcuts')showView('shortcuts');else showView('jobs');
491
494
  setInterval(()=>{if(location.hash==='#/usage')void drawUsage();},30000);
492
495
  setInterval(()=>void refreshVersion(),30000);
package/dist/dashboard.js CHANGED
@@ -71,7 +71,7 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
71
71
  const discoveredJobs = async (forceRefresh = false) => {
72
72
  try {
73
73
  const jobs = overviewProvider
74
- ? (await overviewProvider(forceRefresh, forceRefresh ? 'dashboard.jobs-refresh' : 'internal.unknown')).map(item => ({ ...item.job, githubState: item.githubState, runtimeStatus: deriveJobRuntimeStatus(item.job, item.githubState), mergedAt: item.mergedAt, closedAt: item.closedAt, draft: item.draft, publication: item.publication }))
74
+ ? (await overviewProvider(forceRefresh, forceRefresh ? 'dashboard.jobs-refresh' : 'internal.unknown')).map(item => ({ ...item.job, githubState: item.githubState, runtimeStatus: deriveJobRuntimeStatus(item.job, item.githubState), mergedAt: item.mergedAt, closedAt: item.closedAt, draft: item.draft, publication: item.publication, publicationFailure: item.publicationFailure }))
75
75
  : jobId ? [await store.load(jobId)] : [];
76
76
  lastKnownJobs = jobs;
77
77
  return jobs;
@@ -202,7 +202,7 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
202
202
  res.end('Ignored');
203
203
  return;
204
204
  }
205
- if (!event.repository || !webhook.repositories.has(event.repository) || (webhook.pullRequest !== undefined && event.pullRequest !== null && event.pullRequest !== webhook.pullRequest)) {
205
+ if (!event.repository || !webhook.repositories.has(event.repository) || (webhook.jobId !== undefined && event.jobId !== undefined && event.jobId !== null && event.jobId !== webhook.jobId) || (webhook.pullRequest !== undefined && event.pullRequest !== null && event.pullRequest !== webhook.pullRequest)) {
206
206
  res.writeHead(202);
207
207
  res.end('Ignored');
208
208
  return;
@@ -238,6 +238,31 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
238
238
  }
239
239
  return;
240
240
  }
241
+ if (new URL(req.url ?? '/', 'http://127.0.0.1').pathname === '/api/control') {
242
+ if (req.method !== 'POST' || !resolvedActions?.control) {
243
+ res.writeHead(404);
244
+ res.end('Control API unavailable');
245
+ return;
246
+ }
247
+ try {
248
+ const chunks = [];
249
+ for await (const chunk of req)
250
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
251
+ const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
252
+ const result = await resolvedActions.control(body);
253
+ if (!res.headersSent)
254
+ res.writeHead(200, { 'content-type': 'application/json' });
255
+ if (!res.writableEnded)
256
+ res.end(JSON.stringify(result));
257
+ }
258
+ catch (error) {
259
+ if (!res.headersSent)
260
+ res.writeHead(409);
261
+ if (!res.writableEnded)
262
+ res.end(error instanceof Error ? error.message : String(error));
263
+ }
264
+ return;
265
+ }
241
266
  if (new URL(req.url ?? '/', 'http://127.0.0.1').pathname === '/api/job-action') {
242
267
  const url = new URL(req.url ?? '/', 'http://127.0.0.1');
243
268
  const requestedJobId = url.searchParams.get('id') ?? '';
@@ -0,0 +1,76 @@
1
+ const legacy = {
2
+ quota_exceeded: { method: 'worker.codex.launch', action: 'return', execution: 'skip', mock: 'worker_quota' },
3
+ model_not_found: { method: 'worker.codex.launch', action: 'return', execution: 'skip', mock: 'worker_model_not_found' },
4
+ provider_unavailable: { method: 'worker.codex.launch', action: 'return', execution: 'skip', mock: 'worker_provider_unavailable' },
5
+ github_projection_failure: { method: 'github.projectLocalMerge', action: 'throw', execution: 'skip', error: { code: 'SERVICE_UNAVAILABLE', message: 'E2E transient GitHub projection transport failure', status: 503 } },
6
+ };
7
+ export function parseTestInjection(value) {
8
+ if (typeof value === 'string')
9
+ return legacy[value];
10
+ if (!value || typeof value !== 'object' || Array.isArray(value))
11
+ return undefined;
12
+ const v = value;
13
+ if (v.method !== 'worker.codex.launch' && v.method !== 'github.projectLocalMerge' && v.method !== 'browserWorkspace.command')
14
+ return undefined;
15
+ if (v.action !== 'throw' && v.action !== 'return' && v.action !== 'pass')
16
+ return undefined;
17
+ if (v.execution !== 'skip' && v.execution !== 'after' && v.execution !== 'run')
18
+ return undefined;
19
+ const error = v.error;
20
+ if (v.action === 'throw' && (!error || typeof error !== 'object' || typeof error.message !== 'string' || typeof error.code !== 'string'))
21
+ return undefined;
22
+ const mock = v.mock;
23
+ if (v.action === 'return' && (v.method !== 'worker.codex.launch' || !['worker_quota', 'worker_model_not_found', 'worker_provider_unavailable'].includes(String(mock))))
24
+ return undefined;
25
+ if (v.method === 'worker.codex.launch' && v.action !== 'pass' && (v.action !== 'return' || v.execution !== 'skip'))
26
+ return undefined;
27
+ if (v.action === 'pass' && v.execution !== 'run')
28
+ return undefined;
29
+ if (v.action !== 'pass' && v.execution === 'run')
30
+ return undefined;
31
+ return { method: v.method, action: v.action, execution: v.execution, error: error, mock: mock };
32
+ }
33
+ export function authorizedInjection(repository, raw, env = process.env) {
34
+ return env.E2E_REPO && repository === env.E2E_REPO ? parseTestInjection(raw) : undefined;
35
+ }
36
+ function mockWorker(name) {
37
+ const message = name === 'worker_quota' ? 'rate_limit_exceeded: usage limit reached' : name === 'worker_model_not_found' ? 'model_not_found: model is unavailable' : 'provider_unavailable: provider refused the request';
38
+ const promise = Promise.reject(new Error(message));
39
+ promise.catch(() => { });
40
+ return { id: 'e2e-injected', launched: Promise.resolve(), promise, cancel: () => { }, processExit: { exitCode: 1, signal: null, stderr: message, terminationReason: null } };
41
+ }
42
+ /** Caller must supply a configuration already consumed durably before this invocation. */
43
+ export function withTestInjection(method, config, execute) {
44
+ if (!config || config.method !== method || config.action === 'pass')
45
+ return execute();
46
+ const apply = (original) => {
47
+ if (config.action === 'throw') {
48
+ const error = Object.assign(new Error(config.error.message), { code: config.error.code, status: config.error.status });
49
+ throw error;
50
+ }
51
+ if (method === 'worker.codex.launch' && config.mock)
52
+ return mockWorker(config.mock);
53
+ return original;
54
+ };
55
+ return config.execution === 'after' ? Promise.resolve(execute()).then(apply) : apply();
56
+ }
57
+ /** Shared atomic consumer, scoped solely by E2E_REPO and exact method. */
58
+ export async function consumeMethodInjection(relay, repository, jobId, method) {
59
+ if (!process.env.E2E_REPO || process.env.E2E_REPO !== repository || !relay.mutateJob)
60
+ return undefined;
61
+ let config;
62
+ await relay.mutateJob(jobId, job => {
63
+ if (job.repository !== repository)
64
+ return job;
65
+ const task = job.tasks.find(t => authorizedInjection(job.repository, t.testInjection)?.method === method);
66
+ if (!task)
67
+ return job;
68
+ config = authorizedInjection(job.repository, task.testInjection);
69
+ delete task.testInjection;
70
+ task.updatedAt = new Date(Math.max(Date.now(), Date.parse(task.updatedAt) + 1)).toISOString();
71
+ return job;
72
+ });
73
+ if (config && (await relay.loadJob(jobId))?.tasks.some(t => authorizedInjection(repository, t.testInjection)?.method === method))
74
+ throw Error(`E2E injection consumption not committed: ${jobId}`);
75
+ return config;
76
+ }
@@ -15,17 +15,18 @@ export function githubAppConfig(args, env = process.env) {
15
15
  throw new Error('GitHub App auth requires app id, installation id, and private key file');
16
16
  return { appId, installationId, privateKeyFile };
17
17
  }
18
- export async function githubAuthContext(args, env = process.env) {
18
+ export async function githubAuthContext(args, env = process.env, probeLogin = true) {
19
19
  const config = githubAppConfig(args, env);
20
20
  let options = null;
21
21
  if (config)
22
22
  options = { appId: config.appId, installationId: config.installationId, privateKey: await readFile(config.privateKeyFile, 'utf8') };
23
23
  const client = new GhApiClient(options);
24
24
  const trustedAuthors = new Set();
25
- trustedAuthors.add(await client.currentLogin());
25
+ if (probeLogin)
26
+ trustedAuthors.add(await client.currentLogin());
26
27
  for (const author of (value(args, '--trusted-authors') || env.AGENTS_RELAY_TRUSTED_AUTHORS || '').split(',').map(item => item.trim()).filter(Boolean))
27
28
  trustedAuthors.add(author);
28
- if (options) {
29
+ if (options && probeLogin) {
29
30
  const local = await client.localLogin();
30
31
  if (local)
31
32
  trustedAuthors.add(local);
@@ -0,0 +1,15 @@
1
+ /** Fresh unbound ACTIVE jobs are highest priority; terminal projection debt is last. */
2
+ export function orderGitHubBindingCandidates(items) {
3
+ return [...items].sort((a, b) => score(a.job) - score(b.job) || a.job.createdAt.localeCompare(b.job.createdAt));
4
+ }
5
+ function score(job) {
6
+ const bound = (job.externalPrNumber ?? (job.prNumber > 0 ? job.prNumber : 0)) > 0;
7
+ const terminal = ['COMPLETED', 'CANCELLED'].includes(job.state);
8
+ if (!terminal && !bound)
9
+ return 0;
10
+ if (!terminal && bound)
11
+ return 1;
12
+ if (terminal && bound)
13
+ return 2;
14
+ return 3;
15
+ }
@@ -241,13 +241,18 @@ export class GhApiClient {
241
241
  let stdout = '';
242
242
  child.stdout?.on('data', (chunk) => { stdout += chunk.toString(); });
243
243
  child.on('error', () => resolve(null));
244
- child.on('close', () => {
244
+ child.on('close', (code) => {
245
+ if (code !== 0) {
246
+ resolve(null);
247
+ return;
248
+ }
245
249
  try {
246
250
  const value = JSON.parse(stdout);
247
251
  const hosts = value.hosts;
248
252
  const github = hosts?.['github.com'];
249
253
  const active = Array.isArray(github) ? github.find(item => item.active === true) : undefined;
250
- resolve(typeof active?.login === 'string' && active.login.length > 0 ? active.login : null);
254
+ const healthy = active && active.state !== 'error' && typeof active.error !== 'string';
255
+ resolve(healthy && typeof active?.login === 'string' && active.login.length > 0 ? active.login : null);
251
256
  }
252
257
  catch {
253
258
  resolve(null);
@@ -0,0 +1,22 @@
1
+ /** A PR is a read-only collaboration record after authoritative local completion. */
2
+ export async function archiveDisplayPullRequest(client, repository, number, jobId, publishedSha, state) {
3
+ if (state !== 'OPEN')
4
+ return 'already_closed';
5
+ const marker = `Agents Relay local completion: ${jobId}`;
6
+ const path = `repos/${repository}/issues/${number}/comments?per_page=100`;
7
+ const comments = await client.request(path);
8
+ if (!Array.isArray(comments))
9
+ throw new Error('Cannot verify PR archive comment idempotency');
10
+ const alreadyCommented = comments.some(item => item && typeof item === 'object'
11
+ && 'body' in item && typeof item.body === 'string' && item.body.includes(marker));
12
+ if (!alreadyCommented) {
13
+ await client.request(`repos/${repository}/issues/${number}/comments`, {
14
+ method: 'POST',
15
+ body: JSON.stringify({ body: `${marker}\nPublished local main commit: ${publishedSha}.\nThis PR is closed as a review record; GitHub did not perform a merge.` }),
16
+ });
17
+ }
18
+ await client.request(`repos/${repository}/pulls/${number}`, {
19
+ method: 'PATCH', body: JSON.stringify({ state: 'closed' }),
20
+ });
21
+ return 'archived';
22
+ }
@@ -14,7 +14,7 @@ export function parsePackagePublishFailureMarker(body) {
14
14
  const data = JSON.parse(body.slice(start + prefix.length, end));
15
15
  if (typeof data.runId !== 'string' || !/^\d+$/.test(data.runId) || !Number.isInteger(data.attempt) || Number(data.attempt) < 1 || typeof data.sha !== 'string' || !/^[a-f0-9]{7,40}$/i.test(data.sha) || typeof data.stage !== 'string' || !data.stage)
16
16
  return null;
17
- return { runId: data.runId, attempt: Number(data.attempt), sha: data.sha, stage: data.stage };
17
+ return { runId: data.runId, attempt: Number(data.attempt), sha: data.sha, stage: data.stage, ...(typeof data.jobId === 'string' && data.jobId ? { jobId: data.jobId } : {}) };
18
18
  }
19
19
  catch {
20
20
  return null;
@@ -45,6 +45,7 @@ export function parsePackagePublicationMarker(body) {
45
45
  version: value.version,
46
46
  registry: typeof value.registry === 'string' ? value.registry : null,
47
47
  sha: typeof value.sha === 'string' ? value.sha : null,
48
+ ...(typeof value.jobId === 'string' && value.jobId ? { jobId: value.jobId } : {}),
48
49
  };
49
50
  }
50
51
  export function verifyGitHubWebhook(secret, body, signature) {
@@ -65,14 +66,16 @@ function pullRequestFacts(payload, action) {
65
66
  const sha = typeof head?.sha === 'string' ? head.sha : null;
66
67
  if (!ref || !sha)
67
68
  return null;
69
+ const updated = typeof pr.updated_at === 'string' && Number.isFinite(Date.parse(pr.updated_at))
70
+ ? { observedAt: new Date(pr.updated_at).toISOString() } : {};
68
71
  if (action === 'closed')
69
- return { state: pr.merged === true ? 'MERGED' : 'CLOSED', head: { ref, sha } };
70
- return { state: 'OPEN', head: { ref, sha } };
72
+ return { state: pr.merged === true ? 'MERGED' : 'CLOSED', head: { ref, sha }, ...updated };
73
+ return { state: 'OPEN', head: { ref, sha }, ...updated };
71
74
  }
72
75
  export function parseGitHubWebhook(headers, body) {
73
76
  const deliveryId = String(headers['x-github-delivery'] ?? '');
74
77
  const event = String(headers['x-github-event'] ?? '');
75
- if (!deliveryId || (event !== 'pull_request' && event !== 'issue_comment'))
78
+ if (!deliveryId || (event !== 'pull_request' && event !== 'issue_comment' && event !== 'commit_comment'))
76
79
  return null;
77
80
  let payload;
78
81
  try {
@@ -84,20 +87,50 @@ export function parseGitHubWebhook(headers, body) {
84
87
  const repository = payload.repository;
85
88
  const repositoryName = typeof repository?.full_name === 'string' ? repository.full_name : null;
86
89
  const action = typeof payload.action === 'string' ? payload.action : null;
90
+ if (!repositoryName)
91
+ return null;
92
+ if (event === 'commit_comment') {
93
+ const comment = payload.comment;
94
+ const trustedPublisher = comment?.user?.login === 'github-actions[bot]';
95
+ const publication = trustedPublisher ? parsePackagePublicationMarker(comment?.body) : null;
96
+ const publishFailure = trustedPublisher ? parsePackagePublishFailureMarker(comment?.body) : null;
97
+ const jobId = publication?.jobId ?? publishFailure?.jobId;
98
+ if ((!publication && !publishFailure) || !jobId)
99
+ return null;
100
+ const commentTime = comment?.created_at;
101
+ const publicationObservedAt = typeof commentTime === 'string' && Number.isFinite(Date.parse(commentTime))
102
+ ? new Date(commentTime).toISOString() : undefined;
103
+ return { deliveryId, event, action, repository: repositoryName, pullRequest: null, jobId, publication,
104
+ ...(publicationObservedAt ? { publicationObservedAt } : {}),
105
+ ...(publishFailure ? { publishFailure } : {}), pullRequestFacts: null };
106
+ }
87
107
  const pr = payload.pull_request;
88
108
  const issue = payload.issue;
89
- const number = Number(pr?.number ?? (issue?.pull_request ? issue.number : 0));
90
- if (!repositoryName || !Number.isInteger(number) || number <= 0)
91
- return null;
92
109
  if (event === 'issue_comment') {
110
+ const issueNumber = Number(issue?.number ?? 0);
111
+ if (!Number.isInteger(issueNumber) || issueNumber <= 0)
112
+ return null;
93
113
  const comment = payload.comment;
94
- const publication = parsePackagePublicationMarker(comment?.body);
95
- const publishFailure = comment?.user?.login === 'github-actions[bot]'
114
+ const trustedPublisher = comment?.user?.login === 'github-actions[bot]';
115
+ const publication = trustedPublisher ? parsePackagePublicationMarker(comment?.body) : null;
116
+ const publishFailure = trustedPublisher
96
117
  ? parsePackagePublishFailureMarker(comment?.body) : null;
118
+ const jobId = publication?.jobId ?? publishFailure?.jobId;
97
119
  if (!publication && !publishFailure)
98
120
  return null;
99
- return { deliveryId, event, action, repository: repositoryName, pullRequest: number, publication, ...(publishFailure ? { publishFailure } : {}), pullRequestFacts: null };
121
+ if (!issue?.pull_request && !jobId)
122
+ return null;
123
+ const commentTime = comment?.created_at;
124
+ const publicationObservedAt = typeof commentTime === 'string' && Number.isFinite(Date.parse(commentTime))
125
+ ? new Date(commentTime).toISOString() : undefined;
126
+ return { deliveryId, event, action, repository: repositoryName,
127
+ pullRequest: issue?.pull_request ? issueNumber : null, ...(jobId ? { jobId } : {}), publication,
128
+ ...(publicationObservedAt ? { publicationObservedAt } : {}),
129
+ ...(publishFailure ? { publishFailure } : {}), pullRequestFacts: null };
100
130
  }
131
+ const number = Number(pr?.number ?? 0);
132
+ if (!Number.isInteger(number) || number <= 0)
133
+ return null;
101
134
  const facts = pullRequestFacts(payload, action);
102
135
  if (!facts)
103
136
  return null;
@@ -1,5 +1,6 @@
1
1
  import { transitionJobEvent } from './scheduler.js';
2
2
  import { tryApplyTaskCancelAction } from './task-actions.js';
3
+ import { decideJobLifecycle, dispatchJobLifecycleSync } from './workflow/lifecycle.js';
3
4
  export const JOB_ACTIVATE_ACTION = 'job.activate';
4
5
  export const JOB_CANCEL_ACTION = 'job.cancel';
5
6
  async function transitionJobAction(job, event, action) {
@@ -34,8 +35,36 @@ async function cancelActiveWork(store, job, reason) {
34
35
  await store.saveTask(task);
35
36
  }
36
37
  }
38
+ function cancelActiveWorkSnapshot(job, reason) {
39
+ for (const plan of (job.plans ?? []).filter(item => item.status === 'PLANNING')) {
40
+ plan.status = 'FAILED';
41
+ plan.error = reason;
42
+ plan.assessment = `Planning failed: ${reason}`;
43
+ plan.leaseOwner = null;
44
+ plan.leaseExpiresAt = null;
45
+ plan.executionId = null;
46
+ plan.updatedAt = new Date().toISOString();
47
+ }
48
+ for (const task of job.tasks)
49
+ tryApplyTaskCancelAction(task, reason);
50
+ }
37
51
  export async function applyJobCancelAction(store, job, reason = 'Job cancelled') {
38
52
  const from = job.state;
53
+ if (store.mutateJob) {
54
+ const updated = await store.mutateJob(job.id, current => {
55
+ const decision = decideJobLifecycle(current, 'cancel');
56
+ if (!decision.accepted)
57
+ throw new Error(`Job ${current.id} is not cancellable (${current.state})`);
58
+ dispatchJobLifecycleSync(current, 'cancel');
59
+ if (!decision.actions.includes('effect.cancelActiveTasks'))
60
+ throw new Error('Unsupported job.cancel effect');
61
+ cancelActiveWorkSnapshot(current, reason);
62
+ current.updatedAt = new Date().toISOString();
63
+ return current;
64
+ });
65
+ Object.assign(job, structuredClone(updated));
66
+ return job;
67
+ }
39
68
  try {
40
69
  await transitionJobEvent(job, 'cancel', {}, async (actionId) => {
41
70
  if (actionId !== 'effect.cancelActiveTasks')