agents-relay 1.0.63 → 1.0.65

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
@@ -668,6 +668,35 @@ async function runJobCommand(action, args) {
668
668
  export function runtimePlanner(args) { const plannerCommand = arg([...args], '--planner-command'); const modelRuntime = arg([...args], '--codex', 'codex'); return plannerCommand ? new CommandObjectivePlanner(plannerCommand) : new AgentObjectivePlanner(modelRuntime); }
669
669
  export function runtimeReviewer() { return { requirements: { capabilities: ['model'] } }; }
670
670
  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(), reviewer: runtimeReviewer(), eventBus: bus, emit, chatgptTabClosePolicy: policy, projectPathForJob: job => resolveManagedProjectPath(job.repository, arg(args, '--project-path'), arg(args, '--workspace-root') || defaultWorkspaceRoot()) }); }
671
+ export function dashboardJobAction(store, reconciler, action) {
672
+ return async (jobId) => {
673
+ const job = await store.load(jobId);
674
+ if (action === 'start') {
675
+ if (job.state !== 'DRAFT')
676
+ throw new Error(`Job ${job.id} is not startable (${job.state})`);
677
+ job.state = 'ACTIVE';
678
+ job.updatedAt = new Date().toISOString();
679
+ await store.saveJob(job);
680
+ }
681
+ else if (action === 'cancel') {
682
+ if (job.state === 'COMPLETED' || job.state === 'CANCELLED')
683
+ throw new Error(`Job ${job.id} is not cancellable (${job.state})`);
684
+ for (const task of job.tasks.filter(item => !['SUCCEEDED', 'CANCELLED'].includes(item.state))) {
685
+ transitionTask(task, 'CANCELLED');
686
+ task.error = 'Job cancelled';
687
+ task.leaseOwner = null;
688
+ task.leaseExpiresAt = null;
689
+ await store.saveTask(task);
690
+ }
691
+ await store.closePullRequest?.();
692
+ job.state = 'CANCELLED';
693
+ job.updatedAt = new Date().toISOString();
694
+ await store.saveJob(job);
695
+ return await store.load(job.id);
696
+ }
697
+ return await reconciler.wake(job.id);
698
+ };
699
+ }
671
700
  export async function createService(args) {
672
701
  const loaded = await storeFor(args);
673
702
  const upstream = eventBus(args);
@@ -687,7 +716,11 @@ export async function createService(args) {
687
716
  void bus.publish(wakeEvent);
688
717
  else
689
718
  void reconciler.reconcile(loaded.job.id); } } : undefined;
690
- const dashboardServer = serveDashboard(loaded.store, Number(arg(args, '--port', String(SERVICE_DEFAULTS.port))), bus, Number(arg(args, '--refresh-ms', String(SERVICE_DEFAULTS.dashboardRefreshMs))), loaded.job.id, usageRegistry, overviewProvider, webhook);
719
+ const dashboardServer = serveDashboard(loaded.store, Number(arg(args, '--port', String(SERVICE_DEFAULTS.port))), bus, Number(arg(args, '--refresh-ms', String(SERVICE_DEFAULTS.dashboardRefreshMs))), loaded.job.id, usageRegistry, overviewProvider, webhook, undefined, undefined, {
720
+ start: dashboardJobAction(loaded.store, reconciler, 'start'),
721
+ reconcile: dashboardJobAction(loaded.store, reconciler, 'reconcile'),
722
+ cancel: dashboardJobAction(loaded.store, reconciler, 'cancel'),
723
+ });
691
724
  await once(dashboardServer, 'listening');
692
725
  const stopWatcher = await reconciler.watch(loaded.job.id);
693
726
  const watchdog = setInterval(() => void reconciler.reconcile(loaded.job.id), Number(arg(args, '--interval', String(SERVICE_DEFAULTS.watchdogMs))));
