agents-relay 1.0.132 → 1.0.133

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/cli.js CHANGED
@@ -7,6 +7,8 @@ import { dashboardManagedJobs, InMemoryStore, GitHubStore, resolvePullRequest, l
7
7
  import { githubAuthContext } from './github-auth.js';
8
8
  import { taskOverallTimeoutMs } from './types.js';
9
9
  import { Reconciler } from './reconciler.js';
10
+ import { dashboardTaskAction, dashboardTaskSetState } from './dashboard-actions.js';
11
+ export { dashboardTaskAction, dashboardTaskSetState } from './dashboard-actions.js';
10
12
  import { CodexAdapter, ChatGptAdapter } from './adapters.js';
11
13
  import { CodexThreadContinuation, CommandContinuation, WebhookContinuation } from './continuation.js';
12
14
  import { eventFor } from './events.js';
@@ -422,32 +424,6 @@ async function runJobCommand(action, args) {
422
424
  export function runtimePlanner(args) { const plannerCommand = arg([...args], '--planner-command'); const modelRuntime = arg([...args], '--codex', 'codex'); return plannerCommand ? new CommandObjectivePlanner(plannerCommand) : new AgentObjectivePlanner(modelRuntime); }
423
425
  export function runtimeReviewer() { return { requirements: { capabilities: ['model'] } }; }
424
426
  export function runtime(store, args, bus, usageRegistry = codexAndZaiUsageRegistry()) { const emit = (event) => { process.stderr.write(`${JSON.stringify(event)}\n`); }; const modelRuntime = arg(args, '--codex', 'codex'); const policy = chatGptTabClosePolicy(args); const planner = runtimePlanner(args); return new Reconciler(store, { owner: arg(args, '--owner', `cli-${process.pid}`), maxConcurrent: Number(arg(args, '--concurrency', '4')), leaseMs: Number(arg(args, '--lease-ms', '300000')), adapters: [new CodexAdapter(modelRuntime), new ChatGptAdapter({ tabClosePolicy: policy })], continuations: [new CodexThreadContinuation(modelRuntime), new CommandContinuation(), new WebhookContinuation()], planner, usageSnapshotProvider: () => usageRegistry.snapshot(), decisionEngine: new LayaCliDecisionEngine(), decisionMode: decisionMode(), reviewer: runtimeReviewer(), eventBus: bus, emit, chatgptTabClosePolicy: policy, projectPathForJob: async (job) => resolveRepositoryProjectPath(await resolveManagedProjectPath(job.repository, arg(args, '--project-path'), arg(args, '--workspace-root') || defaultWorkspaceRoot()), job.projectPath) }); }
425
- export function dashboardTaskAction(store, reconciler, action) {
426
- return async (jobId, taskId) => {
427
- const job = await store.load(jobId);
428
- const task = job.tasks.find(item => item.id === taskId);
429
- if (!task)
430
- throw new Error(`Task ${taskId} not found`);
431
- if (action === 'retry') {
432
- if (!['FAILED', 'BLOCKED'].includes(task.state))
433
- throw new Error(`Task ${task.id} is not restartable (${task.state})`);
434
- task.maxAttempts = extendRetryBudget(task);
435
- await transitionTaskEvent(task, 'retry');
436
- task.error = null;
437
- }
438
- else {
439
- if (!['DRAFT', 'QUEUED', 'RUNNING', 'BLOCKED', 'FAILED'].includes(task.state))
440
- throw new Error(`Task ${task.id} is not cancellable (${task.state})`);
441
- await transitionTaskEvent(task, task.state === 'RUNNING' ? 'task.cancelled' : 'cancel');
442
- task.error = 'Cancelled by user';
443
- }
444
- task.leaseOwner = null;
445
- task.leaseExpiresAt = null;
446
- task.executionId = null;
447
- await store.saveTask(task);
448
- return await reconciler.wake(job.id);
449
- };
450
- }
451
427
  export function dashboardJobAction(store, reconciler, action) {
452
428
  return async (jobId) => {
453
429
  const job = await store.load(jobId);
@@ -501,6 +477,7 @@ export async function createService(args) {
501
477
  cancel: dashboardJobAction(loaded.store, reconciler, 'cancel'),
502
478
  taskRetry: dashboardTaskAction(loaded.store, reconciler, 'retry'),
503
479
  taskCancel: dashboardTaskAction(loaded.store, reconciler, 'cancel'),
480
+ taskSetState: dashboardTaskSetState(loaded.store),
504
481
  });
505
482
  await once(dashboardServer, 'listening');
506
483
  const stopWatcher = await reconciler.watch(loaded.job.id);
@@ -0,0 +1,58 @@
1
+ import { extendRetryBudget, transitionTaskEvent } from './scheduler.js';
2
+ import { applyTaskWorkflowDecision } from './workflow/runtime.js';
3
+ export function dashboardTaskAction(store, reconciler, action) {
4
+ return async (jobId, taskId) => {
5
+ const job = await store.load(jobId);
6
+ const task = job.tasks.find(item => item.id === taskId);
7
+ if (!task)
8
+ throw new Error(`Task ${taskId} not found`);
9
+ if (action === 'retry') {
10
+ if (!['FAILED', 'BLOCKED'].includes(task.state))
11
+ throw new Error(`Task ${task.id} is not restartable (${task.state})`);
12
+ task.maxAttempts = extendRetryBudget(task);
13
+ await transitionTaskEvent(task, 'retry');
14
+ task.error = null;
15
+ }
16
+ else {
17
+ if (!['DRAFT', 'QUEUED', 'RUNNING', 'BLOCKED', 'FAILED'].includes(task.state))
18
+ throw new Error(`Task ${task.id} is not cancellable (${task.state})`);
19
+ await transitionTaskEvent(task, task.state === 'RUNNING' ? 'task.cancelled' : 'cancel');
20
+ task.error = 'Cancelled by user';
21
+ }
22
+ task.leaseOwner = null;
23
+ task.leaseExpiresAt = null;
24
+ task.executionId = null;
25
+ await store.saveTask(task);
26
+ return await reconciler.wake(job.id);
27
+ };
28
+ }
29
+ export function dashboardTaskSetState(store) {
30
+ return async (jobId, taskId, state) => {
31
+ const job = await store.load(jobId);
32
+ const task = job.tasks.find(item => item.id === taskId);
33
+ if (!task)
34
+ throw new Error(`Task ${taskId} not found`);
35
+ if (task.state === state)
36
+ return job;
37
+ const now = new Date().toISOString();
38
+ const from = task.state;
39
+ applyTaskWorkflowDecision(task, {
40
+ accepted: true,
41
+ target: state,
42
+ guard: null,
43
+ actions: [],
44
+ mutations: [],
45
+ effects: [],
46
+ reason: 'accepted',
47
+ }, now);
48
+ task.manualStateOverrides = [
49
+ ...(task.manualStateOverrides ?? []),
50
+ { from, to: state, at: now, actor: 'dashboard-user' },
51
+ ].slice(-20);
52
+ task.leaseOwner = null;
53
+ task.leaseExpiresAt = null;
54
+ task.executionId = null;
55
+ await store.saveTask(task);
56
+ return await store.load(job.id);
57
+ };
58
+ }
@@ -298,6 +298,8 @@ function counts(tasks){const out={};for(const task of Array.isArray(tasks)?tasks
298
298
  function compactCounts(input){const order=['RUNNING','BLOCKED','FAILED','QUEUED','DRAFT','SUCCEEDED','CANCELLED'];return order.filter(state=>input[state]).map(state=>input[state]+' '+state).join(', ')||'No tasks';}
299
299
  function relativeTime(timestamp){if(!timestamp)return '—';const parsed=Date.parse(timestamp);if(!Number.isFinite(parsed))return value(timestamp);const seconds=Math.max(0,Math.round((Date.now()-parsed)/1000));if(seconds<60)return 'just now';if(seconds<3600)return Math.floor(seconds/60)+'m ago';if(seconds<86400)return Math.floor(seconds/3600)+'h ago';return Math.floor(seconds/86400)+'d ago';}
300
300
  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;}
301
+ 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;}
302
+ 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;}
301
303
  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;}
302
304
  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;}
