agents-relay 2.0.68 → 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 (52) hide show
  1. package/README.md +32 -1
  2. package/dist/adapters.js +39 -3
  3. package/dist/blockers.js +17 -4
  4. package/dist/cli-blocker.js +12 -28
  5. package/dist/cli-help.js +11 -8
  6. package/dist/cli-job-create.js +17 -9
  7. package/dist/cli-local-merge-config.js +4 -2
  8. package/dist/cli-projection.js +23 -55
  9. package/dist/cli-review.js +7 -24
  10. package/dist/cli-support.js +57 -1
  11. package/dist/cli-worker-task.js +63 -0
  12. package/dist/cli.js +177 -125
  13. package/dist/dashboard-html.js +15 -12
  14. package/dist/dashboard.js +26 -1
  15. package/dist/e2e-injection.js +76 -0
  16. package/dist/github-binding-order.js +15 -0
  17. package/dist/github-client.js +7 -2
  18. package/dist/github-pr-archive.js +22 -0
  19. package/dist/github-webhook.js +31 -6
  20. package/dist/job-actions.js +29 -0
  21. package/dist/local-authority-store.js +10 -1
  22. package/dist/local-commit-events.js +2 -0
  23. package/dist/local-git-merge.js +17 -6
  24. package/dist/local-job-registry.js +10 -4
  25. package/dist/local-merge-delivery.js +3 -2
  26. package/dist/local-merge-projection.js +60 -0
  27. package/dist/managed-worker-input.js +13 -6
  28. package/dist/manual-plan.js +29 -7
  29. package/dist/planner-runtime.js +182 -41
  30. package/dist/planner.js +35 -2
  31. package/dist/pool.js +22 -6
  32. package/dist/reconciler-cancellation.js +21 -0
  33. package/dist/reconciler-review.js +74 -18
  34. package/dist/reconciler-runtime.js +69 -34
  35. package/dist/reconciler.js +92 -26
  36. package/dist/relayd-control.js +184 -0
  37. package/dist/relayd-dashboard-actions.js +108 -0
  38. package/dist/relayd-utilities.js +107 -0
  39. package/dist/relayd.js +216 -206
  40. package/dist/review-generation-fence.js +12 -0
  41. package/dist/runtime-status-events.js +21 -0
  42. package/dist/runtime-status.js +13 -0
  43. package/dist/task-actions.js +59 -1
  44. package/dist/troubleshooter.js +21 -2
  45. package/dist/unblock.js +47 -15
  46. package/dist/worker-rejection.js +11 -0
  47. package/dist/workflow-effect-failures.js +30 -0
  48. package/dist/workspace.js +95 -6
  49. package/package.json +4 -3
  50. package/skills/agents-relay/agents/planner.agent.md +3 -0
  51. package/skills/agents-relay/contracts/orchestrator-workflows.md +4 -7
  52. package/workflow/review-lifecycle.mmd +3 -0
package/dist/cli.js CHANGED
@@ -1,9 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
- import { openLocalManagedStore } from './local-managed-store.js';
5
4
  import { configureLocalGitMerge } from './cli-local-merge-config.js';
6
- import { DisabledGitHubClient, noGitHubMode } from './no-github.js';
5
+ import { noGitHubMode } from './no-github.js';
7
6
  import { discoverLocalRelayStoreSnapshots } from './relay-store.js';
8
7
  import { realpathSync } from 'node:fs';
9
8
  import { once } from 'node:events';
@@ -26,83 +25,24 @@ import { applyJobActivateAction, applyJobCancelAction } from './job-actions.js';
26
25
  import { resolveExecutionContext } from './execution-context.js';
27
26
  import { startServiceExternalActionFlusher } from './service-external-actions.js';
28
27
  import { handleServicePublication } from './service-publication.js';
29
- import { defaultWorkspaceRoot, resolveManagedProjectPath, resolveRepositoryProjectPath } from './workspace.js';
28
+ import { defaultWorkspaceRoot, prepareLocalJobExecutionWorktree, resolveManagedProjectPath, resolveRepositoryProjectPath } from './workspace.js';
30
29
  import { runBlockerCommand } from './cli-blocker.js';
31
30
  import { runEscalationCommand } from './cli-escalation.js';
32
31
  import { runProjectionCommand } from './cli-projection.js';
