agents-relay 2.0.5 → 2.0.7
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/README.md +43 -8
- package/dist/dashboard-html.js +4 -49
- package/dist/dashboard.js +11 -8
- package/dist/github-webhook.js +33 -14
- package/dist/pool.js +6 -71
- package/dist/projected-store.js +3 -0
- package/dist/relayd.js +10 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,6 +4,44 @@ Agents Relay is a generic TypeScript runtime for durable asynchronous agent jobs
|
|
|
4
4
|
|
|
5
5
|
V2 separates **operational execution state** from that collaboration record. A local RelayStore may use SQLite/WAL for runtime snapshots, event idempotency and a durable GitHub projection outbox. ProjectedDurableStore bootstraps from the existing GitHub representation, commits Job/Plan/Task runtime changes locally, and lets GitHubProjectionWriter project them asynchronously. GitHub therefore remains the human-readable durable Job/Plan/Task/Review surface without being required for every high-frequency runtime transition.
|
|
6
6
|
|
|
7
|
+
## V2 architecture
|
|
8
|
+
|
|
9
|
+
```mermaid
|
|
10
|
+
flowchart LR
|
|
11
|
+
Human[Human / orchestrator] --> GH[GitHub PR + comments<br/>collaboration + audit surface]
|
|
12
|
+
GH --> Inbound[Typed inbound GitHub facts]
|
|
13
|
+
Worker[Workers / adapters] --> Events[Event Bus<br/>lifecycle + progress facts]
|
|
14
|
+
Inbound --> Coord[ReconcileCoordinator]
|
|
15
|
+
Events --> Coord
|
|
16
|
+
Coord --> Graphs[Executable Mermaid graphs<br/>workflow authority]
|
|
17
|
+
Graphs --> Runtime[TypeScript primitives<br/>facts + guards + effects]
|
|
18
|
+
Runtime --> Store[(SQLite/WAL RelayStore<br/>operational execution truth)]
|
|
19
|
+
Store --> Outbox[Projection outbox]
|
|
20
|
+
Outbox --> Writer[GitHubProjectionWriter]
|
|
21
|
+
Writer --> GH
|
|
22
|
+
Store --> Views[Dashboard / scheduler / recovery]
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
RelayStore owns local operational execution facts; GitHub remains the human-readable collaboration and audit projection. Worker events are authoritative lifecycle inputs for model-backed tasks, but they are not a separate durable database. The graph engine owns workflow branching; TypeScript owns facts, persistence, locking/claims, I/O, and primitive guard/action implementations.
|
|
26
|
+
|
|
27
|
+
### Workflow graph navigation
|
|
28
|
+
|
|
29
|
+
| Area | Canonical graph |
|
|
30
|
+
| --- | --- |
|
|
31
|
+
| Job lifecycle | [`workflow/job-lifecycle-v2.mmd`](workflow/job-lifecycle-v2.mmd) |
|
|
32
|
+
| Task lifecycle | [`workflow/task-lifecycle-v2.mmd`](workflow/task-lifecycle-v2.mmd) |
|
|
33
|
+
| Review lifecycle | [`workflow/review-lifecycle-v2.mmd`](workflow/review-lifecycle-v2.mmd) |
|
|
34
|
+
| Planner trigger | [`workflow/planner-trigger-v2.mmd`](workflow/planner-trigger-v2.mmd) |
|
|
35
|
+
| Planner orchestration | [`workflow/planner-orchestration-v2.mmd`](workflow/planner-orchestration-v2.mmd) |
|
|
36
|
+
| Blocker resolution | [`workflow/blocker-resolution-v2.mmd`](workflow/blocker-resolution-v2.mmd) |
|
|
37
|
+
| Authoritative unblock | [`workflow/authoritative-unblock-v2.mmd`](workflow/authoritative-unblock-v2.mmd) |
|
|
38
|
+
| Scheduled run | [`workflow/scheduled-run-v2.mmd`](workflow/scheduled-run-v2.mmd) |
|
|
39
|
+
| Schedule cycle | [`workflow/schedule-cycle-v2.mmd`](workflow/schedule-cycle-v2.mmd) |
|
|
40
|
+
| Troubleshooter | [`workflow/troubleshooter-v2.mmd`](workflow/troubleshooter-v2.mmd) |
|
|
41
|
+
| Related-task action policy | [`workflow/task-related-action-v2.mmd`](workflow/task-related-action-v2.mmd) |
|
|
42
|
+
|
|
43
|
+
Legacy [`workflow/job-lifecycle.mmd`](workflow/job-lifecycle.mmd) and [`workflow/task-lifecycle.mmd`](workflow/task-lifecycle.mmd) are compatibility/parity fixtures only. See [`docs/workflow-engine.md`](docs/workflow-engine.md) and [`docs/architecture-v2.md`](docs/architecture-v2.md).
|
|
44
|
+
|
|
7
45
|
## Quick start
|
|
8
46
|
|
|
9
47
|
For GitHub-backed work, create or adopt the PR through Agents Relay itself. This guarantees the durable job marker exists before any managed task is submitted.
|
|
@@ -50,7 +88,7 @@ npx agents-relay task update --repo OWNER/REPO --pr 12 --id job-1 --task-id chil
|
|
|
50
88
|
|
|
51
89
|
Jobs separate durable lifecycle from derived runtime status. Durable lifecycle is `DRAFT | ACTIVE | COMPLETED | CANCELLED`: a newly created unscheduled Job is DRAFT, creating executable work activates it, and scheduled Jobs are ACTIVE because the scheduler is allowed to advance them. Runtime status (`IDLE`, `WAITING`, `QUEUED`, `RUNNING`, `BLOCKED`, `FAILED`, `REVIEWING`, `MERGE_PENDING`, `MERGED`, or `CANCELLED`) is computed from Tasks, Review, PR, and schedule facts and is never persisted as lifecycle truth.
|
|
52
90
|
|
|
53
|
-
Lifecycle transition authority lives in the directly renderable
|
|
91
|
+
Lifecycle transition authority lives in the directly renderable V2 flowchart sources linked in [Workflow graph navigation](#workflow-graph-navigation). The legacy `stateDiagram-v2` files remain compatibility fixtures only; see [`docs/workflow-engine.md`](docs/workflow-engine.md) for the executable Mermaid profile and runtime boundary.
|
|
54
92
|
|
|
55
93
|
The canonical Mermaid Graph Engine is the authoritative durable workflow decision path. Job/Task lifecycle transitions plus Review, Planner, Blocker/Unblock, Schedule, and Troubleshooter orchestration branches are defined by the v2 workflow graphs and executed through the shared graph runtime. TypeScript supplies facts and primitive action/effect implementations; it must not duplicate those workflow branches as a second procedural state machine.
|
|
56
94
|
|
|
@@ -86,10 +124,7 @@ Task producers must follow `skills/agents-relay/contracts/task-job-contract.md`;
|
|
|
86
124
|
Execution routing is code-driven: Relay builds complete executable worker+model routes from authoritative runtime/model/resource facts, removes only impossible routes, then calls the generic typed `decide(request, contract)` boundary once. Free-text routing guidance and task/resource/capability signals are soft decision facts; decision failure falls back deterministically to a usable Codex default route. See [Execution Router and typed decisions](docs/worker-model-routers.md).
|
|
87
125
|
All task producers use the same story-size contract: `S`≈5 minutes, `M`≈15 minutes, and `L`≈45 minutes of normal-reasoning execution, with `M` as the default. `high` reasoning doubles that overall window. ChatGPT Browser Worker start-ack timeout is derived as one third of the effective overall timeout, clamped between 1 and 5 minutes. Orchestrators and planners should choose `storySize`, not raw timeout milliseconds; `--timeout` remains only as an explicit compatibility/debug override.
|
|
88
126
|
|
|
89
|
-
Autonomous jobs can also be scheduled
|
|
90
|
-
stores a five-field cron expression, IANA timezone, next run, and missed-run /
|
|
91
|
-
concurrency policy; GitHub issue labels provide the indexed discovery surface.
|
|
92
|
-
A repository heartbeat invokes `tick` at 10-minute resolution. See
|
|
127
|
+
Autonomous jobs can also be scheduled. The Job marker stores a five-field cron expression, IANA timezone, next run, and missed-run/concurrency policy. The daemon evaluates the SQLite-backed managed Job view and validates durable schedule facts before claiming a due slot. A repository heartbeat invokes `tick` at 10-minute resolution. See
|
|
93
128
|
[Scheduled autonomous jobs](docs/scheduled-autonomous-jobs.md) for the exact
|
|
94
129
|
operational contract.
|
|
95
130
|
|
|
@@ -152,7 +187,7 @@ This small live suite covers two Temporary Chat submission gates: a normal text
|
|
|
152
187
|
prompt and a prompt with an attachment. It does not exercise worker lifecycle
|
|
153
188
|
events, durable-thread reuse, resume, or retry behavior.
|
|
154
189
|
|
|
155
|
-
Open the dashboard on localhost port 8787. The marker format is intentionally public and append-friendly
|
|
190
|
+
Open the dashboard on localhost port 8787. The marker format is intentionally public and append-friendly. Daemon/workspace operation uses `ProjectedDurableStore` over SQLite RelayStore plus the authenticated GitHub projection adapter; GitHubStore is not the daemon operational state database.
|
|
156
191
|
|
|
157
192
|
External product feedback uses the reusable `src/feedback.ts` contract. See [docs/feedback-intake.md](docs/feedback-intake.md) for the schema, attachment limits, privacy rules, materialization path, and provenance envelope for later Jobs/Tasks/PRs.
|
|
158
193
|
|
|
@@ -162,7 +197,7 @@ reconcile leases and launches ready tasks without waiting inside the runtime sch
|
|
|
162
197
|
|
|
163
198
|
For automation, Agents Relay can use GitHub App installation authentication via `AGENTS_RELAY_GITHUB_APP_ID`, `AGENTS_RELAY_GITHUB_APP_INSTALLATION_ID`, and `AGENTS_RELAY_GITHUB_APP_PRIVATE_KEY_FILE` (or matching CLI flags). Webhook mode is local-first: targeted webhook reconciliation is primary, dashboard auto-refresh reads the disposable in-process view restored from SQLite RelayStore state, and the broad watchdog drops to a 30-minute fallback. No separate persisted dashboard Job cache is maintained. See [docs/github-app-rate-limit.md](docs/github-app-rate-limit.md).
|
|
164
199
|
|
|
165
|
-
|
|
200
|
+
Run one logical reconciliation authority per Job. `ReconcileCoordinator` serializes in-process work; use a distributed atomic lease before multiple independent reconcilers own the same Job. Do not put secrets, credentials, private prompts, or large private payloads in the PR-body Job marker or PR-comment Task markers; store only summaries and artifact references.
|
|
166
201
|
|
|
167
202
|
Model-backed tasks are routed by Relay before launch. Normal task producers omit adapter/provider/model; explicit routing flags remain compatibility/debug constraints. See [docs/architecture.md](docs/architecture.md) and [skills/agents-relay/SKILL.md](skills/agents-relay/SKILL.md).
|
|
168
203
|
|
|
@@ -182,7 +217,7 @@ Managed tasks are descriptive work for agents, not shell commands. Use `codex` o
|
|
|
182
217
|
|
|
183
218
|
Every model-backed task declares a durable output with `--output task-pr` or `--output file --output-path PATH`. Agents Relay bootstraps workers with the managed PR URL, durable task-comment URL, job/task identity, project, output contract, and mandatory terminal-event contract; workers retrieve the task intent from GitHub rather than receiving a duplicate copy of the stored task prompt.
|
|
184
219
|
|
|
185
|
-
For Codex and ChatGPT, **events are authoritative
|
|
220
|
+
For Codex and ChatGPT, **correlated worker events are authoritative lifecycle inputs**, not a separate durable store. Event publication uses the canonical Neo `events-bus` surface (`events__publish` for sandboxed/hosted workers, or `NEO_EVENTS_EMIT` for direct local workers); Agents Relay subscribes, validates correlation, applies graph transitions, persists results in RelayStore, and projects them to GitHub. Relay publishes `task.process.launched` only after successful adapter launch; `task.started` is worker-owned and must be a new, correlated start acknowledgement. Codex publishes `task.process.exited` when its synchronous runtime exits, while ChatGPT publishes `task.process.async_exited` after its submission runtime exits; neither substitutes for `task.completed`, `task.failed`, `task.blocked`, or `task.cancelled`. The ChatGPT worker-owned tab follows the configured close policy: `after-start` waits for the correlated start acknowledgement, `never` keeps it open for debug, and `after-terminal` waits for a new exact-task terminal event; start-ack failure closes only the owned tab after 60 seconds. The executing agent must publish exactly one correlated terminal event. Relay persists the matching durable state only from that terminal event. Model-backed tasks require an event bus and time out explicitly if no terminal event arrives.
|
|
186
221
|
|
|
187
222
|
The ChatGPT adapter uses the `chatgpt-browser-worker` path selected by the shared execution-context resolver; Relay does not bundle its own copy. The resolver applies project-local > Neo shared > global `~/.agents` precedence at execution time, so durable tasks store names/intent rather than stale absolute skill paths. It opens a fresh Temporary Chat tab, uses the account defaults, submits the prompt, verifies acceptance, and applies `--chatgpt-tab-close-policy` (or `AGENTS_RELAY_CHATGPT_TAB_CLOSE_POLICY`): `after-start` by default, `never` for test/debug, or `after-terminal` after a new exact-task terminal event. It never closes a user tab and does not poll for the assistant response, resume/reopen a thread, or use conversation text as the result channel. Any observed ChatGPT thread ID is diagnostic only.
|
|
188
223
|
|
package/dist/dashboard-html.js
CHANGED
|
@@ -395,64 +395,19 @@ function creditProvider(provider){const row=element('section','usage-provider');
|
|
|
395
395
|
function shortReset(reset){if(!Number.isFinite(reset))return '';const at=new Date(reset*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 'Reset '+relative;}
|
|
396
396
|
function githubBucket(label,bucket){const row=element('div','usage-row');const heading=element('div','usage-label');heading.append(element('span','',text(label)),element('span','',text(bucket.remaining+' / '+bucket.limit)));const percent=bucket.limit>0?Math.max(0,Math.min(100,100*bucket.remaining/bucket.limit)):0;const track=element('div','usage-track');track.setAttribute('role','progressbar');track.setAttribute('aria-label',label+' remaining');track.setAttribute('aria-valuemin','0');track.setAttribute('aria-valuemax','100');track.setAttribute('aria-valuenow',String(Math.round(percent)));const fill=element('div','usage-fill');fill.style.width=percent+'%';track.append(fill);row.append(heading,track);const reset=shortReset(bucket.reset);if(reset){const hint=element('div','subtle',text(reset));hint.title=new Date(bucket.reset*1000).toLocaleString();row.append(hint);}return row;}
|
|
397
397
|
function githubCounterLine(label,values){const entries=Object.entries(values||{}).sort((a,b)=>Number(b[1])-Number(a[1])).slice(0,8);return entries.length?element('div','subtle',text(label+' · '+entries.map(([key,count])=>key+' '+count).join(' · '))):null;}
|
|
398
|
-
function githubObserved(label,observed){
|
|
399
|
-
if(!observed)return null;
|
|
400
|
-
if(Number.isFinite(observed.limit)&&Number.isFinite(observed.remaining)){const row=githubBucket(label,observed);if(observed.reason)row.title=observed.reason;return row;}
|
|
401
|
-
const state=observed.throttled?'Throttled':observed.available===true?'Available':observed.available===false?'Unavailable':'Unknown';
|
|
402
|
-
const parts=[label+' observed',state];
|
|
403
|
-
if(observed.status!==null)parts.push('status '+observed.status);
|
|
404
|
-
if(observed.retryAfterSeconds!==null)parts.push('retry '+observed.retryAfterSeconds+'s');
|
|
405
|
-
const row=element('div',observed.throttled?'usage-row error-state':'usage-row subtle');
|
|
406
|
-
row.textContent=parts.filter(Boolean).join(' · ');
|
|
407
|
-
if(observed.reason)row.title=observed.reason;
|
|
408
|
-
return row;
|
|
409
|
-
}
|
|
410
|
-
function githubSecondary(status){
|
|
411
|
-
const rejection=status?.runtimeRateUsage?.rejection;
|
|
412
|
-
const row=element('div','usage-row');
|
|
413
|
-
const heading=element('div','usage-label');
|
|
414
|
-
if(rejection?.kind!=='secondary'){heading.append(element('span','',text('Secondary protection')),element('span','',text('No active cooldown')));row.append(heading);return row;}
|
|
415
|
-
const start=Date.parse(rejection.observedAt);
|
|
416
|
-
const until=Date.parse(rejection.cooldownUntil||status?.cooldownUntil||'');
|
|
417
|
-
if(!Number.isFinite(start)||!Number.isFinite(until)||until<=start){heading.append(element('span','',text('Secondary protection')),element('span','',text('Throttled')));row.classList.add('error-state');row.append(heading);return row;}
|
|
418
|
-
const total=until-start;
|
|
419
|
-
const remaining=Math.max(0,until-Date.now());
|
|
420
|
-
const percent=Math.max(0,Math.min(100,100*remaining/total));
|
|
421
|
-
heading.append(element('span','',text('Secondary cooldown')),element('span','',text(Math.ceil(remaining/1000)+'s')));
|
|
422
|
-
const track=element('div','usage-track');track.setAttribute('role','progressbar');track.setAttribute('aria-label','Secondary cooldown remaining');track.setAttribute('aria-valuemin','0');track.setAttribute('aria-valuemax','100');track.setAttribute('aria-valuenow',String(Math.round(percent)));const fill=element('div','usage-fill');fill.style.width=percent+'%';track.append(fill);
|
|
423
|
-
const hint=element('div','subtle',text('Retry '+new Date(until).toLocaleString()));hint.title=new Date(until).toLocaleString();
|
|
424
|
-
row.append(heading,track,hint);
|
|
425
|
-
return row;
|
|
426
|
-
}
|
|
427
398
|
function githubDiagnostics(status){const body=element('div','usage-details-body');if(status?.calls)body.append(element('div','subtle',text('Calls · GraphQL '+(status.calls.graphql||0)+' · REST '+(status.calls.rest||0)+' · quota '+(status.calls.rateLimit||0))));const operations=githubCounterLine('GraphQL operations',status?.graphql?.operations);if(operations)body.append(operations);const contexts=githubCounterLine('GraphQL contexts',status?.graphql?.contexts);if(contexts)body.append(contexts);if(status?.readPaths){body.append(element('div','subtle',text('Reads · scans '+(status.readPaths.workspaceScans||0)+' · PR '+(status.readPaths.prRefreshes||0)+' · coalesced '+(status.readPaths.prRefreshCoalesced||0))));const triggers=githubCounterLine('Refresh triggers',status.readPaths.prRefreshesByTrigger);if(triggers)body.append(triggers);const coalesced=githubCounterLine('Coalesced',status.readPaths.prRefreshCoalescedByTrigger);if(coalesced)body.append(coalesced);}if(!body.children.length)return null;const details=element('details','usage-details');details.append(element('summary','',text('Diagnostics')),body);return details;}
|
|
428
|
-
function githubUsageCard(
|
|
399
|
+
function githubUsageCard(effective,status){
|
|
429
400
|
const card=element('section','usage-provider');
|
|
430
401
|
card.append(element('h3','',text('GitHub API')));
|
|
431
|
-
const resources=snapshot?.resources||{};
|
|
432
|
-
const observed=status?.runtimeRateUsage?.resources||{};
|
|
433
402
|
const bars=element('div','usage-bars');
|
|
434
|
-
bars.append(
|
|
435
|
-
|
|
436
|
-
for(const row of [githubObserved('REST',observed.core),githubObserved('GraphQL',observed.graphql),githubObserved('Search',observed.search)])if(row){bars.append(row);observedCount++;}
|
|
437
|
-
if(!observedCount)bars.append(element('p','empty',text('No request-time headers observed')));
|
|
438
|
-
bars.append(element('div','usage-label',text('Primary quota (/rate_limit)')));
|
|
439
|
-
let quotaCount=0;
|
|
440
|
-
for(const pair of [['REST',resources.core],['GraphQL',resources.graphql],['Search',resources.search]])if(pair[1]){bars.append(githubBucket(pair[0],pair[1]));quotaCount++;}
|
|
441
|
-
if(!quotaCount)bars.append(element('p','empty',text('Quota unavailable')));
|
|
442
|
-
if(status?.runtimeRateUsage?.rejection&&status.runtimeRateUsage.rejection.kind!=='secondary'){
|
|
443
|
-
const rejection=status.runtimeRateUsage.rejection;
|
|
444
|
-
bars.append(element('div','error-state',text('Throttled · '+(rejection.kind==='unknown'?'rate limit':rejection.kind)+' limit')));
|
|
445
|
-
}
|
|
446
|
-
if(status?.limited&&status?.runtimeRateUsage?.rejection?.kind!=='secondary'){
|
|
447
|
-
const until=status.cooldownUntil?' until '+new Date(status.cooldownUntil).toLocaleTimeString():'';
|
|
448
|
-
bars.append(element('div','error-state',text('Cooldown active'+until)));
|
|
449
|
-
}
|
|
403
|
+
if(effective)bars.append(githubBucket('Available',effective));
|
|
404
|
+
else bars.append(element('p','empty',text('Quota unavailable')));
|
|
450
405
|
const diagnostics=githubDiagnostics(status);
|
|
451
406
|
if(diagnostics)bars.append(diagnostics);
|
|
452
407
|
card.append(bars);
|
|
453
408
|
return card;
|
|
454
409
|
}
|
|
455
|
-
async function drawUsage(){try{const overview=await loadJSON('/api/usage');usageRoot.replaceChildren();usageMeta.textContent='Real account telemetry when available';const providers=Array.isArray(overview.providers)?overview.providers:[];const credits=Array.isArray(overview.credits)?overview.credits:[];const grid=element('div','usage-providers');if(overview.
|
|
410
|
+
async function drawUsage(){try{const overview=await loadJSON('/api/usage');usageRoot.replaceChildren();usageMeta.textContent='Real account telemetry when available';const providers=Array.isArray(overview.providers)?overview.providers:[];const credits=Array.isArray(overview.credits)?overview.credits:[];const grid=element('div','usage-providers');if(overview.githubEffective||overview.githubStatus)grid.append(githubUsageCard(overview.githubEffective,overview.githubStatus));for(const provider of providers)grid.append(usageProvider(provider));for(const provider of credits)grid.append(creditProvider(provider));if(!grid.children.length){usageRoot.append(element('p','empty',text('Usage telemetry is unavailable.')));return;}usageRoot.append(grid);}catch(error){usageMeta.textContent='Unavailable';usageRoot.replaceChildren(element('p','error-state',text('Usage unavailable: '+error.message)));}}
|
|
456
411
|
async function drawRules(){rulesStatus.textContent='';try{const rules=await loadJSON('/api/execution-router-rules');rulesText.value=typeof rules.text==='string'?rules.text:'';rulesMeta.textContent=rules.hash&&rules.hash!=='none'?'Loaded · '+rules.hash.slice(0,12):'No custom rules saved';}catch(error){rulesMeta.textContent='Unavailable';rulesStatus.textContent='Could not load rules: '+error.message;}}
|
|
457
412
|
async function saveRules(){rulesSave.disabled=true;rulesStatus.textContent='Saving…';try{const response=await fetch('/api/execution-router-rules',{method:'PUT',headers:{'content-type':'text/plain; charset=utf-8'},body:rulesText.value});if(!response.ok)throw new Error(response.status+' '+await response.text());const rules=await response.json();rulesMeta.textContent='Saved · '+rules.hash.slice(0,12);rulesStatus.textContent='Saved. New routing decisions will load these rules.';}catch(error){rulesStatus.textContent='Save failed: '+error.message;}finally{rulesSave.disabled=false;}}
|
|
458
413
|
async function drawShortcuts(){shortcutsStatus.textContent='';shortcutsText.value=chatShortcutText();}
|
package/dist/dashboard.js
CHANGED
|
@@ -10,6 +10,13 @@ import { summarizeRuntime } from './runtime-summary.js';
|
|
|
10
10
|
const GITHUB_REPOSITORY_PATTERN = /^([A-Za-z0-9](?:[A-Za-z0-9-]{0,36}[A-Za-z0-9])?)\/([A-Za-z0-9._-]{1,100})$/;
|
|
11
11
|
const SSE_HEARTBEAT_MS = 15000;
|
|
12
12
|
const EVENTS_HISTORY_FILE = process.env.EVENTS_BUS_DATA_DIR ? join(process.env.EVENTS_BUS_DATA_DIR, 'events.jsonl') : join(homedir(), 'Library', 'Application Support', 'EventsBus', 'events.jsonl');
|
|
13
|
+
function effectiveGitHubUsage(snapshot, runtime) {
|
|
14
|
+
const candidates = [snapshot?.resources.core, snapshot?.resources.graphql, snapshot?.resources.search, runtime?.resources.core, runtime?.resources.graphql, runtime?.resources.search]
|
|
15
|
+
.filter((candidate) => candidate !== undefined && candidate !== null)
|
|
16
|
+
.filter(candidate => Number.isFinite(candidate.limit) && Number(candidate.limit) > 0 && Number.isFinite(candidate.remaining) && Number.isFinite(candidate.reset));
|
|
17
|
+
const lowest = candidates.reduce((current, candidate) => !current || Number(candidate.remaining) / Number(candidate.limit) < Number(current.remaining) / Number(current.limit) ? candidate : current, null);
|
|
18
|
+
return lowest ? { limit: Number(lowest.limit), remaining: Number(lowest.remaining), reset: Number(lowest.reset) } : null;
|
|
19
|
+
}
|
|
13
20
|
function isPositiveInteger(candidate) {
|
|
14
21
|
return typeof candidate === 'number' && Number.isInteger(candidate) && candidate > 0;
|
|
15
22
|
}
|
|
@@ -149,7 +156,9 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
|
|
|
149
156
|
}
|
|
150
157
|
catch { /* quota telemetry is best-effort during GitHub degradation */ }
|
|
151
158
|
}
|
|
152
|
-
|
|
159
|
+
const runtime = (githubStatus.runtimeRateUsage ?? null);
|
|
160
|
+
const githubEffective = effectiveGitHubUsage(github, runtime);
|
|
161
|
+
return JSON.stringify({ ...account, githubEffective, githubStatus });
|
|
153
162
|
};
|
|
154
163
|
const server = createServer(async (req, res) => {
|
|
155
164
|
if (req.url === '/') {
|
|
@@ -192,13 +201,7 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
|
|
|
192
201
|
res.writeHead(202);
|
|
193
202
|
res.end('Accepted');
|
|
194
203
|
queueMicrotask(() => {
|
|
195
|
-
|
|
196
|
-
? webhook.refresh(event)
|
|
197
|
-
.then(jobIds => broadcastSnapshots(jobIds))
|
|
198
|
-
.catch(error => process.stderr.write(`warning: webhook dashboard snapshot unavailable: ${error instanceof Error ? error.message : String(error)}\n`))
|
|
199
|
-
: Promise.resolve();
|
|
200
|
-
void refreshDashboard
|
|
201
|
-
.then(() => webhook.wake(event))
|
|
204
|
+
void Promise.resolve(webhook.wake(event))
|
|
202
205
|
.catch(error => process.stderr.write(`warning: webhook wake unavailable: ${error instanceof Error ? error.message : String(error)}\n`));
|
|
203
206
|
});
|
|
204
207
|
return;
|
package/dist/github-webhook.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
2
|
export const PACKAGE_PUBLISHED_MARKER = 'agents-relay:package-published:v1';
|
|
3
|
-
const WAKE_EVENTS = new Set(['pull_request', 'issue_comment', 'pull_request_review', 'pull_request_review_comment', 'check_run', 'check_suite', 'workflow_run']);
|
|
4
3
|
export function parsePackagePublicationMarker(body) {
|
|
5
4
|
if (typeof body !== 'string')
|
|
6
5
|
return null;
|
|
@@ -34,10 +33,25 @@ export function verifyGitHubWebhook(secret, body, signature) {
|
|
|
34
33
|
const actual = Buffer.from(signature);
|
|
35
34
|
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
|
36
35
|
}
|
|
36
|
+
function pullRequestFacts(payload, action) {
|
|
37
|
+
if (action !== 'synchronize' && action !== 'closed')
|
|
38
|
+
return null;
|
|
39
|
+
const pr = payload.pull_request;
|
|
40
|
+
if (!pr)
|
|
41
|
+
return null;
|
|
42
|
+
const head = pr.head;
|
|
43
|
+
const ref = typeof head?.ref === 'string' ? head.ref : null;
|
|
44
|
+
const sha = typeof head?.sha === 'string' ? head.sha : null;
|
|
45
|
+
if (!ref || !sha)
|
|
46
|
+
return null;
|
|
47
|
+
if (action === 'closed')
|
|
48
|
+
return { state: pr.merged === true ? 'MERGED' : 'CLOSED', head: { ref, sha } };
|
|
49
|
+
return { state: 'OPEN', head: { ref, sha } };
|
|
50
|
+
}
|
|
37
51
|
export function parseGitHubWebhook(headers, body) {
|
|
38
52
|
const deliveryId = String(headers['x-github-delivery'] ?? '');
|
|
39
53
|
const event = String(headers['x-github-event'] ?? '');
|
|
40
|
-
if (!deliveryId ||
|
|
54
|
+
if (!deliveryId || (event !== 'pull_request' && event !== 'issue_comment'))
|
|
41
55
|
return null;
|
|
42
56
|
let payload;
|
|
43
57
|
try {
|
|
@@ -47,19 +61,24 @@ export function parseGitHubWebhook(headers, body) {
|
|
|
47
61
|
return null;
|
|
48
62
|
}
|
|
49
63
|
const repository = payload.repository;
|
|
50
|
-
const
|
|
64
|
+
const repositoryName = typeof repository?.full_name === 'string' ? repository.full_name : null;
|
|
65
|
+
const action = typeof payload.action === 'string' ? payload.action : null;
|
|
66
|
+
const pr = payload.pull_request;
|
|
51
67
|
const issue = payload.issue;
|
|
52
|
-
const number = Number(
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
68
|
+
const number = Number(pr?.number ?? (issue?.pull_request ? issue.number : 0));
|
|
69
|
+
if (!repositoryName || !Number.isInteger(number) || number <= 0)
|
|
70
|
+
return null;
|
|
71
|
+
if (event === 'issue_comment') {
|
|
72
|
+
const comment = payload.comment;
|
|
73
|
+
const publication = parsePackagePublicationMarker(comment?.body);
|
|
74
|
+
if (!publication)
|
|
75
|
+
return null;
|
|
76
|
+
return { deliveryId, event, action, repository: repositoryName, pullRequest: number, publication, pullRequestFacts: null };
|
|
77
|
+
}
|
|
78
|
+
const facts = pullRequestFacts(payload, action);
|
|
79
|
+
if (!facts)
|
|
80
|
+
return null;
|
|
81
|
+
return { deliveryId, event, action, repository: repositoryName, pullRequest: number, publication: null, pullRequestFacts: facts };
|
|
63
82
|
}
|
|
64
83
|
export class DeliveryDeduper {
|
|
65
84
|
ttlMs;
|
package/dist/pool.js
CHANGED
|
@@ -92,44 +92,8 @@ export class RepositoryWorkerPool {
|
|
|
92
92
|
this.jobTargets.set(jobId, { repository, prNumber });
|
|
93
93
|
await runScheduledBucket(this.client, repository, this.trustedAuthors, { prNumber, jobId, bucket }, { reconcile: async () => { await this.reconcileTarget(repository, prNumber, jobId, 'schedule.tick'); }, idle: async () => { } }, tickAt, () => this.makeStore(repository, prNumber));
|
|
94
94
|
}
|
|
95
|
-
async consumeGitHubSignals(
|
|
96
|
-
|
|
97
|
-
return;
|
|
98
|
-
const repository = signals.find(signal => signal.repository)?.repository;
|
|
99
|
-
const prNumber = signals.find(signal => signal.prNumber)?.prNumber;
|
|
100
|
-
if (!repository || !prNumber || !this.repositories.includes(repository))
|
|
101
|
-
return;
|
|
102
|
-
if (!this.refreshWebhookSnapshot) {
|
|
103
|
-
process.stderr.write(`warning: GitHub webhook signal refresh unavailable for ${repository}#${prNumber}\n`);
|
|
104
|
-
return;
|
|
105
|
-
}
|
|
106
|
-
const reasons = [...new Set(signals.map(signal => signal.reason))];
|
|
107
|
-
const trigger = reasons.join('+') || 'github.webhook';
|
|
108
|
-
const snapshot = await this.refreshWebhookSnapshot(repository, prNumber, trigger);
|
|
109
|
-
const sourceEvents = signals.map(signal => signal.event).filter((event) => Boolean(event));
|
|
110
|
-
for (const item of snapshot.overviews) {
|
|
111
|
-
this.jobTargets.set(item.job.id, { repository, prNumber });
|
|
112
|
-
this.seedTargetSnapshot(repository, prNumber, item.job.id, snapshot.seed);
|
|
113
|
-
if (this.watchedBus) {
|
|
114
|
-
for (const event of sourceEvents) {
|
|
115
|
-
const publication = event.data?.publication;
|
|
116
|
-
if (publication && typeof publication === 'object') {
|
|
117
|
-
const value = publication;
|
|
118
|
-
await this.watchedBus.publish(eventFor(item.job.id, 'release', null, 'package.published', 'succeeded', `Published ${String(value.package ?? 'package')}@${String(value.version ?? 'unknown')}`, 'orchestrator', {
|
|
119
|
-
deliveryId: event.data?.deliveryId,
|
|
120
|
-
repository,
|
|
121
|
-
prNumber,
|
|
122
|
-
package: value.package,
|
|
123
|
-
version: value.version,
|
|
124
|
-
registry: value.registry,
|
|
125
|
-
sha: value.sha,
|
|
126
|
-
}, event.source));
|
|
127
|
-
}
|
|
128
|
-
await this.watchedBus.publish(eventFor(item.job.id, 'github', null, 'github.webhook', 'queued', event.message, 'orchestrator', { ...(event.data ?? {}), coordinatorIngress: false, coordinatorRouted: true }, event.source));
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
await this.coordinator.accept({ jobId: item.job.id, reason: trigger, repository, prNumber });
|
|
132
|
-
}
|
|
95
|
+
async consumeGitHubSignals(_signals) {
|
|
96
|
+
// GitHub webhooks are fact-only inputs. They must never trigger a GitHub reread.
|
|
133
97
|
}
|
|
134
98
|
async consumeSignals(jobId, signals) {
|
|
135
99
|
for (const signal of signals) {
|
|
@@ -258,16 +222,8 @@ export class RepositoryWorkerPool {
|
|
|
258
222
|
await this.scheduleSignalCoordinator.acceptTransport({ jobId: event.job_id, reason: this.signalReason(event), event, repository, prNumber });
|
|
259
223
|
return;
|
|
260
224
|
}
|
|
261
|
-
if (ingressWebhook
|
|
262
|
-
await this.githubSignalCoordinator.acceptTransport({
|
|
263
|
-
jobId: `${repository}#${prNumber}`,
|
|
264
|
-
reason: this.signalReason(event),
|
|
265
|
-
event,
|
|
266
|
-
repository,
|
|
267
|
-
prNumber,
|
|
268
|
-
});
|
|
225
|
+
if (ingressWebhook)
|
|
269
226
|
return;
|
|
270
|
-
}
|
|
271
227
|
if (routedWebhook)
|
|
272
228
|
return;
|
|
273
229
|
const lifecycle = ['task.started', 'task.completed', 'task.failed', 'task.blocked', 'task.unblocked', 'task.cancelled', 'review.started', 'review.approved', 'review.changes_required', 'review.blocked'].includes(event.type);
|
|
@@ -301,30 +257,9 @@ export class RepositoryWorkerPool {
|
|
|
301
257
|
}
|
|
302
258
|
await this.scheduleSignalCoordinator.accept({ jobId: job.id, reason: this.signalReason(event), event, repository: job.repository, prNumber: job.prNumber });
|
|
303
259
|
}
|
|
304
|
-
async webhookSignal(
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
const source = { id: `github/${wake.repository}/delivery/${wake.deliveryId}`, component: 'github-webhook' };
|
|
308
|
-
const event = eventFor('github-webhook', 'github', null, 'github.webhook', 'queued', `GitHub webhook ${wake.event}${wake.action ? ':' + wake.action : ''}`, 'orchestrator', {
|
|
309
|
-
deliveryId: wake.deliveryId,
|
|
310
|
-
event: wake.event,
|
|
311
|
-
action: wake.action,
|
|
312
|
-
repository: wake.repository,
|
|
313
|
-
prNumber: wake.pullRequest,
|
|
314
|
-
publication: wake.publication ?? null,
|
|
315
|
-
coordinatorIngress: true,
|
|
316
|
-
}, source);
|
|
317
|
-
if (bus) {
|
|
318
|
-
await bus.publish(event);
|
|
319
|
-
return;
|
|
320
|
-
}
|
|
321
|
-
await this.githubSignalCoordinator.accept({
|
|
322
|
-
jobId: `${wake.repository}#${wake.pullRequest}`,
|
|
323
|
-
reason: this.signalReason(event),
|
|
324
|
-
event,
|
|
325
|
-
repository: wake.repository,
|
|
326
|
-
prNumber: wake.pullRequest,
|
|
327
|
-
});
|
|
260
|
+
async webhookSignal(_bus, _wake) {
|
|
261
|
+
// Uncorrelated webhook wakeups are intentionally ignored. A caller must first
|
|
262
|
+
// resolve the target from local cached state and then call webhookWake().
|
|
328
263
|
}
|
|
329
264
|
async webhookWake(bus, job, wake) {
|
|
330
265
|
if (bus) {
|
package/dist/projected-store.js
CHANGED
|
@@ -231,6 +231,9 @@ export class ProjectedDurableStore {
|
|
|
231
231
|
});
|
|
232
232
|
}
|
|
233
233
|
async comments() { return this.github.comments(); }
|
|
234
|
+
async applyPullRequestFacts(facts) {
|
|
235
|
+
await this.relay.savePullRequestFacts(structuredClone(facts));
|
|
236
|
+
}
|
|
234
237
|
async pullRequestState() {
|
|
235
238
|
return (await this.ensurePullRequestFacts()).state;
|
|
236
239
|
}
|
package/dist/relayd.js
CHANGED
|
@@ -291,14 +291,18 @@ export async function runDaemon(argv) {
|
|
|
291
291
|
secret,
|
|
292
292
|
repositories: new Set(repositories),
|
|
293
293
|
wake: async (event) => {
|
|
294
|
-
|
|
294
|
+
const targets = cachedOverviews.filter(item => item.job.repository === event.repository && item.job.prNumber === event.pullRequest);
|
|
295
|
+
if (targets.length === 0)
|
|
295
296
|
return;
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
297
|
+
for (const target of targets) {
|
|
298
|
+
const store = runtimeStore(event.repository, event.pullRequest);
|
|
299
|
+
if (event.pullRequestFacts && store.applyPullRequestFacts) {
|
|
300
|
+
await store.applyPullRequestFacts({ ...event.pullRequestFacts, observedAt: new Date().toISOString() });
|
|
301
|
+
target.githubState = event.pullRequestFacts.state;
|
|
302
|
+
}
|
|
303
|
+
await pool.webhookWake(bus, target.job, event);
|
|
301
304
|
}
|
|
305
|
+
cachedOverviews = dashboardManagedJobs(cachedOverviews);
|
|
302
306
|
}
|
|
303
307
|
} : undefined;
|
|
304
308
|
const exactJob = async (jobId) => {
|