303
305
  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;}
@@ -305,7 +307,8 @@ function parseAgentGraphSource(source){if(typeof source!=='string'||!source.trim
305
307
  function agentGraphField(task){if(typeof task.agentGraph!=='string'||!task.agentGraph.trim())return null;const box=element('div','detail-group');box.style.gridColumn='1 / -1';box.append(element('p','detail-label',text('Task agent graph')));const graph=element('div','agent-graph');const parsed=parseAgentGraphSource(task.agentGraph);if(parsed&&parsed.waves.length){parsed.waves.forEach((wave,index)=>{const row=element('div','agent-wave');if(index>0)row.append(element('span','agent-arrow',text('↓')));for(const id of wave)row.append(element('span','agent-node',text(id+' · '+(parsed.agents.get(id)||id))));graph.append(row);});}const source=document.createElement('pre');source.className='agent-graph-source';source.textContent=task.agentGraph;graph.append(source);box.append(graph);return box;}
306
308
  function clearEvents(){displayedEventIds.clear();eventFeed.replaceChildren(element('li','empty',text('Waiting for events.')));}
307
309
  async function loadEventHistory(){if(!selectedJob){clearEvents();return;}const params=new URLSearchParams({job:selectedJob,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();}}
308
- function selectJob(id){selectedJob=id;selectedTaskId=null;eventTaskId=null;rememberSelection();clearEvents();location.hash=id?'#/jobs/'+encodeURIComponent(id):'#/jobs';renderAll();void loadEventHistory();}
310
+ function copySelectedJobIdentity(id){const job=jobs.find(candidate=>candidate.id===id);if(!job||!navigator.clipboard?.writeText)return;void navigator.clipboard.writeText(repoName(job.repository)+'/pr#'+job.prNumber).catch(()=>{});}
311
+ function selectJob(id){selectedJob=id;selectedTaskId=null;eventTaskId=null;rememberSelection();clearEvents();location.hash=id?'#/jobs/'+encodeURIComponent(id):'#/jobs';copySelectedJobIdentity(id);renderAll();void loadEventHistory();}
309
312
  function matchesQuickFilter(job){if(quickMode==='autonomous')return job.executionMode==='autonomous';if(quickMode==='draft')return job.draft||job.state==='DRAFT';if(quickMode==='status')return !!selectedRuntimeStatus&&(job.runtimeStatus||'IDLE')===selectedRuntimeStatus;return true;}
310
313
  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)||pr.includes(normalized);}
311
314
  function filteredJobs(){return jobs.filter(job=>(!selectedRepo||job.repository===selectedRepo)&&matchesQuickFilter(job)&&matchesSearch(job));}
@@ -332,16 +335,17 @@ function actionButton(job,action,label,icon){const button=document.createElement
332
335
  async function runJobAction(job,action,button){button.disabled=true;try{const response=await fetch('/api/job-action?id='+encodeURIComponent(job.id)+'&action='+encodeURIComponent(action),{method:'POST'});if(!response.ok)throw new Error(response.status+' '+response.statusText);await refresh(true);}catch(error){button.title=labelForAction(action)+': '+error.message;}finally{renderAll();}}
333
336
  function labelForAction(action){return action==='start'?'Start':action==='reconcile'?'Reconcile':'Cancel';}
334
337
  function repoName(repository){if(typeof repository!=='string'||!repository)return 'unknown-repo';const parts=repository.split('/').filter(Boolean);return parts.at(-1)||repository;}
335
- 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(job.id===selectedJob));row.addEventListener('click',()=>selectJob(job.id));const content=element('div','job-content');const select=document.createElement('button');select.type='button';select.className='job-button';select.setAttribute('aria-current',String(job.id===selectedJob));select.title=job.title||job.id;select.addEventListener('click',event=>{event.stopPropagation();selectJob(job.id);});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)states.append(element('span','badge flag-review',text('REVIEW REQUIRED')));if(isCompleteUnmerged(job))states.append(element('span','badge MERGE_PENDING',text('MERGE PENDING')));const actions=element('span','job-actions');actions.append(actionButton(job,'start','Start','▶'),actionButton(job,'reconcile','Reconcile','↻'),actionButton(job,'cancel','Cancel','×'));const primary=element('div','job-primary');primary.append(states,actions);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));}}
336
- function taskNode(task,children,depth){const li=element('li');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',()=>{selectedTaskId=task.id;eventTaskId=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)));li.append(row);const nested=children.get(task.id)||[];if(nested.length){const ul=element('ul','tree');for(const child of nested)ul.append(taskNode(child,children,depth+1));li.append(ul);}return li;}
338
+ 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(job.id===selectedJob));row.addEventListener('click',()=>selectJob(job.id));const content=element('div','job-content');const select=document.createElement('button');select.type='button';select.className='job-button';select.setAttribute('aria-current',String(job.id===selectedJob));select.title=job.title||job.id;select.addEventListener('click',event=>{event.stopPropagation();selectJob(job.id);});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('PUBLISHED v'+job.publication.version)));if(isCompleteUnmerged(job))states.append(element('span','badge MERGE_PENDING',text('MERGE PENDING')));const actions=element('span','job-actions');actions.append(actionButton(job,'start','Start','▶'),actionButton(job,'reconcile','Reconcile','↻'),actionButton(job,'cancel','Cancel','×'));const primary=element('div','job-primary');primary.append(states,actions);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));}}
339
+ function taskNode(task,children,depth){const li=element('li');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)));li.append(row);const nested=children.get(task.id)||[];if(nested.length){const ul=element('ul','tree');for(const child of nested)ul.append(taskNode(child,children,depth+1));li.append(ul);}return li;}
337
340
  function buildTree(tasks){taskTree.replaceChildren();if(!Array.isArray(tasks)||!tasks.length){taskTree.append(element('li','empty',text('No tasks in durable state.')));return;}const known=new Set(tasks.map(task=>task.id));const children=new Map();const roots=[];for(const task of tasks){if(!task.parentTaskId||!known.has(task.parentTaskId))roots.push(task);else{const siblings=children.get(task.parentTaskId)||[];siblings.push(task);children.set(task.parentTaskId,siblings);}}for(const task of roots)taskTree.append(taskNode(task,children,0));}