33
32
  import { helpText, usage } from './cli-help.js';
34
33
  import { parseAgentGraph } from './agent-graph.js';
35
- import { runJobUnblockCommand, runTaskUnblockCommand } from './cli-unblock.js';
36
- import { createJobEntity, createTaskEntity } from './workflow/entity-creation.js';
34
+ import { runTaskUnblockCommand } from './cli-unblock.js';
35
+ import { createJobEntity } from './workflow/entity-creation.js';
37
36
  import { runCliEntrypoint } from './cli-shutdown.js';
38
37
  import { shutdownOwnedProcesses } from './owned-process.js';
39
38
  export { helpText } from './cli-help.js';
40
39
  import { runReviewCommand } from './cli-review.js';
41
40
  import { normalizeThreadId, runtimeReviewer } from './reviewer-thread.js';
42
41
  export { runtimeReviewer } from './reviewer-thread.js';
43
- import { SERVICE_DEFAULTS, ServiceEventBus, arg, hasArg, textOption, integerArg, repository, pullRequest, storeFor, saveLocal, eventBus, publishWake, priority, storySize, executionMode, chatGptTabClosePolicy, requestedExecutionMode, continuation, scheduleFromArgs, jobTagFromArgs, requestManagedReconciliation, relayControlJob, relayTaskCommand, withEventBus } from './cli-support.js';
42
+ import { SERVICE_DEFAULTS, ServiceEventBus, arg, hasArg, textOption, integerArg, repository, storeFor, saveLocal, eventBus, publishWake, priority, storySize, executionMode, chatGptTabClosePolicy, requestedExecutionMode, continuation, scheduleFromArgs, jobTagFromArgs, requestManagedReconciliation, relayControlJob, relayControlCommand, relayJobList, relayJobStateCommand, relayTaskCommand, withEventBus } from './cli-support.js';
44
43
  export { SERVICE_DEFAULTS, eventBus, executionMode, chatGptTabClosePolicy } from './cli-support.js';
45
- import { ensureManagedGitHubJob } from './cli-job-create.js';
44
+ import { workerTaskFromArgs } from './cli-worker-task.js';
46
45
  export { ensureManagedGitHubJob } from './cli-job-create.js';