package/dist/dashboard.js CHANGED
@@ -87,13 +87,21 @@ export const dashboardHtml = `<!doctype html>
87
87
  .workspace { display:grid; gap:1rem; }
88
88
  .job-list,.tree,.event-list { list-style:none; margin:0; padding:0; }
89
89
  .job-list { display:grid; gap:.35rem; }
90
- .job-button { width:100%; display:grid; gap:.2rem; padding:.5rem; border:1px solid transparent; border-radius:.4rem; background:transparent; color:inherit; text-align:left; font:inherit; cursor:pointer; }
91
- .job-button[aria-current="true"] { border-color:var(--accent); background:var(--accent2); }
92
- .job-button.complete-unmerged { border-color:#d69a1b; background:#fff8df; }
93
- .job-button.complete-unmerged[aria-current="true"] { border-color:#b77900; background:#fff0bd; }
90
+ .job-row { display:grid; gap:.2rem; padding:.35rem .45rem; border:1px solid transparent; border-radius:.4rem; }
91
+ .job-row[aria-current="true"] { border-color:var(--accent); background:var(--accent2); }
92
+ .job-row.complete-unmerged { border-color:#d69a1b; background:#fff8df; }
93
+ .job-row.complete-unmerged[aria-current="true"] { border-color:#b77900; background:#fff0bd; }
94
+ .job-button { min-width:0; display:grid; gap:.2rem; padding:.12rem 0; border:0; background:transparent; color:inherit; text-align:left; font:inherit; cursor:pointer; }
94
95
  .job-title { font-weight:750; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
95
96
  .job-meta { display:flex; gap:.4rem; flex-wrap:wrap; color:var(--muted); font-size:.7rem; }
96
97
  .job-meta span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
98
+ .job-primary { display:flex; align-items:center; justify-content:space-between; gap:.45rem; min-width:0; }
99
+ .job-status-line { display:flex; align-items:center; gap:.3rem; min-width:0; white-space:nowrap; }
100
+ .job-status-line .badge { flex:0 0 auto; }
101
+ .job-actions { display:flex; align-items:center; gap:.2rem; }
102
+ .job-action { width:1.8rem; height:1.8rem; display:inline-grid; place-items:center; padding:0; border:1px solid var(--border); border-radius:.35rem; background:var(--surface); color:var(--muted); font:inherit; font-size:.95rem; line-height:1; cursor:pointer; }
103
+ .job-action:hover:not(:disabled) { color:var(--accent); border-color:var(--accent); }
104
+ .job-action:disabled { cursor:default; opacity:.4; }
97
105
  .status-pill,.badge { display:inline-flex; align-items:center; border-radius:999px; padding:.06rem .42rem; min-height:1.35rem; font-size:.68rem; font-weight:800; }
98
106
  .status-pill.ok,.badge.ACTIVE,.badge.RUNNING,.badge.SUCCEEDED,.badge.COMPLETED,.badge.MERGED { background:#e5f3e9; color:var(--ok); }
99
107
  .status-pill.warn,.badge.BLOCKED,.badge.DRAFT,.badge.QUEUED,.badge.WAITING,.badge.OPEN { background:#fff1d2; color:var(--warn); }
@@ -237,7 +245,11 @@ function syncFilters(){
237
245
  selectedRepo=repos.includes(previousRepo)?previousRepo:'';
238
246
  repoFilter.value=selectedRepo;
239
247
  }
240
- 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 button=document.createElement('button');button.type='button';button.className='job-button'+(isCompleteUnmerged(job)?' complete-unmerged':'');button.setAttribute('aria-current',String(job.id===selectedJob));button.title=job.title||job.id;button.addEventListener('click',()=>selectJob(job.id));const meta=element('span','job-meta');meta.append(element('span','badge '+value(job.runtimeStatus||'IDLE'),text(value(job.runtimeStatus||'IDLE'))),element('span','badge '+value(job.state),text(job.state)),element('span','badge mode-'+value(job.executionMode||'fixed'),text(value(job.executionMode||'fixed').toUpperCase())),element('span',job.draft?'badge DRAFT':'',text(job.draft?'DRAFT':value(job.githubState||'UNKNOWN'))));if(isCompleteUnmerged(job))meta.append(element('span','badge MERGE_PENDING',text('MERGE PENDING')));meta.append(element('span','',text(job.repository||'unknown repo')),element('span','',text(relativeTime(job.updatedAt))));button.append(element('span','job-title',text('PR#'+value(job.prNumber)+': '+value(job.title||job.id))),meta,element('span','subtle',text(compactCounts(counts(job.tasks)))));jobList.append(element('li','',button));}}
248
+ function actionEnabled(job,action){if(action==='start')return job.state==='DRAFT'&&job.githubState!=='CLOSED'&&job.githubState!=='MERGED';if(action==='reconcile')return job.state==='ACTIVE'&&job.githubState==='OPEN';if(action==='cancel')return job.state!=='COMPLETED'&&job.state!=='CANCELLED'&&job.githubState==='OPEN';return false;}
249
+ function actionButton(job,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=!actionEnabled(job,action);button.addEventListener('click',event=>{event.stopPropagation();void runJobAction(job,action,button);});return button;}
250
+ 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();}}
251
+ function labelForAction(action){return action==='start'?'Start':action==='reconcile'?'Reconcile':'Cancel';}
252
+ 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));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',()=>selectJob(job.id));select.append(element('span','job-title',text('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'))),element('span','badge '+value(job.githubState||'UNKNOWN'),text(value(job.githubState||'UNKNOWN'))));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 meta=element('span','job-meta');meta.append(element('span','badge mode-'+value(job.executionMode||'fixed'),text(value(job.executionMode||'fixed').toUpperCase())),element('span',job.draft?'badge DRAFT':'',text(job.draft?'DRAFT':value(job.githubState||'UNKNOWN'))));if(isCompleteUnmerged(job))meta.append(element('span','badge MERGE_PENDING',text('MERGE PENDING')));meta.append(element('span','',text(job.repository||'unknown repo')),element('span','',text(relativeTime(job.updatedAt))));content.append(select,primary,meta,element('span','subtle',text(compactCounts(counts(job.tasks)))));row.append(content);jobList.append(element('li','',row));}}
241
253
  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;}
242
254
  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));}
243
255
  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;}
@@ -287,7 +299,7 @@ async function refreshSelected(){if(!selectedJob){await refresh();return;}select
287
299
  </script>
288
300
  </body>
289
301
  </html>`;