338
341
  function attemptBudget(task){const attempt=Number(task?.attempt)||0;const max=Number(task?.maxAttempts)||0;if(max>=3&&max%3===0){const effectiveMax=Math.max(max,Math.ceil(attempt/3)*3);return attempt+' / 3×'+(effectiveMax/3);}return attempt+' / '+max;}
339
- function taskActionEnabled(task,action){if(action==='retry')return ['FAILED','BLOCKED'].includes(task.state);if(action==='cancel')return ['DRAFT','QUEUED','BLOCKED','FAILED'].includes(task.state);return false;}
340
- function taskActionButton(task,action,label,icon){const button=document.createElement('button');button.type='button';button.className='job-action';button.textContent=icon;button.title=label;button.setAttribute('aria-label',label);button.disabled=!taskActionEnabled(task,action);button.addEventListener('click',()=>void runTaskAction(task,action,button));return button;}
341
- async function runTaskAction(task,action,button){const job=selected();if(!job)return;button.disabled=true;try{const response=await fetch('/api/task-action?id='+encodeURIComponent(job.id)+'&task='+encodeURIComponent(task.id)+'&action='+encodeURIComponent(action),{method:'POST'});if(!response.ok)throw new Error(response.status+' '+response.statusText);const updated=await response.json();const index=jobs.findIndex(candidate=>candidate.id===job.id);if(index>=0)jobs[index]=updated;else jobs.push(updated);renderAll();void loadEventHistory();}catch(error){button.title=(action==='retry'?'Restart':'Cancel')+': '+error.message;}finally{renderAll();}}
342
- function renderTaskDetail(task){taskDetailRoot.replaceChildren();if(!task){taskDetailRoot.append(element('p','empty',text('Select a task to inspect execution details.')));return;}const actions=element('div','task-actions');actions.append(taskActionButton(task,'retry','Restart task','↻'),taskActionButton(task,'cancel','Cancel task','×'));const routing=task.routing||{};const groups=element('div','detail-groups');groups.append(field('Task',task.id),field('Kind',task.kind||'work'),field('State',task.state),field('Planning decision',task.planningDecisionId||'—'),field('Planning source',task.planningSource||'—'),field('Planning round',task.planningRound===undefined?'—':String(task.planningRound)),field('Project / agent',value(task.projectName)+' / '+value(task.agentName)),field('Parent task',task.parentTaskId||'root'),field('Adapter',task.adapter),field('Provider / model',value(routing.provider)+' / '+value(routing.model)),field('Reasoning / profile',value(routing.reasoning||routing.profile)),field('Attempt',attemptBudget(task)),field('Dependencies',Array.isArray(task.dependencies)&&task.dependencies.length?task.dependencies.join(', '):'none'),taskCommentField(task),chatField(task));const graph=agentGraphField(task);if(graph)groups.append(graph);if(task.plannerResult)groups.append(field('Objective status',task.plannerResult.objective_status),markdownField('Assessment',task.plannerResult.assessment));if(task.error)groups.append(field('Error',task.error));taskDetailRoot.append(actions,groups);}
342
+ const TASK_STATES=['DRAFT','QUEUED','RUNNING','BLOCKED','SUCCEEDED','FAILED','CANCELLED'];
343
+ async function setTaskState(task,state,select){const job=selected();if(!job||state===task.state)return;select.disabled=true;try{const response=await fetch('/api/task-state?id='+encodeURIComponent(job.id)+'&task='+encodeURIComponent(task.id)+'&state='+encodeURIComponent(state),{method:'POST'});if(!response.ok)throw new Error(response.status+' '+response.statusText);const updated=await response.json();const index=jobs.findIndex(candidate=>candidate.id===job.id);if(index>=0)jobs[index]=updated;else jobs.push(updated);renderAll();void loadEventHistory();}catch(error){select.title='Set task state: '+error.message;select.value=task.state;}finally{select.disabled=false;}}
344
+ function taskStateField(task){const box=element('div','detail-group');box.append(element('p','detail-label',text('State')));const select=document.createElement('select');select.className='job-filter task-state-select';select.setAttribute('aria-label','Task state');for(const state of TASK_STATES)select.append(new Option(state,state));select.value=task.state;select.addEventListener('change',()=>void setTaskState(task,select.value,select));box.append(select);return box;}
345
+ function manualOverrideField(task){const rows=Array.isArray(task.manualStateOverrides)?task.manualStateOverrides:[];const last=rows.at(-1);return field('Manual override',last?last.from+' → '+last.to+' · '+relativeTime(last.at):'—');}
346
+ function renderTaskDetail(task){taskDetailRoot.replaceChildren();if(!task){taskDetailRoot.append(element('p','empty',text('Select a task to inspect execution details.')));return;}const routing=task.routing||{};const groups=element('div','detail-groups');groups.append(field('Task',task.id),field('Kind',task.kind||'work'),taskStateField(task),manualOverrideField(task),field('Planning decision',task.planningDecisionId||'—'),field('Planning source',task.planningSource||'—'),field('Planning round',task.planningRound===undefined?'—':String(task.planningRound)),field('Project / agent',value(task.projectName)+' / '+value(task.agentName)),field('Parent task',task.parentTaskId||'root'),field('Adapter',task.adapter),field('Provider / model',value(routing.provider)+' / '+value(routing.model)),field('Reasoning / profile',value(routing.reasoning||routing.profile)),field('Attempt',attemptBudget(task)),field('Dependencies',Array.isArray(task.dependencies)&&task.dependencies.length?task.dependencies.join(', '):'none'),taskCommentField(task),chatField(task));const job=selected();if(task.reviewHeadSha&&job)groups.append(commitField('HEAD commit',job,task.reviewHeadSha));const graph=agentGraphField(task);if(graph)groups.append(graph);if(task.plannerResult)groups.append(field('Objective status',task.plannerResult.objective_status),markdownField('Assessment',task.plannerResult.assessment));if(task.error)groups.append(field('Error',task.error));taskDetailRoot.append(groups);}
343
347
  function reviewStatus(job){const review=job?.review;if(!review)return 'Not started';if(review.outcome==='approved'&&review.headSha===job.headSha)return 'MERGE READY · approved';const stale=Array.isArray(job?.tasks)&&job.tasks.some(task=>task.agentName==='reviewer'&&task.reviewHeadSha&&job.headSha&&task.reviewHeadSha!==job.headSha);if(stale)return 'STALE · awaiting review';return String(review.outcome||'pending').replace('_',' ').toUpperCase();}