47
- async function workerTaskFromArgs(job, args, now) {
48
- const model = arg(args, '--model');
49
- const adapterValue = arg(args, '--adapter');
50
- const adapter = (adapterValue || 'codex');
51
- const outputKind = arg(args, '--output');
52
- const outputPath = arg(args, '--output-path');
53
- const input = await textOption(args, '--input', '--input-file');
54
- const agentGraph = hasArg(args, '--agent-graph') || hasArg(args, '--agent-graph-file') ? await textOption(args, '--agent-graph', '--agent-graph-file') : undefined;
55
- if (agentGraph)
56
- parseAgentGraph(agentGraph);
57
- if (!input.trim())
58
- throw new Error('task create requires --input-file PATH|- or --input MARKDOWN');
59
- if (!['codex', 'chatgpt', 'orchestrator'].includes(adapter))
60
- throw new Error('task create --adapter must be codex, chatgpt, or orchestrator');
61
- if (outputKind !== 'task-pr' && outputKind !== 'file')
62
- throw new Error('task create requires --output task-pr|file');
63
- if (outputKind === 'file' && !outputPath)
64
- throw new Error('--output file requires --output-path');
65
- if (adapter !== 'orchestrator' && ['--provider', '--profile', '--reasoning', '--cwd', '--chatgpt-project'].some(flag => hasArg(args, flag)) && !model)
66
- throw new Error('task create explicit routing requires --model');
67
- const routing = adapter === 'orchestrator' || !model ? undefined : { provider: arg(args, '--provider', 'openai'), model, profile: arg(args, '--profile') || undefined, reasoning: arg(args, '--reasoning') || undefined, cwd: arg(args, '--cwd') || undefined, projectId: arg(args, '--chatgpt-project') || undefined, decidedBy: 'cli', decidedAt: now };
68
- const workerRequirements = adapter === 'orchestrator' || routing ? undefined : { capabilities: [], runtime: adapterValue ? adapter : undefined };
69
- const closePolicy = hasArg(args, '--chatgpt-tab-close-policy') ? chatGptTabClosePolicy(args) : undefined;
70
- const size = storySize(args);
71
- const dependencies = arg(args, '--deps').split(',').filter(Boolean);
72
- const timeoutMs = adapter === 'orchestrator' ? 0 : hasArg(args, '--timeout') ? integerArg(args, '--timeout', taskOverallTimeoutMs(size, routing?.reasoning)) : routing ? taskOverallTimeoutMs(size, routing.reasoning) : 0;
73
- return createTaskEntity(job, {
74
- jobId: job.id,
75
- id: arg(args, '--task-id', 'initial'),
76
- priority: priority(args, job.priority ?? 'P2'),
77
- storySize: size,
78
- projectName: arg(args, '--project') || undefined,
79
- agentName: arg(args, '--agent') || undefined,
80
- agentGraph,
81
- parentTaskId: arg(args, '--parent') || null,
82
- dependencies,
83
- capabilities: arg(args, '--capabilities').split(',').filter(Boolean),
84
- workerRequirements,
85
- adapter,
86
- chatgptTabClosePolicy: closePolicy,
87
- input: input || 'true',
88
- attachments: arg(args, '--attachments').split(',').filter(Boolean),
89
- output: outputKind === 'file' ? { kind: 'file', path: outputPath } : { kind: 'task_pr' },
90
- routing,
91
- continuation: continuation(args),
92
- continuationDeliveredAt: null,
93
- attempt: 0,
94
- maxAttempts: Number(arg(args, '--max-attempts', '3')),
95
- leaseOwner: null,
96
- leaseExpiresAt: null,
97
- executionId: null,
98
- threadId: null,
99
- result: null,
100
- error: null,
101
- timeoutMs,
102
- createdAt: now,
103
- updatedAt: now
104
- }, { defer: hasArg(args, '--draft') });
105
- }
106
46
  async function runManagedTaskCommand(action, args) {
107
47
  const jobId = arg(args, '--id');
108
48
  if (!jobId)
@@ -133,16 +73,100 @@ async function runManagedTaskCommand(action, args) {
133
73
  console.log(JSON.stringify(task, null, 2));
134
74
  }
135
75
  async function runTaskCommand(action, args) {
136
- if (!['create', 'update', 'show', 'list', 'start', 'complete', 'retry', 'unblock', 'cancel'].includes(action))
76
+ if (!['create', 'update', 'show', 'list', 'start', 'complete', 'retry', 'recover', 'unblock', 'cancel'].includes(action))
137
77
  throw new Error(usage);
138
- const managed = !noGitHubMode(args) && Boolean(arg(args, '--repo') && arg(args, '--pr') && !arg(args, '--file'));
139
- if (managed && ['create', 'start', 'complete', 'retry', 'cancel'].includes(action)) {
140
- await runManagedTaskCommand(action, args);
141
- return;
78
+ const managed = !arg(args, '--file') && Boolean(arg(args, '--repo') && arg(args, '--id'));
79
+ if (managed) {
80
+ const jobId = arg(args, '--id');
81
+ if (action === 'list') {
82
+ const job = await relayControlJob(args, jobId);
83
+ console.log(JSON.stringify(job.tasks, null, 2));
84
+ return;
85
+ }
86
+ const taskId = arg(args, '--task-id', action === 'create' ? randomUUID() : '');
87
+ if (action === 'show') {
88
+ if (!taskId)
89
+ throw new Error('task show requires --task-id');
90
+ const job = await relayControlJob(args, jobId);
91
+ const task = job.tasks.find(item => item.id === taskId);
92
+ if (!task)
93
+ throw new Error(`Task ${taskId} not found`);
94
+ console.log(JSON.stringify(task, null, 2));
95
+ return;
96
+ }
97
+ if (['create', 'start', 'complete', 'retry', 'cancel'].includes(action)) {
98
+ await runManagedTaskCommand(action, args);
99
+ return;
100
+ }
101
+ if (action === 'recover') {
102
+ if (!taskId)
103
+ throw new Error('task recover requires --task-id');
104
+ const recoveryTaskId = arg(args, '--recovery-task-id');
105
+ if (!recoveryTaskId)
106
+ throw new Error('task recover requires --recovery-task-id');
107
+ const job = await relayControlCommand(args, { command: 'task.recover', jobId, taskId, recoveryTaskId });
108
+ console.log(JSON.stringify(job.tasks.find(item => item.id === taskId), null, 2));
109
+ return;
110
+ }
111
+ if (action === 'unblock') {
112
+ if (!taskId)
113
+ throw new Error('task unblock requires --task-id');
114
+ const reason = arg(args, '--reason');
115
+ if (!reason.trim())
116
+ throw new Error('task unblock requires --reason');
117
+ const job = await relayControlCommand(args, { command: 'task.unblock', jobId, taskId, reason });
118
+ console.log(JSON.stringify(job.tasks.find(item => item.id === taskId), null, 2));
119
+ return;
120
+ }
121
+ if (action === 'update') {
122
+ const job = await relayControlJob(args, jobId);
123
+ const task = job.tasks.find(item => item.id === taskId);
124
+ if (!task)
125
+ throw new Error(`Task ${taskId} not found`);
126
+ const expectedUpdatedAt = task.updatedAt;
127
+ if (['RUNNING', 'UNBLOCKED', 'SUCCEEDED', 'CANCELLED'].includes(task.state))
128
+ throw new Error(`Task ${taskId} is ${task.state}; durable execution fields are immutable in this state`);
129
+ if (hasArg(args, '--adapter')) {
130
+ const adapter = arg(args, '--adapter');
131
+ if (!['codex', 'chatgpt', 'orchestrator'].includes(adapter))
132
+ throw new Error('--adapter must be codex, chatgpt, or orchestrator');
133
+ task.adapter = adapter;
134
+ }
135
+ if (hasArg(args, '--priority'))
136
+ task.priority = priority(args, task.priority ?? job.priority ?? 'P2');
137
+ if (hasArg(args, '--story-size')) {
138
+ task.storySize = storySize(args, task.storySize ?? 'M');
139
+ if (!hasArg(args, '--timeout'))
140
+ task.timeoutMs = taskOverallTimeoutMs(task.storySize, task.routing?.reasoning);
141
+ }
142
+ if (hasArg(args, '--input') || hasArg(args, '--input-file'))
143
+ task.input = await textOption(args, '--input', '--input-file');
144
+ if (hasArg(args, '--project'))
145
+ task.projectName = arg(args, '--project') || undefined;
146
+ if (hasArg(args, '--agent'))
147
+ task.agentName = arg(args, '--agent') || undefined;
148
+ if (hasArg(args, '--parent'))
149
+ task.parentTaskId = arg(args, '--parent') || null;
150
+ if (hasArg(args, '--deps'))
151
+ task.dependencies = arg(args, '--deps').split(',').filter(Boolean);
152
+ if (hasArg(args, '--capabilities'))
153
+ task.capabilities = arg(args, '--capabilities').split(',').filter(Boolean);
154
+ task.timeoutMs = integerArg(args, '--timeout', task.timeoutMs);
155
+ task.maxAttempts = integerArg(args, '--max-attempts', task.maxAttempts);
156
+ if (hasArg(args, '--draft') && hasArg(args, '--queue'))
157
+ throw new Error('--draft and --queue are mutually exclusive');
158
+ if (hasArg(args, '--draft') && task.state === 'QUEUED')
159
+ await applyTaskHoldAction(task);
160
+ if (hasArg(args, '--queue'))
161
+ await applyTaskQueueAction(job, task);
162
+ const updated = await relayTaskCommand(args, { jobId, taskId, action: 'update', task, expectedUpdatedAt });
163
+ console.log(JSON.stringify(updated.tasks.find(item => item.id === taskId), null, 2));
164
+ return;
165
+ }
142
166
  }
143
167
  const loaded = await storeFor(args);
144
168
  const { store, localFile } = loaded;
145
- const job = managed && action === 'update' ? await relayControlJob(args, loaded.job.id) : loaded.job;
169
+ const job = loaded.job;
146
170
  if (action === 'list') {
147
171
  console.log(JSON.stringify(job.tasks, null, 2));
148
172
  return;
@@ -196,11 +220,14 @@ async function runTaskCommand(action, args) {
196
220
  console.log(JSON.stringify(task, null, 2));
197
221
  return;
198
222
  }
199
- if (action === 'retry' || action === 'cancel') {
223
+ if (action === 'retry' || action === 'recover' || action === 'cancel') {
200
224
  let outputJob = job;
201
225
  let outputTask = task;
202
- if (action === 'retry') {
203
- const result = await dispatchTaskAction(store, { id: 'task.retry', jobId: job.id, taskId: id });
226
+ if (action === 'retry' || action === 'recover') {
227
+ const recoveryTaskId = action === 'recover' ? arg(args, '--recovery-task-id') : '';
228
+ if (action === 'recover' && !recoveryTaskId)
229
+ throw new Error('task recover requires --recovery-task-id');
230
+ const result = await dispatchTaskAction(store, { id: action === 'recover' ? 'task.recover' : 'task.retry', jobId: job.id, taskId: id, ...(recoveryTaskId ? { recoveryTaskId } : {}) });
204
231
  if (result.job.state === 'DRAFT') {
205
232
  await applyJobActivateAction(result.job);
206
233
  await store.saveJob(result.job);
@@ -338,13 +365,7 @@ async function runJobCommand(action, args) {
338
365
  throw new Error('job create with --head requires --objective-file PATH|- or --objective MARKDOWN containing the complete standalone Job context');
339
366
  }
340
367
  if (action === 'list') {
341
- const snapshots = await discoverLocalRelayStoreSnapshots(join(homedir(), '.agents-relay', 'stores'));
342
- const overviews = snapshots.flatMap(snapshot => snapshot.jobs.filter(job => job.repository === repo)
343
- .map(job => ({
344
- job, githubState: snapshot.facts?.state ?? 'UNKNOWN',
345
- draft: false, mergedAt: null, closedAt: null, publication: snapshot.publications[job.id]?.latest ?? null, publicationFailure: snapshot.publications[job.id]?.failure ?? null,
346
- })));
347
- console.log(JSON.stringify(dashboardManagedJobs(overviews), null, 2));
368
+ console.log(JSON.stringify(await relayJobList(args), null, 2));
348
369
  return;
349
370
  }
350
371
  const id = arg(args, '--id');
@@ -358,49 +379,39 @@ async function runJobCommand(action, args) {
358
379
  const objective = createObjective ?? '';
359
380
  if (hasArg(args, '--local-merge-source') !== hasArg(args, '--local-merge-target'))
360
381
  throw new Error('Supply both --local-merge-source and --local-merge-target');
361
- const auth = { client: new DisabledGitHubClient(), trustedAuthors: new Set() };
362
- const result = await ensureManagedGitHubJob(auth.client, repo, auth.trustedAuthors, {
363
- id, noGitHub: noGitHubMode(args),
364
- localMergeSource: arg(args, '--local-merge-source') || undefined,
365
- localMergeTarget: arg(args, '--local-merge-target') || undefined,
366
- title: arg(args, '--title') || undefined,
367
- objective: objective || undefined,
368
- projectPath: arg(args, '--project-path') || undefined,
369
- priority: priority(args),
370
- executionMode: requestedExecutionMode(args),
371
- schedule: scheduleFromArgs(args),
372
- tag: jobTagFromArgs(args),
373
- reviewEnabled: !hasArg(args, '--no-review'),
374
- continuation: continuation(args),
375
- ...(hasArg(args, '--reviewer-thread-id') ? { reviewerThreadId: arg(args, '--reviewer-thread-id') } : {}),
376
- ...(hasArg(args, '--creator-thread-id') ? { creatorThreadId: arg(args, '--creator-thread-id') } : {}),
377
- prNumber,
378
- head: arg(args, '--head') || undefined,
379
- base: arg(args, '--base', 'main'),
380
- body: arg(args, '--body') || objective || undefined
381
- });
382
- console.log(JSON.stringify({ repository: repo, pr: result.pr.number, job: result.job.id, executionMode: result.job.executionMode, state: result.job.state }, null, 2));
382
+ const result = await relayControlCommand(args, { command: 'job.create', repository: repo, initialPlan: hasArg(args, '--initial-plan'), options: {
383
+ id, title: arg(args, '--title') || undefined, objective: objective || undefined, projectPath: arg(args, '--project-path') || undefined,
384
+ priority: priority(args), executionMode: requestedExecutionMode(args), schedule: scheduleFromArgs(args), tag: jobTagFromArgs(args), reviewEnabled: !hasArg(args, '--no-review'), continuation: continuation(args),
385
+ reviewerThreadId: hasArg(args, '--reviewer-thread-id') ? arg(args, '--reviewer-thread-id') : undefined, creatorThreadId: hasArg(args, '--creator-thread-id') ? arg(args, '--creator-thread-id') : undefined,
386
+ prNumber, head: arg(args, '--head') || undefined, base: arg(args, '--base', 'main'), body: arg(args, '--body') || objective || undefined,
387
+ localMergeSource: arg(args, '--local-merge-source') || undefined, localMergeTarget: arg(args, '--local-merge-target') || undefined,
388
+ } });
389
+ console.log(JSON.stringify(result, null, 2));
383
390
  return;
384
391
  }
385
- const loaded = await storeFor(args);
386
- const { store, job } = loaded;
392
+ const job = await relayControlJob(args, id);
387
393
  if (action === 'unblock') {
388
- await runJobUnblockCommand(args, store, job);
394
+ const reason = arg(args, '--reason');
395
+ if (!reason.trim())
396
+ throw new Error('job unblock requires --reason');
397
+ const updated = await relayControlCommand(args, { command: 'job.unblock', jobId: id, reason });
398
+ console.log(JSON.stringify(updated, null, 2));
389
399
  return;
390
400
  }
391
401
  if (action === 'show') {
392
402
  console.log(JSON.stringify(job, null, 2));
393
403
  return;
394
404
  }
395
- if (job.repository !== repo || (prValue && job.prNumber !== prNumber)) {
405
+ const boundPrNumber = job.externalPrNumber ?? (job.prNumber > 0 ? job.prNumber : 0);
406
+ if (job.repository !== repo || (prValue && boundPrNumber !== prNumber)) {
396
407
  throw new Error('Local Job/PR binding mismatch');
397
408
  }
398
409
  if (action === 'merge') {
399
410
  throw new Error('GitHub is audit-only. Configure local merge source/target and reconcile the local Job instead.');
400
411
  }
401
412
  if (action === 'cancel') {
402
- await applyJobCancelAction(store, job);
403
- console.log(JSON.stringify(await store.load(id), null, 2));
413
+ const updated = await relayJobStateCommand(args, job.id, 'CANCELLED');
414
+ console.log(JSON.stringify(updated, null, 2));
404
415
  return;
405
416
  }
406
417
  if (hasArg(args, '--title'))
@@ -445,12 +456,13 @@ async function runJobCommand(action, args) {
445
456
  throw new Error('--continue-kind and --continue-target must be supplied together');
446
457
  job.continuation = next;
447
458
  }
448
- job.updatedAt = new Date().toISOString();
449
- await store.saveJob(job);
450
- console.log(JSON.stringify(await store.load(id), null, 2));
459
+ const patch = {};
460
+ for (const key of ['title', 'objective', 'projectPath', 'priority', 'executionMode', 'schedule', 'tag', 'autoMerge', 'reviewEnabled', 'reviewerThreadId', 'creatorThreadId', 'continuation', 'localMerge'])
461
+ patch[key] = job[key];
462
+ console.log(JSON.stringify(await relayControlCommand(args, { command: 'job.update', jobId: id, patch }), null, 2));
451
463
  }
452
464
  export function runtimePlanner(args) { const plannerCommand = arg([...args], '--planner-command'); const modelRuntime = arg([...args], '--codex', 'codex'); return plannerCommand ? new CommandObjectivePlanner(plannerCommand) : new AgentObjectivePlanner(modelRuntime); }
453
- 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, { noGitHub: noGitHubMode(args), 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: executionDecisionEngine(), decisionMode: decisionMode(), reviewer: runtimeReviewer(args), eventBus: bus, emit, chatgptTabClosePolicy: policy, projectPathForJob: async (job) => resolveRepositoryProjectPath(await resolveManagedProjectPath(noGitHubMode(args) ? undefined : job.repository, arg(args, '--project-path'), arg(args, '--workspace-root') || defaultWorkspaceRoot()), job.projectPath) }); }
465
+ 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, { noGitHub: noGitHubMode(args), 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: executionDecisionEngine(), decisionMode: decisionMode(), reviewer: runtimeReviewer(args), eventBus: bus, emit, chatgptTabClosePolicy: policy, projectPathForJob: async (job) => { const workspaceRoot = arg(args, '--workspace-root') || defaultWorkspaceRoot(); const canonical = await resolveManagedProjectPath(noGitHubMode(args) ? undefined : job.repository, arg(args, '--project-path'), workspaceRoot); return job.prNumber === 0 && job.localMerge?.sourceRef ? prepareLocalJobExecutionWorktree(canonical, job, workspaceRoot) : resolveRepositoryProjectPath(canonical, job.projectPath); } }); }
454
466
  export function dashboardJobAction(store, reconciler, action) {
455
467
  return async (jobId) => {
456
468
  const job = await store.load(jobId);
@@ -490,7 +502,8 @@ export async function createService(args) {
490
502
  const webhook = !noGitHubMode(args) && repo && webhookSecret ? {
491
503
  secret: webhookSecret,
492
504
  repositories: new Set([repo]),
493
- pullRequest: loaded.job.prNumber,
505
+ pullRequest: loaded.job.externalPrNumber ?? loaded.job.prNumber,
506
+ jobId: loaded.job.id,
494
507
  wake: async (event) => {
495
508
  if (event.pullRequestFacts && loaded.store.applyPullRequestFacts) {
496
509
  await loaded.store.applyPullRequestFacts({
@@ -606,19 +619,18 @@ async function main() {
606
619
  return;
607
620
  }
608
621
  if (command === 'init') {
622
+ const file = arg(args, '--file');
609
623
  const now = new Date().toISOString();
610
624
  const initialSchedule = scheduleFromArgs(args);
611
- const job = createJobEntity({ id: arg(args, '--id', randomUUID()), title: arg(args, '--title', 'Agents Relay job'), objective: arg(args, '--objective') || undefined, priority: priority(args), executionMode: executionMode(args), schedule: initialSchedule, autoMerge: !hasArg(args, '--no-auto-merge'), reviewEnabled: !hasArg(args, '--no-review'), prNumber: Number(arg(args, '--pr', '0')), repository: arg(args, '--repo'), continuation: continuation(args), createdAt: now, updatedAt: now, tasks: [] }, { activate: Boolean(initialSchedule) });
612
- let store;
613
- let file = null;
614
- if (arg(args, '--repo') && arg(args, '--pr'))
615
- store = openLocalManagedStore(repository(args), pullRequest(args)).store;
616
- else {
617
- file = arg(args, '--file');
618
- if (!file)
619
- throw new Error('init requires --repo/--pr or explicit --file demo mode');
620
- store = new InMemoryStore(job);
625
+ const id = arg(args, '--id', randomUUID());
626
+ if (!file) {
627
+ const repo = repository(args);
628
+ const result = await relayControlCommand(args, { command: 'job.create', repository: repo, options: { id, title: arg(args, '--title', 'Agents Relay job'), objective: arg(args, '--objective') || undefined, priority: priority(args), executionMode: executionMode(args), schedule: initialSchedule, reviewEnabled: !hasArg(args, '--no-review'), continuation: continuation(args), prNumber: Number(arg(args, '--pr', '0')) || 0 } });
629
+ console.log(JSON.stringify(result, null, 2));
630
+ return;
621
631
  }
632
+ const job = createJobEntity({ id, title: arg(args, '--title', 'Agents Relay job'), objective: arg(args, '--objective') || undefined, priority: priority(args), executionMode: executionMode(args), schedule: initialSchedule, autoMerge: !hasArg(args, '--no-auto-merge'), reviewEnabled: !hasArg(args, '--no-review'), prNumber: Number(arg(args, '--pr', '0')), repository: arg(args, '--repo'), continuation: continuation(args), createdAt: now, updatedAt: now, tasks: [] }, { activate: Boolean(initialSchedule) });
633
+ const store = new InMemoryStore(job);
622
634
  await store.saveJob(job);
623
635
  await saveLocal(file, job);
624
636
  console.log(`Initialized ${job.id}`);
@@ -641,6 +653,46 @@ async function main() {
641
653
  console.log(JSON.stringify(await response.json(), null, 2));
642
654
  return;
643
655
  }
656
+ if (!arg(args, '--file') && ['plan', 'reconcile', 'record', 'agent-register', 'agent-discover'].includes(command)) {
657
+ const jobId = arg(args, '--id');
658
+ if (!jobId)
659
+ throw new Error(`${command} requires --repo and --id`);
660
+ if (command === 'plan') {
661
+ const guidance = hasArg(args, '--prompt') || hasArg(args, '--prompt-file') ? await textOption(args, '--prompt', '--prompt-file') : undefined;
662
+ console.log(JSON.stringify(await relayControlCommand(args, { command: 'job.plan', jobId, ...(guidance?.trim() ? { guidance: guidance.trim() } : {}) }), null, 2));
663
+ return;
664
+ }
665
+ if (command === 'reconcile') {
666
+ console.log(JSON.stringify(await relayControlCommand(args, { command: 'job.reconcile', jobId }), null, 2));
667
+ return;
668
+ }
669
+ if (command === 'record') {
670
+ const now = new Date().toISOString();
671
+ const id = arg(args, '--task-id', randomUUID());
672
+ const summary = arg(args, '--summary');
673
+ if (!summary)
674
+ throw new Error('record requires --summary');
675
+ const commit = arg(args, '--commit');
676
+ const task = { jobId, id, projectName: arg(args, '--project') || undefined, agentName: arg(args, '--agent', 'orchestrator'), parentTaskId: arg(args, '--parent') || null, dependencies: arg(args, '--deps').split(',').filter(Boolean), capabilities: [], adapter: 'orchestrator', input: arg(args, '--input', summary), continuation: null, continuationDeliveredAt: null, state: 'SUCCEEDED', attempt: 1, maxAttempts: 1, leaseOwner: null, leaseExpiresAt: null, executionId: commit || null, threadId: null, result: { summary, data: commit ? { commit } : undefined }, error: null, timeoutMs: 0, createdAt: now, updatedAt: now };
677
+ const updated = await relayControlCommand(args, { command: 'record', jobId, task });
678
+ console.log(JSON.stringify(updated.tasks.find(item => item.id === id), null, 2));
679
+ return;
680
+ }
681
+ if (command === 'agent-register') {
682
+ const now = new Date().toISOString();
683
+ const agent = { id: arg(args, '--agent-id', randomUUID()), name: arg(args, '--name'), responsibility: arg(args, '--responsibility'), role: arg(args, '--role'), capabilities: arg(args, '--capabilities').split(',').filter(Boolean), boundaries: arg(args, '--boundaries').split('|').filter(Boolean), endpoint: { kind: arg(args, '--endpoint-kind', 'adapter'), target: arg(args, '--endpoint') }, runtime: { adapter: arg(args, '--runtime', arg(args, '--adapter', 'shell')), provider: arg(args, '--provider') || undefined, model: arg(args, '--model') || undefined }, availability: arg(args, '--availability', 'available'), routing: { labels: arg(args, '--labels').split(',').filter(Boolean).reduce((out, item) => { const [key, value] = item.split('=', 2); if (key && value)
684
+ out[key] = value; return out; }, {}), trustDomains: arg(args, '--trust-domains').split(',').filter(Boolean), regions: arg(args, '--regions').split(',').filter(Boolean) }, evidence: { evaluations: [], outcomes: { succeeded: 0, failed: 0, timedOut: 0 }, lastObservedAt: null }, registeredAt: now, updatedAt: now };
685
+ if (!agent.name || !agent.responsibility || !agent.role || !agent.endpoint.target)
686
+ throw new Error('--name, --responsibility, --role, and --endpoint are required');
687
+ console.log(JSON.stringify(await relayControlCommand(args, { command: 'agent.register', jobId, agent }), null, 2));
688
+ return;
689
+ }
690
+ if (command === 'agent-discover') {
691
+ const requirements = { capabilities: arg(args, '--capabilities').split(',').filter(Boolean), runtime: arg(args, '--runtime') || undefined, availability: arg(args, '--availability').split(',').filter(Boolean), trustDomain: arg(args, '--trust-domain') || undefined, taskKind: arg(args, '--task-kind') || undefined, explore: args.includes('--explore') };
692
+ console.log(JSON.stringify(await relayControlCommand(args, { command: 'agent.discover', jobId, requirements }), null, 2));
693
+ return;
694
+ }
695
+ }
644
696
  const loaded = await storeFor(args);
645
697
  const { store, job, localFile } = loaded;
646
698
  if (command === 'plan') {
@@ -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.publication?.version)states.append(element('span','badge SUCCEEDED',text('v'+job.publication.version)));if(job.publicationFailure)states.append(element('span','badge FAILED',text('PUBLISH FAILED: '+value(job.publicationFailure.stage))));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
@@ -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') ?? '';