agents-relay 1.0.90 → 1.0.91
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/dist/cli-help.js +192 -0
- package/dist/cli-support.js +103 -0
- package/dist/cli.js +6 -293
- package/dist/dashboard-html.js +292 -0
- package/dist/dashboard.js +2 -292
- package/dist/reconciler-review.js +158 -0
- package/dist/reconciler-runtime.js +404 -0
- package/dist/reconciler.js +65 -534
- package/dist/scheduler.js +11 -33
- package/dist/store-github.js +217 -0
- package/dist/store-task-definition.js +19 -0
- package/dist/store.js +3 -235
- package/dist/workflow/contracts.js +84 -0
- package/dist/workflow/definitions.js +19 -5
- package/dist/workflow/effects.js +74 -0
- package/dist/workflow/mermaid.js +6 -2
- package/dist/workflow/rollout.js +70 -56
- package/dist/workflow/runtime.js +39 -0
- package/dist/workflow/task-reconcile.js +48 -0
- package/package.json +1 -1
- package/workflow/job-lifecycle.mmd +10 -5
- package/workflow/task-lifecycle.mmd +12 -10
package/dist/scheduler.js
CHANGED
|
@@ -1,16 +1,7 @@
|
|
|
1
1
|
import { decideJobLifecycle, decideTaskLifecycle } from './workflow/rollout.js';
|
|
2
|
-
|
|
3
|
-
export
|
|
4
|
-
|
|
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
|
-
function scheduledCycleTasks(job) {
|
|
9
|
-
const slot = job.schedule?.currentRunSlot ? Date.parse(job.schedule.currentRunSlot) : Number.NaN;
|
|
10
|
-
if (!Number.isFinite(slot))
|
|
11
|
-
return job.tasks;
|
|
12
|
-
return job.tasks.filter(task => Date.parse(task.createdAt) >= slot);
|
|
13
|
-
}
|
|
2
|
+
import { reconcileTaskDependencies } from './workflow/task-reconcile.js';
|
|
3
|
+
export { dependencyReady, detectCycles } from './workflow/task-reconcile.js';
|
|
4
|
+
import { applyJobWorkflowDecision, applyTaskWorkflowDecision } from './workflow/runtime.js';
|
|
14
5
|
export function completionGateSatisfied(job) {
|
|
15
6
|
if (job.schedule)
|
|
16
7
|
return false;
|
|
@@ -20,22 +11,10 @@ export function completionGateSatisfied(job) {
|
|
|
20
11
|
return planners.some(task => task.state === 'SUCCEEDED' && task.plannerResult?.objective_status === 'satisfied') && job.tasks.every(task => task.state === 'SUCCEEDED');
|
|
21
12
|
}
|
|
22
13
|
export function schedule(job) {
|
|
23
|
-
|
|
24
|
-
const scopedTasks =
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const ready = dependencyReady(task, map);
|
|
28
|
-
const facts = {
|
|
29
|
-
dependenciesSatisfied: task.dependencies.length > 0 && ready,
|
|
30
|
-
dependenciesUnsatisfied: task.dependencies.length > 0 && !ready,
|
|
31
|
-
runnable: ready,
|
|
32
|
-
};
|
|
33
|
-
const decision = decideTaskLifecycle(task, 'reconcile', facts);
|
|
34
|
-
if (decision.reason === 'ambiguous')
|
|
35
|
-
throw new Error(`Ambiguous automatic task transition from ${task.state}`);
|
|
36
|
-
if (decision.accepted && decision.target)
|
|
37
|
-
applyTaskState(task, decision.target);
|
|
38
|
-
}
|
|
14
|
+
reconcileTaskDependencies(job);
|
|
15
|
+
const scopedTasks = job.schedule?.currentRunSlot
|
|
16
|
+
? job.tasks.filter(task => Date.parse(task.createdAt) >= Date.parse(job.schedule?.currentRunSlot ?? ''))
|
|
17
|
+
: job.tasks;
|
|
39
18
|
if (job.schedule) {
|
|
40
19
|
const planners = scopedTasks.filter(task => task.kind === 'planner');
|
|
41
20
|
const latest = planners.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id)).at(-1);
|
|
@@ -56,15 +35,14 @@ export function extendRetryBudget(task, windowSize = 3) {
|
|
|
56
35
|
const usedOrBudgeted = Math.max(task.maxAttempts, task.attempt);
|
|
57
36
|
return Math.max(windowSize, (Math.floor(usedOrBudgeted / windowSize) + 1) * windowSize);
|
|
58
37
|
}
|
|
59
|
-
function applyTaskState(task, state) { task.state = state; if (['QUEUED', 'RUNNING', 'SUCCEEDED'].includes(state))
|
|
60
|
-
task.error = null; task.updatedAt = new Date().toISOString(); }
|
|
61
38
|
export async function transitionTaskEvent(task, event, context = {}) {
|
|
62
39
|
const decision = decideTaskLifecycle(task, event, context);
|
|
63
40
|
if (!decision.accepted || !decision.target)
|
|
64
41
|
throw new Error(decision.reason === 'ambiguous'
|
|
65
42
|
? `Ambiguous workflow transition from ${task.state} on ${event}`
|
|
66
43
|
: `No enabled workflow transition from ${task.state} on ${event}`);
|
|
67
|
-
|
|
44
|
+
applyTaskWorkflowDecision(task, decision);
|
|
45
|
+
return decision;
|
|
68
46
|
}
|
|
69
47
|
export async function transitionJobEvent(job, event, context = {}) {
|
|
70
48
|
const decision = decideJobLifecycle(job, event, context);
|
|
@@ -72,8 +50,8 @@ export async function transitionJobEvent(job, event, context = {}) {
|
|
|
72
50
|
throw new Error(decision.reason === 'ambiguous'
|
|
73
51
|
? `Ambiguous workflow transition from ${job.state} on ${event}`
|
|
74
52
|
: `No enabled workflow transition from ${job.state} on ${event}`);
|
|
75
|
-
job
|
|
76
|
-
|
|
53
|
+
applyJobWorkflowDecision(job, decision);
|
|
54
|
+
return decision;
|
|
77
55
|
}
|
|
78
56
|
export const SCHEDULE_RESOLUTION_MS = 600_000;
|
|
79
57
|
export const RUN_BUCKET_LABEL_COUNT = 144;
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { parseAgent, parseJob, parseTask, renderAgentComment, renderJobBody, renderTaskComment, JOB_MARKER } from './markers.js';
|
|
2
|
+
import { isTaskForJob, sameTaskDefinition } from './store-task-definition.js';
|
|
3
|
+
export class GitHubStore {
|
|
4
|
+
client;
|
|
5
|
+
repository;
|
|
6
|
+
prNumber;
|
|
7
|
+
trustedAuthors;
|
|
8
|
+
constructor(client, repository, prNumber, trustedAuthors = new Set()) {
|
|
9
|
+
this.client = client;
|
|
10
|
+
this.repository = repository;
|
|
11
|
+
this.prNumber = prNumber;
|
|
12
|
+
this.trustedAuthors = trustedAuthors;
|
|
13
|
+
}
|
|
14
|
+
isTrusted(comment) { return comment.authorLogin !== null && this.trustedAuthors.has(comment.authorLogin); }
|
|
15
|
+
repoParts() {
|
|
16
|
+
const [owner, name] = this.repository.split('/', 2);
|
|
17
|
+
if (!owner || !name)
|
|
18
|
+
throw new Error(`Invalid GitHub repository ${this.repository}`);
|
|
19
|
+
return { owner, name };
|
|
20
|
+
}
|
|
21
|
+
async graphql(query, variables) {
|
|
22
|
+
if (!this.client.graphql)
|
|
23
|
+
throw new Error('GitHub GraphQL client is required');
|
|
24
|
+
return await this.client.graphql(query, variables);
|
|
25
|
+
}
|
|
26
|
+
async prNode() {
|
|
27
|
+
const { owner, name } = this.repoParts();
|
|
28
|
+
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 body}}}', { owner, name, number: this.prNumber });
|
|
29
|
+
const repository = data.repository;
|
|
30
|
+
const pr = repository?.pullRequest;
|
|
31
|
+
if (!pr)
|
|
32
|
+
throw new Error(`Pull request #${this.prNumber} not found in ${this.repository}`);
|
|
33
|
+
return pr;
|
|
34
|
+
}
|
|
35
|
+
async pullRequestBody() {
|
|
36
|
+
const pr = await this.prNode();
|
|
37
|
+
return typeof pr.body === 'string' ? pr.body : '';
|
|
38
|
+
}
|
|
39
|
+
async list() {
|
|
40
|
+
const { owner, name } = this.repoParts();
|
|
41
|
+
const all = [];
|
|
42
|
+
let cursor = null;
|
|
43
|
+
do {
|
|
44
|
+
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 url body updatedAt author{login}} pageInfo{hasNextPage endCursor}}}}}', { owner, name, number: this.prNumber, cursor });
|
|
45
|
+
const repository = data.repository;
|
|
46
|
+
const pr = repository?.pullRequest;
|
|
47
|
+
const comments = pr?.comments;
|
|
48
|
+
const nodes = Array.isArray(comments?.nodes) ? comments.nodes : [];
|
|
49
|
+
for (const value of nodes) {
|
|
50
|
+
const author = value.author;
|
|
51
|
+
all.push({
|
|
52
|
+
id: Number(value.databaseId ?? 0),
|
|
53
|
+
nodeId: typeof value.id === 'string' ? value.id : undefined,
|
|
54
|
+
url: typeof value.url === 'string' ? value.url : undefined,
|
|
55
|
+
body: String(value.body ?? ''),
|
|
56
|
+
updatedAt: String(value.updatedAt ?? ''),
|
|
57
|
+
authorLogin: typeof author?.login === 'string' ? author.login : null
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
const pageInfo = comments?.pageInfo;
|
|
61
|
+
cursor = pageInfo?.hasNextPage === true && typeof pageInfo.endCursor === 'string' ? pageInfo.endCursor : null;
|
|
62
|
+
} while (cursor);
|
|
63
|
+
return all;
|
|
64
|
+
}
|
|
65
|
+
async comments() { return this.list(); }
|
|
66
|
+
async pullRequestState() {
|
|
67
|
+
const pr = await this.prNode();
|
|
68
|
+
if (pr.merged === true || typeof pr.mergedAt === 'string')
|
|
69
|
+
return 'MERGED';
|
|
70
|
+
return String(pr.state ?? '').toUpperCase() === 'CLOSED' ? 'CLOSED' : 'OPEN';
|
|
71
|
+
}
|
|
72
|
+
async mergePullRequest(expectedHeadSha) {
|
|
73
|
+
const payload = { merge_method: 'squash' };
|
|
74
|
+
if (expectedHeadSha)
|
|
75
|
+
payload.sha = expectedHeadSha;
|
|
76
|
+
const result = await this.client.request('repos/' + this.repository + '/pulls/' + this.prNumber + '/merge', {
|
|
77
|
+
method: 'PUT',
|
|
78
|
+
body: JSON.stringify(payload),
|
|
79
|
+
});
|
|
80
|
+
if (result.merged !== true)
|
|
81
|
+
throw new Error(typeof result.message === 'string' ? result.message : 'Pull request #' + this.prNumber + ' was not merged');
|
|
82
|
+
}
|
|
83
|
+
async closePullRequest() {
|
|
84
|
+
await this.client.request('repos/' + this.repository + '/pulls/' + this.prNumber, { method: 'PATCH', body: JSON.stringify({ state: 'closed' }) });
|
|
85
|
+
}
|
|
86
|
+
async updatePullRequestBody(body) {
|
|
87
|
+
await this.client.request('repos/' + this.repository + '/pulls/' + this.prNumber, { method: 'PATCH', body: JSON.stringify({ body }) });
|
|
88
|
+
}
|
|
89
|
+
async headSha() {
|
|
90
|
+
const { owner, name } = this.repoParts();
|
|
91
|
+
const data = await this.graphql('query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){headRefOid}}}', { owner, name, number: this.prNumber });
|
|
92
|
+
const repository = data.repository;
|
|
93
|
+
const pr = repository?.pullRequest;
|
|
94
|
+
return typeof pr?.headRefOid === 'string' ? pr.headRefOid : null;
|
|
95
|
+
}
|
|
96
|
+
async load(jobId) {
|
|
97
|
+
const comments = (await this.list()).filter(comment => this.isTrusted(comment));
|
|
98
|
+
const bodyJob = parseJob(await this.pullRequestBody());
|
|
99
|
+
const commentJobs = comments.map(x => parseJob(x.body)).filter((x) => x !== null && typeof x.id === 'string' && x.id.length > 0);
|
|
100
|
+
const jobs = bodyJob ? [bodyJob] : commentJobs;
|
|
101
|
+
const job = jobs.find(x => x.id === jobId);
|
|
102
|
+
if (!job) {
|
|
103
|
+
const conflicting = bodyJob ?? commentJobs[0];
|
|
104
|
+
if (conflicting)
|
|
105
|
+
throw new Error(`JOB_ID_MISMATCH: PR #${this.prNumber} is bound to ${conflicting.id}, not ${jobId}`);
|
|
106
|
+
throw new Error(`JOB_MARKER_MISSING: Managed GitHub job ${jobId} has no durable ${JOB_MARKER} marker in PR #${this.prNumber}`);
|
|
107
|
+
}
|
|
108
|
+
const soleTrustedJob = jobs.length === 1;
|
|
109
|
+
job.tasks = comments.map(comment => {
|
|
110
|
+
const task = parseTask(comment.body);
|
|
111
|
+
if (!task)
|
|
112
|
+
return null;
|
|
113
|
+
const taskUrl = comment.url ?? (comment.id > 0 ? `https://github.com/${this.repository}/pull/${this.prNumber}#issuecomment-${comment.id}` : undefined);
|
|
114
|
+
return taskUrl ? { ...task, taskUrl } : task;
|
|
115
|
+
}).filter((x) => x !== null).filter(task => isTaskForJob(task, jobId, soleTrustedJob));
|
|
116
|
+
return job;
|
|
117
|
+
}
|
|
118
|
+
async validateJobBinding(jobId) {
|
|
119
|
+
const body = await this.pullRequestBody();
|
|
120
|
+
const bodyHasMarker = body.includes(`<!-- ${JOB_MARKER} `);
|
|
121
|
+
const bodyJob = parseJob(body);
|
|
122
|
+
if (bodyHasMarker && !bodyJob)
|
|
123
|
+
throw new Error(`JOB_MARKER_MALFORMED: PR #${this.prNumber} body contains a ${JOB_MARKER} marker that cannot be parsed`);
|
|
124
|
+
if (bodyJob) {
|
|
125
|
+
if (bodyJob.id !== jobId)
|
|
126
|
+
throw new Error(`JOB_ID_MISMATCH: PR #${this.prNumber} is bound to ${bodyJob.id}, not ${jobId}`);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const comments = (await this.list()).filter(comment => this.isTrusted(comment));
|
|
130
|
+
const markerComments = comments.filter(comment => comment.body.includes(JOB_MARKER));
|
|
131
|
+
const parsed = markerComments.map(comment => parseJob(comment.body));
|
|
132
|
+
const malformed = parsed.filter(job => job === null).length;
|
|
133
|
+
if (malformed > 0)
|
|
134
|
+
throw new Error(`JOB_MARKER_MALFORMED: PR #${this.prNumber} contains ${malformed} trusted ${JOB_MARKER} marker(s) that cannot be parsed`);
|
|
135
|
+
const jobs = parsed.filter((job) => job !== null);
|
|
136
|
+
const matches = jobs.filter(job => job.id === jobId);
|
|
137
|
+
if (matches.length === 0) {
|
|
138
|
+
if (jobs.length > 0)
|
|
139
|
+
throw new Error(`JOB_ID_MISMATCH: PR #${this.prNumber} is bound to ${jobs.map(job => job.id).join(', ')}, not ${jobId}`);
|
|
140
|
+
throw new Error(`JOB_MARKER_MISSING: Managed GitHub job ${jobId} has no durable ${JOB_MARKER} marker in PR #${this.prNumber}`);
|
|
141
|
+
}
|
|
142
|
+
if (matches.length > 1)
|
|
143
|
+
throw new Error(`JOB_MARKER_DUPLICATED: PR #${this.prNumber} contains ${matches.length} trusted ${JOB_MARKER} markers for job ${jobId}`);
|
|
144
|
+
if (jobs.length > 1)
|
|
145
|
+
throw new Error(`JOB_BINDING_CONFLICT: PR #${this.prNumber} contains trusted ${JOB_MARKER} markers for multiple job ids`);
|
|
146
|
+
}
|
|
147
|
+
async listAgents() { return (await this.list()).filter(comment => this.isTrusted(comment)).map(x => parseAgent(x.body)).filter((x) => x !== null); }
|
|
148
|
+
async comment(body) {
|
|
149
|
+
const pr = await this.prNode();
|
|
150
|
+
await this.graphql('mutation($subjectId:ID!,$body:String!){addComment(input:{subjectId:$subjectId,body:$body}){commentEdge{node{id}}}}', { subjectId: pr.id, body });
|
|
151
|
+
}
|
|
152
|
+
async updateComment(comment, body) {
|
|
153
|
+
if (!comment.nodeId)
|
|
154
|
+
throw new Error('GitHub comment node id is required for GraphQL update');
|
|
155
|
+
await this.graphql('mutation($id:ID!,$body:String!){updateIssueComment(input:{id:$id,body:$body}){issueComment{id}}}', { id: comment.nodeId, body });
|
|
156
|
+
}
|
|
157
|
+
async deleteComment(comment) {
|
|
158
|
+
if (!comment.nodeId)
|
|
159
|
+
throw new Error('GitHub comment node id is required for GraphQL delete');
|
|
160
|
+
await this.graphql('mutation($id:ID!){deleteIssueComment(input:{id:$id}){clientMutationId}}', { id: comment.nodeId });
|
|
161
|
+
}
|
|
162
|
+
async saveJob(job) {
|
|
163
|
+
const currentBody = await this.pullRequestBody();
|
|
164
|
+
const bodyJob = parseJob(currentBody);
|
|
165
|
+
if (bodyJob && bodyJob.id !== job.id)
|
|
166
|
+
throw new Error(`PR #${this.prNumber} body already has a different ${JOB_MARKER} marker`);
|
|
167
|
+
const comments = (await this.list()).filter(comment => this.isTrusted(comment));
|
|
168
|
+
const jobComments = comments.filter(comment => parseJob(comment.body) !== null);
|
|
169
|
+
const conflicts = jobComments.filter(comment => parseJob(comment.body)?.id !== job.id);
|
|
170
|
+
if (conflicts.length > 0)
|
|
171
|
+
throw new Error(`PR #${this.prNumber} already has a different ${JOB_MARKER} marker`);
|
|
172
|
+
await this.updatePullRequestBody(renderJobBody(job));
|
|
173
|
+
const persisted = parseJob(await this.pullRequestBody());
|
|
174
|
+
if (!persisted || persisted.id !== job.id)
|
|
175
|
+
throw new Error(`Failed to persist ${JOB_MARKER} marker in PR #${this.prNumber} body for job ${job.id}`);
|
|
176
|
+
for (const legacy of jobComments.filter(comment => parseJob(comment.body)?.id === job.id))
|
|
177
|
+
await this.deleteComment(legacy);
|
|
178
|
+
}
|
|
179
|
+
async appendTask(task) {
|
|
180
|
+
if (!task.jobId)
|
|
181
|
+
throw new Error('GitHub task requires jobId');
|
|
182
|
+
await this.load(task.jobId);
|
|
183
|
+
const comments = (await this.list()).filter(comment => this.isTrusted(comment));
|
|
184
|
+
const matches = comments.filter(comment => {
|
|
185
|
+
const existing = parseTask(comment.body);
|
|
186
|
+
return existing !== null && existing.jobId === task.jobId && existing.id === task.id;
|
|
187
|
+
});
|
|
188
|
+
if (matches.length === 0) {
|
|
189
|
+
await this.comment(renderTaskComment(task));
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const parsed = matches.map(comment => ({ comment, task: parseTask(comment.body) }));
|
|
193
|
+
if (parsed.some(item => !sameTaskDefinition(item.task, task)))
|
|
194
|
+
throw new Error(`Task ${task.id} already exists with a different definition`);
|
|
195
|
+
for (const duplicate of parsed.slice(1))
|
|
196
|
+
await this.deleteComment(duplicate.comment);
|
|
197
|
+
}
|
|
198
|
+
async saveTask(task) {
|
|
199
|
+
const comments = (await this.list()).filter(comment => this.isTrusted(comment));
|
|
200
|
+
const existing = comments.filter(comment => { const markerTask = parseTask(comment.body); return markerTask !== null && markerTask.jobId === task.jobId && markerTask.id === task.id; });
|
|
201
|
+
if (existing.length > 0) {
|
|
202
|
+
await this.updateComment(existing[0], renderTaskComment(task));
|
|
203
|
+
for (const duplicate of existing.slice(1))
|
|
204
|
+
await this.deleteComment(duplicate);
|
|
205
|
+
}
|
|
206
|
+
else
|
|
207
|
+
await this.appendTask(task);
|
|
208
|
+
}
|
|
209
|
+
async saveAgent(agent) {
|
|
210
|
+
const comments = (await this.list()).filter(comment => this.isTrusted(comment));
|
|
211
|
+
const existing = comments.find(comment => parseAgent(comment.body)?.id === agent.id);
|
|
212
|
+
if (existing)
|
|
213
|
+
await this.updateComment(existing, renderAgentComment(agent));
|
|
214
|
+
else
|
|
215
|
+
await this.comment(renderAgentComment(agent));
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
function immutableTaskDefinition(task) {
|
|
2
|
+
const routing = task.routing ? {
|
|
3
|
+
provider: task.routing.provider, model: task.routing.model, profile: task.routing.profile,
|
|
4
|
+
reasoning: task.routing.reasoning, cwd: task.routing.cwd, projectId: task.routing.projectId
|
|
5
|
+
} : undefined;
|
|
6
|
+
return {
|
|
7
|
+
jobId: task.jobId, id: task.id, priority: task.priority, storySize: task.storySize ?? 'M', projectName: task.projectName, agentName: task.agentName, agentGraph: task.agentGraph,
|
|
8
|
+
kind: task.kind ?? 'work', planningDecisionId: task.planningDecisionId, planningSource: task.planningSource, planningRound: task.planningRound, parentTaskId: task.parentTaskId, dependencies: task.dependencies, capabilities: task.capabilities, workerRequirements: task.workerRequirements, workerSelection: task.workerSelection, adapter: task.adapter,
|
|
9
|
+
input: task.input, attachments: task.attachments, output: task.output, routing, chatgptTabClosePolicy: task.chatgptTabClosePolicy ?? 'after-start', continuation: task.continuation ?? null, maxAttempts: task.maxAttempts, timeoutMs: task.timeoutMs
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export function sameTaskDefinition(left, right) {
|
|
13
|
+
return JSON.stringify(immutableTaskDefinition(left)) === JSON.stringify(immutableTaskDefinition(right));
|
|
14
|
+
}
|
|
15
|
+
export function isTaskForJob(task, jobId, soleTrustedJob) {
|
|
16
|
+
if (task.jobId === jobId)
|
|
17
|
+
return true;
|
|
18
|
+
return !task.jobId && soleTrustedJob;
|
|
19
|
+
}
|
package/dist/store.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { createSign } from 'node:crypto';
|
|
3
|
-
import { parseAgent, parseJob, parseTask, renderAgentComment,
|
|
3
|
+
import { parseAgent, parseJob, parseTask, renderAgentComment, renderJobComment, renderTaskComment, AGENT_MARKER, JOB_MARKER, TASK_MARKER } from './markers.js';
|
|
4
|
+
import { isTaskForJob, sameTaskDefinition } from './store-task-definition.js';
|
|
4
5
|
export async function loadManagedGitHubPullRequestGraphql(client, repository, prNumber, trustedAuthors) {
|
|
5
6
|
const [owner, name] = repository.split('/', 2);
|
|
6
7
|
if (!owner || !name || !Number.isInteger(prNumber) || prNumber <= 0 || !client.graphql)
|
|
@@ -86,20 +87,6 @@ export class InMemoryStore {
|
|
|
86
87
|
return [...this.values.values()].map(parseTask).filter((x) => x !== null).filter(task => isTaskForJob(task, jobId, jobs.length === 1));
|
|
87
88
|
}
|
|
88
89
|
}
|
|
89
|
-
function immutableTaskDefinition(task) {
|
|
90
|
-
const routing = task.routing ? {
|
|
91
|
-
provider: task.routing.provider, model: task.routing.model, profile: task.routing.profile,
|
|
92
|
-
reasoning: task.routing.reasoning, cwd: task.routing.cwd, projectId: task.routing.projectId
|
|
93
|
-
} : undefined;
|
|
94
|
-
return {
|
|
95
|
-
jobId: task.jobId, id: task.id, priority: task.priority, storySize: task.storySize ?? 'M', projectName: task.projectName, agentName: task.agentName, agentGraph: task.agentGraph,
|
|
96
|
-
kind: task.kind ?? 'work', planningDecisionId: task.planningDecisionId, planningSource: task.planningSource, planningRound: task.planningRound, parentTaskId: task.parentTaskId, dependencies: task.dependencies, capabilities: task.capabilities, workerRequirements: task.workerRequirements, workerSelection: task.workerSelection, adapter: task.adapter,
|
|
97
|
-
input: task.input, attachments: task.attachments, output: task.output, routing, chatgptTabClosePolicy: task.chatgptTabClosePolicy ?? 'after-start', continuation: task.continuation ?? null, maxAttempts: task.maxAttempts, timeoutMs: task.timeoutMs
|
|
98
|
-
};
|
|
99
|
-
}
|
|
100
|
-
function sameTaskDefinition(left, right) {
|
|
101
|
-
return JSON.stringify(immutableTaskDefinition(left)) === JSON.stringify(immutableTaskDefinition(right));
|
|
102
|
-
}
|
|
103
90
|
async function githubGraphql(client, query, variables) {
|
|
104
91
|
if (!client.graphql)
|
|
105
92
|
throw new Error('GitHub GraphQL client is required');
|
|
@@ -408,221 +395,7 @@ export class GhApiClient {
|
|
|
408
395
|
throw new Error('gh authentication did not return a login');
|
|
409
396
|
}
|
|
410
397
|
}
|
|
411
|
-
export
|
|
412
|
-
client;
|
|
413
|
-
repository;
|
|
414
|
-
prNumber;
|
|
415
|
-
trustedAuthors;
|
|
416
|
-
constructor(client, repository, prNumber, trustedAuthors = new Set()) {
|
|
417
|
-
this.client = client;
|
|
418
|
-
this.repository = repository;
|
|
419
|
-
this.prNumber = prNumber;
|
|
420
|
-
this.trustedAuthors = trustedAuthors;
|
|
421
|
-
}
|
|
422
|
-
isTrusted(comment) { return comment.authorLogin !== null && this.trustedAuthors.has(comment.authorLogin); }
|
|
423
|
-
repoParts() {
|
|
424
|
-
const [owner, name] = this.repository.split('/', 2);
|
|
425
|
-
if (!owner || !name)
|
|
426
|
-
throw new Error(`Invalid GitHub repository ${this.repository}`);
|
|
427
|
-
return { owner, name };
|
|
428
|
-
}
|
|
429
|
-
async graphql(query, variables) {
|
|
430
|
-
if (!this.client.graphql)
|
|
431
|
-
throw new Error('GitHub GraphQL client is required');
|
|
432
|
-
return await this.client.graphql(query, variables);
|
|
433
|
-
}
|
|
434
|
-
async prNode() {
|
|
435
|
-
const { owner, name } = this.repoParts();
|
|
436
|
-
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 body}}}', { owner, name, number: this.prNumber });
|
|
437
|
-
const repository = data.repository;
|
|
438
|
-
const pr = repository?.pullRequest;
|
|
439
|
-
if (!pr)
|
|
440
|
-
throw new Error(`Pull request #${this.prNumber} not found in ${this.repository}`);
|
|
441
|
-
return pr;
|
|
442
|
-
}
|
|
443
|
-
async pullRequestBody() {
|
|
444
|
-
const pr = await this.prNode();
|
|
445
|
-
return typeof pr.body === 'string' ? pr.body : '';
|
|
446
|
-
}
|
|
447
|
-
async list() {
|
|
448
|
-
const { owner, name } = this.repoParts();
|
|
449
|
-
const all = [];
|
|
450
|
-
let cursor = null;
|
|
451
|
-
do {
|
|
452
|
-
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 url body updatedAt author{login}} pageInfo{hasNextPage endCursor}}}}}', { owner, name, number: this.prNumber, cursor });
|
|
453
|
-
const repository = data.repository;
|
|
454
|
-
const pr = repository?.pullRequest;
|
|
455
|
-
const comments = pr?.comments;
|
|
456
|
-
const nodes = Array.isArray(comments?.nodes) ? comments.nodes : [];
|
|
457
|
-
for (const value of nodes) {
|
|
458
|
-
const author = value.author;
|
|
459
|
-
all.push({
|
|
460
|
-
id: Number(value.databaseId ?? 0),
|
|
461
|
-
nodeId: typeof value.id === 'string' ? value.id : undefined,
|
|
462
|
-
url: typeof value.url === 'string' ? value.url : undefined,
|
|
463
|
-
body: String(value.body ?? ''),
|
|
464
|
-
updatedAt: String(value.updatedAt ?? ''),
|
|
465
|
-
authorLogin: typeof author?.login === 'string' ? author.login : null
|
|
466
|
-
});
|
|
467
|
-
}
|
|
468
|
-
const pageInfo = comments?.pageInfo;
|
|
469
|
-
cursor = pageInfo?.hasNextPage === true && typeof pageInfo.endCursor === 'string' ? pageInfo.endCursor : null;
|
|
470
|
-
} while (cursor);
|
|
471
|
-
return all;
|
|
472
|
-
}
|
|
473
|
-
async comments() { return this.list(); }
|
|
474
|
-
async pullRequestState() {
|
|
475
|
-
const pr = await this.prNode();
|
|
476
|
-
if (pr.merged === true || typeof pr.mergedAt === 'string')
|
|
477
|
-
return 'MERGED';
|
|
478
|
-
return String(pr.state ?? '').toUpperCase() === 'CLOSED' ? 'CLOSED' : 'OPEN';
|
|
479
|
-
}
|
|
480
|
-
async mergePullRequest(expectedHeadSha) {
|
|
481
|
-
const payload = { merge_method: 'squash' };
|
|
482
|
-
if (expectedHeadSha)
|
|
483
|
-
payload.sha = expectedHeadSha;
|
|
484
|
-
const result = await this.client.request('repos/' + this.repository + '/pulls/' + this.prNumber + '/merge', {
|
|
485
|
-
method: 'PUT',
|
|
486
|
-
body: JSON.stringify(payload),
|
|
487
|
-
});
|
|
488
|
-
if (result.merged !== true)
|
|
489
|
-
throw new Error(typeof result.message === 'string' ? result.message : 'Pull request #' + this.prNumber + ' was not merged');
|
|
490
|
-
}
|
|
491
|
-
async closePullRequest() {
|
|
492
|
-
await this.client.request('repos/' + this.repository + '/pulls/' + this.prNumber, { method: 'PATCH', body: JSON.stringify({ state: 'closed' }) });
|
|
493
|
-
}
|
|
494
|
-
async updatePullRequestBody(body) {
|
|
495
|
-
await this.client.request('repos/' + this.repository + '/pulls/' + this.prNumber, { method: 'PATCH', body: JSON.stringify({ body }) });
|
|
496
|
-
}
|
|
497
|
-
async headSha() {
|
|
498
|
-
const { owner, name } = this.repoParts();
|
|
499
|
-
const data = await this.graphql('query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){headRefOid}}}', { owner, name, number: this.prNumber });
|
|
500
|
-
const repository = data.repository;
|
|
501
|
-
const pr = repository?.pullRequest;
|
|
502
|
-
return typeof pr?.headRefOid === 'string' ? pr.headRefOid : null;
|
|
503
|
-
}
|
|
504
|
-
async load(jobId) {
|
|
505
|
-
const comments = (await this.list()).filter(comment => this.isTrusted(comment));
|
|
506
|
-
const bodyJob = parseJob(await this.pullRequestBody());
|
|
507
|
-
const commentJobs = comments.map(x => parseJob(x.body)).filter((x) => x !== null && typeof x.id === 'string' && x.id.length > 0);
|
|
508
|
-
const jobs = bodyJob ? [bodyJob] : commentJobs;
|
|
509
|
-
const job = jobs.find(x => x.id === jobId);
|
|
510
|
-
if (!job) {
|
|
511
|
-
const conflicting = bodyJob ?? commentJobs[0];
|
|
512
|
-
if (conflicting)
|
|
513
|
-
throw new Error(`JOB_ID_MISMATCH: PR #${this.prNumber} is bound to ${conflicting.id}, not ${jobId}`);
|
|
514
|
-
throw new Error(`JOB_MARKER_MISSING: Managed GitHub job ${jobId} has no durable ${JOB_MARKER} marker in PR #${this.prNumber}`);
|
|
515
|
-
}
|
|
516
|
-
const soleTrustedJob = jobs.length === 1;
|
|
517
|
-
job.tasks = comments.map(comment => {
|
|
518
|
-
const task = parseTask(comment.body);
|
|
519
|
-
if (!task)
|
|
520
|
-
return null;
|
|
521
|
-
const taskUrl = comment.url ?? (comment.id > 0 ? `https://github.com/${this.repository}/pull/${this.prNumber}#issuecomment-${comment.id}` : undefined);
|
|
522
|
-
return taskUrl ? { ...task, taskUrl } : task;
|
|
523
|
-
}).filter((x) => x !== null).filter(task => isTaskForJob(task, jobId, soleTrustedJob));
|
|
524
|
-
return job;
|
|
525
|
-
}
|
|
526
|
-
async validateJobBinding(jobId) {
|
|
527
|
-
const body = await this.pullRequestBody();
|
|
528
|
-
const bodyHasMarker = body.includes(`<!-- ${JOB_MARKER} `);
|
|
529
|
-
const bodyJob = parseJob(body);
|
|
530
|
-
if (bodyHasMarker && !bodyJob)
|
|
531
|
-
throw new Error(`JOB_MARKER_MALFORMED: PR #${this.prNumber} body contains a ${JOB_MARKER} marker that cannot be parsed`);
|
|
532
|
-
if (bodyJob) {
|
|
533
|
-
if (bodyJob.id !== jobId)
|
|
534
|
-
throw new Error(`JOB_ID_MISMATCH: PR #${this.prNumber} is bound to ${bodyJob.id}, not ${jobId}`);
|
|
535
|
-
return;
|
|
536
|
-
}
|
|
537
|
-
const comments = (await this.list()).filter(comment => this.isTrusted(comment));
|
|
538
|
-
const markerComments = comments.filter(comment => comment.body.includes(JOB_MARKER));
|
|
539
|
-
const parsed = markerComments.map(comment => parseJob(comment.body));
|
|
540
|
-
const malformed = parsed.filter(job => job === null).length;
|
|
541
|
-
if (malformed > 0)
|
|
542
|
-
throw new Error(`JOB_MARKER_MALFORMED: PR #${this.prNumber} contains ${malformed} trusted ${JOB_MARKER} marker(s) that cannot be parsed`);
|
|
543
|
-
const jobs = parsed.filter((job) => job !== null);
|
|
544
|
-
const matches = jobs.filter(job => job.id === jobId);
|
|
545
|
-
if (matches.length === 0) {
|
|
546
|
-
if (jobs.length > 0)
|
|
547
|
-
throw new Error(`JOB_ID_MISMATCH: PR #${this.prNumber} is bound to ${jobs.map(job => job.id).join(', ')}, not ${jobId}`);
|
|
548
|
-
throw new Error(`JOB_MARKER_MISSING: Managed GitHub job ${jobId} has no durable ${JOB_MARKER} marker in PR #${this.prNumber}`);
|
|
549
|
-
}
|
|
550
|
-
if (matches.length > 1)
|
|
551
|
-
throw new Error(`JOB_MARKER_DUPLICATED: PR #${this.prNumber} contains ${matches.length} trusted ${JOB_MARKER} markers for job ${jobId}`);
|
|
552
|
-
if (jobs.length > 1)
|
|
553
|
-
throw new Error(`JOB_BINDING_CONFLICT: PR #${this.prNumber} contains trusted ${JOB_MARKER} markers for multiple job ids`);
|
|
554
|
-
}
|
|
555
|
-
async listAgents() { return (await this.list()).filter(comment => this.isTrusted(comment)).map(x => parseAgent(x.body)).filter((x) => x !== null); }
|
|
556
|
-
async comment(body) {
|
|
557
|
-
const pr = await this.prNode();
|
|
558
|
-
await this.graphql('mutation($subjectId:ID!,$body:String!){addComment(input:{subjectId:$subjectId,body:$body}){commentEdge{node{id}}}}', { subjectId: pr.id, body });
|
|
559
|
-
}
|
|
560
|
-
async updateComment(comment, body) {
|
|
561
|
-
if (!comment.nodeId)
|
|
562
|
-
throw new Error('GitHub comment node id is required for GraphQL update');
|
|
563
|
-
await this.graphql('mutation($id:ID!,$body:String!){updateIssueComment(input:{id:$id,body:$body}){issueComment{id}}}', { id: comment.nodeId, body });
|
|
564
|
-
}
|
|
565
|
-
async deleteComment(comment) {
|
|
566
|
-
if (!comment.nodeId)
|
|
567
|
-
throw new Error('GitHub comment node id is required for GraphQL delete');
|
|
568
|
-
await this.graphql('mutation($id:ID!){deleteIssueComment(input:{id:$id}){clientMutationId}}', { id: comment.nodeId });
|
|
569
|
-
}
|
|
570
|
-
async saveJob(job) {
|
|
571
|
-
const currentBody = await this.pullRequestBody();
|
|
572
|
-
const bodyJob = parseJob(currentBody);
|
|
573
|
-
if (bodyJob && bodyJob.id !== job.id)
|
|
574
|
-
throw new Error(`PR #${this.prNumber} body already has a different ${JOB_MARKER} marker`);
|
|
575
|
-
const comments = (await this.list()).filter(comment => this.isTrusted(comment));
|
|
576
|
-
const jobComments = comments.filter(comment => parseJob(comment.body) !== null);
|
|
577
|
-
const conflicts = jobComments.filter(comment => parseJob(comment.body)?.id !== job.id);
|
|
578
|
-
if (conflicts.length > 0)
|
|
579
|
-
throw new Error(`PR #${this.prNumber} already has a different ${JOB_MARKER} marker`);
|
|
580
|
-
await this.updatePullRequestBody(renderJobBody(job));
|
|
581
|
-
const persisted = parseJob(await this.pullRequestBody());
|
|
582
|
-
if (!persisted || persisted.id !== job.id)
|
|
583
|
-
throw new Error(`Failed to persist ${JOB_MARKER} marker in PR #${this.prNumber} body for job ${job.id}`);
|
|
584
|
-
for (const legacy of jobComments.filter(comment => parseJob(comment.body)?.id === job.id))
|
|
585
|
-
await this.deleteComment(legacy);
|
|
586
|
-
}
|
|
587
|
-
async appendTask(task) {
|
|
588
|
-
if (!task.jobId)
|
|
589
|
-
throw new Error('GitHub task requires jobId');
|
|
590
|
-
await this.load(task.jobId);
|
|
591
|
-
const comments = (await this.list()).filter(comment => this.isTrusted(comment));
|
|
592
|
-
const matches = comments.filter(comment => {
|
|
593
|
-
const existing = parseTask(comment.body);
|
|
594
|
-
return existing !== null && existing.jobId === task.jobId && existing.id === task.id;
|
|
595
|
-
});
|
|
596
|
-
if (matches.length === 0) {
|
|
597
|
-
await this.comment(renderTaskComment(task));
|
|
598
|
-
return;
|
|
599
|
-
}
|
|
600
|
-
const parsed = matches.map(comment => ({ comment, task: parseTask(comment.body) }));
|
|
601
|
-
if (parsed.some(item => !sameTaskDefinition(item.task, task)))
|
|
602
|
-
throw new Error(`Task ${task.id} already exists with a different definition`);
|
|
603
|
-
for (const duplicate of parsed.slice(1))
|
|
604
|
-
await this.deleteComment(duplicate.comment);
|
|
605
|
-
}
|
|
606
|
-
async saveTask(task) {
|
|
607
|
-
const comments = (await this.list()).filter(comment => this.isTrusted(comment));
|
|
608
|
-
const existing = comments.filter(comment => { const markerTask = parseTask(comment.body); return markerTask !== null && markerTask.jobId === task.jobId && markerTask.id === task.id; });
|
|
609
|
-
if (existing.length > 0) {
|
|
610
|
-
await this.updateComment(existing[0], renderTaskComment(task));
|
|
611
|
-
for (const duplicate of existing.slice(1))
|
|
612
|
-
await this.deleteComment(duplicate);
|
|
613
|
-
}
|
|
614
|
-
else
|
|
615
|
-
await this.appendTask(task);
|
|
616
|
-
}
|
|
617
|
-
async saveAgent(agent) {
|
|
618
|
-
const comments = (await this.list()).filter(comment => this.isTrusted(comment));
|
|
619
|
-
const existing = comments.find(comment => parseAgent(comment.body)?.id === agent.id);
|
|
620
|
-
if (existing)
|
|
621
|
-
await this.updateComment(existing, renderAgentComment(agent));
|
|
622
|
-
else
|
|
623
|
-
await this.comment(renderAgentComment(agent));
|
|
624
|
-
}
|
|
625
|
-
}
|
|
398
|
+
export { GitHubStore } from './store-github.js';
|
|
626
399
|
export async function listManagedGitHubJobs(client, repository, trustedAuthors) {
|
|
627
400
|
if (!client.graphql)
|
|
628
401
|
throw new Error('GitHub GraphQL client is required');
|
|
@@ -681,8 +454,3 @@ export async function listManagedGitHubJobs(client, repository, trustedAuthors)
|
|
|
681
454
|
} while (cursor);
|
|
682
455
|
return output.sort((a, b) => b.job.prNumber - a.job.prNumber);
|
|
683
456
|
}
|
|
684
|
-
function isTaskForJob(task, jobId, soleTrustedJob) {
|
|
685
|
-
if (task.jobId === jobId)
|
|
686
|
-
return true;
|
|
687
|
-
return !task.jobId && soleTrustedJob;
|
|
688
|
-
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
export const WORKFLOW_EVENT_NAMES = [
|
|
2
|
+
'activate',
|
|
3
|
+
'block',
|
|
4
|
+
'cancel',
|
|
5
|
+
'dispatch',
|
|
6
|
+
'effect.cancelActiveTasks.failed',
|
|
7
|
+
'effect.launchTask.failed',
|
|
8
|
+
'effect.mergePullRequest.failed',
|
|
9
|
+
'fail',
|
|
10
|
+
'hold',
|
|
11
|
+
'implementation.completed',
|
|
12
|
+
'pr.closed',
|
|
13
|
+
'pr.merged',
|
|
14
|
+
'reconcile',
|
|
15
|
+
'reconcile.blocked',
|
|
16
|
+
'reconcile.completed',
|
|
17
|
+
'reconcile.started',
|
|
18
|
+
'release',
|
|
19
|
+
'retry',
|
|
20
|
+
'review.approved',
|
|
21
|
+
'task.blocked',
|
|
22
|
+
'task.cancelled',
|
|
23
|
+
'task.completed',
|
|
24
|
+
'task.failed',
|
|
25
|
+
'task.started',
|
|
26
|
+
];
|
|
27
|
+
export const WORKFLOW_FACT_NAMES = [
|
|
28
|
+
'dependenciesSatisfied',
|
|
29
|
+
'dependenciesUnsatisfied',
|
|
30
|
+
'runnable',
|
|
31
|
+
'dispatchable',
|
|
32
|
+
'notMerged',
|
|
33
|
+
'autoMerge',
|
|
34
|
+
];
|
|
35
|
+
export const WORKFLOW_MUTATION_NAMES = [
|
|
36
|
+
'mutation.clearError',
|
|
37
|
+
'mutation.clearFailureReason',
|
|
38
|
+
];
|
|
39
|
+
export const WORKFLOW_EFFECT_NAMES = [
|
|
40
|
+
'effect.launchTask',
|
|
41
|
+
'effect.cancelActiveTasks',
|
|
42
|
+
'effect.mergePullRequest',
|
|
43
|
+
];
|
|
44
|
+
export const WORKFLOW_EFFECT_POLICIES = {
|
|
45
|
+
'effect.launchTask': {
|
|
46
|
+
idempotency: 'job-task-attempt',
|
|
47
|
+
failureEvent: 'effect.launchTask.failed',
|
|
48
|
+
retry: 'workflow',
|
|
49
|
+
observability: 'workflow.effect',
|
|
50
|
+
},
|
|
51
|
+
'effect.cancelActiveTasks': {
|
|
52
|
+
idempotency: 'job-transition',
|
|
53
|
+
failureEvent: 'effect.cancelActiveTasks.failed',
|
|
54
|
+
retry: 'workflow',
|
|
55
|
+
observability: 'workflow.effect',
|
|
56
|
+
},
|
|
57
|
+
'effect.mergePullRequest': {
|
|
58
|
+
idempotency: 'job-head-sha',
|
|
59
|
+
failureEvent: 'effect.mergePullRequest.failed',
|
|
60
|
+
retry: 'workflow',
|
|
61
|
+
observability: 'workflow.effect',
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
export function workflowEffectFailureEvent(effect) {
|
|
65
|
+
return WORKFLOW_EFFECT_POLICIES[effect].failureEvent;
|
|
66
|
+
}
|
|
67
|
+
const mutations = new Set(WORKFLOW_MUTATION_NAMES);
|
|
68
|
+
const effects = new Set(WORKFLOW_EFFECT_NAMES);
|
|
69
|
+
export function classifyWorkflowActions(actions) {
|
|
70
|
+
const foundMutations = [];
|
|
71
|
+
const foundEffects = [];
|
|
72
|
+
for (const action of actions) {
|
|
73
|
+
if (mutations.has(action))
|
|
74
|
+
foundMutations.push(action);
|
|
75
|
+
else if (effects.has(action))
|
|
76
|
+
foundEffects.push(action);
|
|
77
|
+
else
|
|
78
|
+
throw new Error(`Unknown canonical workflow action ${action}`);
|
|
79
|
+
}
|
|
80
|
+
return { mutations: foundMutations, effects: foundEffects };
|
|
81
|
+
}
|
|
82
|
+
export function emptyWorkflowDecision(reason) {
|
|
83
|
+
return { accepted: false, target: null, guard: null, actions: [], mutations: [], effects: [], reason };
|
|
84
|
+
}
|