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,49 @@
|
|
|
1
|
+
import { assertJobTransition, assertTaskTransition } from './types.js';
|
|
2
|
+
export function dependencyReady(task, tasks) { return task.dependencies.every(id => tasks.get(id)?.state === 'SUCCEEDED'); }
|
|
3
|
+
export function detectCycles(tasks) { const map = new Map(tasks.map(t => [t.id, t])); const visiting = new Set(); const done = new Set(); const visit = (id) => { if (visiting.has(id))
|
|
4
|
+
throw new Error(`Dependency cycle at ${id}`); if (done.has(id))
|
|
5
|
+
return; visiting.add(id); for (const dep of map.get(id)?.dependencies ?? [])
|
|
6
|
+
visit(dep); visiting.delete(id); done.add(id); }; for (const task of tasks)
|
|
7
|
+
visit(task.id); }
|
|
8
|
+
export function completionGateSatisfied(job) {
|
|
9
|
+
if (job.executionMode !== 'autonomous')
|
|
10
|
+
return true;
|
|
11
|
+
const planners = job.tasks.filter(task => task.kind === 'planner');
|
|
12
|
+
return planners.some(task => task.state === 'SUCCEEDED' && task.plannerResult?.objective_status === 'satisfied') && job.tasks.every(task => task.state === 'SUCCEEDED');
|
|
13
|
+
}
|
|
14
|
+
export function schedule(job) {
|
|
15
|
+
detectCycles(job.tasks);
|
|
16
|
+
if (job.tasks.length === 0) {
|
|
17
|
+
if (job.executionMode === 'autonomous' && job.state !== 'WAITING') {
|
|
18
|
+
assertJobTransition(job.state, 'WAITING');
|
|
19
|
+
job.state = 'WAITING';
|
|
20
|
+
job.updatedAt = new Date().toISOString();
|
|
21
|
+
}
|
|
22
|
+
return job;
|
|
23
|
+
}
|
|
24
|
+
const map = new Map(job.tasks.map(t => [t.id, t]));
|
|
25
|
+
for (const task of job.tasks) {
|
|
26
|
+
if (['QUEUED', 'WAITING'].includes(task.state) && dependencyReady(task, map))
|
|
27
|
+
transitionTask(task, 'READY');
|
|
28
|
+
}
|
|
29
|
+
const active = job.tasks.some(t => ['READY', 'RUNNING'].includes(t.state));
|
|
30
|
+
const blocked = job.tasks.some(t => t.state === 'BLOCKED');
|
|
31
|
+
const waiting = job.tasks.some(t => ['QUEUED', 'WAITING'].includes(t.state));
|
|
32
|
+
const failed = job.tasks.some(t => t.state === 'FAILED');
|
|
33
|
+
const cancelled = job.tasks.every(t => t.state === 'CANCELLED');
|
|
34
|
+
const next = failed ? 'FAILED' : active ? 'RUNNING' : blocked ? 'BLOCKED' : waiting ? 'WAITING' : cancelled ? 'CANCELLED' : completionGateSatisfied(job) ? 'COMPLETED' : 'WAITING';
|
|
35
|
+
if (job.state !== next) {
|
|
36
|
+
assertJobTransition(job.state, next);
|
|
37
|
+
job.state = next;
|
|
38
|
+
job.updatedAt = new Date().toISOString();
|
|
39
|
+
}
|
|
40
|
+
return job;
|
|
41
|
+
}
|
|
42
|
+
export function extendRetryBudget(task, windowSize = 3) {
|
|
43
|
+
if (!Number.isInteger(windowSize) || windowSize < 1)
|
|
44
|
+
throw new Error('Retry window size must be a positive integer');
|
|
45
|
+
const usedOrBudgeted = Math.max(task.maxAttempts, task.attempt);
|
|
46
|
+
return Math.max(windowSize, (Math.floor(usedOrBudgeted / windowSize) + 1) * windowSize);
|
|
47
|
+
}
|
|
48
|
+
export function transitionTask(task, state) { assertTaskTransition(task.state, state); task.state = state; if (['READY', 'RUNNING', 'SUCCEEDED'].includes(state))
|
|
49
|
+
task.error = null; task.updatedAt = new Date().toISOString(); }
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createSign } from 'node:crypto';
|
|
3
|
+
import { parseAgent, parseJob, parseTask, renderAgentComment, renderJobComment, renderTaskComment, AGENT_MARKER, JOB_MARKER, TASK_MARKER } from './markers.js';
|
|
4
|
+
export async function loadManagedGitHubPullRequestGraphql(client, repository, prNumber, trustedAuthors) {
|
|
5
|
+
const [owner, name] = repository.split('/', 2);
|
|
6
|
+
if (!owner || !name || !Number.isInteger(prNumber) || prNumber <= 0 || !client.graphql)
|
|
7
|
+
return [];
|
|
8
|
+
const query = 'query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){state isDraft merged mergedAt closedAt body comments(last:100){nodes{author{login} body}}}}}';
|
|
9
|
+
const data = await client.graphql(query, { owner, name, number: prNumber });
|
|
10
|
+
const repo = data.repository;
|
|
11
|
+
const pr = repo?.pullRequest;
|
|
12
|
+
if (!pr)
|
|
13
|
+
return [];
|
|
14
|
+
const comments = pr.comments?.nodes;
|
|
15
|
+
const trusted = (Array.isArray(comments) ? comments : []).filter(item => {
|
|
16
|
+
const row = item;
|
|
17
|
+
const author = row.author;
|
|
18
|
+
return typeof author?.login === 'string' && trustedAuthors.has(author.login);
|
|
19
|
+
});
|
|
20
|
+
const jobs = trusted.map(item => parseJob(String(item.body ?? ''))).filter((item) => item !== null);
|
|
21
|
+
const tasks = trusted.map(item => parseTask(String(item.body ?? ''))).filter((item) => item !== null);
|
|
22
|
+
const state = String(pr.state ?? '').toUpperCase();
|
|
23
|
+
const githubState = pr.merged === true ? 'MERGED' : state === 'CLOSED' ? 'CLOSED' : 'OPEN';
|
|
24
|
+
return jobs.map(job => {
|
|
25
|
+
job.tasks = tasks.filter(task => task.jobId === job.id || (!task.jobId && jobs.length === 1));
|
|
26
|
+
if (!job.description && typeof pr.body === 'string' && pr.body)
|
|
27
|
+
job.description = pr.body;
|
|
28
|
+
return { job, githubState, draft: pr.isDraft === true, mergedAt: typeof pr.mergedAt === 'string' ? pr.mergedAt : null, closedAt: typeof pr.closedAt === 'string' ? pr.closedAt : null };
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
export async function loadManagedGitHubJobGraphql(client, repository, prNumber, jobId, trustedAuthors) {
|
|
32
|
+
return (await loadManagedGitHubPullRequestGraphql(client, repository, prNumber, trustedAuthors)).find(item => item.job.id === jobId) ?? null;
|
|
33
|
+
}
|
|
34
|
+
const COMPLETED_JOB_HISTORY_LIMIT = 10;
|
|
35
|
+
function isCompletedManagedJob(item) { return ['COMPLETED', 'CANCELLED'].includes(item.job.state) || item.githubState !== 'OPEN'; }
|
|
36
|
+
function completedAt(item) { return item.mergedAt ?? item.closedAt ?? item.job.updatedAt; }
|
|
37
|
+
export function dashboardManagedJobs(items) {
|
|
38
|
+
const unfinished = items.filter(item => !isCompletedManagedJob(item));
|
|
39
|
+
const completed = items.filter(isCompletedManagedJob).sort((a, b) => completedAt(b).localeCompare(completedAt(a)) || b.job.prNumber - a.job.prNumber).slice(0, COMPLETED_JOB_HISTORY_LIMIT);
|
|
40
|
+
return [...unfinished, ...completed];
|
|
41
|
+
}
|
|
42
|
+
export class InMemoryStore {
|
|
43
|
+
values = new Map();
|
|
44
|
+
constructor(job) {
|
|
45
|
+
if (job) {
|
|
46
|
+
this.values.set(`${JOB_MARKER}:${job.id}`, renderJobComment(job));
|
|
47
|
+
for (const task of job.tasks)
|
|
48
|
+
this.values.set(`${TASK_MARKER}:${task.jobId ?? ''}:${task.id}`, renderTaskComment(task));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async load(jobId) { const job = await this.findJob(jobId); if (!job)
|
|
52
|
+
throw new Error(`Job ${jobId} not found`); job.tasks = this.tasksForJob(jobId); return job; }
|
|
53
|
+
async saveJob(job) { this.values.set(`${JOB_MARKER}:${job.id}`, renderJobComment(job)); }
|
|
54
|
+
async appendTask(task) {
|
|
55
|
+
const key = `${TASK_MARKER}:${task.jobId ?? ''}:${task.id}`;
|
|
56
|
+
const existing = this.values.get(key);
|
|
57
|
+
if (existing) {
|
|
58
|
+
const current = parseTask(existing);
|
|
59
|
+
if (current && sameTaskDefinition(current, task))
|
|
60
|
+
return;
|
|
61
|
+
throw new Error(`Task ${task.id} already exists with a different definition`);
|
|
62
|
+
}
|
|
63
|
+
this.values.set(key, renderTaskComment(task));
|
|
64
|
+
}
|
|
65
|
+
async saveTask(task) { this.values.set(`${TASK_MARKER}:${task.jobId ?? ''}:${task.id}`, renderTaskComment(task)); }
|
|
66
|
+
async listAgents() { return [...this.values.values()].map(parseAgent).filter((x) => x !== null); }
|
|
67
|
+
async saveAgent(agent) { this.values.set(`${AGENT_MARKER}:${agent.id}`, renderAgentComment(agent)); }
|
|
68
|
+
async comments() { return [...this.values.values()].map((body, i) => ({ id: i + 1, body, updatedAt: new Date().toISOString(), authorLogin: 'local' })); }
|
|
69
|
+
findJob(jobId) { return [...this.values.values()].map(parseJob).find((x) => x !== null && x.id === jobId) ?? null; }
|
|
70
|
+
tasksForJob(jobId) {
|
|
71
|
+
const jobs = [...this.values.values()].map(parseJob).filter((x) => x !== null && typeof x.id === 'string' && x.id.length > 0);
|
|
72
|
+
return [...this.values.values()].map(parseTask).filter((x) => x !== null).filter(task => isTaskForJob(task, jobId, jobs.length === 1));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function immutableTaskDefinition(task) {
|
|
76
|
+
const routing = task.routing ? {
|
|
77
|
+
provider: task.routing.provider, model: task.routing.model, profile: task.routing.profile,
|
|
78
|
+
reasoning: task.routing.reasoning, cwd: task.routing.cwd, projectId: task.routing.projectId
|
|
79
|
+
} : undefined;
|
|
80
|
+
return {
|
|
81
|
+
jobId: task.jobId, id: task.id, priority: task.priority, projectName: task.projectName, agentName: task.agentName,
|
|
82
|
+
kind: task.kind ?? 'work', parentTaskId: task.parentTaskId, dependencies: task.dependencies, capabilities: task.capabilities, adapter: task.adapter,
|
|
83
|
+
input: task.input, routing, continuation: task.continuation ?? null, maxAttempts: task.maxAttempts, timeoutMs: task.timeoutMs
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function sameTaskDefinition(left, right) {
|
|
87
|
+
return JSON.stringify(immutableTaskDefinition(left)) === JSON.stringify(immutableTaskDefinition(right));
|
|
88
|
+
}
|
|
89
|
+
async function githubGraphql(client, query, variables) {
|
|
90
|
+
if (!client.graphql)
|
|
91
|
+
throw new Error('GitHub GraphQL client is required');
|
|
92
|
+
return await client.graphql(query, variables);
|
|
93
|
+
}
|
|
94
|
+
function repositoryParts(repository) {
|
|
95
|
+
const [owner, name] = repository.split('/', 2);
|
|
96
|
+
if (!owner || !name)
|
|
97
|
+
throw new Error(`Invalid GitHub repository ${repository}`);
|
|
98
|
+
return { owner, name };
|
|
99
|
+
}
|
|
100
|
+
async function headHasNoAheadCommits(client, repository, base, head) {
|
|
101
|
+
const path = `repos/${repository}/compare/${encodeURIComponent(base)}...${encodeURIComponent(head)}`;
|
|
102
|
+
const comparison = await client.request(path);
|
|
103
|
+
return Number(comparison.ahead_by) === 0;
|
|
104
|
+
}
|
|
105
|
+
async function bootstrapEmptyHeadCommit(client, repository, head) {
|
|
106
|
+
const { owner, name } = repositoryParts(repository);
|
|
107
|
+
const data = await githubGraphql(client, 'query($owner:String!,$name:String!,$qualified:String!){repository(owner:$owner,name:$name){ref(qualifiedName:$qualified){target{... on Commit{oid tree{oid}}}}}}', { owner, name, qualified: `refs/heads/${head}` });
|
|
108
|
+
const repo = data.repository;
|
|
109
|
+
const ref = repo?.ref;
|
|
110
|
+
const target = ref?.target;
|
|
111
|
+
const tree = target?.tree;
|
|
112
|
+
const expectedHeadOid = typeof target?.oid === 'string' ? target.oid : '';
|
|
113
|
+
const treeOid = typeof tree?.oid === 'string' ? tree.oid : '';
|
|
114
|
+
if (!expectedHeadOid || !treeOid)
|
|
115
|
+
throw new Error(`Unable to resolve head branch ${head}`);
|
|
116
|
+
const created = await client.request(`repos/${repository}/git/commits`, {
|
|
117
|
+
method: 'POST',
|
|
118
|
+
body: JSON.stringify({
|
|
119
|
+
message: 'chore: bootstrap Agents Relay job',
|
|
120
|
+
tree: treeOid,
|
|
121
|
+
parents: [expectedHeadOid],
|
|
122
|
+
}),
|
|
123
|
+
});
|
|
124
|
+
const commitOid = typeof created.sha === 'string' ? created.sha : '';
|
|
125
|
+
if (!commitOid)
|
|
126
|
+
throw new Error(`Unable to create bootstrap commit for ${head}`);
|
|
127
|
+
await client.request(`repos/${repository}/git/refs/heads/${head}`, {
|
|
128
|
+
method: 'PATCH',
|
|
129
|
+
body: JSON.stringify({ sha: commitOid, force: false }),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
function pullRequest(value) {
|
|
133
|
+
const item = value;
|
|
134
|
+
const head = item.head;
|
|
135
|
+
const base = item.base;
|
|
136
|
+
const number = Number(item.number);
|
|
137
|
+
const headRef = typeof item.headRefName === 'string' ? item.headRefName : typeof head?.ref === 'string' ? head.ref : '';
|
|
138
|
+
const baseRef = typeof item.baseRefName === 'string' ? item.baseRefName : typeof base?.ref === 'string' ? base.ref : '';
|
|
139
|
+
if (!Number.isInteger(number) || number <= 0 || !headRef || !baseRef)
|
|
140
|
+
throw new Error('Invalid GitHub pull request response');
|
|
141
|
+
return { number, title: String(item.title ?? ''), body: String(item.body ?? ''), state: String(item.state ?? ''), head: headRef, base: baseRef };
|
|
142
|
+
}
|
|
143
|
+
export async function resolvePullRequest(client, repository, options) {
|
|
144
|
+
const { owner, name } = repositoryParts(repository);
|
|
145
|
+
if (options.prNumber) {
|
|
146
|
+
const data = await githubGraphql(client, 'query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){number title body state headRefName baseRefName}}}', { owner, name, number: options.prNumber });
|
|
147
|
+
const repo = data.repository;
|
|
148
|
+
const pr = repo?.pullRequest;
|
|
149
|
+
if (!pr)
|
|
150
|
+
throw new Error(`Pull request #${options.prNumber} not found`);
|
|
151
|
+
return pullRequest(pr);
|
|
152
|
+
}
|
|
153
|
+
if (!options.head)
|
|
154
|
+
throw new Error('--head is required when --pr is not provided');
|
|
155
|
+
const base = options.base || 'main';
|
|
156
|
+
const find = async () => {
|
|
157
|
+
const data = await githubGraphql(client, 'query($owner:String!,$name:String!,$head:String!,$base:String!){repository(owner:$owner,name:$name){pullRequests(first:20,states:OPEN,headRefName:$head,baseRefName:$base){nodes{number title body state headRefName baseRefName}}}}', { owner, name, head: options.head, base });
|
|
158
|
+
const repo = data.repository;
|
|
159
|
+
const connection = repo?.pullRequests;
|
|
160
|
+
const nodes = Array.isArray(connection?.nodes) ? connection.nodes : [];
|
|
161
|
+
const match = nodes.map(pullRequest).find(pr => pr.head === options.head && pr.base === base);
|
|
162
|
+
return match ?? null;
|
|
163
|
+
};
|
|
164
|
+
const existing = await find();
|
|
165
|
+
if (existing)
|
|
166
|
+
return existing;
|
|
167
|
+
if (!options.title)
|
|
168
|
+
throw new Error('--title is required when creating a pull request');
|
|
169
|
+
if (await headHasNoAheadCommits(client, repository, base, options.head)) {
|
|
170
|
+
await bootstrapEmptyHeadCommit(client, repository, options.head);
|
|
171
|
+
const afterBootstrap = await find();
|
|
172
|
+
if (afterBootstrap)
|
|
173
|
+
return afterBootstrap;
|
|
174
|
+
}
|
|
175
|
+
const repositoryData = await githubGraphql(client, 'query($owner:String!,$name:String!){repository(owner:$owner,name:$name){id}}', { owner, name });
|
|
176
|
+
const repo = repositoryData.repository;
|
|
177
|
+
if (typeof repo?.id !== 'string')
|
|
178
|
+
throw new Error(`Repository ${repository} not found`);
|
|
179
|
+
const create = async () => {
|
|
180
|
+
const data = await githubGraphql(client, 'mutation($repositoryId:ID!,$base:String!,$head:String!,$title:String!,$body:String!){createPullRequest(input:{repositoryId:$repositoryId,baseRefName:$base,headRefName:$head,title:$title,body:$body}){pullRequest{number title body state headRefName baseRefName}}}', { repositoryId: repo.id, base, head: options.head, title: options.title, body: options.body ?? '' });
|
|
181
|
+
const result = data.createPullRequest;
|
|
182
|
+
if (!result?.pullRequest)
|
|
183
|
+
throw new Error('GitHub createPullRequest returned no pull request');
|
|
184
|
+
return pullRequest(result.pullRequest);
|
|
185
|
+
};
|
|
186
|
+
try {
|
|
187
|
+
return await create();
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
const raced = await find();
|
|
191
|
+
if (raced)
|
|
192
|
+
return raced;
|
|
193
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
194
|
+
if (/No commits between|no commits between/i.test(message)) {
|
|
195
|
+
await bootstrapEmptyHeadCommit(client, repository, options.head);
|
|
196
|
+
const afterBootstrap = await find();
|
|
197
|
+
if (afterBootstrap)
|
|
198
|
+
return afterBootstrap;
|
|
199
|
+
return create();
|
|
200
|
+
}
|
|
201
|
+
throw error;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
function base64Url(value) {
|
|
205
|
+
return Buffer.from(value).toString('base64url');
|
|
206
|
+
}
|
|
207
|
+
export function createGitHubAppJwt(options, now = Math.floor(Date.now() / 1000)) {
|
|
208
|
+
const header = base64Url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
|
|
209
|
+
const payload = base64Url(JSON.stringify({ iat: now - 60, exp: now + 540, iss: options.appId }));
|
|
210
|
+
const unsigned = `${header}.${payload}`;
|
|
211
|
+
const signer = createSign('RSA-SHA256');
|
|
212
|
+
signer.update(unsigned);
|
|
213
|
+
signer.end();
|
|
214
|
+
return `${unsigned}.${signer.sign(options.privateKey).toString('base64url')}`;
|
|
215
|
+
}
|
|
216
|
+
export class GhApiClient {
|
|
217
|
+
appAuth;
|
|
218
|
+
cooldownUntil = 0;
|
|
219
|
+
rateLimitFailures = 0;
|
|
220
|
+
installationToken = null;
|
|
221
|
+
constructor(appAuth = null) {
|
|
222
|
+
this.appAuth = appAuth;
|
|
223
|
+
}
|
|
224
|
+
rateLimitStatus() {
|
|
225
|
+
const limited = Date.now() < this.cooldownUntil;
|
|
226
|
+
return { limited, cooldownUntil: limited ? new Date(this.cooldownUntil).toISOString() : null };
|
|
227
|
+
}
|
|
228
|
+
assertAvailable() {
|
|
229
|
+
if (Date.now() < this.cooldownUntil)
|
|
230
|
+
throw new Error(`GitHub API cooldown active until ${new Date(this.cooldownUntil).toISOString()}`);
|
|
231
|
+
}
|
|
232
|
+
noteFailure(message) {
|
|
233
|
+
if (!/rate limit|secondary rate|HTTP 429/i.test(message))
|
|
234
|
+
return;
|
|
235
|
+
this.rateLimitFailures += 1;
|
|
236
|
+
const delay = Math.min(15 * 60_000, 60_000 * (2 ** Math.min(this.rateLimitFailures - 1, 4)));
|
|
237
|
+
this.cooldownUntil = Date.now() + delay;
|
|
238
|
+
}
|
|
239
|
+
noteSuccess() { if (Date.now() >= this.cooldownUntil)
|
|
240
|
+
this.rateLimitFailures = 0; }
|
|
241
|
+
appJwt(now = Math.floor(Date.now() / 1000)) {
|
|
242
|
+
if (!this.appAuth)
|
|
243
|
+
throw new Error('GitHub App authentication is not configured');
|
|
244
|
+
return createGitHubAppJwt(this.appAuth, now);
|
|
245
|
+
}
|
|
246
|
+
async appRequest(path, method = 'GET') {
|
|
247
|
+
const response = await fetch(`https://api.github.com${path}`, {
|
|
248
|
+
method,
|
|
249
|
+
headers: {
|
|
250
|
+
accept: 'application/vnd.github+json',
|
|
251
|
+
authorization: `Bearer ${this.appJwt()}`,
|
|
252
|
+
'x-github-api-version': '2026-03-10',
|
|
253
|
+
'user-agent': 'agents-relay',
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
const text = await response.text();
|
|
257
|
+
if (!response.ok)
|
|
258
|
+
throw new Error(`GitHub App API ${response.status}: ${text}`);
|
|
259
|
+
return JSON.parse(text);
|
|
260
|
+
}
|
|
261
|
+
async installationAccessToken() {
|
|
262
|
+
if (!this.appAuth)
|
|
263
|
+
throw new Error('GitHub App authentication is not configured');
|
|
264
|
+
if (this.installationToken && this.installationToken.expiresAt - Date.now() > 5 * 60_000)
|
|
265
|
+
return this.installationToken.value;
|
|
266
|
+
const parsed = await this.appRequest(`/app/installations/${this.appAuth.installationId}/access_tokens`, 'POST');
|
|
267
|
+
const token = typeof parsed.token === 'string' ? parsed.token : '';
|
|
268
|
+
const expiresAt = typeof parsed.expires_at === 'string' ? Date.parse(parsed.expires_at) : 0;
|
|
269
|
+
if (!token || !Number.isFinite(expiresAt) || expiresAt <= Date.now())
|
|
270
|
+
throw new Error('GitHub App installation token response was invalid');
|
|
271
|
+
this.installationToken = { value: token, expiresAt };
|
|
272
|
+
return token;
|
|
273
|
+
}
|
|
274
|
+
async commandEnv() {
|
|
275
|
+
if (!this.appAuth)
|
|
276
|
+
return process.env;
|
|
277
|
+
const token = await this.installationAccessToken();
|
|
278
|
+
return { ...process.env, GH_TOKEN: token, GITHUB_TOKEN: token };
|
|
279
|
+
}
|
|
280
|
+
async spawnGh(args, input, env = process.env, rateLimited = true) {
|
|
281
|
+
if (rateLimited)
|
|
282
|
+
this.assertAvailable();
|
|
283
|
+
return new Promise((resolve, reject) => {
|
|
284
|
+
const child = spawn('gh', args, { env });
|
|
285
|
+
let stdout = '';
|
|
286
|
+
let stderr = '';
|
|
287
|
+
child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
288
|
+
child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
289
|
+
child.on('error', reject);
|
|
290
|
+
if (input !== undefined)
|
|
291
|
+
child.stdin.write(input);
|
|
292
|
+
child.stdin.end();
|
|
293
|
+
child.on('close', code => {
|
|
294
|
+
if (code !== 0) {
|
|
295
|
+
const message = stderr.trim() || `gh exited ${code}`;
|
|
296
|
+
if (rateLimited)
|
|
297
|
+
this.noteFailure(message);
|
|
298
|
+
reject(new Error(message));
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (rateLimited)
|
|
302
|
+
this.noteSuccess();
|
|
303
|
+
resolve(stdout);
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
async graphql(query, variables = {}) {
|
|
308
|
+
this.assertAvailable();
|
|
309
|
+
const payload = JSON.stringify({ query, variables });
|
|
310
|
+
const stdout = await this.spawnGh(['api', 'graphql', '--input', '-'], payload, await this.commandEnv());
|
|
311
|
+
const parsed = JSON.parse(stdout);
|
|
312
|
+
if (Array.isArray(parsed.errors) && parsed.errors.length > 0) {
|
|
313
|
+
const message = JSON.stringify(parsed.errors);
|
|
314
|
+
this.noteFailure(message);
|
|
315
|
+
throw new Error(message);
|
|
316
|
+
}
|
|
317
|
+
return parsed.data ?? parsed;
|
|
318
|
+
}
|
|
319
|
+
async request(path, init = {}) {
|
|
320
|
+
this.assertAvailable();
|
|
321
|
+
const args = ['api', path];
|
|
322
|
+
const method = String(init.method ?? 'GET');
|
|
323
|
+
if (method !== 'GET')
|
|
324
|
+
args.push('--method', method);
|
|
325
|
+
if (init.body)
|
|
326
|
+
args.push('--input', '-');
|
|
327
|
+
const stdout = await this.spawnGh(args, init.body ? String(init.body) : undefined, await this.commandEnv());
|
|
328
|
+
if (!stdout.trim())
|
|
329
|
+
return null;
|
|
330
|
+
try {
|
|
331
|
+
return JSON.parse(stdout);
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
return stdout;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
async localLogin() {
|
|
338
|
+
return new Promise((resolve) => {
|
|
339
|
+
const child = spawn('gh', ['auth', 'status', '--active', '--json', 'hosts']);
|
|
340
|
+
let stdout = '';
|
|
341
|
+
child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
342
|
+
child.on('error', () => resolve(null));
|
|
343
|
+
child.on('close', () => {
|
|
344
|
+
try {
|
|
345
|
+
const value = JSON.parse(stdout);
|
|
346
|
+
const hosts = value.hosts;
|
|
347
|
+
const github = hosts?.['github.com'];
|
|
348
|
+
const active = Array.isArray(github) ? github.find(item => item.active === true) : undefined;
|
|
349
|
+
resolve(typeof active?.login === 'string' && active.login.length > 0 ? active.login : null);
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
resolve(null);
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
async currentLogin() {
|
|
358
|
+
if (this.appAuth) {
|
|
359
|
+
const app = await this.appRequest('/app');
|
|
360
|
+
if (typeof app.slug === 'string' && app.slug.length > 0)
|
|
361
|
+
return `${app.slug}[bot]`;
|
|
362
|
+
throw new Error('GitHub App identity lookup returned no slug');
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
const local = await this.localLogin();
|
|
366
|
+
if (local)
|
|
367
|
+
return local;
|
|
368
|
+
try {
|
|
369
|
+
const value = await this.graphql('query { viewer { login } }');
|
|
370
|
+
const viewer = value.viewer;
|
|
371
|
+
if (typeof viewer?.login === 'string' && viewer.login.length > 0)
|
|
372
|
+
return viewer.login;
|
|
373
|
+
}
|
|
374
|
+
catch { /* handled below */ }
|
|
375
|
+
}
|
|
376
|
+
const local = await this.localLogin();
|
|
377
|
+
if (local)
|
|
378
|
+
return local;
|
|
379
|
+
throw new Error('gh authentication did not return a login');
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
export class GitHubStore {
|
|
383
|
+
client;
|
|
384
|
+
repository;
|
|
385
|
+
prNumber;
|
|
386
|
+
trustedAuthors;
|
|
387
|
+
constructor(client, repository, prNumber, trustedAuthors = new Set()) {
|
|
388
|
+
this.client = client;
|
|
389
|
+
this.repository = repository;
|
|
390
|
+
this.prNumber = prNumber;
|
|
391
|
+
this.trustedAuthors = trustedAuthors;
|
|
392
|
+
}
|
|
393
|
+
isTrusted(comment) { return comment.authorLogin !== null && this.trustedAuthors.has(comment.authorLogin); }
|
|
394
|
+
repoParts() {
|
|
395
|
+
const [owner, name] = this.repository.split('/', 2);
|
|
396
|
+
if (!owner || !name)
|
|
397
|
+
throw new Error(`Invalid GitHub repository ${this.repository}`);
|
|
398
|
+
return { owner, name };
|
|
399
|
+
}
|
|
400
|
+
async graphql(query, variables) {
|
|
401
|
+
if (!this.client.graphql)
|
|
402
|
+
throw new Error('GitHub GraphQL client is required');
|
|
403
|
+
return await this.client.graphql(query, variables);
|
|
404
|
+
}
|
|
405
|
+
async prNode() {
|
|
406
|
+
const { owner, name } = this.repoParts();
|
|
407
|
+
const data = await this.graphql('query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){id state merged mergedAt closedAt}}}', { owner, name, number: this.prNumber });
|
|
408
|
+
const repository = data.repository;
|
|
409
|
+
const pr = repository?.pullRequest;
|
|
410
|
+
if (!pr)
|
|
411
|
+
throw new Error(`Pull request #${this.prNumber} not found in ${this.repository}`);
|
|
412
|
+
return pr;
|
|
413
|
+
}
|
|
414
|
+
async list() {
|
|
415
|
+
const { owner, name } = this.repoParts();
|
|
416
|
+
const all = [];
|
|
417
|
+
let cursor = null;
|
|
418
|
+
do {
|
|
419
|
+
const data = await this.graphql('query($owner:String!,$name:String!,$number:Int!,$cursor:String){repository(owner:$owner,name:$name){pullRequest(number:$number){comments(first:100,after:$cursor){nodes{id databaseId body updatedAt author{login}} pageInfo{hasNextPage endCursor}}}}}', { owner, name, number: this.prNumber, cursor });
|
|
420
|
+
const repository = data.repository;
|
|
421
|
+
const pr = repository?.pullRequest;
|
|
422
|
+
const comments = pr?.comments;
|
|
423
|
+
const nodes = Array.isArray(comments?.nodes) ? comments.nodes : [];
|
|
424
|
+
for (const value of nodes) {
|
|
425
|
+
const author = value.author;
|
|
426
|
+
all.push({
|
|
427
|
+
id: Number(value.databaseId ?? 0),
|
|
428
|
+
nodeId: typeof value.id === 'string' ? value.id : undefined,
|
|
429
|
+
body: String(value.body ?? ''),
|
|
430
|
+
updatedAt: String(value.updatedAt ?? ''),
|
|
431
|
+
authorLogin: typeof author?.login === 'string' ? author.login : null
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
const pageInfo = comments?.pageInfo;
|
|
435
|
+
cursor = pageInfo?.hasNextPage === true && typeof pageInfo.endCursor === 'string' ? pageInfo.endCursor : null;
|
|
436
|
+
} while (cursor);
|
|
437
|
+
return all;
|
|
438
|
+
}
|
|
439
|
+
async comments() { return this.list(); }
|
|
440
|
+
async pullRequestState() {
|
|
441
|
+
const pr = await this.prNode();
|
|
442
|
+
if (pr.merged === true || typeof pr.mergedAt === 'string')
|
|
443
|
+
return 'MERGED';
|
|
444
|
+
return String(pr.state ?? '').toUpperCase() === 'CLOSED' ? 'CLOSED' : 'OPEN';
|
|
445
|
+
}
|
|
446
|
+
async load(jobId) {
|
|
447
|
+
const comments = (await this.list()).filter(comment => this.isTrusted(comment));
|
|
448
|
+
const jobs = comments.map(x => parseJob(x.body)).filter((x) => x !== null && typeof x.id === 'string' && x.id.length > 0);
|
|
449
|
+
const job = jobs.find(x => x.id === jobId);
|
|
450
|
+
if (!job)
|
|
451
|
+
throw new Error(`Managed GitHub job ${jobId} has no durable ${JOB_MARKER} marker in PR #${this.prNumber}; run agents-relay job adopt --repo ${this.repository} --pr ${this.prNumber} --id ${jobId}`);
|
|
452
|
+
const soleTrustedJob = jobs.length === 1;
|
|
453
|
+
job.tasks = comments.map(x => parseTask(x.body)).filter((x) => x !== null).filter(task => isTaskForJob(task, jobId, soleTrustedJob));
|
|
454
|
+
return job;
|
|
455
|
+
}
|
|
456
|
+
async listAgents() { return (await this.list()).filter(comment => this.isTrusted(comment)).map(x => parseAgent(x.body)).filter((x) => x !== null); }
|
|
457
|
+
async comment(body) {
|
|
458
|
+
const pr = await this.prNode();
|
|
459
|
+
await this.graphql('mutation($subjectId:ID!,$body:String!){addComment(input:{subjectId:$subjectId,body:$body}){commentEdge{node{id}}}}', { subjectId: pr.id, body });
|
|
460
|
+
}
|
|
461
|
+
async updateComment(comment, body) {
|
|
462
|
+
if (!comment.nodeId)
|
|
463
|
+
throw new Error('GitHub comment node id is required for GraphQL update');
|
|
464
|
+
await this.graphql('mutation($id:ID!,$body:String!){updateIssueComment(input:{id:$id,body:$body}){issueComment{id}}}', { id: comment.nodeId, body });
|
|
465
|
+
}
|
|
466
|
+
async deleteComment(comment) {
|
|
467
|
+
if (!comment.nodeId)
|
|
468
|
+
throw new Error('GitHub comment node id is required for GraphQL delete');
|
|
469
|
+
await this.graphql('mutation($id:ID!){deleteIssueComment(input:{id:$id}){clientMutationId}}', { id: comment.nodeId });
|
|
470
|
+
}
|
|
471
|
+
async saveJob(job) {
|
|
472
|
+
const comments = (await this.list()).filter(comment => this.isTrusted(comment));
|
|
473
|
+
const jobComments = comments.filter(comment => parseJob(comment.body) !== null);
|
|
474
|
+
const conflicts = jobComments.filter(comment => parseJob(comment.body)?.id !== job.id);
|
|
475
|
+
if (conflicts.length > 0)
|
|
476
|
+
throw new Error(`PR #${this.prNumber} already has a different ${JOB_MARKER} marker`);
|
|
477
|
+
const matches = jobComments.filter(comment => parseJob(comment.body)?.id === job.id);
|
|
478
|
+
if (matches.length === 0)
|
|
479
|
+
await this.comment(renderJobComment(job));
|
|
480
|
+
else {
|
|
481
|
+
await this.updateComment(matches[0], renderJobComment(job));
|
|
482
|
+
for (const duplicate of matches.slice(1))
|
|
483
|
+
await this.deleteComment(duplicate);
|
|
484
|
+
}
|
|
485
|
+
const durable = (await this.list()).filter(comment => this.isTrusted(comment)).filter(comment => parseJob(comment.body)?.id === job.id);
|
|
486
|
+
if (durable.length !== 1)
|
|
487
|
+
throw new Error(`Failed to persist exactly one ${JOB_MARKER} marker for job ${job.id}`);
|
|
488
|
+
}
|
|
489
|
+
async appendTask(task) {
|
|
490
|
+
if (!task.jobId)
|
|
491
|
+
throw new Error('GitHub task requires jobId');
|
|
492
|
+
await this.load(task.jobId);
|
|
493
|
+
const comments = (await this.list()).filter(comment => this.isTrusted(comment));
|
|
494
|
+
const matches = comments.filter(comment => {
|
|
495
|
+
const existing = parseTask(comment.body);
|
|
496
|
+
return existing !== null && existing.jobId === task.jobId && existing.id === task.id;
|
|
497
|
+
});
|
|
498
|
+
if (matches.length === 0) {
|
|
499
|
+
await this.comment(renderTaskComment(task));
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
const parsed = matches.map(comment => ({ comment, task: parseTask(comment.body) }));
|
|
503
|
+
if (parsed.some(item => !sameTaskDefinition(item.task, task)))
|
|
504
|
+
throw new Error(`Task ${task.id} already exists with a different definition`);
|
|
505
|
+
for (const duplicate of parsed.slice(1))
|
|
506
|
+
await this.deleteComment(duplicate.comment);
|
|
507
|
+
}
|
|
508
|
+
async saveTask(task) {
|
|
509
|
+
const comments = (await this.list()).filter(comment => this.isTrusted(comment));
|
|
510
|
+
const existing = comments.filter(comment => { const markerTask = parseTask(comment.body); return markerTask !== null && markerTask.jobId === task.jobId && markerTask.id === task.id; });
|
|
511
|
+
if (existing.length > 0) {
|
|
512
|
+
await this.updateComment(existing[0], renderTaskComment(task));
|
|
513
|
+
for (const duplicate of existing.slice(1))
|
|
514
|
+
await this.deleteComment(duplicate);
|
|
515
|
+
}
|
|
516
|
+
else
|
|
517
|
+
await this.appendTask(task);
|
|
518
|
+
}
|
|
519
|
+
async saveAgent(agent) {
|
|
520
|
+
const comments = (await this.list()).filter(comment => this.isTrusted(comment));
|
|
521
|
+
const existing = comments.find(comment => parseAgent(comment.body)?.id === agent.id);
|
|
522
|
+
if (existing)
|
|
523
|
+
await this.updateComment(existing, renderAgentComment(agent));
|
|
524
|
+
else
|
|
525
|
+
await this.comment(renderAgentComment(agent));
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
export async function listManagedGitHubJobs(client, repository, trustedAuthors) {
|
|
529
|
+
if (!client.graphql)
|
|
530
|
+
throw new Error('GitHub GraphQL client is required');
|
|
531
|
+
const [owner, name] = repository.split('/', 2);
|
|
532
|
+
if (!owner || !name)
|
|
533
|
+
throw new Error(`Invalid GitHub repository ${repository}`);
|
|
534
|
+
const output = [];
|
|
535
|
+
const seen = new Set();
|
|
536
|
+
const collect = (nodes) => {
|
|
537
|
+
for (const pr of nodes) {
|
|
538
|
+
const number = Number(pr.number);
|
|
539
|
+
if (!Number.isInteger(number) || number <= 0 || seen.has(number))
|
|
540
|
+
continue;
|
|
541
|
+
seen.add(number);
|
|
542
|
+
const comments = pr.comments;
|
|
543
|
+
const rows = Array.isArray(comments?.nodes) ? comments.nodes : [];
|
|
544
|
+
const trusted = rows.filter(item => {
|
|
545
|
+
const author = item.author;
|
|
546
|
+
return typeof author?.login === 'string' && trustedAuthors.has(author.login);
|
|
547
|
+
});
|
|
548
|
+
const jobs = trusted.map(item => parseJob(String(item.body ?? ''))).filter((item) => item !== null);
|
|
549
|
+
const tasks = trusted.map(item => parseTask(String(item.body ?? ''))).filter((item) => item !== null);
|
|
550
|
+
const state = String(pr.state ?? '').toUpperCase();
|
|
551
|
+
const githubState = pr.merged === true || state === 'MERGED' ? 'MERGED' : state === 'CLOSED' ? 'CLOSED' : 'OPEN';
|
|
552
|
+
for (const job of jobs) {
|
|
553
|
+
if (!job.description && typeof pr.body === 'string' && pr.body)
|
|
554
|
+
job.description = pr.body;
|
|
555
|
+
job.prNumber = number;
|
|
556
|
+
job.repository = repository;
|
|
557
|
+
job.tasks = tasks.filter(task => task.jobId === job.id || (!task.jobId && jobs.length === 1));
|
|
558
|
+
output.push({ job, githubState, draft: pr.isDraft === true, mergedAt: typeof pr.mergedAt === 'string' ? pr.mergedAt : null, closedAt: typeof pr.closedAt === 'string' ? pr.closedAt : null });
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
};
|
|
562
|
+
let cursor = null;
|
|
563
|
+
let firstPage = true;
|
|
564
|
+
do {
|
|
565
|
+
const query = firstPage
|
|
566
|
+
? 'query($owner:String!,$name:String!,$cursor:String){repository(owner:$owner,name:$name){open:pullRequests(first:50,after:$cursor,states:OPEN,orderBy:{field:UPDATED_AT,direction:DESC}){nodes{number body state isDraft merged mergedAt closedAt comments(first:100){nodes{author{login} body}}} pageInfo{hasNextPage endCursor}} recentClosed:pullRequests(first:20,states:CLOSED,orderBy:{field:UPDATED_AT,direction:DESC}){nodes{number body state isDraft merged mergedAt closedAt comments(first:100){nodes{author{login} body}}}}}}'
|
|
567
|
+
: 'query($owner:String!,$name:String!,$cursor:String){repository(owner:$owner,name:$name){open:pullRequests(first:50,after:$cursor,states:OPEN,orderBy:{field:UPDATED_AT,direction:DESC}){nodes{number body state isDraft merged mergedAt closedAt comments(first:100){nodes{author{login} body}}} pageInfo{hasNextPage endCursor}}}}';
|
|
568
|
+
const data = await client.graphql(query, { owner, name, cursor });
|
|
569
|
+
const repo = data.repository;
|
|
570
|
+
const open = repo?.open;
|
|
571
|
+
collect(Array.isArray(open?.nodes) ? open.nodes : []);
|
|
572
|
+
if (firstPage) {
|
|
573
|
+
const recentClosed = repo?.recentClosed;
|
|
574
|
+
collect(Array.isArray(recentClosed?.nodes) ? recentClosed.nodes : []);
|
|
575
|
+
}
|
|
576
|
+
const pageInfo = open?.pageInfo;
|
|
577
|
+
cursor = pageInfo?.hasNextPage === true && typeof pageInfo.endCursor === 'string' ? pageInfo.endCursor : null;
|
|
578
|
+
firstPage = false;
|
|
579
|
+
} while (cursor);
|
|
580
|
+
return output.sort((a, b) => b.job.prNumber - a.job.prNumber);
|
|
581
|
+
}
|
|
582
|
+
function isTaskForJob(task, jobId, soleTrustedJob) {
|
|
583
|
+
if (task.jobId === jobId)
|
|
584
|
+
return true;
|
|
585
|
+
return !task.jobId && soleTrustedJob;
|
|
586
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
const jobTransitions = { OPEN: ['RUNNING', 'WAITING', 'BLOCKED', 'COMPLETED', 'CANCELLED'], RUNNING: ['OPEN', 'WAITING', 'BLOCKED', 'COMPLETED', 'FAILED', 'CANCELLED'], WAITING: ['RUNNING', 'BLOCKED', 'COMPLETED', 'CANCELLED'], BLOCKED: ['RUNNING', 'CANCELLED'], COMPLETED: ['RUNNING', 'CANCELLED'], FAILED: ['RUNNING', 'CANCELLED'], CANCELLED: ['RUNNING'] };
|
|
2
|
+
const taskTransitions = { QUEUED: ['READY', 'BLOCKED', 'CANCELLED'], READY: ['RUNNING', 'BLOCKED', 'FAILED', 'CANCELLED'], RUNNING: ['WAITING', 'BLOCKED', 'SUCCEEDED', 'FAILED', 'CANCELLED', 'READY'], WAITING: ['READY', 'BLOCKED', 'CANCELLED'], BLOCKED: ['READY', 'CANCELLED'], SUCCEEDED: [], FAILED: ['READY', 'CANCELLED'], CANCELLED: [] };
|
|
3
|
+
export function assertJobTransition(from, to) { if (from !== to && !jobTransitions[from].includes(to))
|
|
4
|
+
throw new Error(`Invalid job transition ${from} -> ${to}`); }
|
|
5
|
+
export function assertTaskTransition(from, to) { if (from !== to && !taskTransitions[from].includes(to))
|
|
6
|
+
throw new Error(`Invalid task transition ${from} -> ${to}`); }
|