agents-relay 1.0.177 → 2.0.1
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/README.md +4 -2
- package/dist/adapters.js +26 -2
- package/dist/blockers.js +60 -65
- package/dist/cli-support.js +33 -1
- package/dist/cli.js +72 -86
- package/dist/dashboard-actions.js +13 -35
- package/dist/dashboard-html.js +1 -1
- package/dist/dashboard.js +39 -0
- package/dist/execution-recovery.js +37 -0
- package/dist/graph-runtime.js +1 -0
- package/dist/job-actions.js +53 -0
- package/dist/manual-plan.js +15 -17
- package/dist/planner-runtime.js +135 -0
- package/dist/planner.js +76 -60
- package/dist/pool.js +54 -10
- package/dist/projected-store.js +314 -0
- package/dist/reconciler-review.js +185 -110
- package/dist/reconciler-runtime.js +67 -7
- package/dist/reconciler.js +134 -282
- package/dist/relay-store.js +380 -0
- package/dist/relayd-main.js +6 -0
- package/dist/relayd.js +122 -29
- package/dist/runtime-status.js +2 -1
- package/dist/scheduled-jobs.js +40 -31
- package/dist/scheduler.js +19 -18
- package/dist/store.js +1 -1
- package/dist/task-actions.js +139 -0
- package/dist/troubleshooter.js +14 -8
- package/dist/types.js +3 -3
- package/dist/unblock.js +31 -21
- package/dist/workflow/authoritative-unblock-v2.js +20 -0
- package/dist/workflow/blocker-resolution-v2.js +33 -0
- package/dist/workflow/contracts.js +21 -0
- package/dist/workflow/definitions-v2.js +75 -0
- package/dist/workflow/dispatcher.js +56 -0
- package/dist/workflow/entity-creation.js +32 -0
- package/dist/workflow/graph-v2.js +391 -0
- package/dist/workflow/job-reconcile-v2.js +19 -0
- package/dist/workflow/lifecycle.js +82 -4
- package/dist/workflow/planner-orchestration-v2.js +58 -0
- package/dist/workflow/planner-trigger-v2.js +24 -0
- package/dist/workflow/review-v2.js +42 -0
- package/dist/workflow/runtime.js +15 -1
- package/dist/workflow/schedule-cycle-v2.js +25 -0
- package/dist/workflow/scheduled-run-v2.js +30 -0
- package/dist/workflow/task-action-policy.js +19 -0
- package/dist/workflow/task-reconcile.js +6 -6
- package/dist/workflow/task-related-action-v2.js +42 -0
- package/dist/workflow/troubleshooter-v2.js +29 -0
- package/dist/workspace.js +6 -1
- package/package.json +3 -3
- package/skills/agents-relay/agents/planner.agent.md +5 -5
- package/skills/agents-relay/contracts/task-job-contract.md +2 -51
- package/workflow/authoritative-unblock-v2.mmd +15 -0
- package/workflow/blocker-resolution-v2.mmd +73 -0
- package/workflow/job-lifecycle-v2.mmd +70 -0
- package/workflow/job-lifecycle.mmd +2 -2
- package/workflow/planner-orchestration-v2.mmd +65 -0
- package/workflow/planner-trigger-v2.mmd +32 -0
- package/workflow/review-lifecycle-v2.mmd +124 -0
- package/workflow/schedule-cycle-v2.mmd +19 -0
- package/workflow/scheduled-run-v2.mmd +55 -0
- package/workflow/task-lifecycle-v2.mmd +201 -0
- package/workflow/task-lifecycle.mmd +3 -0
- package/workflow/task-related-action-v2.mmd +28 -0
- package/workflow/troubleshooter-v2.mmd +43 -0
package/dist/pool.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { GitHubStore, listManagedGitHubJobs, withGitHubRequestContext } from './store.js';
|
|
2
2
|
import { eventFor } from './events.js';
|
|
3
|
+
import { runScheduledBucket } from './scheduled-jobs.js';
|
|
4
|
+
import { scheduleBucket } from './scheduler.js';
|
|
3
5
|
import { ReconcileCoordinator } from './reconcile-coordinator.js';
|
|
4
6
|
const RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
|
|
5
7
|
export function compareJobPriority(a, b) { return RANK[a.priority ?? 'P2'] - RANK[b.priority ?? 'P2'] || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id); }
|
|
@@ -25,27 +27,31 @@ export class RepositoryWorkerPool {
|
|
|
25
27
|
makeReconciler;
|
|
26
28
|
isRateLimited;
|
|
27
29
|
refreshWebhookSnapshot;
|
|
30
|
+
makeStore;
|
|
28
31
|
running = false;
|
|
29
32
|
pending = false;
|
|
30
33
|
reconcileLocks = new Map();
|
|
31
34
|
coordinator;
|
|
32
35
|
githubSignalCoordinator;
|
|
36
|
+
scheduleSignalCoordinator;
|
|
33
37
|
watchedBus;
|
|
34
38
|
jobTargets = new Map();
|
|
35
39
|
targetSeeds = new Map();
|
|
36
|
-
constructor(client, repository, trustedAuthors, concurrency, makeReconciler, isRateLimited = () => false, refreshWebhookSnapshot) {
|
|
40
|
+
constructor(client, repository, trustedAuthors, concurrency, makeReconciler, isRateLimited = () => false, refreshWebhookSnapshot, makeStore = (repository, prNumber, seed) => new GitHubStore(this.client, repository, prNumber, this.trustedAuthors, seed)) {
|
|
37
41
|
this.client = client;
|
|
38
42
|
this.trustedAuthors = trustedAuthors;
|
|
39
43
|
this.concurrency = concurrency;
|
|
40
44
|
this.makeReconciler = makeReconciler;
|
|
41
45
|
this.isRateLimited = isRateLimited;
|
|
42
46
|
this.refreshWebhookSnapshot = refreshWebhookSnapshot;
|
|
47
|
+
this.makeStore = makeStore;
|
|
43
48
|
this.repositories = [...new Set(typeof repository === 'string' ? [repository] : repository)];
|
|
44
49
|
const reportCoordinatorError = (error, signal) => {
|
|
45
50
|
process.stderr.write(`warning: reconcile coordinator failed for ${signal.jobId} (${signal.reason}): ${error instanceof Error ? error.message : String(error)}\n`);
|
|
46
51
|
};
|
|
47
52
|
this.coordinator = new ReconcileCoordinator((jobId, signals) => this.consumeSignals(jobId, signals), reportCoordinatorError);
|
|
48
53
|
this.githubSignalCoordinator = new ReconcileCoordinator((_key, signals) => this.consumeGitHubSignals(signals), reportCoordinatorError);
|
|
54
|
+
this.scheduleSignalCoordinator = new ReconcileCoordinator((jobId, signals) => this.consumeScheduleSignals(jobId, signals), reportCoordinatorError);
|
|
49
55
|
}
|
|
50
56
|
repositories;
|
|
51
57
|
async reconcileExclusive(jobId, work) {
|
|
@@ -71,6 +77,21 @@ export class RepositoryWorkerPool {
|
|
|
71
77
|
}
|
|
72
78
|
return `event.${event.type}`;
|
|
73
79
|
}
|
|
80
|
+
async consumeScheduleSignals(jobId, signals) {
|
|
81
|
+
if (this.isRateLimited() || signals.length === 0)
|
|
82
|
+
return;
|
|
83
|
+
const signal = [...signals].reverse().find(item => item.event?.type === 'schedule.tick');
|
|
84
|
+
const event = signal?.event;
|
|
85
|
+
const repository = signal?.repository ?? (typeof event?.data?.repository === 'string' ? event.data.repository : undefined);
|
|
86
|
+
const prValue = signal?.prNumber ?? Number(event?.data?.prNumber);
|
|
87
|
+
const prNumber = Number.isInteger(prValue) && Number(prValue) > 0 ? Number(prValue) : undefined;
|
|
88
|
+
const bucket = typeof event?.data?.bucket === 'string' ? event.data.bucket : undefined;
|
|
89
|
+
const tickAt = typeof event?.data?.tickAt === 'string' ? new Date(event.data.tickAt) : new Date(event?.timestamp ?? Date.now());
|
|
90
|
+
if (!repository || !prNumber || !bucket || !this.repositories.includes(repository) || Number.isNaN(tickAt.getTime()))
|
|
91
|
+
return;
|
|
92
|
+
this.jobTargets.set(jobId, { repository, prNumber });
|
|
93
|
+
await runScheduledBucket(this.client, repository, this.trustedAuthors, { prNumber, jobId, bucket }, { reconcile: async () => { await this.reconcileTarget(repository, prNumber, jobId, 'schedule.tick'); }, idle: async () => { } }, tickAt, () => this.makeStore(repository, prNumber));
|
|
94
|
+
}
|
|
74
95
|
async consumeGitHubSignals(signals) {
|
|
75
96
|
if (this.isRateLimited() || signals.length === 0)
|
|
76
97
|
return;
|
|
@@ -111,8 +132,6 @@ export class RepositoryWorkerPool {
|
|
|
111
132
|
}
|
|
112
133
|
}
|
|
113
134
|
async consumeSignals(jobId, signals) {
|
|
114
|
-
if (this.isRateLimited())
|
|
115
|
-
return;
|
|
116
135
|
for (const signal of signals) {
|
|
117
136
|
if (signal.repository && signal.prNumber && this.repositories.includes(signal.repository)) {
|
|
118
137
|
this.jobTargets.set(jobId, { repository: signal.repository, prNumber: signal.prNumber });
|
|
@@ -128,7 +147,7 @@ export class RepositoryWorkerPool {
|
|
|
128
147
|
const seed = mayConsumeSeed ? this.targetSeeds.get(seedKey) ?? null : null;
|
|
129
148
|
if (mayConsumeSeed)
|
|
130
149
|
this.targetSeeds.delete(seedKey);
|
|
131
|
-
const store =
|
|
150
|
+
const store = this.makeStore(target.repository, target.prNumber, seed);
|
|
132
151
|
const reconciler = this.makeReconciler(store, Math.max(1, this.concurrency));
|
|
133
152
|
let shouldReconcile = false;
|
|
134
153
|
const reasons = [];
|
|
@@ -155,7 +174,8 @@ export class RepositoryWorkerPool {
|
|
|
155
174
|
// OPEN-only runnable filter can strand durable jobs after GitHub merges/closes them.
|
|
156
175
|
for (const job of terminalManagedJobs(discovered)) {
|
|
157
176
|
await this.reconcileExclusive(job.id, async () => {
|
|
158
|
-
const store =
|
|
177
|
+
const store = this.makeStore(job.repository, job.prNumber);
|
|
178
|
+
await store.hydrate?.(job);
|
|
159
179
|
await withGitHubRequestContext(this.client, 'pool.terminal-reconcile', () => this.makeReconciler(store, 0).reconcile(job.id));
|
|
160
180
|
});
|
|
161
181
|
}
|
|
@@ -164,7 +184,8 @@ export class RepositoryWorkerPool {
|
|
|
164
184
|
for (const job of jobs) {
|
|
165
185
|
if (capacity <= 0)
|
|
166
186
|
break;
|
|
167
|
-
const store =
|
|
187
|
+
const store = this.makeStore(job.repository, job.prNumber);
|
|
188
|
+
await store.hydrate?.(job);
|
|
168
189
|
const ready = job.tasks.filter(task => (task.state === 'QUEUED' && task.executionId === null) || (task.state === 'DRAFT' && task.dependencies.length > 0)).length;
|
|
169
190
|
const expiredRunning = job.tasks.some(task => ['RUNNING', 'QUEUED'].includes(task.state) && task.executionId !== null && task.leaseExpiresAt !== null && new Date(task.leaseExpiresAt).getTime() <= Date.now());
|
|
170
191
|
if (!needsLifecycleReconciliation(job))
|
|
@@ -177,16 +198,24 @@ export class RepositoryWorkerPool {
|
|
|
177
198
|
}
|
|
178
199
|
}
|
|
179
200
|
async reconcileKnown(discovered) {
|
|
180
|
-
|
|
201
|
+
const recovered = [];
|
|
202
|
+
for (const item of discovered) {
|
|
203
|
+
if (item.githubState !== 'OPEN' || item.job.state !== 'ACTIVE') {
|
|
204
|
+
recovered.push(item);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
181
207
|
this.jobTargets.set(item.job.id, { repository: item.job.repository, prNumber: item.job.prNumber });
|
|
182
|
-
const store =
|
|
208
|
+
const store = this.makeStore(item.job.repository, item.job.prNumber);
|
|
209
|
+
await store.hydrate?.(item.job);
|
|
183
210
|
const reconciler = this.makeReconciler(store, 0);
|
|
211
|
+
let recoveredJob = item.job;
|
|
184
212
|
if (typeof reconciler.recoverStartupOrphans === 'function')
|
|
185
213
|
await this.reconcileExclusive(item.job.id, async () => {
|
|
186
|
-
await withGitHubRequestContext(this.client, 'pool.startup-orphan-recovery', () => reconciler.recoverStartupOrphans(item.job.id));
|
|
214
|
+
recoveredJob = await withGitHubRequestContext(this.client, 'pool.startup-orphan-recovery', () => reconciler.recoverStartupOrphans(item.job.id));
|
|
187
215
|
});
|
|
216
|
+
recovered.push({ ...item, job: recoveredJob });
|
|
188
217
|
}
|
|
189
|
-
await this.reconcileDiscovered(
|
|
218
|
+
await this.reconcileDiscovered(recovered);
|
|
190
219
|
}
|
|
191
220
|
async reconcile() {
|
|
192
221
|
if (this.isRateLimited())
|
|
@@ -225,6 +254,10 @@ export class RepositoryWorkerPool {
|
|
|
225
254
|
const prNumber = Number.isInteger(prValue) && prValue > 0 ? prValue : undefined;
|
|
226
255
|
const routedWebhook = event.type === 'github.webhook' && event.data?.coordinatorRouted === true;
|
|
227
256
|
const ingressWebhook = event.type === 'github.webhook' && event.data?.coordinatorIngress === true && !routedWebhook;
|
|
257
|
+
if (event.type === 'schedule.tick' && repository && prNumber) {
|
|
258
|
+
await this.scheduleSignalCoordinator.acceptTransport({ jobId: event.job_id, reason: this.signalReason(event), event, repository, prNumber });
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
228
261
|
if (ingressWebhook && repository && prNumber) {
|
|
229
262
|
await this.githubSignalCoordinator.acceptTransport({
|
|
230
263
|
jobId: `${repository}#${prNumber}`,
|
|
@@ -257,6 +290,17 @@ export class RepositoryWorkerPool {
|
|
|
257
290
|
this.watchedBus = undefined;
|
|
258
291
|
};
|
|
259
292
|
}
|
|
293
|
+
async scheduleTickSignal(bus, job, now = new Date()) {
|
|
294
|
+
if (!job.schedule)
|
|
295
|
+
return;
|
|
296
|
+
const event = eventFor(job.id, 'scheduler', null, 'schedule.tick', 'queued', `Schedule tick for ${job.schedule.nextRunAt}`, 'orchestrator', { repository: job.repository, prNumber: job.prNumber, bucket: scheduleBucket(new Date(job.schedule.nextRunAt)), tickAt: now.toISOString() }, { agent: 'relay', component: 'scheduler-timer' });
|
|
297
|
+
// Use the same coordinator-owned schedule path whether transport is available or not.
|
|
298
|
+
if (bus) {
|
|
299
|
+
await bus.publish(event);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
await this.scheduleSignalCoordinator.accept({ jobId: job.id, reason: this.signalReason(event), event, repository: job.repository, prNumber: job.prNumber });
|
|
303
|
+
}
|
|
260
304
|
async webhookSignal(bus, wake) {
|
|
261
305
|
if (!wake.repository || !wake.pullRequest)
|
|
262
306
|
return;
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { registerBlockerDependency, resolveBlockerDependencies } from './blockers.js';
|
|
2
|
+
import { sameTaskDefinition } from './store-task-definition.js';
|
|
3
|
+
function taskKey(task) { return `task:${task.jobId ?? ''}:${task.id}`; }
|
|
4
|
+
function planKey(plan) { return `plan:${plan.jobId}:${plan.id}`; }
|
|
5
|
+
function retryHeader(error, name) {
|
|
6
|
+
if (!error || typeof error !== 'object')
|
|
7
|
+
return null;
|
|
8
|
+
const value = error;
|
|
9
|
+
if (name === 'retry-after' && value.retryAfter !== undefined)
|
|
10
|
+
return String(value.retryAfter);
|
|
11
|
+
const headers = value.response?.headers ?? value.headers;
|
|
12
|
+
if (!headers)
|
|
13
|
+
return null;
|
|
14
|
+
if (typeof headers.get === 'function')
|
|
15
|
+
return headers.get(name);
|
|
16
|
+
const record = headers;
|
|
17
|
+
const hit = Object.entries(record).find(([key]) => key.toLowerCase() === name);
|
|
18
|
+
return hit && hit[1] !== undefined ? String(hit[1]) : null;
|
|
19
|
+
}
|
|
20
|
+
export function githubProjectionRetryAt(error, attempt, now = new Date()) {
|
|
21
|
+
const retryAfter = Number(retryHeader(error, 'retry-after'));
|
|
22
|
+
if (Number.isFinite(retryAfter) && retryAfter > 0)
|
|
23
|
+
return new Date(now.getTime() + retryAfter * 1000);
|
|
24
|
+
const reset = Number(retryHeader(error, 'x-ratelimit-reset'));
|
|
25
|
+
if (Number.isFinite(reset) && reset > now.getTime() / 1000)
|
|
26
|
+
return new Date(reset * 1000 + 1000);
|
|
27
|
+
const backoffMs = Math.min(300_000, 1_000 * (2 ** Math.min(8, Math.max(0, attempt))));
|
|
28
|
+
return new Date(now.getTime() + backoffMs);
|
|
29
|
+
}
|
|
30
|
+
export function isTerminalProjectionFailure(item, error) {
|
|
31
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
32
|
+
return (item.operation === 'append_task' || item.operation === 'append_plan')
|
|
33
|
+
&& /already exists with a different definition$/.test(message);
|
|
34
|
+
}
|
|
35
|
+
export class GitHubProjectionWriter {
|
|
36
|
+
relay;
|
|
37
|
+
github;
|
|
38
|
+
constructor(relay, github) {
|
|
39
|
+
this.relay = relay;
|
|
40
|
+
this.github = github;
|
|
41
|
+
}
|
|
42
|
+
async flushOnce(limit = 50, now = new Date()) {
|
|
43
|
+
const items = await this.relay.claimDueProjections(limit, now);
|
|
44
|
+
let projected = 0;
|
|
45
|
+
let deferred = 0;
|
|
46
|
+
for (const item of items) {
|
|
47
|
+
try {
|
|
48
|
+
await this.apply(item);
|
|
49
|
+
await this.relay.completeProjection(item.id);
|
|
50
|
+
projected += 1;
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
54
|
+
if (isTerminalProjectionFailure(item, error))
|
|
55
|
+
await this.relay.deadLetterProjection(item.id, message);
|
|
56
|
+
else {
|
|
57
|
+
await this.relay.retryProjection(item.id, message, githubProjectionRetryAt(error, item.attempts, now));
|
|
58
|
+
deferred += 1;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { projected, deferred };
|
|
63
|
+
}
|
|
64
|
+
async apply(item) {
|
|
65
|
+
const payload = item.payload;
|
|
66
|
+
switch (payload.operation) {
|
|
67
|
+
case 'save_job':
|
|
68
|
+
await this.github.saveJob(payload.value);
|
|
69
|
+
return;
|
|
70
|
+
case 'append_plan':
|
|
71
|
+
await this.github.appendPlan(payload.value);
|
|
72
|
+
return;
|
|
73
|
+
case 'save_plan':
|
|
74
|
+
await this.github.savePlan(payload.value);
|
|
75
|
+
return;
|
|
76
|
+
case 'append_task':
|
|
77
|
+
await this.github.appendTask(payload.value);
|
|
78
|
+
return;
|
|
79
|
+
case 'save_task':
|
|
80
|
+
await this.github.saveTask(payload.value);
|
|
81
|
+
return;
|
|
82
|
+
case 'save_agent':
|
|
83
|
+
await this.github.saveAgent(payload.value);
|
|
84
|
+
return;
|
|
85
|
+
case 'update_pr_body':
|
|
86
|
+
if (!this.github.updatePullRequestBody)
|
|
87
|
+
throw new Error('GitHub projection target cannot update PR body');
|
|
88
|
+
await this.github.updatePullRequestBody(payload.value);
|
|
89
|
+
return;
|
|
90
|
+
case 'merge_pull_request':
|
|
91
|
+
if (!this.github.mergePullRequest)
|
|
92
|
+
throw new Error('GitHub projection target cannot merge pull request');
|
|
93
|
+
await this.github.mergePullRequest(payload.expectedHeadSha);
|
|
94
|
+
return;
|
|
95
|
+
case 'close_pull_request':
|
|
96
|
+
if (!this.github.closePullRequest)
|
|
97
|
+
throw new Error('GitHub projection target cannot close pull request');
|
|
98
|
+
await this.github.closePullRequest();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
export class ProjectedDurableStore {
|
|
104
|
+
relay;
|
|
105
|
+
github;
|
|
106
|
+
relatedStore;
|
|
107
|
+
projection;
|
|
108
|
+
seededFacts;
|
|
109
|
+
constructor(relay, github, seed = null, relatedStore) {
|
|
110
|
+
this.relay = relay;
|
|
111
|
+
this.github = github;
|
|
112
|
+
this.relatedStore = relatedStore;
|
|
113
|
+
this.projection = new GitHubProjectionWriter(relay, github);
|
|
114
|
+
this.seededFacts = pullRequestFactsFromSeed(seed);
|
|
115
|
+
if (this.seededFacts)
|
|
116
|
+
void this.relay.savePullRequestFacts(this.seededFacts).catch(() => { });
|
|
117
|
+
}
|
|
118
|
+
/** Ingest trusted GitHub records without echoing them back through the projection outbox. */
|
|
119
|
+
async hydrate(job) {
|
|
120
|
+
await this.relay.ingestJobSnapshot(structuredClone(job));
|
|
121
|
+
}
|
|
122
|
+
async load(jobId) {
|
|
123
|
+
const local = await this.relay.loadJob(jobId);
|
|
124
|
+
if (local)
|
|
125
|
+
return structuredClone(local);
|
|
126
|
+
const remote = await this.github.load(jobId);
|
|
127
|
+
await this.relay.saveJob(remote);
|
|
128
|
+
return structuredClone(remote);
|
|
129
|
+
}
|
|
130
|
+
async saveJob(job) {
|
|
131
|
+
const value = structuredClone(job);
|
|
132
|
+
await this.relay.saveJobWithProjection(value, {
|
|
133
|
+
idempotencyKey: `save_job:${job.id}`,
|
|
134
|
+
coalesceKey: `job:${job.id}`,
|
|
135
|
+
operation: 'save_job',
|
|
136
|
+
payload: { operation: 'save_job', value },
|
|
137
|
+
priority: ['COMPLETED', 'CANCELLED'].includes(job.state) ? 100 : 20,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
async appendPlan(plan) {
|
|
141
|
+
const job = await this.load(plan.jobId);
|
|
142
|
+
const existing = (job.plans ?? []).find(item => item.id === plan.id);
|
|
143
|
+
if (existing) {
|
|
144
|
+
if (JSON.stringify({ ...existing, planUrl: undefined }) === JSON.stringify({ ...plan, planUrl: undefined }))
|
|
145
|
+
return;
|
|
146
|
+
throw new Error(`Plan ${plan.id} already exists with a different definition`);
|
|
147
|
+
}
|
|
148
|
+
job.plans = [...(job.plans ?? []), structuredClone(plan)].sort((a, b) => a.round - b.round);
|
|
149
|
+
await this.relay.saveJobWithProjection(job, {
|
|
150
|
+
idempotencyKey: `append_plan:${plan.jobId}:${plan.id}`,
|
|
151
|
+
operation: 'append_plan',
|
|
152
|
+
payload: { operation: 'append_plan', value: plan },
|
|
153
|
+
priority: 30,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
async savePlan(plan) {
|
|
157
|
+
const job = await this.load(plan.jobId);
|
|
158
|
+
const plans = [...(job.plans ?? [])];
|
|
159
|
+
const index = plans.findIndex(item => item.id === plan.id);
|
|
160
|
+
if (index >= 0)
|
|
161
|
+
plans[index] = structuredClone(plan);
|
|
162
|
+
else
|
|
163
|
+
plans.push(structuredClone(plan));
|
|
164
|
+
job.plans = plans.sort((a, b) => a.round - b.round);
|
|
165
|
+
await this.relay.saveJobWithProjection(job, {
|
|
166
|
+
idempotencyKey: `save_plan:${plan.jobId}:${plan.id}`,
|
|
167
|
+
coalesceKey: planKey(plan),
|
|
168
|
+
operation: 'save_plan',
|
|
169
|
+
payload: { operation: 'save_plan', value: plan },
|
|
170
|
+
priority: 30,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
async appendTask(task) {
|
|
174
|
+
if (!task.jobId)
|
|
175
|
+
throw new Error('Projected task requires jobId');
|
|
176
|
+
const job = await this.load(task.jobId);
|
|
177
|
+
const existing = job.tasks.find(item => item.id === task.id);
|
|
178
|
+
if (existing) {
|
|
179
|
+
if (sameTaskDefinition(existing, task))
|
|
180
|
+
return;
|
|
181
|
+
throw new Error(`Task ${task.id} already exists with a different definition`);
|
|
182
|
+
}
|
|
183
|
+
job.tasks = [...job.tasks, structuredClone(task)];
|
|
184
|
+
await this.relay.saveJobWithProjection(job, {
|
|
185
|
+
idempotencyKey: `append_task:${task.jobId}:${task.id}`,
|
|
186
|
+
operation: 'append_task',
|
|
187
|
+
payload: { operation: 'append_task', value: task },
|
|
188
|
+
priority: 40,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
async saveTask(task) {
|
|
192
|
+
if (!task.jobId)
|
|
193
|
+
throw new Error('Projected task requires jobId');
|
|
194
|
+
const job = await this.load(task.jobId);
|
|
195
|
+
const tasks = [...job.tasks];
|
|
196
|
+
const index = tasks.findIndex(item => item.id === task.id);
|
|
197
|
+
if (index >= 0)
|
|
198
|
+
tasks[index] = structuredClone(task);
|
|
199
|
+
else
|
|
200
|
+
tasks.push(structuredClone(task));
|
|
201
|
+
job.tasks = tasks;
|
|
202
|
+
const value = structuredClone(task);
|
|
203
|
+
const terminal = ['SUCCEEDED', 'FAILED', 'BLOCKED', 'UNBLOCKED', 'CANCELLED'].includes(task.state);
|
|
204
|
+
await this.relay.saveJobWithProjection(job, {
|
|
205
|
+
idempotencyKey: `save_task:${task.jobId}:${task.id}`,
|
|
206
|
+
coalesceKey: taskKey(task),
|
|
207
|
+
operation: 'save_task',
|
|
208
|
+
payload: { operation: 'save_task', value },
|
|
209
|
+
priority: terminal ? 100 : 50,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
async listAgents() {
|
|
213
|
+
const local = await this.relay.loadAgents();
|
|
214
|
+
if (local !== null)
|
|
215
|
+
return structuredClone(local);
|
|
216
|
+
const remote = await this.github.listAgents();
|
|
217
|
+
await this.relay.saveAgents(remote);
|
|
218
|
+
return structuredClone(remote);
|
|
219
|
+
}
|
|
220
|
+
async saveAgent(agent) {
|
|
221
|
+
if (await this.relay.loadAgents() === null) {
|
|
222
|
+
await this.relay.saveAgents(await this.github.listAgents());
|
|
223
|
+
}
|
|
224
|
+
const value = structuredClone(agent);
|
|
225
|
+
await this.relay.saveAgentWithProjection(value, {
|
|
226
|
+
idempotencyKey: `save_agent:${agent.id}`,
|
|
227
|
+
coalesceKey: `agent:${agent.id}`,
|
|
228
|
+
operation: 'save_agent',
|
|
229
|
+
payload: { operation: 'save_agent', value },
|
|
230
|
+
priority: 10,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
async comments() { return this.github.comments(); }
|
|
234
|
+
async pullRequestState() {
|
|
235
|
+
return (await this.ensurePullRequestFacts()).state;
|
|
236
|
+
}
|
|
237
|
+
async pullRequestHead() {
|
|
238
|
+
return (await this.ensurePullRequestFacts()).head;
|
|
239
|
+
}
|
|
240
|
+
async headSha() { return (await this.pullRequestHead())?.sha ?? null; }
|
|
241
|
+
async mergePullRequest(expectedHeadSha) {
|
|
242
|
+
await this.relay.enqueueProjection({
|
|
243
|
+
idempotencyKey: `merge_pull_request:${expectedHeadSha ?? 'current'}`,
|
|
244
|
+
operation: 'merge_pull_request',
|
|
245
|
+
payload: { operation: 'merge_pull_request', expectedHeadSha },
|
|
246
|
+
priority: 200,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
async closePullRequest() {
|
|
250
|
+
await this.relay.enqueueProjection({
|
|
251
|
+
idempotencyKey: 'close_pull_request',
|
|
252
|
+
operation: 'close_pull_request',
|
|
253
|
+
payload: { operation: 'close_pull_request' },
|
|
254
|
+
priority: 200,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
async updatePullRequestBody(body) {
|
|
258
|
+
await this.relay.enqueueProjection({
|
|
259
|
+
idempotencyKey: 'update_pr_body',
|
|
260
|
+
coalesceKey: 'pr_body',
|
|
261
|
+
operation: 'update_pr_body',
|
|
262
|
+
payload: { operation: 'update_pr_body', value: body },
|
|
263
|
+
priority: 80,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
async registerBlockerDependent(jobId, dependent) {
|
|
267
|
+
if (!this.relatedStore)
|
|
268
|
+
throw new Error('Projected store requires a related-store factory for blocker dependencies');
|
|
269
|
+
await registerBlockerDependency({
|
|
270
|
+
blockerStore: this,
|
|
271
|
+
blockerJobId: jobId,
|
|
272
|
+
dependentStore: this.relatedStore(dependent.repository, dependent.prNumber),
|
|
273
|
+
dependentJobId: dependent.jobId,
|
|
274
|
+
dependentTaskId: dependent.taskId,
|
|
275
|
+
resolutionMode: dependent.resolutionMode,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
async resolveBlockerDependents(jobId, resolution) {
|
|
279
|
+
if (!this.relatedStore)
|
|
280
|
+
throw new Error('Projected store requires a related-store factory for blocker dependencies');
|
|
281
|
+
return resolveBlockerDependencies({
|
|
282
|
+
blockerStore: this,
|
|
283
|
+
blockerJobId: jobId,
|
|
284
|
+
resolution,
|
|
285
|
+
dependentStore: (repository, prNumber) => this.relatedStore(repository, prNumber),
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
async ensurePullRequestFacts() {
|
|
289
|
+
const local = await localPullRequestFacts(this.relay, this.seededFacts);
|
|
290
|
+
if (local)
|
|
291
|
+
return local;
|
|
292
|
+
if (!this.github.pullRequestState || !this.github.pullRequestHead)
|
|
293
|
+
throw new Error('Pull request facts have not been observed locally');
|
|
294
|
+
const [state, head] = await Promise.all([this.github.pullRequestState(), this.github.pullRequestHead()]);
|
|
295
|
+
const facts = { state, head, observedAt: new Date().toISOString() };
|
|
296
|
+
await this.relay.savePullRequestFacts(facts);
|
|
297
|
+
return facts;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
function pullRequestFactsFromSeed(seed) {
|
|
301
|
+
const pr = seed?.prNode;
|
|
302
|
+
if (!pr || Object.keys(pr).length === 0)
|
|
303
|
+
return null;
|
|
304
|
+
const rawState = typeof pr.state === 'string' ? pr.state.toUpperCase() : '';
|
|
305
|
+
const state = pr.merged === true || rawState === 'MERGED' ? 'MERGED' : rawState === 'CLOSED' ? 'CLOSED' : rawState === 'OPEN' ? 'OPEN' : null;
|
|
306
|
+
if (!state)
|
|
307
|
+
return null;
|
|
308
|
+
const ref = typeof pr.headRefName === 'string' ? pr.headRefName : '';
|
|
309
|
+
const sha = typeof pr.headRefOid === 'string' ? pr.headRefOid : '';
|
|
310
|
+
return { state, head: ref && sha ? { ref, sha } : null, observedAt: new Date().toISOString() };
|
|
311
|
+
}
|
|
312
|
+
async function localPullRequestFacts(relay, seededFacts) {
|
|
313
|
+
return await relay.loadPullRequestFacts() ?? seededFacts;
|
|
314
|
+
}
|