290
- export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobId, usageRegistry, overviewProvider, webhook, statusProvider, exactJobProvider) {
302
+ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobId, usageRegistry, overviewProvider, webhook, statusProvider, exactJobProvider, actions) {
291
303
  const webhookDeliveries = new DeliveryDeduper();
292
304
  const clients = new Set();
293
305
  const subscribedJobs = new Set();
@@ -428,6 +440,26 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
428
440
  }
429
441
  return;
430
442
  }
443
+ if (new URL(req.url ?? '/', 'http://127.0.0.1').pathname === '/api/job-action') {
444
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1');
445
+ const requestedJobId = url.searchParams.get('id') ?? '';
446
+ const action = url.searchParams.get('action') ?? '';
447
+ const handler = action === 'start' ? actions?.start : action === 'reconcile' ? actions?.reconcile : action === 'cancel' ? actions?.cancel : undefined;
448
+ if (req.method !== 'POST' || !/^[A-Za-z0-9_-]+$/.test(requestedJobId) || !handler) {
449
+ res.writeHead(404);
450
+ res.end('Job action unavailable');
451
+ return;
452
+ }
453
+ try {
454
+ res.writeHead(200, { 'content-type': 'application/json' });
455
+ res.end(JSON.stringify(await handler(requestedJobId)));
456
+ }
457
+ catch (error) {
458
+ res.writeHead(409);
459
+ res.end(error instanceof Error ? error.message : String(error));
460
+ }
461
+ return;
462
+ }
431
463
  if (new URL(req.url ?? '/', 'http://127.0.0.1').pathname === '/api/job') {
432
464
  try {
433
465
  const requestedJobId = new URL(req.url ?? '/', 'http://127.0.0.1').searchParams.get('id') ?? undefined;
package/dist/relayd.js CHANGED
@@ -11,7 +11,7 @@ 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 { isEntrypoint, runtimePlanner, runtimeReviewer, startService, SERVICE_DEFAULTS } from './cli.js';
14
+ import { dashboardJobAction, isEntrypoint, runtimePlanner, runtimeReviewer, startService, SERVICE_DEFAULTS } from './cli.js';
15
15
  import { codexAndZaiUsageRegistry } from './usage.js';
16
16
  import { defaultWorkspaceRoot, discoverWorkspaceRepositories, resolveManagedProjectPath } from './workspace.js';
17
17
  import { deriveJobRuntimeStatus } from './runtime-status.js';
@@ -120,6 +120,13 @@ export async function runDaemon(argv) {
120
120
  const initial = cachedOverviews;
121
121
  const fallback = initial[0]?.job;
122
122
  const dashboardStore = fallback ? new GitHubStore(client, fallback.repository, fallback.prNumber, trusted) : new InMemoryStore();
123
+ const workspaceJobAction = (action) => async (jobId) => {
124
+ const overview = cachedOverviews.find(item => item.job.id === jobId);
125
+ if (!overview)
126
+ throw new Error(`Managed job ${jobId} not found`);
127
+ const store = new GitHubStore(client, overview.job.repository, overview.job.prNumber, trusted);
128
+ return await dashboardJobAction(store, makeReconciler(store, concurrency), action)(jobId);
129
+ };
123
130
  const secretFile = value(argv, '--github-webhook-secret-file');
124
131
  const secret = value(argv, '--github-webhook-secret') || process.env.AGENTS_RELAY_GITHUB_WEBHOOK_SECRET || (secretFile ? (await readFile(secretFile, 'utf8')).trim() : '');
125
132
  const webhook = secret ? {
@@ -159,7 +166,11 @@ export async function runDaemon(argv) {
159
166
  return null;
160
167
  }
161
168
  };
162
- const server = serveDashboard(dashboardStore, Number(value(argv, '--port', String(SERVICE_DEFAULTS.port))), bus, Number(value(argv, '--refresh-ms', String(SERVICE_DEFAULTS.dashboardRefreshMs))), fallback?.id, usageRegistry, overviews, webhook, () => client.rateLimitStatus(), exactJob);
169
+ const server = serveDashboard(dashboardStore, Number(value(argv, '--port', String(SERVICE_DEFAULTS.port))), bus, Number(value(argv, '--refresh-ms', String(SERVICE_DEFAULTS.dashboardRefreshMs))), fallback?.id, usageRegistry, overviews, webhook, () => client.rateLimitStatus(), exactJob, {
170
+ start: workspaceJobAction('start'),
171
+ reconcile: workspaceJobAction('reconcile'),
172
+ cancel: workspaceJobAction('cancel'),
173
+ });
163
174
  await once(server, 'listening');
164
175
  const reconcileSafely = async () => {
165
176
  if (client.rateLimitStatus().limited)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "1.0.63",
3
+ "version": "1.0.65",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -4,7 +4,7 @@ This contract is the shared protocol for every component that creates, schedules
4
4
 
5
5
  ## Job and Task authoring format
6
6
 
7
- Job objectives and Task inputs are **Markdown** and are durable execution context, not short labels. When the work has a workflow, state model, architecture, dependency graph, or relationship that is clearer visually, put a Mermaid block first and follow it with Markdown details. Otherwise use structured Markdown text directly.
7
+ Job objectives and Task inputs are **Markdown** and are durable execution context, not short labels. Use Mermaid where a diagram materially explains a workflow, state model, architecture, dependency graph, relationship, or other visual structure. **Mermaid-first applies to that explanation/section, not to the entire Job objective or Task input.** Put the diagram before the prose that explains that visual model, but keep normal document structure: start with the goal/context when that is what the reader needs first. Do not force a Mermaid block at the top of every objective/input, and do not add decorative or redundant diagrams when Markdown is clearer.
8
8
 
9
9
  The trusted machine-readable Job/Task marker stores lifecycle and execution metadata only. Do **not** duplicate the Job objective or Task input inside the JSON marker; those long-form Markdown bodies remain the human-readable durable content rendered by the Job/Task comment (and, for a Job objective, the PR body). Readers reconstruct `objective` / `input` from that visible Markdown while remaining backward-compatible with legacy markers that embedded them.
10
10