agents-relay 1.0.0

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 (62) hide show
  1. package/.github/workflows/publish.yml +91 -0
  2. package/AGENTS.md +16 -0
  3. package/LICENSE +21 -0
  4. package/README.md +102 -0
  5. package/dist/adapters.js +311 -0
  6. package/dist/cli.js +455 -0
  7. package/dist/continuation.js +21 -0
  8. package/dist/dashboard.js +446 -0
  9. package/dist/events.js +36 -0
  10. package/dist/github-auth.js +34 -0
  11. package/dist/github-webhook.js +47 -0
  12. package/dist/markers.js +42 -0
  13. package/dist/planner.js +172 -0
  14. package/dist/pool.js +98 -0
  15. package/dist/reconciler.js +434 -0
  16. package/dist/registry.js +27 -0
  17. package/dist/relayd.js +177 -0
  18. package/dist/scheduler.js +49 -0
  19. package/dist/store.js +586 -0
  20. package/dist/types.js +6 -0
  21. package/dist/usage.js +370 -0
  22. package/dist/workspace.js +76 -0
  23. package/docs/agent-network.md +34 -0
  24. package/docs/architecture.md +120 -0
  25. package/docs/autonomous-objective-jobs.md +121 -0
  26. package/docs/example.md +30 -0
  27. package/docs/github-app-rate-limit.md +124 -0
  28. package/docs/service.md +43 -0
  29. package/pack.json +326 -0
  30. package/package.json +14 -0
  31. package/scripts/npm-version.mjs +11 -0
  32. package/skills/agents-relay/SKILL.md +77 -0
  33. package/skills/agents-relay/agents/planner.agent.md +28 -0
  34. package/src/adapters.ts +231 -0
  35. package/src/cli.ts +324 -0
  36. package/src/continuation.ts +6 -0
  37. package/src/dashboard.ts +421 -0
  38. package/src/events.ts +25 -0
  39. package/src/github-auth.ts +35 -0
  40. package/src/github-webhook.ts +37 -0
  41. package/src/markers.ts +33 -0
  42. package/src/planner.ts +150 -0
  43. package/src/pool.ts +87 -0
  44. package/src/reconciler.ts +235 -0
  45. package/src/registry.ts +35 -0
  46. package/src/relayd.ts +137 -0
  47. package/src/scheduler.ts +27 -0
  48. package/src/store.ts +526 -0
  49. package/src/types.ts +45 -0
  50. package/src/usage.ts +385 -0
  51. package/src/workspace.ts +62 -0
  52. package/test/adapters.test.js +303 -0
  53. package/test/autonomous.test.js +119 -0
  54. package/test/core.test.js +363 -0
  55. package/test/dashboard.test.js +178 -0
  56. package/test/github-auth.test.js +51 -0
  57. package/test/github-webhook.test.js +21 -0
  58. package/test/service.test.js +116 -0
  59. package/test/store.test.js +390 -0
  60. package/test/usage.test.js +88 -0
  61. package/test/workspace.test.js +95 -0
  62. package/tsconfig.json +4 -0
