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/scheduler.js CHANGED
@@ -1,16 +1,7 @@
1
1
  import { decideJobLifecycle, decideTaskLifecycle } from './workflow/rollout.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
- 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
- detectCycles(job.tasks);
24
- const scopedTasks = scheduledCycleTasks(job);
25
- const map = new Map(job.tasks.map(t => [t.id, t]));
26
- for (const task of scopedTasks) {
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
- applyTaskState(task, decision.target);
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.state = decision.target;
76
- job.updatedAt = new Date().toISOString();
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, renderJobBody, renderJobComment, renderTaskComment, AGENT_MARKER, JOB_MARKER, TASK_MARKER } from './markers.js';
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 class GitHubStore {
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
+ }