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.
- package/.github/workflows/publish.yml +91 -0
- package/AGENTS.md +16 -0
- package/LICENSE +21 -0
- package/README.md +102 -0
- package/dist/adapters.js +311 -0
- package/dist/cli.js +455 -0
- package/dist/continuation.js +21 -0
- package/dist/dashboard.js +446 -0
- package/dist/events.js +36 -0
- package/dist/github-auth.js +34 -0
- package/dist/github-webhook.js +47 -0
- package/dist/markers.js +42 -0
- package/dist/planner.js +172 -0
- package/dist/pool.js +98 -0
- package/dist/reconciler.js +434 -0
- package/dist/registry.js +27 -0
- package/dist/relayd.js +177 -0
- package/dist/scheduler.js +49 -0
- package/dist/store.js +586 -0
- package/dist/types.js +6 -0
- package/dist/usage.js +370 -0
- package/dist/workspace.js +76 -0
- package/docs/agent-network.md +34 -0
- package/docs/architecture.md +120 -0
- package/docs/autonomous-objective-jobs.md +121 -0
- package/docs/example.md +30 -0
- package/docs/github-app-rate-limit.md +124 -0
- package/docs/service.md +43 -0
- package/pack.json +326 -0
- package/package.json +14 -0
- package/scripts/npm-version.mjs +11 -0
- package/skills/agents-relay/SKILL.md +77 -0
- package/skills/agents-relay/agents/planner.agent.md +28 -0
- package/src/adapters.ts +231 -0
- package/src/cli.ts +324 -0
- package/src/continuation.ts +6 -0
- package/src/dashboard.ts +421 -0
- package/src/events.ts +25 -0
- package/src/github-auth.ts +35 -0
- package/src/github-webhook.ts +37 -0
- package/src/markers.ts +33 -0
- package/src/planner.ts +150 -0
- package/src/pool.ts +87 -0
- package/src/reconciler.ts +235 -0
- package/src/registry.ts +35 -0
- package/src/relayd.ts +137 -0
- package/src/scheduler.ts +27 -0
- package/src/store.ts +526 -0
- package/src/types.ts +45 -0
- package/src/usage.ts +385 -0
- package/src/workspace.ts +62 -0
- package/test/adapters.test.js +303 -0
- package/test/autonomous.test.js +119 -0
- package/test/core.test.js +363 -0
- package/test/dashboard.test.js +178 -0
- package/test/github-auth.test.js +51 -0
- package/test/github-webhook.test.js +21 -0
- package/test/service.test.js +116 -0
- package/test/store.test.js +390 -0
- package/test/usage.test.js +88 -0
- package/test/workspace.test.js +95 -0
- package/tsconfig.json +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { createHmac } from 'node:crypto';
|
|
4
|
+
import { DeliveryDeduper, parseGitHubWebhook, verifyGitHubWebhook } from '../dist/github-webhook.js';
|
|
5
|
+
|
|
6
|
+
test('GitHub webhook verifies HMAC, filters events, and extracts PR identity', () => {
|
|
7
|
+
const body = Buffer.from(JSON.stringify({ action: 'synchronize', repository: { full_name: 'lalalic/agents-relay' }, pull_request: { number: 11 } }));
|
|
8
|
+
const secret = 'test-secret';
|
|
9
|
+
const signature = 'sha256=' + createHmac('sha256', secret).update(body).digest('hex');
|
|
10
|
+
assert.equal(verifyGitHubWebhook(secret, body, signature), true);
|
|
11
|
+
assert.equal(verifyGitHubWebhook(secret, body, 'sha256=bad'), false);
|
|
12
|
+
assert.deepEqual(parseGitHubWebhook({ 'x-github-delivery': 'delivery-1', 'x-github-event': 'pull_request' }, body), { deliveryId: 'delivery-1', event: 'pull_request', action: 'synchronize', repository: 'lalalic/agents-relay', pullRequest: 11 });
|
|
13
|
+
assert.equal(parseGitHubWebhook({ 'x-github-delivery': 'delivery-2', 'x-github-event': 'ping' }, body), null);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('GitHub webhook delivery IDs are idempotent', () => {
|
|
17
|
+
const dedupe = new DeliveryDeduper(1000);
|
|
18
|
+
assert.equal(dedupe.accept('same', 100), true);
|
|
19
|
+
assert.equal(dedupe.accept('same', 200), false);
|
|
20
|
+
assert.equal(dedupe.accept('same', 1200), true);
|
|
21
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import test from 'node:test';
|
|
7
|
+
import { normalizeDaemonArguments } from '../dist/relayd.js';
|
|
8
|
+
import { createService, startService, SERVICE_DEFAULTS } from '../dist/cli.js';
|
|
9
|
+
import { nextPatchVersion } from '../scripts/npm-version.mjs';
|
|
10
|
+
|
|
11
|
+
test('daemon arguments require and normalize durable identifiers', () => {
|
|
12
|
+
const argv = ['--repo', 'OWNER/REPO', '--pr', '1', '--id', 'JOB_ID', '--port', '9000'];
|
|
13
|
+
const normalized = normalizeDaemonArguments(argv);
|
|
14
|
+
assert.equal(normalized.repository, 'OWNER/REPO');
|
|
15
|
+
assert.equal(normalized.pullRequest, 1);
|
|
16
|
+
assert.equal(normalized.jobId, 'JOB_ID');
|
|
17
|
+
assert.deepEqual(normalized.serviceArguments, argv);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('repository daemon wires account usage telemetry into dashboard', async () => {
|
|
21
|
+
const source = await readFile(new URL('../src/relayd.ts', import.meta.url), 'utf8');
|
|
22
|
+
assert.match(source, /const usageRegistry = codexAndZaiUsageRegistry\(\)/);
|
|
23
|
+
assert.match(source, /fallback\?\.id,usageRegistry,overviews,webhook,\(\)=>client\.rateLimitStatus\(\),exactJob/);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('daemon arguments support repository pool mode and validate legacy pairing', () => {
|
|
27
|
+
const pooled = normalizeDaemonArguments(['--repo','OWNER/REPO','--port','8787']);
|
|
28
|
+
assert.equal(pooled.pullRequest, null); assert.equal(pooled.jobId, null);
|
|
29
|
+
assert.throws(() => normalizeDaemonArguments(['--pr','1','--id','job']), /--repo is required/);
|
|
30
|
+
assert.throws(() => normalizeDaemonArguments(['--repo','OWNER/REPO','--pr','0','--id','job']), /--pr must be positive/);
|
|
31
|
+
assert.throws(() => normalizeDaemonArguments(['--repo','OWNER/REPO','--pr','1']), /supplied together/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('daemon arguments default to workspace mode without changing --repo mode', () => {
|
|
35
|
+
const workspace = normalizeDaemonArguments(['--workspace', '/tmp/Workspace']);
|
|
36
|
+
assert.equal(workspace.repository, null);
|
|
37
|
+
assert.equal(workspace.workspaceRoot, '/tmp/Workspace');
|
|
38
|
+
assert.equal(workspace.pullRequest, null);
|
|
39
|
+
assert.throws(() => normalizeDaemonArguments(['--repo', 'OWNER/REPO', '--workspace', '/tmp/Workspace']), /mutually exclusive/);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('service defaults and package bins use stable package entrypoints', async () => {
|
|
43
|
+
const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
|
|
44
|
+
assert.deepEqual(pkg.bin, { 'agents-relay': 'dist/cli.js', 'agents-relayd': 'dist/relayd.js' });
|
|
45
|
+
assert.equal(SERVICE_DEFAULTS.port, 8787);
|
|
46
|
+
assert.equal(SERVICE_DEFAULTS.watchdogMs, 300000);
|
|
47
|
+
assert.equal(SERVICE_DEFAULTS.webhookWatchdogMs, 1800000);
|
|
48
|
+
assert.equal(SERVICE_DEFAULTS.dashboardRefreshMs, 300000);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('release version selection starts at 1.0.0 and increments only the patch', () => {
|
|
52
|
+
assert.equal(nextPatchVersion(''), '1.0.0');
|
|
53
|
+
assert.equal(nextPatchVersion('1.0.0'), '1.0.1');
|
|
54
|
+
assert.equal(nextPatchVersion('2.7.9'), '2.7.10');
|
|
55
|
+
assert.throws(() => nextPatchVersion('latest'), /Unsupported npm version/);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('CLI and daemon help are successful and do not require job storage', () => {
|
|
59
|
+
for (const [entry, args, expected] of [
|
|
60
|
+
['dist/cli.js', ['--help'], 'npx agents-relay <command>'],
|
|
61
|
+
['dist/cli.js', ['job', '--help'], 'npx agents-relay job <create|adopt|repair>'],
|
|
62
|
+
['dist/cli.js', ['job', 'create', '--help'], 'Create or reuse an open PR'],
|
|
63
|
+
['dist/cli.js', ['job', 'adopt', '--help'], 'Adopt an existing PR'],
|
|
64
|
+
['dist/cli.js', ['job', 'repair', '--help'], 'Repair duplicate durable markers'],
|
|
65
|
+
['dist/cli.js', ['init', '--help'], 'Initialize a new job'],
|
|
66
|
+
['dist/cli.js', ['submit', '--help'], 'Queue a task for a managed job'],
|
|
67
|
+
['dist/cli.js', ['record', '--help'], 'Backfill work completed outside the relay'],
|
|
68
|
+
['dist/cli.js', ['status', '--help'], 'Show the durable state for a job'],
|
|
69
|
+
['dist/cli.js', ['reconcile', '--help'], 'Reconcile a job once'],
|
|
70
|
+
['dist/cli.js', ['retry', '--help'], 'Retry a task by setting it READY'],
|
|
71
|
+
['dist/cli.js', ['cancel', '--help'], 'Cancel a task'],
|
|
72
|
+
['dist/cli.js', ['serve', '--help'], 'Run the dashboard and reconciliation service'],
|
|
73
|
+
['dist/cli.js', ['agent-register', '--help'], 'Register an agent role'],
|
|
74
|
+
['dist/cli.js', ['agent-discover', '--help'], 'Find registered agents'],
|
|
75
|
+
['dist/relayd.js', ['--help'], 'npx agents-relayd [options]']
|
|
76
|
+
]) {
|
|
77
|
+
const result = spawnSync(process.execPath, [entry, ...args], { encoding: 'utf8' });
|
|
78
|
+
assert.equal(result.status, 0, `${entry} ${args.join(' ')} failed: ${result.stderr}`);
|
|
79
|
+
assert.match(result.stdout, new RegExp(expected.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('published bin files are executable after build and npm pack', async () => {
|
|
84
|
+
assert.equal((await stat('dist/cli.js')).mode & 0o111, 0o111);
|
|
85
|
+
assert.equal((await stat('dist/relayd.js')).mode & 0o111, 0o111);
|
|
86
|
+
const packed = spawnSync('npm', ['pack', '--dry-run', '--json'], { encoding: 'utf8' });
|
|
87
|
+
assert.equal(packed.status, 0, packed.stderr);
|
|
88
|
+
const files = new Map(JSON.parse(packed.stdout)[0].files.map(file => [file.path, file.mode]));
|
|
89
|
+
assert.equal(files.get('dist/cli.js'), 0o755);
|
|
90
|
+
assert.equal(files.get('dist/relayd.js'), 0o755);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('service stop clears watchdog and closes dashboard before resolving', async () => {
|
|
94
|
+
const directory = await mkdtemp(join(tmpdir(), 'agents-relay-service-'));
|
|
95
|
+
const file = join(directory, 'job.json');
|
|
96
|
+
await writeFile(file, JSON.stringify({ id: 'cleanup-job', tasks: [] }));
|
|
97
|
+
const service = await createService(['--file', file, '--port', '0', '--interval', '60000', '--refresh-ms', '15000']);
|
|
98
|
+
await service.stop();
|
|
99
|
+
assert.equal(service.dashboardServer.address(), null);
|
|
100
|
+
assert.equal(service.watchdogCleared, true);
|
|
101
|
+
await rm(directory, { recursive: true, force: true });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
test('startup reconciliation failure does not take down dashboard', async () => {
|
|
106
|
+
const directory = await mkdtemp(join(tmpdir(), 'agents-relay-degraded-'));
|
|
107
|
+
const file = join(directory, 'job.json');
|
|
108
|
+
await writeFile(file, JSON.stringify({ id: 'degraded-job', title: 'Degraded', prNumber: 0, state: 'OPEN', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), tasks: [] }));
|
|
109
|
+
const service = await createService(['--file', file, '--port', '0']);
|
|
110
|
+
service.reconciler.reconcile = async () => { throw new Error('GitHub rate limited'); };
|
|
111
|
+
// startService owns construction, so assert the equivalent startup policy directly:
|
|
112
|
+
try { await service.reconciler.reconcile(service.job.id); } catch {}
|
|
113
|
+
assert.notEqual(service.dashboardServer.address(), null);
|
|
114
|
+
await service.stop();
|
|
115
|
+
await rm(directory, { recursive: true, force: true });
|
|
116
|
+
});
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { dashboardManagedJobs, GitHubStore, InMemoryStore } from '../dist/store.js';
|
|
4
|
+
import { AGENT_MARKER, JOB_MARKER, marker, parseAgent, parseJob, parseTask, TASK_MARKER } from '../dist/markers.js';
|
|
5
|
+
|
|
6
|
+
test('marker parser tolerates Mermaid arrows inside JSON string values', () => {
|
|
7
|
+
const description = 'mermaid\\nflowchart TD\\nA --> B';
|
|
8
|
+
const body = marker(JOB_MARKER, { id: 'mermaid-job', title: 'Mermaid', description, tasks: undefined });
|
|
9
|
+
const parsed = parseJob(body);
|
|
10
|
+
assert.equal(parsed?.id, 'mermaid-job');
|
|
11
|
+
assert.equal(parsed?.description, description);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const now = () => new Date().toISOString();
|
|
15
|
+
const task = (jobId, id, state = 'QUEUED') => ({
|
|
16
|
+
jobId,
|
|
17
|
+
id,
|
|
18
|
+
parentTaskId: null,
|
|
19
|
+
dependencies: [],
|
|
20
|
+
capabilities: [],
|
|
21
|
+
adapter: 'shell',
|
|
22
|
+
input: 'true',
|
|
23
|
+
state,
|
|
24
|
+
attempt: 0,
|
|
25
|
+
maxAttempts: 2,
|
|
26
|
+
leaseOwner: null,
|
|
27
|
+
leaseExpiresAt: null,
|
|
28
|
+
executionId: null,
|
|
29
|
+
threadId: null,
|
|
30
|
+
result: null,
|
|
31
|
+
error: null,
|
|
32
|
+
timeoutMs: 1000,
|
|
33
|
+
createdAt: now(),
|
|
34
|
+
updatedAt: now(),
|
|
35
|
+
continuation: null,
|
|
36
|
+
continuationDeliveredAt: null
|
|
37
|
+
});
|
|
38
|
+
const job = id => ({
|
|
39
|
+
id,
|
|
40
|
+
title: id,
|
|
41
|
+
prNumber: 1,
|
|
42
|
+
repository: 'owner/repo',
|
|
43
|
+
state: 'OPEN',
|
|
44
|
+
continuation: null,
|
|
45
|
+
createdAt: now(),
|
|
46
|
+
updatedAt: now(),
|
|
47
|
+
tasks: undefined
|
|
48
|
+
});
|
|
49
|
+
const githubComment = (body, login = 'bot', id = Math.floor(Math.random() * 1_000_000) + 100) => ({
|
|
50
|
+
id,
|
|
51
|
+
body,
|
|
52
|
+
updated_at: now(),
|
|
53
|
+
user: { login }
|
|
54
|
+
});
|
|
55
|
+
const trustedClient = comments => ({
|
|
56
|
+
request: async () => { throw new Error('REST should not be used by GitHubStore'); },
|
|
57
|
+
graphql: async (query, variables = {}) => {
|
|
58
|
+
if (query.includes('pullRequest(number:$number){id state merged mergedAt closedAt}')) return { repository:{ pullRequest:{ id:'PR_1', state:'OPEN', merged:false, mergedAt:null, closedAt:null } } };
|
|
59
|
+
if (query.includes('comments(first:100')) return { repository:{ pullRequest:{ comments:{ nodes:comments.map(item=>({id:'IC_'+item.id,databaseId:item.id,body:item.body,updatedAt:item.updated_at,author:{login:item.user.login}})), pageInfo:{hasNextPage:false,endCursor:null} } } } };
|
|
60
|
+
if (query.includes('addComment')) { const item=githubComment(variables.body); comments.push(item); return { addComment:{ commentEdge:{ node:{ id:'IC_'+item.id } } } }; }
|
|
61
|
+
if (query.includes('updateIssueComment')) { const id=Number(String(variables.id).replace('IC_','')); const item=comments.find(row=>row.id===id); item.body=variables.body; return { updateIssueComment:{ issueComment:{id:variables.id} } }; }
|
|
62
|
+
if (query.includes('deleteIssueComment')) { const id=Number(String(variables.id).replace('IC_','')); const index=comments.findIndex(row=>row.id===id); if(index>=0) comments.splice(index,1); return { deleteIssueComment:{clientMutationId:null} }; }
|
|
63
|
+
throw new Error('Unhandled fake GraphQL query: '+query);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
const githubWithComments = comments => new GitHubStore(trustedClient(comments), 'owner/repo', 1, new Set(['bot']));
|
|
67
|
+
const memoryWithComments = comments => {
|
|
68
|
+
const store = new InMemoryStore();
|
|
69
|
+
for (const item of comments) {
|
|
70
|
+
const parsedJob = parseJob(item.body);
|
|
71
|
+
if (parsedJob) void store.saveJob(parsedJob);
|
|
72
|
+
const parsedTask = parseTask(item.body);
|
|
73
|
+
if (parsedTask) void store.appendTask(parsedTask);
|
|
74
|
+
}
|
|
75
|
+
return store;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
test('same task id remains scoped to its job', async () => {
|
|
79
|
+
const shared = 'shared-task';
|
|
80
|
+
const first = task('job-a', shared, 'SUCCEEDED');
|
|
81
|
+
first.result = { summary: 'first job' };
|
|
82
|
+
const second = task('job-b', shared);
|
|
83
|
+
const comments = [
|
|
84
|
+
githubComment(marker(JOB_MARKER, job('job-a'))),
|
|
85
|
+
githubComment(marker(JOB_MARKER, job('job-b'))),
|
|
86
|
+
githubComment(marker(TASK_MARKER, first)),
|
|
87
|
+
githubComment(marker(TASK_MARKER, second))
|
|
88
|
+
];
|
|
89
|
+
const github = githubWithComments(comments);
|
|
90
|
+
const memory = memoryWithComments(comments);
|
|
91
|
+
|
|
92
|
+
assert.deepEqual((await github.load('job-a')).tasks.map(item => item.id), [shared]);
|
|
93
|
+
assert.deepEqual((await memory.load('job-a')).tasks.map(item => item.id), [shared]);
|
|
94
|
+
assert.deepEqual((await github.load('job-b')).tasks.map(item => item.id), [shared]);
|
|
95
|
+
assert.deepEqual((await memory.load('job-b')).tasks.map(item => item.id), [shared]);
|
|
96
|
+
|
|
97
|
+
const loaded = await github.load('job-a');
|
|
98
|
+
loaded.tasks[0].state = 'CANCELLED';
|
|
99
|
+
await github.saveTask(loaded.tasks[0]);
|
|
100
|
+
assert.equal((await github.load('job-a')).tasks[0].state, 'CANCELLED');
|
|
101
|
+
assert.equal((await github.load('job-b')).tasks[0].state, 'QUEUED');
|
|
102
|
+
|
|
103
|
+
const loadedMemory = await memory.load('job-a');
|
|
104
|
+
loadedMemory.tasks[0].state = 'CANCELLED';
|
|
105
|
+
await memory.saveTask(loadedMemory.tasks[0]);
|
|
106
|
+
assert.equal((await memory.load('job-a')).tasks[0].state, 'CANCELLED');
|
|
107
|
+
assert.equal((await memory.load('job-b')).tasks[0].state, 'QUEUED');
|
|
108
|
+
|
|
109
|
+
const updated = comments.find(item => item.body.includes('"jobId":"job-a"') && item.body.includes(shared));
|
|
110
|
+
assert.match(updated.body, /CANCELLED/);
|
|
111
|
+
assert.doesNotMatch(updated.body, /"jobId":"job-b"/);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('legacy tasks join only one trusted job and are never guessed with multiple', async () => {
|
|
115
|
+
const legacyMarker = marker(TASK_MARKER, { ...task('', 'legacy', 'SUCCEEDED'), jobId: undefined });
|
|
116
|
+
const soleJob = [githubComment(marker(JOB_MARKER, job('solo'))), githubComment(legacyMarker)];
|
|
117
|
+
const multipleJobs = [githubComment(marker(JOB_MARKER, job('one'))), githubComment(marker(JOB_MARKER, job('two'))), githubComment(legacyMarker)];
|
|
118
|
+
const soleGithub = githubWithComments(soleJob);
|
|
119
|
+
const soleMemory = memoryWithComments(soleJob);
|
|
120
|
+
const multipleGithub = githubWithComments(multipleJobs);
|
|
121
|
+
const multipleMemory = memoryWithComments(multipleJobs);
|
|
122
|
+
|
|
123
|
+
assert.deepEqual((await soleGithub.load('solo')).tasks.map(item => item.id), ['legacy']);
|
|
124
|
+
assert.deepEqual((await soleMemory.load('solo')).tasks.map(item => item.id), ['legacy']);
|
|
125
|
+
assert.deepEqual((await multipleGithub.load('one')).tasks, []);
|
|
126
|
+
assert.deepEqual((await multipleMemory.load('one')).tasks, []);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('untrusted task markers are ignored and updates survive a fresh store', async () => {
|
|
130
|
+
const durable = task('job-a', 'durable', 'SUCCEEDED');
|
|
131
|
+
durable.result = { summary: 'durable' };
|
|
132
|
+
const comments = [
|
|
133
|
+
githubComment(marker(JOB_MARKER, job('job-a'))),
|
|
134
|
+
githubComment(marker(TASK_MARKER, durable)),
|
|
135
|
+
githubComment(marker(TASK_MARKER, task('job-a', 'forged')), 'attacker', 999)
|
|
136
|
+
];
|
|
137
|
+
const github = githubWithComments(comments);
|
|
138
|
+
const memory = memoryWithComments(comments.filter(item => item.user.login === 'bot'));
|
|
139
|
+
const loaded = await github.load('job-a');
|
|
140
|
+
|
|
141
|
+
assert.deepEqual(loaded.tasks.map(item => item.id), ['durable']);
|
|
142
|
+
assert.deepEqual((await memory.load('job-a')).tasks.map(item => item.id), ['durable']);
|
|
143
|
+
loaded.tasks[0].result.summary = 'updated';
|
|
144
|
+
await github.saveTask(loaded.tasks[0]);
|
|
145
|
+
|
|
146
|
+
const fresh = githubWithComments(comments);
|
|
147
|
+
const reloaded = await fresh.load('job-a');
|
|
148
|
+
assert.equal(reloaded.tasks[0].result.summary, 'updated');
|
|
149
|
+
assert.equal(reloaded.tasks.some(item => item.id === 'forged'), false);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
class ManagedGitHubClient {
|
|
153
|
+
constructor(pulls = []) {
|
|
154
|
+
this.pulls = pulls;
|
|
155
|
+
this.comments = new Map();
|
|
156
|
+
this.nextPr = Math.max(0, ...pulls.map(item => item.number)) + 1;
|
|
157
|
+
this.nextComment = 1;
|
|
158
|
+
}
|
|
159
|
+
pulls;
|
|
160
|
+
comments;
|
|
161
|
+
nextPr;
|
|
162
|
+
nextComment;
|
|
163
|
+
async graphql(query, variables = {}) {
|
|
164
|
+
const pr = this.pulls.find(item => item.number === Number(variables.number)) ?? this.pulls[0];
|
|
165
|
+
if (query.includes('pullRequest(number:$number){id state merged mergedAt closedAt}')) return { repository:{ pullRequest: pr ? { id:'PR_'+pr.number, state:String(pr.state||'open').toUpperCase(), merged:false, mergedAt:null, closedAt:null } : null } };
|
|
166
|
+
if (query.includes('pullRequest(number:$number){number title body state headRefName baseRefName}')) return { repository:{ pullRequest: pr ? {number:pr.number,title:pr.title,body:pr.body,state:String(pr.state||'open').toUpperCase(),headRefName:pr.head.ref,baseRefName:pr.base.ref} : null } };
|
|
167
|
+
if (query.includes('pullRequests(first:20')) {
|
|
168
|
+
const nodes=this.pulls.filter(item=>item.state==='open'&&(!variables.head||item.head.ref===variables.head)&&(!variables.base||item.base.ref===variables.base)).map(item=>({number:item.number,title:item.title,body:item.body,state:'OPEN',headRefName:item.head.ref,baseRefName:item.base.ref}));
|
|
169
|
+
return {repository:{pullRequests:{nodes}}};
|
|
170
|
+
}
|
|
171
|
+
if (query.includes('query($owner:String!,$name:String!){repository(owner:$owner,name:$name){id}}')) return {repository:{id:'REPO_owner_repo'}};
|
|
172
|
+
if (query.includes('createPullRequest')) {
|
|
173
|
+
const pull={number:this.nextPr++,title:variables.title,body:variables.body,state:'open',head:{ref:variables.head},base:{ref:variables.base}};
|
|
174
|
+
this.pulls.push(pull);
|
|
175
|
+
return {createPullRequest:{pullRequest:{number:pull.number,title:pull.title,body:pull.body,state:'OPEN',headRefName:pull.head.ref,baseRefName:pull.base.ref}}};
|
|
176
|
+
}
|
|
177
|
+
if (query.includes('comments(first:100')) { const values=this.comments.get(Number(variables.number)) ?? []; return { repository:{ pullRequest:{ comments:{ nodes:values.map(item=>({id:'IC_'+item.id,databaseId:item.id,body:item.body,updatedAt:item.updated_at,author:{login:item.user.login}})), pageInfo:{hasNextPage:false,endCursor:null} } } } }; }
|
|
178
|
+
if (query.includes('addComment')) { const prNumber=Number(String(variables.subjectId).replace('PR_','')); const values=this.comments.get(prNumber) ?? []; const item=githubComment(variables.body,'bot',this.nextComment++); values.push(item); this.comments.set(prNumber,values); return { addComment:{commentEdge:{node:{id:'IC_'+item.id}}} }; }
|
|
179
|
+
if (query.includes('updateIssueComment')) { const id=Number(String(variables.id).replace('IC_','')); for(const values of this.comments.values()){const item=values.find(row=>row.id===id); if(item){item.body=variables.body; return {updateIssueComment:{issueComment:{id:variables.id}}};}} }
|
|
180
|
+
if (query.includes('deleteIssueComment')) { const id=Number(String(variables.id).replace('IC_','')); for(const values of this.comments.values()){const index=values.findIndex(row=>row.id===id); if(index>=0){values.splice(index,1); return {deleteIssueComment:{clientMutationId:null}};}} }
|
|
181
|
+
throw new Error('Unhandled fake GraphQL query: '+query);
|
|
182
|
+
}
|
|
183
|
+
async request(path, init = {}) {
|
|
184
|
+
const method = String(init.method ?? 'GET');
|
|
185
|
+
if (path.includes('/compare/')) return { ahead_by: 1 };
|
|
186
|
+
if (path.includes('/pulls?')) return this.pulls.filter(item => item.state === 'open');
|
|
187
|
+
const pullMatch = path.match(/\/pulls\/(\d+)$/);
|
|
188
|
+
if (method === 'GET' && pullMatch) return this.pulls.find(item => item.number === Number(pullMatch[1]));
|
|
189
|
+
if (method === 'POST' && path.endsWith('/pulls')) {
|
|
190
|
+
const body = JSON.parse(String(init.body));
|
|
191
|
+
const pull = { number: this.nextPr++, title: body.title, body: body.body, state: 'open', head: { ref: body.head }, base: { ref: body.base } };
|
|
192
|
+
this.pulls.push(pull);
|
|
193
|
+
return pull;
|
|
194
|
+
}
|
|
195
|
+
const commentsMatch = path.match(/\/issues\/(\d+)\/comments/);
|
|
196
|
+
if (method === 'GET' && commentsMatch) return this.comments.get(Number(commentsMatch[1])) ?? [];
|
|
197
|
+
if (method === 'POST' && commentsMatch) {
|
|
198
|
+
const pr = Number(commentsMatch[1]);
|
|
199
|
+
const values = this.comments.get(pr) ?? [];
|
|
200
|
+
const body = JSON.parse(String(init.body));
|
|
201
|
+
const comment = githubComment(body.body, 'bot', this.nextComment++);
|
|
202
|
+
values.push(comment);
|
|
203
|
+
this.comments.set(pr, values);
|
|
204
|
+
return comment;
|
|
205
|
+
}
|
|
206
|
+
const commentMatch = path.match(/\/issues\/comments\/(\d+)$/);
|
|
207
|
+
if (commentMatch) {
|
|
208
|
+
const id = Number(commentMatch[1]);
|
|
209
|
+
for (const values of this.comments.values()) {
|
|
210
|
+
const index = values.findIndex(item => item.id === id);
|
|
211
|
+
if (index < 0) continue;
|
|
212
|
+
if (method === 'DELETE') { values.splice(index, 1); return null; }
|
|
213
|
+
const body = JSON.parse(String(init.body));
|
|
214
|
+
values[index].body = body.body;
|
|
215
|
+
return values[index];
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
throw new Error('Unhandled fake GitHub request: ' + method + ' ' + path);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
test('managed job creation creates one PR and one durable job marker idempotently', async () => {
|
|
223
|
+
const { ensureManagedGitHubJob } = await import('../dist/cli.js');
|
|
224
|
+
const client = new ManagedGitHubClient();
|
|
225
|
+
const trusted = new Set(['bot']);
|
|
226
|
+
const options = { id: 'managed', title: 'Managed work', head: 'feat/managed', base: 'main', body: 'body' };
|
|
227
|
+
|
|
228
|
+
const first = await ensureManagedGitHubJob(client, 'owner/repo', trusted, options);
|
|
229
|
+
const second = await ensureManagedGitHubJob(client, 'owner/repo', trusted, options);
|
|
230
|
+
|
|
231
|
+
assert.equal(first.pr.number, second.pr.number);
|
|
232
|
+
assert.equal(first.job.description, 'body');
|
|
233
|
+
assert.equal(second.job.description, 'body');
|
|
234
|
+
assert.equal(client.pulls.length, 1);
|
|
235
|
+
const comments = client.comments.get(first.pr.number) ?? [];
|
|
236
|
+
assert.equal(comments.filter(item => parseJob(item.body)?.id === 'managed').length, 1);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('dashboard keeps every unfinished job and only the latest ten completed jobs', () => {
|
|
240
|
+
const overview = (id, state, completedAt) => ({ job: { ...job(id), state, prNumber: Number(id.replace('job-', '')) }, githubState: completedAt ? 'MERGED' : 'OPEN', draft: id === 'job-99', mergedAt: completedAt, closedAt: null });
|
|
241
|
+
const unfinished = [overview('job-1', 'OPEN', null), overview('job-2', 'FAILED', null)];
|
|
242
|
+
const completed = Array.from({ length: 12 }, (_, index) => overview(`job-${index + 10}`, 'COMPLETED', `2026-01-${String(index + 1).padStart(2, '0')}T00:00:00.000Z`));
|
|
243
|
+
const result = dashboardManagedJobs([...completed, ...unfinished]);
|
|
244
|
+
assert.deepEqual(result.slice(0, 2).map(item => item.job.id), ['job-1', 'job-2']);
|
|
245
|
+
assert.equal(result.length, 12);
|
|
246
|
+
assert.deepEqual(result.slice(2).map(item => item.job.id), ['job-21', 'job-20', 'job-19', 'job-18', 'job-17', 'job-16', 'job-15', 'job-14', 'job-13', 'job-12']);
|
|
247
|
+
assert.equal(result.some(item => item.job.id === 'job-10'), false);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test('managed job adoption initializes an existing PR with zero comments', async () => {
|
|
251
|
+
const { ensureManagedGitHubJob } = await import('../dist/cli.js');
|
|
252
|
+
const client = new ManagedGitHubClient([{ number: 6, title: 'Existing', body: '', state: 'open', head: { ref: 'feat/existing' }, base: { ref: 'main' } }]);
|
|
253
|
+
const result = await ensureManagedGitHubJob(client, 'owner/repo', new Set(['bot']), { id: 'adopted', prNumber: 6 });
|
|
254
|
+
|
|
255
|
+
assert.equal(result.pr.number, 6);
|
|
256
|
+
assert.equal(client.pulls.length, 1);
|
|
257
|
+
assert.equal((client.comments.get(6) ?? []).filter(item => parseJob(item.body)?.id === 'adopted').length, 1);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('managed job repair removes duplicate markers without creating another', async () => {
|
|
261
|
+
const { ensureManagedGitHubJob } = await import('../dist/cli.js');
|
|
262
|
+
const client = new ManagedGitHubClient([{ number: 6, title: 'Existing', body: '', state: 'open', head: { ref: 'feat/existing' }, base: { ref: 'main' } }]);
|
|
263
|
+
const duplicate = marker(JOB_MARKER, job('repair'));
|
|
264
|
+
client.comments.set(6, [githubComment(duplicate, 'bot', 1), githubComment(duplicate, 'bot', 2)]);
|
|
265
|
+
client.nextComment = 3;
|
|
266
|
+
|
|
267
|
+
await ensureManagedGitHubJob(client, 'owner/repo', new Set(['bot']), { id: 'repair', prNumber: 6 });
|
|
268
|
+
|
|
269
|
+
assert.equal((client.comments.get(6) ?? []).filter(item => parseJob(item.body)?.id === 'repair').length, 1);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test('managed GitHub task submission is gated on a durable job marker', async () => {
|
|
273
|
+
const { ensureManagedGitHubJob } = await import('../dist/cli.js');
|
|
274
|
+
const client = new ManagedGitHubClient([{ number: 6, title: 'Existing', body: '', state: 'open', head: { ref: 'feat/existing' }, base: { ref: 'main' } }]);
|
|
275
|
+
const store = new GitHubStore(client, 'owner/repo', 6, new Set(['bot']));
|
|
276
|
+
|
|
277
|
+
await assert.rejects(() => store.load('gated'), /has no durable agents-relay:job:v1 marker/);
|
|
278
|
+
await assert.rejects(() => store.appendTask(task('gated', 'child')), /has no durable agents-relay:job:v1 marker/);
|
|
279
|
+
await ensureManagedGitHubJob(client, 'owner/repo', new Set(['bot']), { id: 'gated', prNumber: 6 });
|
|
280
|
+
const durable = await store.load('gated');
|
|
281
|
+
await store.appendTask(task(durable.id, 'child'));
|
|
282
|
+
|
|
283
|
+
const comments = client.comments.get(6) ?? [];
|
|
284
|
+
const jobIndex = comments.findIndex(item => parseJob(item.body)?.id === 'gated');
|
|
285
|
+
const taskIndex = comments.findIndex(item => parseTask(item.body)?.id === 'child');
|
|
286
|
+
assert.ok(jobIndex >= 0);
|
|
287
|
+
assert.ok(taskIndex > jobIndex);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test('agent registrations persist through trusted stores and ignore forged markers', async () => {
|
|
291
|
+
const agent = { id:'researcher', name:'Researcher', responsibility:'public 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() };
|
|
292
|
+
const comments = [githubComment(marker(JOB_MARKER, job('job-a'))), githubComment(marker(AGENT_MARKER, agent)), githubComment(marker(AGENT_MARKER, {...agent, id:'forged'}), 'attacker', 1001)];
|
|
293
|
+
const store = githubWithComments(comments);
|
|
294
|
+
assert.deepEqual((await store.listAgents()).map(item => item.id), ['researcher']);
|
|
295
|
+
agent.availability = 'degraded';
|
|
296
|
+
await store.saveAgent(agent);
|
|
297
|
+
const fresh = githubWithComments(comments);
|
|
298
|
+
assert.equal((await fresh.listAgents())[0].availability, 'degraded');
|
|
299
|
+
assert.equal(parseAgent(comments[1].body).id, 'researcher');
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
test('task append is idempotent by job id and task id and rejects conflicting replay', async () => {
|
|
304
|
+
const durableJob = job('idem');
|
|
305
|
+
const comments = [githubComment(marker(JOB_MARKER, durableJob), 'bot', 1)];
|
|
306
|
+
const store = githubWithComments(comments);
|
|
307
|
+
const first = task('idem', 'child');
|
|
308
|
+
first.capabilities = ['coding'];
|
|
309
|
+
await store.appendTask(first);
|
|
310
|
+
const replay = { ...first, createdAt: now(), updatedAt: now() };
|
|
311
|
+
await store.appendTask(replay);
|
|
312
|
+
assert.equal(comments.filter(item => parseTask(item.body)?.id === 'child').length, 1);
|
|
313
|
+
|
|
314
|
+
const conflict = { ...replay, input: 'different work' };
|
|
315
|
+
await assert.rejects(() => store.appendTask(conflict), /different definition/);
|
|
316
|
+
assert.equal(comments.filter(item => parseTask(item.body)?.id === 'child').length, 1);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test('saveTask collapses duplicate durable markers for one job and task id', async () => {
|
|
320
|
+
const durableJob = job('dedupe');
|
|
321
|
+
const child = task('dedupe', 'child');
|
|
322
|
+
const comments = [
|
|
323
|
+
githubComment(marker(JOB_MARKER, durableJob), 'bot', 1),
|
|
324
|
+
githubComment(marker(TASK_MARKER, child), 'bot', 2),
|
|
325
|
+
githubComment(marker(TASK_MARKER, child), 'bot', 3)
|
|
326
|
+
];
|
|
327
|
+
const client = new ManagedGitHubClient([{ number: 6, title: 'Existing', body: '', state: 'open', head: { ref: 'feat/existing' }, base: { ref: 'main' } }]);
|
|
328
|
+
client.comments.set(6, comments);
|
|
329
|
+
client.nextComment = 4;
|
|
330
|
+
const store = new GitHubStore(client, 'owner/repo', 6, new Set(['bot']));
|
|
331
|
+
child.state = 'RUNNING';
|
|
332
|
+
await store.saveTask(child);
|
|
333
|
+
assert.equal((client.comments.get(6) ?? []).filter(item => parseTask(item.body)?.id === 'child').length, 1);
|
|
334
|
+
assert.equal((await store.load('dedupe')).tasks[0].state, 'RUNNING');
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test('managed job creation proactively bootstraps a head branch with no ahead commits', async () => {
|
|
338
|
+
const { ensureManagedGitHubJob } = await import('../dist/cli.js');
|
|
339
|
+
const calls = [];
|
|
340
|
+
let bootstrapped = false;
|
|
341
|
+
let pr = null;
|
|
342
|
+
const comments = [];
|
|
343
|
+
const client = {
|
|
344
|
+
currentLogin: async () => 'bot',
|
|
345
|
+
request: async (path, init = {}) => {
|
|
346
|
+
calls.push(path);
|
|
347
|
+
if (path.includes('/compare/')) return { ahead_by: bootstrapped ? 1 : 0, behind_by: 5 };
|
|
348
|
+
if (path.endsWith('/git/commits') && init.method === 'POST') {
|
|
349
|
+
const body = JSON.parse(String(init.body));
|
|
350
|
+
assert.equal(body.tree, 'tree');
|
|
351
|
+
assert.deepEqual(body.parents, ['parent']);
|
|
352
|
+
return { sha: 'bootstrap' };
|
|
353
|
+
}
|
|
354
|
+
if (path.includes('/git/refs/heads/feat/empty') && init.method === 'PATCH') {
|
|
355
|
+
const body = JSON.parse(String(init.body));
|
|
356
|
+
assert.equal(body.sha, 'bootstrap');
|
|
357
|
+
assert.equal(body.force, false);
|
|
358
|
+
bootstrapped = true;
|
|
359
|
+
return { object: { sha: 'bootstrap' } };
|
|
360
|
+
}
|
|
361
|
+
throw new Error('Unhandled fake request: '+path);
|
|
362
|
+
},
|
|
363
|
+
graphql: async (query, variables = {}) => {
|
|
364
|
+
calls.push(query);
|
|
365
|
+
if (query.includes('pullRequests(first:20')) return {repository:{pullRequests:{nodes:pr?[{number:7,title:pr.title,body:pr.body,state:'OPEN',headRefName:pr.head.ref,baseRefName:pr.base.ref}]:[]}}};
|
|
366
|
+
if (query.includes('query($owner:String!,$name:String!){repository(owner:$owner,name:$name){id}}')) return {repository:{id:'REPO_owner_repo'}};
|
|
367
|
+
if (query.includes('createPullRequest')) {
|
|
368
|
+
if (!bootstrapped) throw new Error('createPullRequest must not run before bootstrap');
|
|
369
|
+
pr={number:7,title:variables.title,body:variables.body,state:'open',head:{ref:variables.head},base:{ref:variables.base}};
|
|
370
|
+
return {createPullRequest:{pullRequest:{number:7,title:pr.title,body:pr.body,state:'OPEN',headRefName:pr.head.ref,baseRefName:pr.base.ref}}};
|
|
371
|
+
}
|
|
372
|
+
if (query.includes('ref(qualifiedName:$qualified)')) return {repository:{ref:{target:{oid:'parent',tree:{oid:'tree'}}}}};
|
|
373
|
+
if (query.includes('pullRequest(number:$number){id state merged mergedAt closedAt}')) return {repository:{pullRequest:pr?{id:'PR_7',state:'OPEN',merged:false,mergedAt:null,closedAt:null}:null}};
|
|
374
|
+
if (query.includes('comments(first:100')) return {repository:{pullRequest:{comments:{nodes:comments.map(item=>({id:'IC_'+item.id,databaseId:item.id,body:item.body,updatedAt:item.updated_at,author:{login:item.user.login}})),pageInfo:{hasNextPage:false,endCursor:null}}}}};
|
|
375
|
+
if (query.includes('addComment')) { const item=githubComment(variables.body,'bot',comments.length+1); comments.push(item); return {addComment:{commentEdge:{node:{id:'IC_'+item.id}}}}; }
|
|
376
|
+
if (query.includes('updateIssueComment')) { const id=Number(String(variables.id).replace('IC_','')); const item=comments.find(row=>row.id===id); item.body=variables.body; return {updateIssueComment:{issueComment:{id:variables.id}}}; }
|
|
377
|
+
if (query.includes('deleteIssueComment')) { const id=Number(String(variables.id).replace('IC_','')); const index=comments.findIndex(row=>row.id===id); if(index>=0) comments.splice(index,1); return {deleteIssueComment:{clientMutationId:null}}; }
|
|
378
|
+
throw new Error('Unhandled fake GraphQL query: '+query);
|
|
379
|
+
},
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
const result = await ensureManagedGitHubJob(client, 'owner/repo', new Set(['bot']), {
|
|
383
|
+
id: 'empty-job', title: 'Empty branch work', head: 'feat/empty', base: 'main', body: 'body'
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
assert.equal(result.pr.number, 7);
|
|
387
|
+
assert.equal(bootstrapped, true);
|
|
388
|
+
assert.equal(calls.filter(call => String(call).endsWith('/git/commits')).length, 1);
|
|
389
|
+
assert.equal(comments.filter(item => parseJob(item.body)?.id === 'empty-job').length, 1);
|
|
390
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtemp, mkdir, writeFile, utimes } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
import { CodexApiUsageReader, CodexSessionUsageReader, DeepSeekCreditReader, OpenRouterCreditReader, UsageRegistry } from '../dist/usage.js';
|
|
7
|
+
|
|
8
|
+
function jsonl(value) { return JSON.stringify(value) + '\n'; }
|
|
9
|
+
|
|
10
|
+
async function writeRateLimitTelemetry(root, used5h, usedWeek) {
|
|
11
|
+
const directory = join(root, 'sessions', '2026', '09', '18');
|
|
12
|
+
await mkdir(directory, { recursive: true });
|
|
13
|
+
const path = join(directory, 'rollout.jsonl');
|
|
14
|
+
await writeFile(path, jsonl({
|
|
15
|
+
timestamp: '2026-09-18T10:37:47.161Z',
|
|
16
|
+
type: 'event_msg',
|
|
17
|
+
payload: {
|
|
18
|
+
type: 'token_count',
|
|
19
|
+
rate_limits: {
|
|
20
|
+
primary: { used_percent: used5h, window_minutes: 300, resets_at: 1789745000 },
|
|
21
|
+
secondary: { used_percent: usedWeek, window_minutes: 10080, resets_at: 1789805365 }
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}));
|
|
25
|
+
await utimes(path, new Date('2026-09-18T10:38:00Z'), new Date('2026-09-18T10:38:00Z'));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
test('reader uses real account rate-limit percentages', async () => {
|
|
29
|
+
const root = await mkdtemp(join(tmpdir(), 'relay-usage-'));
|
|
30
|
+
await writeRateLimitTelemetry(root, 2, 85);
|
|
31
|
+
const snapshot = await new CodexSessionUsageReader(root, 8, 0, () => new Date('2026-09-18T10:40:00Z')).read();
|
|
32
|
+
assert.equal(snapshot.available, true);
|
|
33
|
+
assert.equal(snapshot.fiveHour?.usedPercent, 2);
|
|
34
|
+
assert.equal(snapshot.weekly?.usedPercent, 85);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('missing rate-limit telemetry stays unavailable', async () => {
|
|
38
|
+
const root = await mkdtemp(join(tmpdir(), 'relay-usage-'));
|
|
39
|
+
const snapshot = await new CodexSessionUsageReader(root, 8, 0, () => new Date('2026-09-18T10:40:00Z')).read();
|
|
40
|
+
assert.equal(snapshot.available, false);
|
|
41
|
+
assert.equal(snapshot.fiveHour, null);
|
|
42
|
+
assert.equal(snapshot.weekly, null);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('usage registry exposes only account limit state', async () => {
|
|
46
|
+
const root = await mkdtemp(join(tmpdir(), 'relay-usage-'));
|
|
47
|
+
await writeRateLimitTelemetry(root, 31.5, 74);
|
|
48
|
+
const overview = await new UsageRegistry(new CodexSessionUsageReader(root, 8, 0, () => new Date('2026-09-18T10:40:00Z'))).snapshot();
|
|
49
|
+
const snapshot = overview.providers.find(provider => provider.id === 'openai').usage;
|
|
50
|
+
const zai = overview.providers.find(provider => provider.id === 'zai').usage;
|
|
51
|
+
assert.deepEqual(Object.keys(snapshot).sort(), ['available', 'fiveHour', 'generatedAt', 'reason', 'weekly']);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
test('DeepSeek credit reader exposes configured account balances', async () => {
|
|
56
|
+
const fetchImpl = async url => {
|
|
57
|
+
assert.equal(url, 'https://api.deepseek.com/user/balance');
|
|
58
|
+
return new Response(JSON.stringify({ is_available: true, balance_infos: [
|
|
59
|
+
{ currency: 'USD', total_balance: '12.34', granted_balance: '2.34', topped_up_balance: '10.00' },
|
|
60
|
+
{ currency: 'CNY', total_balance: '56.78', granted_balance: '0', topped_up_balance: '56.78' }
|
|
61
|
+
] }), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
62
|
+
};
|
|
63
|
+
const snapshot = await new DeepSeekCreditReader('configured', undefined, fetchImpl, () => new Date('2026-09-18T10:40:00Z')).read();
|
|
64
|
+
assert.equal(snapshot.available, true);
|
|
65
|
+
assert.deepEqual(snapshot.balances.map(item => [item.currency, item.remaining]), [['USD', 12.34], ['CNY', 56.78]]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('OpenRouter credit reader exposes purchased, used, and remaining credits', async () => {
|
|
69
|
+
const fetchImpl = async url => {
|
|
70
|
+
assert.equal(url, 'https://openrouter.ai/api/v1/credits');
|
|
71
|
+
return new Response(JSON.stringify({ data: { total_credits: 100.5, total_usage: 25.75 } }), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
72
|
+
};
|
|
73
|
+
const snapshot = await new OpenRouterCreditReader('configured', undefined, fetchImpl, () => new Date('2026-09-18T10:40:00Z')).read();
|
|
74
|
+
assert.equal(snapshot.available, true);
|
|
75
|
+
assert.equal(snapshot.balances[0].currency, 'USD');
|
|
76
|
+
assert.equal(snapshot.balances[0].total, 100.5);
|
|
77
|
+
assert.equal(snapshot.balances[0].used, 25.75);
|
|
78
|
+
assert.equal(snapshot.balances[0].remaining, 74.75);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('usage registry includes credit providers only when readers are configured', async () => {
|
|
82
|
+
const usage = { read: async () => ({ available: false, generatedAt: 'x', fiveHour: null, weekly: null, reason: 'none' }) };
|
|
83
|
+
const credit = { read: async () => ({ available: true, generatedAt: 'x', balances: [{ currency: 'USD', total: 10, used: null, remaining: 10 }], reason: null }) };
|
|
84
|
+
const withoutCredits = await new UsageRegistry(usage, usage).snapshot();
|
|
85
|
+
assert.deepEqual(withoutCredits.credits, []);
|
|
86
|
+
const withCredits = await new UsageRegistry(usage, usage, credit, credit).snapshot();
|
|
87
|
+
assert.deepEqual(withCredits.credits.map(item => item.id), ['deepseek', 'openrouter']);
|
|
88
|
+
});
|