344
- function renderSelectedJob(){jobDetail.replaceChildren();const job=selected();if(!job){selectedJobTitle.textContent='';jobDetail.append(element('div','empty',text('No durable jobs discovered.')));buildTree([]);renderTaskDetail(null);return;}selectedJobTitle.textContent=value(job.title||job.id);if(job.id!==selectedJob){selectedJob=job.id;selectedTaskId=null;eventTaskId=null;clearEvents();rememberSelection();}const summary=element('div','job-summary');summary.append(field('PR state',job.githubState||'UNKNOWN'),field('Lifecycle state',job.state),field('Runtime status',job.runtimeStatus||'IDLE'),field('Execution mode',job.executionMode||'fixed'),field('Review',reviewStatus(job)),field('Review head SHA',job.review?.headSha||job.headSha||'—'),prField(job),field('Repository',job.repository||'—'),field('Last activity',relativeTime(job.updatedAt)),field('Task states',compactCounts(job.tasks)));jobDetail.append(summary);const selectedTask=(job.tasks||[]).find(task=>task.id===selectedTaskId)||null;if(selectedTaskId&&!selectedTask){selectedTaskId=null;eventTaskId=null;clearEvents();rememberSelection();}buildTree(job.tasks||[]);renderTaskDetail(selectedTask);renderEventScope();}
348
+ function renderSelectedJob(){jobDetail.replaceChildren();const job=selected();if(!job){selectedJobTitle.textContent='';jobDetail.append(element('div','empty',text('No durable jobs discovered.')));buildTree([]);renderTaskDetail(null);return;}selectedJobTitle.textContent=value(job.title||job.id);if(job.id!==selectedJob){selectedJob=job.id;selectedTaskId=null;eventTaskId=null;clearEvents();rememberSelection();}const summary=element('div','job-summary');summary.append(field('PR state',job.githubState||'UNKNOWN'),field('Lifecycle state',job.state),field('Runtime status',job.runtimeStatus||'IDLE'),field('Execution mode',job.executionMode||'fixed'),field('Review',reviewStatus(job)),commitField('Review head SHA',job,job.review?.headSha||job.headSha||''),prField(job),field('Repository',job.repository||'—'),field('Last activity',relativeTime(job.updatedAt)),field('Task states',compactCounts(job.tasks)));jobDetail.append(summary);const selectedTask=(job.tasks||[]).find(task=>task.id===selectedTaskId)||null;if(selectedTaskId&&!selectedTask){selectedTaskId=null;eventTaskId=null;clearEvents();rememberSelection();}buildTree(job.tasks||[]);renderTaskDetail(selectedTask);renderEventScope();}
345
349
  function renderAll(){jobs.sort(compareJobs);syncFilters();renderJobList();renderSelectedJob();syncMobileJobsView();}