@@ -0,0 +1,363 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createHmac } from 'node:crypto';
4
+ import { spawn } from 'node:child_process';
5
+ import { once } from 'node:events';
6
+ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
7
+ import { join } from 'node:path';
8
+ import { tmpdir } from 'node:os';
9
+ import { parseTask, marker, TASK_MARKER } from '../dist/markers.js';
10
+ import { detectCycles, dependencyReady, extendRetryBudget, schedule, transitionTask } from '../dist/scheduler.js';
11
+ import { CodexAdapter } from '../dist/adapters.js';
12
+ import { InMemoryStore, GitHubStore } from '../dist/store.js';
13
+ import { Reconciler, compareTaskPriority } from '../dist/reconciler.js';
14
+ import { MemoryEventBus, eventFor, subjectToken } from '../dist/events.js';
15
+ import { serveDashboard, dashboardHtml } from '../dist/dashboard.js';
16
+ import { discoverAgents } from '../dist/registry.js';
17
+ import { compareJobPriority, needsLifecycleReconciliation, runnableManagedJobs, terminalManagedJobs, RepositoryWorkerPool } from '../dist/pool.js';
18
+
19
+ const now = () => new Date().toISOString();
20
+ const task = (id, state='QUEUED', dependencies=[]) => ({jobId:'j',id,parentTaskId:null,dependencies,capabilities:[],adapter:'shell',input:'true',state,attempt:0,maxAttempts:2,leaseOwner:null,leaseExpiresAt:null,executionId:null,threadId:null,result:null,error:null,timeoutMs:1000,createdAt:now(),updatedAt:now(),continuation:null,continuationDeliveredAt:null});
21
+ const job = (tasks=[]) => ({id:'j',title:'x',prNumber:1,repository:'owner/repo',state:'OPEN',continuation:null,createdAt:now(),updatedAt:now(),tasks});
22
+ const graphCommentClient = (comments, prNumber=1, prState='OPEN') => ({
23
+ request: async path => {
24
+ if (path.includes('/pulls?')) return [{number:prNumber,state:prState.toLowerCase(),merged_at:prState==='MERGED'?now():null,closed_at:prState!=='OPEN'?now():null,body:'',head:{ref:'fix'},base:{ref:'main'}}];
25
+ throw new Error('unexpected REST '+path);
26
+ },
27
+ graphql: async (query, variables={}) => {
28
+ if (query.includes('pullRequest(number:$number){id state merged mergedAt closedAt}')) return {repository:{pullRequest:{id:'PR_'+prNumber,state:prState==='MERGED'?'CLOSED':prState,merged:prState==='MERGED',mergedAt:prState==='MERGED'?now():null,closedAt:prState!=='OPEN'?now():null}}};
29
+ if (query.includes('open:pullRequests(first:50')) {
30
+ const node={number:prNumber,body:'',state:prState==='MERGED'?'CLOSED':prState,isDraft:false,merged:prState==='MERGED',mergedAt:prState==='MERGED'?now():null,closedAt:prState!=='OPEN'?now():null,comments:{nodes:comments.map(c=>({body:c.body,author:{login:c.user.login}}))}};
31
+ return {repository:{open:{nodes:prState==='OPEN'?[node]:[],pageInfo:{hasNextPage:false,endCursor:null}},recentClosed:{nodes:prState==='OPEN'?[]:[node]}}};
32
+ }
33
+ if (query.includes('comments(first:100')) return {repository:{pullRequest:{comments:{nodes:comments.map(c=>({id:'IC_'+c.id,databaseId:c.id,body:c.body,updatedAt:c.updated_at,author:{login:c.user.login}})),pageInfo:{hasNextPage:false,endCursor:null}}}}};
34
+ if (query.includes('addComment')) {const c={id:comments.length+1,body:variables.body,updated_at:now(),user:{login:'bot'}};comments.push(c);return {addComment:{commentEdge:{node:{id:'IC_'+c.id}}}};}
35
+ if (query.includes('updateIssueComment')) {const id=Number(String(variables.id).replace('IC_',''));const c=comments.find(x=>x.id===id);c.body=variables.body;return {updateIssueComment:{issueComment:{id:variables.id}}};}
36
+ if (query.includes('deleteIssueComment')) {const id=Number(String(variables.id).replace('IC_',''));const i=comments.findIndex(x=>x.id===id);if(i>=0)comments.splice(i,1);return {deleteIssueComment:{clientMutationId:null}};}
37
+ throw new Error('unexpected GraphQL '+query);
38
+ }
39
+ });
40
+ class FakeAdapter { name='shell'; id='fake'; capabilities=['shell']; launches=0; cancelled=0; resolvers=[]; launch(t, signal) { this.launches += 1; let resolvePromise; let rejectPromise; const promise = new Promise((resolve,reject) => { resolvePromise=resolve; rejectPromise=reject; }); this.resolvers.push({resolve:resolvePromise,reject:rejectPromise}); signal.addEventListener('abort', () => { this.cancelled += 1; rejectPromise(new Error('aborted')); }, {once:true}); return {id:`exec-${this.launches}`,promise,cancel:() => {this.cancelled += 1; rejectPromise(new Error('cancelled'));}}; } }
41
+ test('parses task marker JSON', () => { const t=task('a'); assert.equal(parseTask(marker(TASK_MARKER,t)).id,'a'); });
42
+ test('discovers by hard requirements and keeps evidence explainable', () => {
43
+ const base = { id:'a', name:'A', responsibility:'research', role:'researcher', capabilities:['research'], boundaries:['no credentials'], endpoint:{kind:'adapter',target:'codex'}, runtime:{adapter:'codex'}, availability:'available', routing:{trustDomains:['public']}, evidence:{evaluations:[],outcomes:{succeeded:0,failed:0,timedOut:0},lastObservedAt:null}, registeredAt:now(), updatedAt:now() };
44
+ const proven = { ...base, id:'proven', evidence:{...base.evidence, evaluations:[{taskKind:'research',outcome:'success',observedAt:now()}], outcomes:{succeeded:3,failed:1,timedOut:0}} };
45
+ assert.deepEqual(discoverAgents([base, proven], { capabilities:['research'], runtime:'codex', trustDomain:'public', taskKind:'research' }).map(item => item.agent.id), ['proven','a']);
46
+ assert.equal(discoverAgents([base], { capabilities:['missing'] }).length, 0);
47
+ assert.equal(discoverAgents([base], { explore:true })[0].exploratory, true);
48
+ });
49
+ test('task marker metadata round-trips while legacy markers remain compatible', () => {
50
+ const legacy = task('legacy');
51
+ assert.equal(parseTask(marker(TASK_MARKER, legacy)).projectName, undefined);
52
+ assert.equal(parseTask(marker(TASK_MARKER, legacy)).agentName, undefined);
53
+ const metadata = { ...task('metadata'), projectName: 'Agents Relay', agentName: 'codex', parentTaskId: 'orchestrator' };
54
+ const parsed = parseTask(marker(TASK_MARKER, metadata));
55
+ assert.equal(parsed.projectName, 'Agents Relay');
56
+ assert.equal(parsed.agentName, 'codex');
57
+ assert.equal(parsed.parentTaskId, 'orchestrator');
58
+ });
59
+ test('CLI submit stores optional project and agent metadata with parent', async () => {
60
+ const directory = await mkdtemp(join(tmpdir(), 'agents-relay-task-metadata-'));
61
+ const file = join(directory, 'job.json');
62
+ await writeFile(file, JSON.stringify(job([])));
63
+ const result = spawn(process.execPath, [join('dist', 'cli.js'), 'submit', '--file', file, '--task-id', 'child', '--parent', 'orchestrator', '--project', 'Agents Relay', '--agent', 'codex', '--input', 'build metadata'], { cwd: process.cwd() });
64
+ const [code] = await once(result, 'exit');
65
+ assert.equal(code, 0);
66
+ const saved = JSON.parse(await readFile(file, 'utf8'));
67
+ assert.equal(saved.tasks[0].projectName, 'Agents Relay');
68
+ assert.equal(saved.tasks[0].agentName, 'codex');
69
+ assert.equal(saved.tasks[0].parentTaskId, 'orchestrator');
70
+ });
71
+ test('CLI submit reopens a completed job before publishing new work', async () => {
72
+ const directory = await mkdtemp(join(tmpdir(), 'agents-relay-reopen-submit-'));
73
+ const file = join(directory, 'job.json');
74
+ await writeFile(file, JSON.stringify({...job([]),state:'COMPLETED'}));
75
+ const result = spawn(process.execPath, [join('dist','cli.js'),'submit','--file',file,'--task-id','review','--input','review completed job'], {cwd:process.cwd()});
76
+ const [code] = await once(result,'exit');
77
+ assert.equal(code,0);
78
+ const saved = JSON.parse(await readFile(file,'utf8'));
79
+ assert.equal(saved.state,'OPEN');
80
+ assert.equal(saved.tasks[0].id,'review');
81
+ await rm(directory,{recursive:true,force:true});
82
+ });
83
+ test('CLI record persists completed orchestrator work without pretending it was a worker execution', async () => {
84
+ const directory = await mkdtemp(join(tmpdir(), 'agents-relay-record-'));
85
+ const file = join(directory, 'job.json');
86
+ await writeFile(file, JSON.stringify(job([])));
87
+ const result = spawn(process.execPath, [join('dist', 'cli.js'), 'record', '--file', file, '--task-id', 'truth-fix', '--project', 'agents-relay', '--summary', 'Reconciled durable state with GitHub truth', '--commit', 'abc123'], { cwd: process.cwd() });
88
+ const [code] = await once(result, 'exit');
89
+ assert.equal(code, 0);
90
+ const saved = JSON.parse(await readFile(file, 'utf8'));
91
+ assert.equal(saved.tasks[0].adapter, 'orchestrator');
92
+ assert.equal(saved.tasks[0].agentName, 'orchestrator');
93
+ assert.equal(saved.tasks[0].state, 'SUCCEEDED');
94
+ assert.equal(saved.tasks[0].executionId, 'abc123');
95
+ assert.equal(saved.tasks[0].result.data.commit, 'abc123');
96
+ });
97
+ test('healthy task states clear stale current errors without deleting history events', () => {
98
+ for (const state of ['READY','RUNNING','SUCCEEDED']) {
99
+ const current=task('state-'+state,'FAILED'); current.error='old failure';
100
+ if (state==='RUNNING') current.state='READY';
101
+ if (state==='SUCCEEDED') current.state='RUNNING';
102
+ transitionTask(current,state);
103
+ assert.equal(current.error,null);
104
+ }
105
+ const queued=task('queued-with-old-error','QUEUED'); queued.error='stale dependency error';
106
+ const scheduled=schedule(job([queued]));
107
+ assert.equal(scheduled.tasks[0].state,'READY');
108
+ assert.equal(scheduled.tasks[0].error,null);
109
+ });
110
+ test('manual retry budget advances in three-attempt windows without resetting history', () => {
111
+ assert.equal(extendRetryBudget({attempt:3,maxAttempts:3}),6);
112
+ assert.equal(extendRetryBudget({attempt:5,maxAttempts:3}),6);
113
+ assert.equal(extendRetryBudget({attempt:6,maxAttempts:6}),9);
114
+ assert.equal(extendRetryBudget({attempt:8,maxAttempts:9}),12);
115
+ });
116
+ test('schedules dependencies but leaves BLOCKED manual', () => { const a=task('a','SUCCEEDED'); const b=task('b','BLOCKED',['a']); const c=task('c','QUEUED',['a']); const value=schedule(job([a,b,c])); assert.equal(b.state,'BLOCKED'); assert.equal(c.state,'READY'); assert.equal(value.state,'RUNNING'); });
117
+ test('detects cycles and requires successful dependencies', () => { assert.throws(() => detectCycles([task('a','QUEUED',['b']),task('b','QUEUED',['a'])]), /cycle/); assert.equal(dependencyReady(task('b','QUEUED',['a']),new Map([['a',task('a','RUNNING')]])),false); });
118
+ test('repository pool orders open jobs globally by durable priority', () => { const low={...job([]),id:'low',priority:'P3',createdAt:'2026-01-01T00:00:00.000Z'}; const high={...job([]),id:'high',priority:'P0',createdAt:'2026-02-01T00:00:00.000Z'}; assert.equal(compareJobPriority(high,low)<0,true); assert.deepEqual(runnableManagedJobs([{job:low,githubState:'OPEN',draft:false,mergedAt:null,closedAt:null},{job:high,githubState:'OPEN',draft:false,mergedAt:null,closedAt:null},{job:{...high,id:'merged'},githubState:'MERGED',draft:false,mergedAt:now(),closedAt:now()}]).map(x=>x.id),['high','low']); });
119
+ test('startup reconciliation selects expired durable RUNNING leases after daemon restart', () => {
120
+ const expired=task('expired','RUNNING'); expired.leaseExpiresAt='2026-09-19T16:46:23.718Z';
121
+ const live=task('live','RUNNING'); live.leaseExpiresAt='2026-09-19T17:10:00.000Z';
122
+ assert.equal(needsLifecycleReconciliation(job([expired]),Date.parse('2026-09-19T16:59:20.000Z')),true);
123
+ assert.equal(needsLifecycleReconciliation(job([live]),Date.parse('2026-09-19T16:59:20.000Z')),false);
124
+ });
125
+ test('completed jobs remain behind the runnable gate even if stale task data appears', () => { const review=task('review','QUEUED'); const completed={...job([review]),id:'completed-with-new-work',state:'COMPLETED'}; const item={job:completed,githubState:'OPEN',draft:false,mergedAt:null,closedAt:null}; assert.deepEqual(runnableManagedJobs([item]),[]); });
126
+ test('failed jobs remain runnable so durable failed tasks can be recovered', () => { const failed={...job([]),id:'failed',state:'FAILED'}; const item={job:failed,githubState:'OPEN',draft:false,mergedAt:null,closedAt:null}; assert.deepEqual(runnableManagedJobs([item]).map(x=>x.id),['failed']); });
127
+ test('draft managed PRs stay discoverable but are not runnable', () => { const held={...job([]),id:'held'}; const item={job:held,githubState:'OPEN',draft:true,mergedAt:null,closedAt:null}; assert.deepEqual(runnableManagedJobs([item]),[]); });
128
+ test('repository pool selects stale terminal jobs for lifecycle reconciliation', () => {
129
+ const open={...job([]),id:'open'}; const merged={...job([]),id:'merged',state:'RUNNING'}; const completed={...job([]),id:'done',state:'COMPLETED'}; const closed={...job([]),id:'closed',state:'OPEN'};
130
+ const items=[{job:open,githubState:'OPEN',draft:false,mergedAt:null,closedAt:null},{job:merged,githubState:'MERGED',draft:false,mergedAt:now(),closedAt:now()},{job:completed,githubState:'MERGED',draft:false,mergedAt:now(),closedAt:now()},{job:closed,githubState:'CLOSED',draft:false,mergedAt:null,closedAt:now()}];
131
+ assert.deepEqual(new Set(terminalManagedJobs(items).map(x=>x.id)),new Set(['merged','closed']));
132
+ });
133
+
134
+ test('repository pool reuses startup discovery without another workspace scan', async () => {
135
+ const calls=[];
136
+ const queued=task('startup-task','QUEUED');
137
+ const root={...job([queued]),id:'startup-known',repository:'owner/repo',prNumber:7};
138
+ const overview={job:root,githubState:'OPEN',draft:false,mergedAt:null,closedAt:null};
139
+ const client={request:async()=>{throw new Error('startup known state must not scan REST');},graphql:async()=>{throw new Error('startup known state must not scan GraphQL');}};
140
+ const pool=new RepositoryWorkerPool(client,['owner/repo'],new Set(['bot']),4,(_store,maxConcurrent)=>({reconcile:async id=>{calls.push({id,maxConcurrent});}}));
141
+ await pool.reconcileKnown([overview]);
142
+ assert.deepEqual(calls,[{id:'startup-known',maxConcurrent:1}]);
143
+ });
144
+
145
+ test('repository pool uses targeted wake metadata without a full workspace scan', async () => {
146
+ const bus=new MemoryEventBus();
147
+ const calls=[];
148
+ const client={request:async()=>{throw new Error('full scan should not run for targeted wake');}};
149
+ const pool=new RepositoryWorkerPool(client,['owner/repo'],new Set(['bot']),4,(_store,maxConcurrent)=>({reconcile:async id=>{calls.push({id,maxConcurrent});}}));
150
+ const stop=await pool.watch(bus);
151
+ await bus.publish(eventFor('job-1','task-1',null,'job.wake','queued','wake','orchestrator',{repository:'owner/repo',prNumber:42}));
152
+ await new Promise(resolve=>setTimeout(resolve,0));
153
+ assert.deepEqual(calls,[{id:'job-1',maxConcurrent:4}]);
154
+ await stop();
155
+ });
156
+
157
+ test('repository pool reconciles merged PRs even though they are not runnable', async () => {
158
+ const root={...job([]),id:'merged-pool',state:'OPEN',prNumber:14};
159
+ const comments=[{id:1,body:marker('agents-relay:job:v1',{...root,tasks:undefined}),updated_at:now(),user:{login:'bot'}}];
160
+ const client=graphCommentClient(comments,14,'MERGED');
161
+ const pool=new RepositoryWorkerPool(client,'owner/repo',new Set(['bot']),2,store=>new Reconciler(store,{owner:'test',adapters:[new FakeAdapter()]}));
162
+ await pool.reconcile();
163
+ const durable=comments.map(c=>c.body).map(body=>body.includes('agents-relay:job:v1') ? body : '').find(Boolean);
164
+ assert.match(durable,/\"state\":\"COMPLETED\"/);
165
+ });
166
+ test('task priority orders ready work and inherits job priority', () => { const a=task('a'), b=task('b'), c=task('c'); a.priority='P3'; b.priority='P0'; a.createdAt='2026-01-01T00:00:00.000Z'; b.createdAt='2026-01-02T00:00:00.000Z'; c.createdAt='2025-01-01T00:00:00.000Z'; assert.deepEqual([a,b,c].sort((x,y)=>compareTaskPriority(x,y,'P1')).map(x=>x.id), ['b','c','a']); });
167
+ test('reconciler launches asynchronously and concurrently', async () => { const a=task('a'), b=task('b'), c=task('c'); const store=new InMemoryStore(job([a,b,c])); const adapter=new FakeAdapter(); const reconciler=new Reconciler(store,{owner:'test',maxConcurrent:2,adapters:[adapter]}); const started=Date.now(); const state=await reconciler.reconcile('j'); assert.ok(Date.now()-started < 100); assert.equal(adapter.launches,2); assert.equal((await store.load('j')).tasks.filter(t=>t.state==='RUNNING').length,2); adapter.resolvers[0].resolve({summary:'a'}); adapter.resolvers[1].resolve({summary:'b'}); await new Promise(resolve=>setTimeout(resolve,30)); adapter.resolvers[2]?.resolve({summary:'c'}); await reconciler.idle(); const done=await store.load('j'); assert.equal(done.tasks.filter(t=>t.state==='SUCCEEDED').length,3); assert.equal(state.state,'RUNNING'); });
168
+ test('timeout cancels a live execution and persists terminal failure', async () => { const t=task('slow'); t.timeoutMs=10; t.maxAttempts=1; const store=new InMemoryStore(job([t])); const adapter=new FakeAdapter(); const reconciler=new Reconciler(store,{owner:'test',adapters:[adapter]}); await reconciler.reconcile('j'); await reconciler.idle(); const result=(await store.load('j')).tasks[0]; assert.equal(adapter.cancelled > 0,true); assert.equal(result.state,'FAILED'); });
169
+ test('expired leases recover without awaiting worker work', async () => { const t=task('expired','RUNNING'); t.attempt=1; t.maxAttempts=2; t.executionId='old'; t.leaseExpiresAt=new Date(Date.now()-1000).toISOString(); const store=new InMemoryStore(job([t])); const adapter=new FakeAdapter(); const reconciler=new Reconciler(store,{owner:'test',adapters:[adapter],maxConcurrent:1}); await reconciler.reconcile('j'); assert.equal(adapter.launches,1); assert.equal((await store.load('j')).tasks[0].state,'RUNNING'); adapter.resolvers[0].resolve({summary:'recovered'}); await reconciler.idle(); });
170
+ test('reconciler recovers a completed durable worker thread after daemon restart', async () => {
171
+ const t=task('recovered-thread','RUNNING');
172
+ t.adapter='codex'; t.threadId='thread-1'; t.executionId='lost-execution'; t.leaseOwner='old-daemon'; t.leaseExpiresAt=new Date(Date.now()+600000).toISOString();
173
+ const store=new InMemoryStore(job([t]));
174
+ const adapter={name:'codex',id:'recovering-codex',capabilities:['model','codex'],launch(){ throw new Error('must not relaunch'); },recover:async current=>current.threadId==='thread-1'?{summary:'durable final answer',data:{recoveredFromThread:'thread-1'}}:null};
175
+ const reconciler=new Reconciler(store,{owner:'new-daemon',adapters:[adapter]});
176
+ await reconciler.reconcile('j');
177
+ const result=(await store.load('j')).tasks[0];
178
+ assert.equal(result.state,'SUCCEEDED');
179
+ assert.equal(result.result.summary,'durable final answer');
180
+ assert.equal(result.leaseOwner,null);
181
+ assert.equal(result.leaseExpiresAt,null);
182
+ });
183
+
184
+ test('Codex tasks launch when capabilities are routing metadata', async () => {
185
+ const t=task('codex-capabilities'); t.adapter='codex'; t.capabilities=['coding','testing']; t.routing={provider:'openai',model:'gpt-5-6'};
186
+ const store=new InMemoryStore(job([t])); let launches=0; let resolveExecution;
187
+ const adapter={name:'codex',id:'fake-codex',capabilities:['model','codex'],launch(){ launches+=1; return {id:'codex-exec',promise:new Promise(resolve=>{resolveExecution=resolve;}),cancel:()=>{}}; }};
188
+ const reconciler=new Reconciler(store,{owner:'test',adapters:[adapter]});
189
+ await reconciler.reconcile('j');
190
+ assert.equal(launches,1); assert.equal((await store.load('j')).tasks[0].state,'RUNNING');
191
+ resolveExecution({summary:'completed'}); await reconciler.idle();
192
+ assert.equal((await store.load('j')).tasks[0].state,'SUCCEEDED');
193
+ });
194
+ test('Codex launch requires routing metadata and applies command contract', () => { assert.throws(() => new CodexAdapter('definitely-not-started').launch(task('model','READY'), new AbortController().signal), /routing metadata/); });
195
+ test('GitHubStore trusts only configured authors and persists through a fresh store', async () => { const comments=[]; const client=graphCommentClient(comments); const store=new GitHubStore(client,'owner/repo',1,new Set(['bot'])); await store.saveJob(job()); const forged=marker(TASK_MARKER,task('forged')); comments.push({id:99,body:forged,updated_at:now(),user:{login:'attacker'}}); const t=task('persist'); await store.appendTask(t); t.state='SUCCEEDED'; t.result={summary:'durable'}; await store.saveTask(t); const fresh=new GitHubStore(client,'owner/repo',1,new Set(['bot'])); const loaded=await fresh.load('j'); assert.equal(loaded.tasks.some(item=>item.id==='forged'),false); assert.equal(loaded.tasks.find(item=>item.id==='persist').result.summary,'durable'); });
196
+ test('GitHubStore reads durable comments through GraphQL', async () => { const comments=Array.from({length:101},(_,index)=>({id:index+1,body:'ordinary',updated_at:now(),user:{login:'bot'}})); const root=job(); comments[0].body=marker('agents-relay:job:v1',{...root,tasks:undefined}); const client=graphCommentClient(comments); const store=new GitHubStore(client,'owner/repo',1,new Set(['bot'])); assert.equal((await store.load('j')).id,'j'); });
197
+ test('continuation delivery has a durable dedupe marker', async () => { const t=task('child'); t.continuation={kind:'command',target:'echo'}; const store=new InMemoryStore(job([t])); const calls=[]; const adapter=new FakeAdapter(); const reconciler=new Reconciler(store,{owner:'test',adapters:[adapter],continuations:[{kind:'command',continue:async input=>{calls.push(input);}}]}); await reconciler.reconcile('j'); adapter.resolvers[0].resolve({summary:'result'}); await reconciler.idle(); await reconciler.reconcile('j'); assert.equal(calls.length,1); assert.ok((await store.load('j')).tasks[0].continuationDeliveredAt); });
198
+ test('continuation failures remain retryable and succeed once', async () => { const t=task('retry-cont'); t.continuation={kind:'command',target:'echo'}; const store=new InMemoryStore(job([t])); const adapter=new FakeAdapter(); let attempts=0; const reconciler=new Reconciler(store,{owner:'test',adapters:[adapter],continuations:[{kind:'command',continue:async()=>{attempts+=1;if(attempts<3)throw new Error('temporary');}}]}); await reconciler.reconcile('j'); adapter.resolvers[0].resolve({summary:'result'}); await reconciler.idle(); assert.equal((await store.load('j')).tasks[0].continuationDeliveredAt,null); await reconciler.reconcile('j'); await reconciler.idle(); assert.equal(attempts,3); assert.ok((await store.load('j')).tasks[0].continuationDeliveredAt); });
199
+ test('stale completion cannot delete a replacement execution', async () => { const t=task('race'); const store=new InMemoryStore(job([t])); const adapter=new FakeAdapter(); const reconciler=new Reconciler(store,{owner:'test',adapters:[adapter]}); await reconciler.reconcile('j'); const old=adapter.resolvers[0]; reconciler.live.delete('race'); const durable=await store.load('j'); durable.tasks[0].state='READY'; await store.saveTask(durable.tasks[0]); await reconciler.reconcile('j'); assert.equal(adapter.launches,2); old.resolve({summary:'old'}); await new Promise(resolve=>setTimeout(resolve,20)); assert.equal(reconciler.live.has('race'),true); adapter.resolvers[1].resolve({summary:'new'}); await reconciler.idle(); assert.equal((await store.load('j')).tasks[0].result.summary,'new'); });
200
+ test('thread id persists before a failed execution is retried', async () => {
201
+ const t=task('thread-retry'); t.adapter='chatgpt'; t.capabilities=['model','chatgpt','macbridge']; t.routing={provider:'openai',model:'gpt-5-6'};
202
+ const store=new InMemoryStore(job([t])); let launches=0; let retriedThreadId;
203
+ const adapter={name:'chatgpt',id:'fake-chatgpt',capabilities:['model','chatgpt','macbridge'],launch(taskState){
204
+ launches+=1; retriedThreadId=launches===2 ? taskState.threadId : retriedThreadId;
205
+ let execution;
206
+ const promise=launches===1 ? new Promise((resolve,reject)=>queueMicrotask(()=>{execution.threadId='persisted-chat-thread';execution.onThreadStarted?.('persisted-chat-thread');reject(new Error('transport failed after thread creation'));})) : Promise.resolve({summary:'continued same conversation'});
207
+ execution={id:`chat-exec-${launches}`,promise,cancel:()=>{}}; return execution;
208
+ }};
209
+ const reconciler=new Reconciler(store,{owner:'test',adapters:[adapter]});
210
+ await reconciler.reconcile('j'); await reconciler.idle();
211
+ const finished=await store.load('j');
212
+ assert.equal(launches,2); assert.equal(retriedThreadId,'persisted-chat-thread'); assert.equal(finished.tasks[0].threadId,'persisted-chat-thread'); assert.equal(finished.tasks[0].state,'SUCCEEDED');
213
+ });
214
+ test('worker completion persistence failure is contained and reported without crashing the reconciler', async () => {
215
+ const t=task('persist-fail');
216
+ class FailingCompletionStore extends InMemoryStore {
217
+ failures=0;
218
+ async saveTask(value) {
219
+ if (value.state==='SUCCEEDED' && this.failures++===0) throw new Error('github persistence down');
220
+ return super.saveTask(value);
221
+ }
222
+ }
223
+ const store=new FailingCompletionStore(job([t]));
224
+ const adapter=new FakeAdapter();
225
+ const events=[];
226
+ const reconciler=new Reconciler(store,{owner:'test',adapters:[adapter],emit:event=>events.push(event)});
227
+ await reconciler.reconcile('j');
228
+ adapter.resolvers[0].resolve({summary:'large result was already bounded'});
229
+ await reconciler.idle();
230
+ await new Promise(resolve=>setTimeout(resolve,20));
231
+ assert.equal(events.some(event=>event.type==='runtime.persistence.failed' && /github persistence down/.test(event.message)),true);
232
+ assert.equal((await store.load('j')).tasks[0].state,'RUNNING');
233
+ });
234
+
235
+ test('event transport failure is advisory', async () => { const t=task('event-fail'); const store=new InMemoryStore(job([t])); const bus={publish:async()=>{throw new Error('nats down');},subscribe:async()=>{throw new Error('nats down');},subscribeAll:async()=>{throw new Error('nats down');}}; const adapter=new FakeAdapter(); const reconciler=new Reconciler(store,{owner:'test',adapters:[adapter],eventBus:bus}); await reconciler.watch('j'); await reconciler.reconcile('j'); adapter.resolvers[0].resolve({summary:'still works'}); await reconciler.idle(); assert.equal((await store.load('j')).tasks[0].state,'SUCCEEDED'); });
236
+ test('aggregate edge cases and NATS subject safety are explicit', () => { const empty=job(); assert.equal(schedule(empty).state,'OPEN'); const cancelled=job([task('x','CANCELLED')]); assert.equal(schedule(cancelled).state,'CANCELLED'); assert.throws(()=>subjectToken('bad.id'),/Unsafe/); assert.equal(subjectToken('safe_job-1'),'safe_job-1'); });
237
+ test('live task leases do not expire before their timeout', async () => {
238
+ const startedBeforeLaunch=Date.now(); const t=task('long'); t.timeoutMs=500; const store=new InMemoryStore(job([t])); const adapter=new FakeAdapter();
239
+ const reconciler=new Reconciler(store,{owner:'test',adapters:[adapter],leaseMs:20});
240
+ await reconciler.reconcile('j');
241
+ await new Promise(resolve=>setTimeout(resolve,30));
242
+ const running=await reconciler.reconcile('j'); const taskState=running.tasks[0];
243
+ assert.equal(adapter.launches,1); assert.equal(running.state,'RUNNING'); assert.equal(taskState.attempt,1);
244
+ assert.ok(new Date(taskState.leaseExpiresAt).getTime() >= startedBeforeLaunch+taskState.timeoutMs);
245
+ adapter.resolvers[0].resolve({summary:'finished'});
246
+ await reconciler.idle();
247
+ const finished=await store.load('j');
248
+ assert.equal(finished.tasks[0].state,'SUCCEEDED'); assert.equal(finished.tasks[0].attempt,1);
249
+ });
250
+ test('dashboard uses safe DOM rendering and named live events', () => { assert.equal(dashboardHtml.includes('innerHTML'),false); assert.equal(dashboardHtml.includes("addEventListener('relay'"),true); });
251
+ test('event bus wakes reconciliation and dashboard serves durable state', async () => { const store=new InMemoryStore(job([task('dash','SUCCEEDED')])); const bus=new MemoryEventBus(); const server=serveDashboard(store,0,bus,10000,'j'); await new Promise(resolve=>server.once('listening',resolve)); const address=server.address(); const port=typeof address==='object'&&address ? address.port : 0; const response=await fetch(`http://127.0.0.1:${port}/api/job`); assert.equal(response.status,200); assert.match(await response.text(),/dash/); server.close(); });
252
+ test('dashboard selected-job refresh serves the requested durable overview without a page reload', async () => { const selected={...job([task('selected','SUCCEEDED')]),id:'selected'}; const refreshed={...selected,tasks:[...selected.tasks,task('new-durable-task','QUEUED')]}; const store=new InMemoryStore(selected); const server=serveDashboard(store,0,undefined,10000,'selected',undefined,async()=>[{job:selected,githubState:'OPEN',draft:true,mergedAt:null,closedAt:null}],undefined,undefined,async id=>id==='selected'?refreshed:null); await new Promise(resolve=>server.once('listening',resolve)); const address=server.address(); const port=typeof address==='object'&&address ? address.port : 0; const response=await fetch(`http://127.0.0.1:${port}/api/job?id=selected`); assert.equal(response.status,200); const body=await response.text(); assert.match(body,/selected/); assert.match(body,/new-durable-task/); const missing=await fetch(`http://127.0.0.1:${port}/api/job?id=missing`); assert.equal(missing.status,404); server.close(); });
253
+
254
+
255
+ test('workspace webhook accepts configured repositories and forwards exact PR identity', async () => {
256
+ const selected={...job([task('webhook-task','SUCCEEDED')]),id:'webhook-job',repository:'lalalic/agents-relay',prNumber:9};
257
+ const store=new InMemoryStore(selected);
258
+ const received=[];
259
+ const secret='workspace-secret';
260
+ const server=serveDashboard(store,0,undefined,10000,'webhook-job',undefined,async()=>[{job:selected,githubState:'OPEN',draft:false,mergedAt:null,closedAt:null}],{secret,repositories:new Set(['lalalic/agents-relay','lalalic/neo']),wake:async event=>{received.push(event);}});
261
+ await new Promise(resolve=>server.once('listening',resolve));
262
+ const address=server.address(); const port=typeof address==='object'&&address?address.port:0;
263
+ const body=JSON.stringify({action:'created',repository:{full_name:'lalalic/agents-relay'},issue:{number:9,pull_request:{url:'x'}}});
264
+ const signature='sha256='+createHmac('sha256',secret).update(body).digest('hex');
265
+ const response=await fetch(`http://127.0.0.1:${port}/webhooks/github`,{method:'POST',headers:{'content-type':'application/json','x-github-delivery':'workspace-delivery','x-github-event':'issue_comment','x-hub-signature-256':signature},body});
266
+ assert.equal(response.status,202);
267
+ await new Promise(resolve=>setTimeout(resolve,0));
268
+ assert.equal(received.length,1);
269
+ assert.equal(received[0].repository,'lalalic/agents-relay');
270
+ assert.equal(received[0].pullRequest,9);
271
+ server.close();
272
+ });
273
+
274
+ test('memory event bus supports explicit all-job subscriptions without weakening subject safety', async () => {
275
+ const bus=new MemoryEventBus(); const seen=[]; const unsubscribe=await bus.subscribeAll(async event=>{seen.push(event.job_id);});
276
+ await bus.publish({version:1,event_id:'e1',job_id:'job-a',task_id:'t',parent_task_id:null,type:'job.wake',status:'queued',timestamp:new Date().toISOString(),visibility:'orchestrator',level:'info',message:'wake'});
277
+ await bus.publish({version:1,event_id:'e2',job_id:'job-b',task_id:'t',parent_task_id:null,type:'job.wake',status:'queued',timestamp:new Date().toISOString(),visibility:'orchestrator',level:'info',message:'wake'});
278
+ await unsubscribe(); assert.deepEqual(seen,['job-a','job-b']); assert.throws(()=>subjectToken('*'),/Unsafe/);
279
+ });
280
+
281
+ test('dashboard returns an error without sending success headers first when durable snapshot fails', async () => {
282
+ const store={load:async()=>{throw new Error('durable unavailable');},saveJob:async()=>{throw new Error('unused');},appendEvent:async()=>{throw new Error('unused');}};
283
+ const server=serveDashboard(store,0,undefined,10000,'j'); await new Promise(resolve=>server.once('listening',resolve));
284
+ const address=server.address(); const port=typeof address==='object'&&address?address.port:0;
285
+ const response=await fetch(`http://127.0.0.1:${port}/api/job`); assert.equal(response.status,404); assert.match(await response.text(),/durable unavailable/);
286
+ server.close();
287
+ });
288
+
289
+ test('dashboard usage failure returns 503 without double-sending headers', async () => {
290
+ const store=new InMemoryStore(job()); const usageRegistry={snapshot:async()=>{throw new Error('usage unavailable');}};
291
+ const server=serveDashboard(store,0,undefined,10000,'j',usageRegistry); await new Promise(resolve=>server.once('listening',resolve));
292
+ const address=server.address(); const port=typeof address==='object'&&address?address.port:0;
293
+ const response=await fetch(`http://127.0.0.1:${port}/api/usage`); assert.equal(response.status,503); assert.match(await response.text(),/usage unavailable/);
294
+ server.close();
295
+ });
296
+
297
+
298
+ test('managed model worker input includes durable PR context without changing stored task input', async () => {
299
+ const { managedWorkerInput } = await import('../dist/reconciler.js');
300
+ const t=task('prompt-context'); t.adapter='chatgpt'; t.projectName='agents-relay'; t.parentTaskId='parent'; t.input='implement the change';
301
+ const root=job([t]); root.prNumber=7; root.repository='lalalic/agents-relay';
302
+ const input=managedWorkerInput(root,t);
303
+ assert.match(input,/PR: https:\/\/github\.com\/lalalic\/agents-relay\/pull\/7/);
304
+ assert.match(input,/Job: j/);
305
+ assert.match(input,/Task: prompt-context/);
306
+ assert.match(input,/Parent task: parent/);
307
+ assert.match(input,/Project: agents-relay/);
308
+ assert.match(input,/implement the change$/);
309
+ assert.equal(t.input,'implement the change');
310
+ const shell=task('shell-context'); shell.input='printf ok';
311
+ assert.equal(managedWorkerInput(root,shell),'printf ok');
312
+ });
313
+
314
+ test('completed jobs delete each ChatGPT task thread once and retain durable thread ids', async () => {
315
+ const a=task('chat-a','SUCCEEDED'); a.adapter='chatgpt'; a.threadId='thread-a';
316
+ const b=task('chat-b','SUCCEEDED'); b.adapter='chatgpt'; b.threadId='thread-b';
317
+ const c=task('shell-done','SUCCEEDED'); c.threadId='local-thread';
318
+ const store=new InMemoryStore(job([a,b,c])); const deleted=[];
319
+ const adapter={name:'chatgpt',id:'fake-chatgpt',capabilities:['model','chatgpt','macbridge'],launch(){throw new Error('not launched');},deleteThread:async id=>{deleted.push(id);}};
320
+ const reconciler=new Reconciler(store,{owner:'test',adapters:[adapter]});
321
+ const completed=await reconciler.reconcile('j');
322
+ assert.equal(completed.state,'COMPLETED');
323
+ assert.deepEqual(deleted,['thread-a','thread-b']);
324
+ assert.equal(completed.tasks[0].threadId,'thread-a'); assert.ok(completed.tasks[0].threadDeletedAt);
325
+ assert.equal(completed.tasks[1].threadId,'thread-b'); assert.ok(completed.tasks[1].threadDeletedAt);
326
+ assert.equal(completed.tasks[2].threadDeletedAt,undefined);
327
+ await reconciler.reconcile('j');
328
+ assert.deepEqual(deleted,['thread-a','thread-b']);
329
+ });
330
+
331
+ test('failed terminal thread cleanup is durable and retried without reopening the job', async () => {
332
+ const t=task('cleanup-retry','SUCCEEDED'); t.adapter='chatgpt'; t.threadId='thread-retry-delete';
333
+ const store=new InMemoryStore(job([t])); let attempts=0;
334
+ const adapter={name:'chatgpt',id:'fake-chatgpt',capabilities:['model','chatgpt','macbridge'],launch(){throw new Error('not launched');},deleteThread:async()=>{attempts+=1;if(attempts===1)throw new Error('delete transport down');}};
335
+ const reconciler=new Reconciler(store,{owner:'test',adapters:[adapter]});
336
+ const first=await reconciler.reconcile('j');
337
+ assert.equal(first.state,'COMPLETED'); assert.equal(first.tasks[0].threadCleanupError,'delete transport down'); assert.equal(first.tasks[0].threadDeletedAt,undefined);
338
+ const second=await reconciler.reconcile('j');
339
+ assert.equal(second.state,'COMPLETED'); assert.equal(second.tasks[0].threadCleanupError,null); assert.ok(second.tasks[0].threadDeletedAt); assert.equal(attempts,2);
340
+ });
341
+
342
+ test('merged PR cancels unfinished work and deletes existing ChatGPT threads', async () => {
343
+ const t=task('merged-active','RUNNING'); t.adapter='chatgpt'; t.threadId='thread-merged'; t.executionId='orphan';
344
+ const store=new InMemoryStore(job([t])); store.pullRequestState=async()=> 'MERGED'; const deleted=[];
345
+ const adapter={name:'chatgpt',id:'fake-chatgpt',capabilities:['model','chatgpt','macbridge'],launch(){throw new Error('not launched');},deleteThread:async id=>{deleted.push(id);}};
346
+ const reconciler=new Reconciler(store,{owner:'test',adapters:[adapter]});
347
+ const result=await reconciler.reconcile('j');
348
+ assert.equal(result.tasks[0].state,'CANCELLED'); assert.equal(result.tasks[0].error,'Pull request merged'); assert.deepEqual(deleted,['thread-merged']); assert.ok(result.tasks[0].threadDeletedAt);
349
+ });
350
+
351
+
352
+ test('merged zero-task PR becomes COMPLETED even when scheduler would leave it OPEN', async () => {
353
+ const root=job([]); const store=new InMemoryStore(root); store.pullRequestState=async()=> 'MERGED';
354
+ const reconciler=new Reconciler(store,{owner:'test',adapters:[new FakeAdapter()]});
355
+ const result=await reconciler.reconcile('j');
356
+ assert.equal(result.state,'COMPLETED');
357
+ });
358
+
359
+ test('closed unmerged PR cancels queued work and job without launching', async () => {
360
+ const t=task('closed-queued'); const store=new InMemoryStore(job([t])); store.pullRequestState=async()=> 'CLOSED'; const adapter=new FakeAdapter();
361
+ const reconciler=new Reconciler(store,{owner:'test',adapters:[adapter]}); const result=await reconciler.reconcile('j');
362
+ assert.equal(result.state,'CANCELLED'); assert.equal(result.tasks[0].state,'CANCELLED'); assert.equal(result.tasks[0].error,'Pull request closed'); assert.equal(adapter.launches,0);
363
+ });
@@ -0,0 +1,178 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { dashboardHtml, githubPrUrl } from '../dist/dashboard.js';
4
+
5
+ test('dashboard exposes the operations-console structure safely', () => {
6
+ assert.match(dashboardHtml, /data-dashboard-contract="safe-dom,named-relay-events,snapshot-events,bounded-event-history"/);
7
+ assert.match(dashboardHtml, />Usage<\/button>/);
8
+ assert.match(dashboardHtml, /id="usage-heading">Usage<\/h2>/);
9
+ assert.match(dashboardHtml, /id="events-panel"/);
10
+ assert.match(dashboardHtml, /id="jobs-workspace"/);
11
+ assert.match(dashboardHtml, /jobsWorkspace\.hidden=!isJobs/);
12
+ assert.match(dashboardHtml, /function taskNode\(/);
13
+ assert.match(dashboardHtml, /function renderTaskDetail\(/);
14
+ assert.match(dashboardHtml, /sessionStorage\.setItem\('agents-relay:selected-job'/);
15
+ assert.match(dashboardHtml, /sessionStorage\.setItem\('agents-relay:selected-task'/);
16
+ assert.doesNotMatch(dashboardHtml, /innerHTML|document\.write/);
17
+ });
18
+
19
+
20
+ test('SSE connection health stays out of the durable job event feed', () => {
21
+ assert.match(dashboardHtml, /stream\.onopen=\(\)=>\{connection\.textContent='Live · event stream connected';connection\.className='status-pill ok';\}/);
22
+ assert.match(dashboardHtml, /stream\.onerror=\(\)=>\{connection\.textContent='Degraded · event stream reconnecting';connection\.className='status-pill warn';\}/);
23
+ assert.doesNotMatch(dashboardHtml, /addEvent\(\{type:'connection'/);
24
+ });
25
+
26
+ test('jobs panel supports manual refresh and unfinished-first ordering', () => {
27
+ assert.match(dashboardHtml, /id="jobs-refresh"/);
28
+ assert.match(dashboardHtml, /id="selected-job-refresh"/);
29
+ assert.match(dashboardHtml, /selectedJobRefresh\.addEventListener\('click'/);
30
+ assert.match(dashboardHtml, /loadJSON\('\/api\/job\?id='/);
31
+ assert.match(dashboardHtml, /jobsRefresh\.addEventListener\('click',\(\)=>void refresh\(true\)\)/);
32
+ assert.match(dashboardHtml, /\/api\/jobs'\+\(force\?'\?refresh=1'/);
33
+ assert.match(dashboardHtml, /function isTerminalJob\(job\)/);
34
+ assert.match(dashboardHtml, /jobs\.sort\(compareJobs\)/);
35
+ assert.match(dashboardHtml, /COMPLETED','CANCELLED/);
36
+ assert.match(dashboardHtml, /MERGED','CLOSED/);
37
+ });
38
+
39
+ test('task attempts display as cumulative count over three-attempt windows', () => {
40
+ assert.match(dashboardHtml, /function attemptBudget\(task\)/);
41
+ assert.match(dashboardHtml, /attempt\+' \/ 3×'\+\(max\/3\)/);
42
+ assert.match(dashboardHtml, /field\('Attempt',attemptBudget\(task\)\)/);
43
+ });
44
+
45
+ test('selected job title appears in the panel heading', () => {
46
+ assert.match(dashboardHtml, /id="selected-job-title"/);
47
+ assert.match(dashboardHtml, /selectedJobTitle\.textContent='— '\+value\(job\.title\|\|job\.id\)/);
48
+ assert.doesNotMatch(dashboardHtml, /field\('Title'/);
49
+ assert.doesNotMatch(dashboardHtml, /field\('Description'/);
50
+ assert.match(dashboardHtml, /field\('GitHub state',job\.githubState/);
51
+ assert.match(dashboardHtml, /field\('Durable state',job\.state\)/);
52
+ });
53
+
54
+ test('account usage is limited to two real rate-limit percentage bars', () => {
55
+ assert.match(dashboardHtml, /5-hour remaining/);
56
+ assert.match(dashboardHtml, /Weekly remaining/);
57
+ assert.match(dashboardHtml, /100-windowData\.usedPercent/);
58
+ assert.match(dashboardHtml, /Weekly/);
59
+ assert.match(dashboardHtml, /function usageProvider\(provider\)/);
60
+ assert.match(dashboardHtml, /overview\.providers/);
61
+ assert.match(dashboardHtml, /overview\.credits/);
62
+ assert.match(dashboardHtml, /function creditProvider\(provider\)/);
63
+ assert.match(dashboardHtml, /progressbar/);
64
+ assert.match(dashboardHtml, /Math\.round\(percent\)\+'%'/);
65
+ assert.doesNotMatch(dashboardHtml, /% available|% used/);
66
+ assert.match(dashboardHtml, /Real account telemetry when available/);
67
+ assert.doesNotMatch(dashboardHtml, /Quota \/ balance|Current durable job|requests ·|tokens ·/i);
68
+ });
69
+
70
+ test('GitHub PR links accept only validated durable repository and PR values', () => {
71
+ assert.equal(githubPrUrl('lalalic/agents-relay', 4), 'https://github.com/lalalic/agents-relay/pull/4');
72
+ for (const repository of ['', 'lalalic', '../evil', 'owner/.', 'owner/..', 'owner/repo?x=1']) {
73
+ assert.equal(githubPrUrl(repository, 1), null);
74
+ }
75
+ for (const prNumber of [0, -1, 1.5, '1', Number.NaN]) {
76
+ assert.equal(githubPrUrl('lalalic/agents-relay', prNumber), null);
77
+ }
78
+ });
79
+
80
+ test('job list uses status badges with distinct state classes', () => {
81
+ assert.match(dashboardHtml, /'badge '\+value\(job\.state\)/);
82
+ assert.match(dashboardHtml, /\.badge\.COMPLETED/);
83
+ assert.match(dashboardHtml, /\.badge\.OPEN/);
84
+ assert.match(dashboardHtml, /\.badge\.CANCELLED/);
85
+ assert.match(dashboardHtml, /\.badge\.FAILED/);
86
+ });
87
+
88
+ test('hidden dashboard views cannot be overridden by layout display rules', () => {
89
+ assert.match(dashboardHtml, /\[hidden\] \{ display:none !important; \}/);
90
+ assert.match(dashboardHtml, /jobsWorkspace\.hidden=!isJobs/);
91
+ });
92
+
93
+ test('credit providers render one balance value per provider row', () => {
94
+ assert.match(dashboardHtml, /const balance=credits\.balances\[0\]/);
95
+ assert.match(dashboardHtml, /formatCredit\(balance\.remaining,balance\.currency\)/);
96
+ assert.doesNotMatch(dashboardHtml, /balance\.total!==null/);
97
+ assert.doesNotMatch(dashboardHtml, /balance\.used!==null/);
98
+ });
99
+
100
+ test('job list uses PR-number title and concise state metadata', () => {
101
+ assert.match(dashboardHtml, /text\('PR#'\+value\(job\.prNumber\)\+': '\+value\(job\.title\|\|job\.id\)\)/);
102
+ assert.match(dashboardHtml, /'badge '\+value\(job\.state\)/);
103
+ assert.match(dashboardHtml, /job\.draft\?'badge DRAFT':'/);
104
+ assert.match(dashboardHtml, /job\.draft\?'DRAFT':value\(job\.githubState\|\|'UNKNOWN'\)/);
105
+ assert.match(dashboardHtml, /\.badge\.DRAFT/);
106
+ assert.match(dashboardHtml, /text\(relativeTime\(job\.updatedAt\)\)/);
107
+ assert.doesNotMatch(dashboardHtml, /text\(job\.id\).*'PR #'/);
108
+ });
109
+
110
+
111
+
112
+
113
+ test('task details expose ChatGPT thread as chat link instead of raw execution identifiers', () => {
114
+ assert.match(dashboardHtml, /function chatField\(task\)/);
115
+ assert.match(dashboardHtml, /https:\/\/chatgpt\.com\/c\//);
116
+ assert.match(dashboardHtml, /encodeURIComponent\(task\.threadId\)/);
117
+ assert.match(dashboardHtml, /link\.textContent='Open chat'/);
118
+ assert.doesNotMatch(dashboardHtml, /field\('Execution \/ thread'/);
119
+ });
120
+
121
+
122
+ test('task details omit input and result from dashboard', () => {
123
+ assert.doesNotMatch(dashboardHtml, /markdownField\('Input'/);
124
+ assert.doesNotMatch(dashboardHtml, /markdownField\('Result'/);
125
+ assert.doesNotMatch(dashboardHtml, /function markdownValue\(/);
126
+ assert.doesNotMatch(dashboardHtml, /function inlineMarkdown\(/);
127
+ });
128
+
129
+
130
+ test('selected job body does not scroll and omits task count field', () => {
131
+ assert.match(dashboardHtml, /#jobs-workspace > \.panel:first-child \.panel-body \{ overflow:visible; max-height:none; \}/);
132
+ assert.doesNotMatch(dashboardHtml, /field\('Tasks',String\(\(job\.tasks\|\|\[\]\)\.length\)\)/);
133
+ assert.match(dashboardHtml, /field\('Task states'/);
134
+ });
135
+
136
+
137
+ test('job list exposes repository and repository quick filter without job count or status filter', () => {
138
+ assert.match(dashboardHtml, /id="repo-filter"/);
139
+ assert.doesNotMatch(dashboardHtml, /id="status-filter"/);
140
+ assert.match(dashboardHtml, /All repos/);
141
+ assert.doesNotMatch(dashboardHtml, /All statuses/);
142
+ assert.match(dashboardHtml, /text\(job\.repository\|\|'unknown repo'\)/);
143
+ assert.match(dashboardHtml, /function filteredJobs\(\)/);
144
+ assert.match(dashboardHtml, /sessionStorage\.setItem\('agents-relay:repo-filter'/);
145
+ assert.doesNotMatch(dashboardHtml, /agents-relay:status-filter/);
146
+ assert.doesNotMatch(dashboardHtml, /id="job-count"/);
147
+ });
148
+
149
+
150
+ test('dashboard periodic snapshots stay cache-only until an explicit refresh', async () => {
151
+ const source = await import('node:fs/promises').then(fs => fs.readFile(new URL('../src/dashboard.ts', import.meta.url), 'utf8'));
152
+ assert.match(source, /forceRefresh && requestedJobId && exactJobProvider/);
153
+ assert.match(source, /snapshot\(requestedJobId, true\)/);
154
+ assert.match(source, /if \(clients\.size === 0\) return;/);
155
+ });
156
+
157
+ test('dashboard SSE sends short keepalive comments independent of durable snapshots', async () => {
158
+ const source = await import('node:fs/promises').then(fs => fs.readFile(new URL('../src/dashboard.ts', import.meta.url), 'utf8'));
159
+ assert.match(source, /const SSE_HEARTBEAT_MS = 15000/);
160
+ assert.match(source, /client\.write\(': heartbeat\\n\\n'\)/);
161
+ assert.match(source, /clearInterval\(heartbeat\)/);
162
+ });
163
+
164
+ test('dashboard warns when GitHub API is rate limited', () => {
165
+ assert.match(dashboardHtml, /id="rate-limit-warning"/);
166
+ assert.match(dashboardHtml, /GitHub API rate limit reached\. Showing cached job data\./);
167
+ assert.match(dashboardHtml, /loadJSON\('\/api\/status'\)/);
168
+ assert.match(dashboardHtml, /function renderRateLimitStatus\(status\)/);
169
+ });
170
+
171
+
172
+ test('completed but unmerged jobs are pinned and highlighted for merge follow-up', () => {
173
+ assert.match(dashboardHtml, /function isCompleteUnmerged\(job\)/);
174
+ assert.match(dashboardHtml, /MERGE PENDING/);
175
+ assert.match(dashboardHtml, /complete-unmerged/);
176
+ assert.match(dashboardHtml, /mergePendingDelta/);
177
+ assert.match(dashboardHtml, /job\.state==='COMPLETED'&&job\.githubState==='OPEN'/);
178
+ });
@@ -0,0 +1,51 @@
1
+ import assert from 'node:assert/strict';
2
+ import { generateKeyPairSync, createVerify } from 'node:crypto';
3
+ import test from 'node:test';
4
+ import { githubAppConfig } from '../dist/github-auth.js';
5
+ import { createGitHubAppJwt, GhApiClient } from '../dist/store.js';
6
+
7
+ test('GitHub App config is all-or-nothing and CLI overrides environment', () => {
8
+ assert.equal(githubAppConfig([], {}), null);
9
+ assert.throws(() => githubAppConfig([], { AGENTS_RELAY_GITHUB_APP_ID: '1' }), /requires app id, installation id, and private key file/);
10
+ assert.deepEqual(
11
+ githubAppConfig(
12
+ ['--github-app-id', 'cli-app', '--github-app-installation-id', 'cli-install', '--github-app-private-key-file', '/cli/key.pem'],
13
+ { AGENTS_RELAY_GITHUB_APP_ID: 'env-app', AGENTS_RELAY_GITHUB_APP_INSTALLATION_ID: 'env-install', AGENTS_RELAY_GITHUB_APP_PRIVATE_KEY_FILE: '/env/key.pem' },
14
+ ),
15
+ { appId: 'cli-app', installationId: 'cli-install', privateKeyFile: '/cli/key.pem' },
16
+ );
17
+ });
18
+
19
+ test('GitHub App JWT is RS256 signed with bounded GitHub-compatible claims', () => {
20
+ const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
21
+ const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
22
+ const now = 2_000_000_000;
23
+ const jwt = createGitHubAppJwt({ appId: '12345', installationId: '67890', privateKey: privatePem }, now);
24
+ const [headerPart, payloadPart, signaturePart] = jwt.split('.');
25
+ const header = JSON.parse(Buffer.from(headerPart, 'base64url').toString('utf8'));
26
+ const payload = JSON.parse(Buffer.from(payloadPart, 'base64url').toString('utf8'));
27
+ assert.deepEqual(header, { alg: 'RS256', typ: 'JWT' });
28
+ assert.deepEqual(payload, { iat: now - 60, exp: now + 540, iss: '12345' });
29
+ const verifier = createVerify('RSA-SHA256');
30
+ verifier.update(`${headerPart}.${payloadPart}`); verifier.end();
31
+ assert.equal(verifier.verify(publicKey, Buffer.from(signaturePart, 'base64url')), true);
32
+ });
33
+
34
+
35
+ test('GitHub App identity request uses Bearer JWT over HTTPS instead of GH_TOKEN', async () => {
36
+ const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
37
+ const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
38
+ const originalFetch = globalThis.fetch;
39
+ let authorization = '';
40
+ globalThis.fetch = async (_url, init={}) => {
41
+ authorization = String(init.headers?.authorization ?? '');
42
+ return new Response(JSON.stringify({ slug: 'agents-relay-test' }), { status: 200, headers: { 'content-type': 'application/json' } });
43
+ };
44
+ try {
45
+ const client = new GhApiClient({ appId: '12345', installationId: '67890', privateKey: privatePem });
46
+ assert.equal(await client.currentLogin(), 'agents-relay-test[bot]');
47
+ assert.match(authorization, /^Bearer [^.]+\.[^.]+\.[^.]+$/);
48
+ } finally {
49
+ globalThis.fetch = originalFetch;
50
+ }
51
+ });