346
350
  function isCompleteUnmerged(job){return job.runtimeStatus==='MERGE_PENDING';}
347
351
  function isTerminalJob(job){return ['COMPLETED','CANCELLED'].includes(job.state)||['MERGED','CLOSED'].includes(job.githubState);}
package/dist/dashboard.js CHANGED
@@ -68,7 +68,7 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
68
68
  const discoveredJobs = async (forceRefresh = false) => {
69
69
  try {
70
70
  const jobs = overviewProvider
71
- ? (await overviewProvider(forceRefresh)).map(item => ({ ...item.job, githubState: item.githubState, runtimeStatus: deriveJobRuntimeStatus(item.job, item.githubState), mergedAt: item.mergedAt, closedAt: item.closedAt, draft: item.draft }))
71
+ ? (await overviewProvider(forceRefresh)).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 }))
72
72
  : jobId ? [await store.load(jobId)] : [];
73
73
  lastKnownJobs = jobs;
74
74
  return jobs;
@@ -261,6 +261,32 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
261
261
  }
262
262
  return;
263
263
  }
264
+ if (new URL(req.url ?? '/', 'http://127.0.0.1').pathname === '/api/task-state') {
265
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1');
266
+ const requestedJobId = url.searchParams.get('id') ?? '';
267
+ const requestedTaskId = url.searchParams.get('task') ?? '';
268
+ const requestedState = url.searchParams.get('state') ?? '';
269
+ const validState = ['DRAFT', 'QUEUED', 'RUNNING', 'BLOCKED', 'SUCCEEDED', 'FAILED', 'CANCELLED'].includes(requestedState);
270
+ if (req.method !== 'POST' || !/^[A-Za-z0-9_-]+$/.test(requestedJobId) || !/^[A-Za-z0-9_-]+$/.test(requestedTaskId) || !validState || !resolvedActions?.taskSetState) {
271
+ res.writeHead(404);
272
+ res.end('Task state override unavailable');
273
+ return;
274
+ }
275
+ try {
276
+ const result = await resolvedActions.taskSetState(requestedJobId, requestedTaskId, requestedState);
277
+ if (!res.headersSent)
278
+ res.writeHead(200, { 'content-type': 'application/json' });
279
+ if (!res.writableEnded)
280
+ res.end(JSON.stringify(result));
281
+ }
282
+ catch (error) {
283
+ if (!res.headersSent)
284
+ res.writeHead(409);
285
+ if (!res.writableEnded)
286
+ res.end(error instanceof Error ? error.message : String(error));
287
+ }
288
+ return;
289
+ }
264
290
  if (new URL(req.url ?? '/', 'http://127.0.0.1').pathname === '/api/job') {
265
291
  try {
266
292
  const requestedJobId = new URL(req.url ?? '/', 'http://127.0.0.1').searchParams.get('id') ?? undefined;
@@ -1,7 +1,7 @@
1
1
  import { createHmac, timingSafeEqual } from 'node:crypto';
2
2
  export const PACKAGE_PUBLISHED_MARKER = 'agents-relay:package-published:v1';
3
3
  const WAKE_EVENTS = new Set(['pull_request', 'issue_comment', 'pull_request_review', 'pull_request_review_comment', 'check_run', 'check_suite', 'workflow_run']);
4
- function parsePackagePublication(body) {
4
+ export function parsePackagePublicationMarker(body) {
5
5
  if (typeof body !== 'string')
6
6
  return null;
7
7
  const prefix = '<!-- ' + PACKAGE_PUBLISHED_MARKER + ' ';
@@ -51,7 +51,7 @@ export function parseGitHubWebhook(headers, body) {
51
51
  const issue = payload.issue;
52
52
  const number = Number(pullRequest?.number ?? (issue?.pull_request ? issue.number : 0));
53
53
  const comment = payload.comment;
54
- const publication = event === 'issue_comment' ? parsePackagePublication(comment?.body) : null;
54
+ const publication = event === 'issue_comment' ? parsePackagePublicationMarker(comment?.body) : null;
55
55
  return {
56
56
  deliveryId,
57
57
  event,
package/dist/relayd.js CHANGED
@@ -11,7 +11,8 @@ import { RepositoryWorkerPool, aggregateManagedGitHubJobs } from './pool.js';
11
11
  import { Reconciler } from './reconciler.js';
12
12
  import { dashboardManagedJobs, GitHubStore, InMemoryStore, loadManagedGitHubPullRequestGraphql } from './store.js';
13
13
  import { githubAuthContext } from './github-auth.js';
14
- import { dashboardJobAction, dashboardTaskAction, isEntrypoint, runtimePlanner, runtimeReviewer, startService, SERVICE_DEFAULTS } from './cli.js';
14
+ import { dashboardJobAction, isEntrypoint, runtimePlanner, runtimeReviewer, startService, SERVICE_DEFAULTS } from './cli.js';
15
+ import { dashboardTaskAction, dashboardTaskSetState } from './dashboard-actions.js';
15
16
  import { codexAndZaiUsageRegistry } from './usage.js';
16
17
  import { defaultWorkspaceRoot, discoverWorkspaceRepositories, resolveManagedProjectPath, resolveRepositoryProjectPath } from './workspace.js';
17
18
  import { deriveJobRuntimeStatus } from './runtime-status.js';
@@ -188,6 +189,15 @@ export async function runDaemon(argv) {
188
189
  const store = new GitHubStore(client, overview.job.repository, overview.job.prNumber, trusted);
189
190
  return await dashboardTaskAction(store, makeReconciler(store, concurrency), action)(jobId, taskId);
190
191
  };
192
+ const workspaceTaskSetState = async (jobId, taskId, state) => {
193
+ const overview = cachedOverviews.find(item => item.job.id === jobId);
194
+ if (!overview)
195
+ throw new Error(`Managed job ${jobId} not found`);
196
+ const store = new GitHubStore(client, overview.job.repository, overview.job.prNumber, trusted);
197
+ const result = await dashboardTaskSetState(store)(jobId, taskId, state);
198
+ await refreshPullRequest(overview.job.repository, overview.job.prNumber);
199
+ return result;
200
+ };
191
201
  const secretFile = value(argv, '--github-webhook-secret-file');
192
202
  const secret = value(argv, '--github-webhook-secret') || process.env.AGENTS_RELAY_GITHUB_WEBHOOK_SECRET || (secretFile ? (await readFile(secretFile, 'utf8')).trim() : '');
193
203
  const webhook = secret ? {
@@ -216,14 +226,14 @@ export async function runDaemon(argv) {
216
226
  const cached = cachedOverviews.find(item => item.job.id === jobId);
217
227
  if (!cached)
218
228
  return null;
219
- const cachedJob = () => ({ ...cached.job, githubState: cached.githubState, runtimeStatus: deriveJobRuntimeStatus(cached.job, cached.githubState), mergedAt: cached.mergedAt, closedAt: cached.closedAt, draft: cached.draft });
229
+ const cachedJob = () => ({ ...cached.job, githubState: cached.githubState, runtimeStatus: deriveJobRuntimeStatus(cached.job, cached.githubState), mergedAt: cached.mergedAt, closedAt: cached.closedAt, draft: cached.draft, publication: cached.publication });
220
230
  if (client.rateLimitStatus().limited)
221
231
  return cachedJob();
222
232
  try {
223
233
  const refreshed = (await refreshPullRequest(cached.job.repository, cached.job.prNumber)).find(item => item.job.id === jobId);
224
234
  if (!refreshed)
225
235
  return null;
226
- return { ...refreshed.job, githubState: refreshed.githubState, runtimeStatus: deriveJobRuntimeStatus(refreshed.job, refreshed.githubState), mergedAt: refreshed.mergedAt, closedAt: refreshed.closedAt, draft: refreshed.draft };
236
+ return { ...refreshed.job, githubState: refreshed.githubState, runtimeStatus: deriveJobRuntimeStatus(refreshed.job, refreshed.githubState), mergedAt: refreshed.mergedAt, closedAt: refreshed.closedAt, draft: refreshed.draft, publication: refreshed.publication };
227
237
  }
228
238
  catch (error) {
229
239
  process.stderr.write(`warning: selected job refresh unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
@@ -236,6 +246,7 @@ export async function runDaemon(argv) {
236
246
  cancel: workspaceJobAction('cancel'),
237
247
  taskRetry: workspaceTaskAction('retry'),
238
248
  taskCancel: workspaceTaskAction('cancel'),
249
+ taskSetState: workspaceTaskSetState,
239
250
  });
240
251
  await once(server, 'listening');
241
252
  const reconcileSafely = async () => {
package/dist/store.js CHANGED
@@ -2,6 +2,15 @@ import { spawn } from 'node:child_process';
2
2
  import { createSign } from 'node:crypto';
3
3
  import { parseAgent, parseJob, parseTask, renderAgentComment, renderJobComment, renderTaskComment, AGENT_MARKER, JOB_MARKER, TASK_MARKER } from './markers.js';
4
4
  import { isTaskForJob, sameTaskDefinition } from './store-task-definition.js';
5
+ import { parsePackagePublicationMarker } from './github-webhook.js';
6
+ function publicationFromComments(rows, trustedAuthors) {
7
+ const allowed = rows.filter(item => {
8
+ const author = item.author;
9
+ const login = typeof author?.login === 'string' ? author.login : '';
10
+ return trustedAuthors.has(login) || login === 'github-actions[bot]';
11
+ });
12
+ return allowed.map(item => parsePackagePublicationMarker(String(item.body ?? ''))).filter((item) => item !== null).at(-1) ?? null;
13
+ }
5
14
  export async function loadManagedGitHubPullRequestGraphql(client, repository, prNumber, trustedAuthors) {
6
15
  const [owner, name] = repository.split('/', 2);
7
16
  if (!owner || !name || !Number.isInteger(prNumber) || prNumber <= 0 || !client.graphql)
@@ -21,6 +30,7 @@ export async function loadManagedGitHubPullRequestGraphql(client, repository, pr
21
30
  const bodyJob = parseJob(typeof pr.body === 'string' ? pr.body : '');
22
31
  const commentJobs = trusted.map(item => parseJob(String(item.body ?? ''))).filter((item) => item !== null);
23
32
  const jobs = bodyJob ? [bodyJob] : commentJobs;
33
+ const publication = publicationFromComments((Array.isArray(comments) ? comments : []), trustedAuthors);
24
34
  const tasks = trusted.map(item => {
25
35
  const row = item;
26
36
  const task = parseTask(String(row.body ?? ''));
@@ -35,7 +45,7 @@ export async function loadManagedGitHubPullRequestGraphql(client, repository, pr
35
45
  job.tasks = tasks.filter(task => task.jobId === job.id || (!task.jobId && jobs.length === 1));
36
46
  if (!job.description && typeof pr.body === 'string' && pr.body)
37
47
  job.description = pr.body;
38
- return { job, githubState, draft: pr.isDraft === true, mergedAt: typeof pr.mergedAt === 'string' ? pr.mergedAt : null, closedAt: typeof pr.closedAt === 'string' ? pr.closedAt : null };
48
+ return { job, githubState, draft: pr.isDraft === true, mergedAt: typeof pr.mergedAt === 'string' ? pr.mergedAt : null, closedAt: typeof pr.closedAt === 'string' ? pr.closedAt : null, publication };
39
49
  });
40
50
  }
41
51
  export async function loadManagedGitHubJobGraphql(client, repository, prNumber, jobId, trustedAuthors) {
@@ -456,6 +466,7 @@ export async function listManagedGitHubJobs(client, repository, trustedAuthors)
456
466
  const bodyJob = parseJob(typeof pr.body === 'string' ? pr.body : '');
457
467
  const commentJobs = trusted.map(item => parseJob(String(item.body ?? ''))).filter((item) => item !== null);
458
468
  const jobs = bodyJob ? [bodyJob] : commentJobs;
469
+ const publication = publicationFromComments(rows, trustedAuthors);
459
470
  const tasks = trusted.map(item => parseTask(String(item.body ?? ''))).filter((item) => item !== null);
460
471
  const state = String(pr.state ?? '').toUpperCase();
461
472
  const githubState = pr.merged === true || state === 'MERGED' ? 'MERGED' : state === 'CLOSED' ? 'CLOSED' : 'OPEN';
@@ -465,7 +476,7 @@ export async function listManagedGitHubJobs(client, repository, trustedAuthors)
465
476
  job.prNumber = number;
466
477
  job.repository = repository;
467
478
  job.tasks = tasks.filter(task => task.jobId === job.id || (!task.jobId && jobs.length === 1));
468
- output.push({ job, githubState, draft: pr.isDraft === true, mergedAt: typeof pr.mergedAt === 'string' ? pr.mergedAt : null, closedAt: typeof pr.closedAt === 'string' ? pr.closedAt : null });
479
+ output.push({ job, githubState, draft: pr.isDraft === true, mergedAt: typeof pr.mergedAt === 'string' ? pr.mergedAt : null, closedAt: typeof pr.closedAt === 'string' ? pr.closedAt : null, publication });
469
480
  }
470
481
  }
471
482
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "1.0.132",
3
+ "version": "1.0.133",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {