agents-relay 2.0.67 → 2.0.68
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 +24 -19
- package/dist/cli-blocker.js +3 -5
- package/dist/cli-help.js +12 -10
- package/dist/cli-job-create.js +69 -0
- package/dist/cli-local-merge-config.js +9 -0
- package/dist/cli-support.js +25 -8
- package/dist/cli.js +66 -80
- package/dist/dashboard-html.js +1 -1
- package/dist/dashboard.js +1 -1
- package/dist/github-auth.js +4 -3
- package/dist/github-webhook.js +13 -5
- package/dist/local-authority-store.js +279 -0
- package/dist/local-git-merge.js +113 -0
- package/dist/local-job-path.js +7 -0
- package/dist/local-job-registry.js +23 -0
- package/dist/local-managed-store.js +21 -0
- package/dist/local-merge-delivery.js +52 -0
- package/dist/local-merge-lock.js +103 -0
- package/dist/no-github.js +11 -0
- package/dist/planned-review-head.js +7 -8
- package/dist/pool.js +72 -135
- package/dist/projected-store.js +20 -3
- package/dist/projection-outbox.js +3 -1
- package/dist/publication-store.js +37 -0
- package/dist/reconciler-review.js +25 -11
- package/dist/reconciler-runtime.js +3 -1
- package/dist/reconciler.js +33 -60
- package/dist/relay-store.js +108 -6
- package/dist/relayd.js +119 -124
- package/dist/runtime-status.js +8 -7
- package/dist/scheduled-jobs.js +43 -36
- package/dist/service-external-actions.js +27 -0
- package/dist/service-publication.js +29 -0
- package/dist/store-github.js +17 -6
- package/dist/task-update.js +1 -1
- package/dist/workflow/contracts.js +10 -1
- package/dist/workflow/definitions-v2.js +2 -0
- package/dist/workflow/job-reconcile-v2.js +9 -60
- package/dist/workflow/runtime.js +18 -3
- package/package.json +5 -3
- package/workflow/job-lifecycle.mmd +19 -60
- package/workflow/review-lifecycle.mmd +2 -8
package/dist/dashboard.js
CHANGED
|
@@ -71,7 +71,7 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
|
|
|
71
71
|
const discoveredJobs = async (forceRefresh = false) => {
|
|
72
72
|
try {
|
|
73
73
|
const jobs = overviewProvider
|
|
74
|
-
? (await overviewProvider(forceRefresh, forceRefresh ? 'dashboard.jobs-refresh' : 'internal.unknown')).map(item => ({ ...item.job, githubState: item.githubState, runtimeStatus: deriveJobRuntimeStatus(item.job, item.githubState), mergedAt: item.mergedAt, closedAt: item.closedAt, draft: item.draft, publication: item.publication }))
|
|
74
|
+
? (await overviewProvider(forceRefresh, forceRefresh ? 'dashboard.jobs-refresh' : 'internal.unknown')).map(item => ({ ...item.job, githubState: item.githubState, runtimeStatus: deriveJobRuntimeStatus(item.job, item.githubState), mergedAt: item.mergedAt, closedAt: item.closedAt, draft: item.draft, publication: item.publication, publicationFailure: item.publicationFailure }))
|
|
75
75
|
: jobId ? [await store.load(jobId)] : [];
|
|
76
76
|
lastKnownJobs = jobs;
|
|
77
77
|
return jobs;
|
package/dist/github-auth.js
CHANGED
|
@@ -15,17 +15,18 @@ export function githubAppConfig(args, env = process.env) {
|
|
|
15
15
|
throw new Error('GitHub App auth requires app id, installation id, and private key file');
|
|
16
16
|
return { appId, installationId, privateKeyFile };
|
|
17
17
|
}
|
|
18
|
-
export async function githubAuthContext(args, env = process.env) {
|
|
18
|
+
export async function githubAuthContext(args, env = process.env, probeLogin = true) {
|
|
19
19
|
const config = githubAppConfig(args, env);
|
|
20
20
|
let options = null;
|
|
21
21
|
if (config)
|
|
22
22
|
options = { appId: config.appId, installationId: config.installationId, privateKey: await readFile(config.privateKeyFile, 'utf8') };
|
|
23
23
|
const client = new GhApiClient(options);
|
|
24
24
|
const trustedAuthors = new Set();
|
|
25
|
-
|
|
25
|
+
if (probeLogin)
|
|
26
|
+
trustedAuthors.add(await client.currentLogin());
|
|
26
27
|
for (const author of (value(args, '--trusted-authors') || env.AGENTS_RELAY_TRUSTED_AUTHORS || '').split(',').map(item => item.trim()).filter(Boolean))
|
|
27
28
|
trustedAuthors.add(author);
|
|
28
|
-
if (options) {
|
|
29
|
+
if (options && probeLogin) {
|
|
29
30
|
const local = await client.localLogin();
|
|
30
31
|
if (local)
|
|
31
32
|
trustedAuthors.add(local);
|
package/dist/github-webhook.js
CHANGED
|
@@ -65,9 +65,11 @@ function pullRequestFacts(payload, action) {
|
|
|
65
65
|
const sha = typeof head?.sha === 'string' ? head.sha : null;
|
|
66
66
|
if (!ref || !sha)
|
|
67
67
|
return null;
|
|
68
|
+
const updated = typeof pr.updated_at === 'string' && Number.isFinite(Date.parse(pr.updated_at))
|
|
69
|
+
? { observedAt: new Date(pr.updated_at).toISOString() } : {};
|
|
68
70
|
if (action === 'closed')
|
|
69
|
-
return { state: pr.merged === true ? 'MERGED' : 'CLOSED', head: { ref, sha } };
|
|
70
|
-
return { state: 'OPEN', head: { ref, sha } };
|
|
71
|
+
return { state: pr.merged === true ? 'MERGED' : 'CLOSED', head: { ref, sha }, ...updated };
|
|
72
|
+
return { state: 'OPEN', head: { ref, sha }, ...updated };
|
|
71
73
|
}
|
|
72
74
|
export function parseGitHubWebhook(headers, body) {
|
|
73
75
|
const deliveryId = String(headers['x-github-delivery'] ?? '');
|
|
@@ -91,12 +93,18 @@ export function parseGitHubWebhook(headers, body) {
|
|
|
91
93
|
return null;
|
|
92
94
|
if (event === 'issue_comment') {
|
|
93
95
|
const comment = payload.comment;
|
|
94
|
-
const
|
|
95
|
-
const
|
|
96
|
+
const trustedPublisher = comment?.user?.login === 'github-actions[bot]';
|
|
97
|
+
const publication = trustedPublisher ? parsePackagePublicationMarker(comment?.body) : null;
|
|
98
|
+
const publishFailure = trustedPublisher
|
|
96
99
|
? parsePackagePublishFailureMarker(comment?.body) : null;
|
|
97
100
|
if (!publication && !publishFailure)
|
|
98
101
|
return null;
|
|
99
|
-
|
|
102
|
+
const commentTime = comment?.created_at;
|
|
103
|
+
const publicationObservedAt = typeof commentTime === 'string' && Number.isFinite(Date.parse(commentTime))
|
|
104
|
+
? new Date(commentTime).toISOString() : undefined;
|
|
105
|
+
return { deliveryId, event, action, repository: repositoryName, pullRequest: number, publication,
|
|
106
|
+
...(publicationObservedAt ? { publicationObservedAt } : {}),
|
|
107
|
+
...(publishFailure ? { publishFailure } : {}), pullRequestFacts: null };
|
|
100
108
|
}
|
|
101
109
|
const facts = pullRequestFacts(payload, action);
|
|
102
110
|
if (!facts)
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { GitHubProjectionWriter } from './projected-store.js';
|
|
2
|
+
import { registerBlockerDependency, resolveBlockerDependencies } from './blockers.js';
|
|
3
|
+
import { samePlanDefinition, sameTaskDefinition, DurableDefinitionConflictError, planDefinitionDivergence, taskDefinitionDivergence } from './store-task-definition.js';
|
|
4
|
+
import { mergeInboundJobSnapshot } from './relay-store.js';
|
|
5
|
+
import { publishLocalCommit } from './local-commit-events.js';
|
|
6
|
+
import { decideScheduledRun } from './workflow/scheduled-run-v2.js';
|
|
7
|
+
import { nextScheduleRun, scheduleBucket } from './scheduler.js';
|
|
8
|
+
/**
|
|
9
|
+
* V2 authoritative lifecycle boundary.
|
|
10
|
+
*
|
|
11
|
+
* Job, Plan, Task and Agent reads/writes are SQLite-only. No GitHub marker,
|
|
12
|
+
* remote snapshot, or projection result can create, recover, or mutate them.
|
|
13
|
+
* Inherited PR merge/close/head interfaces are external collaboration adapters,
|
|
14
|
+
* not lifecycle storage. An explicit one-time import must use RelayStore directly.
|
|
15
|
+
*/
|
|
16
|
+
const TASK_STATE_ORDER = {
|
|
17
|
+
DRAFT: 0, QUEUED: 1, UNBLOCKED: 2, RUNNING: 3, BLOCKED: 4,
|
|
18
|
+
SUCCEEDED: 5, FAILED: 5, CANCELLED: 5,
|
|
19
|
+
};
|
|
20
|
+
export class LocalAuthorityDurableStore {
|
|
21
|
+
authority;
|
|
22
|
+
relatedStore;
|
|
23
|
+
noGitHub;
|
|
24
|
+
projection;
|
|
25
|
+
constructor(authority, github, relatedStore, noGitHub = false) {
|
|
26
|
+
this.authority = authority;
|
|
27
|
+
this.relatedStore = relatedStore;
|
|
28
|
+
this.noGitHub = noGitHub;
|
|
29
|
+
this.projection = new GitHubProjectionWriter(authority, github, undefined, true);
|
|
30
|
+
}
|
|
31
|
+
/** Queue one retryable PR merge; do not mutate Job lifecycle until external confirmation. */
|
|
32
|
+
async mergePullRequest(expectedHeadSha) {
|
|
33
|
+
if (this.noGitHub)
|
|
34
|
+
throw new Error('Remote merge unavailable with --no-github; local Graph completion is separate');
|
|
35
|
+
if (!expectedHeadSha)
|
|
36
|
+
throw new Error('External merge requires immutable expected head SHA');
|
|
37
|
+
const facts = await this.authority.loadPullRequestFacts();
|
|
38
|
+
if (facts?.state === 'MERGED')
|
|
39
|
+
return;
|
|
40
|
+
if (facts?.state === 'CLOSED')
|
|
41
|
+
throw new Error('Cannot merge a PR already known closed');
|
|
42
|
+
if (!this.authority.enqueueExternalActionOnce)
|
|
43
|
+
throw new Error('Atomic external action queue is required');
|
|
44
|
+
await this.authority.enqueueExternalActionOnce({
|
|
45
|
+
idempotencyKey: `merge_pull_request:${expectedHeadSha}`,
|
|
46
|
+
operation: 'merge_pull_request',
|
|
47
|
+
payload: { operation: 'merge_pull_request', expectedHeadSha },
|
|
48
|
+
priority: 200,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
async closePullRequest() {
|
|
52
|
+
if (this.noGitHub)
|
|
53
|
+
return; // Local cancel is persisted by Graph; there is no remote PR.
|
|
54
|
+
const facts = await this.authority.loadPullRequestFacts();
|
|
55
|
+
if (facts?.state === 'MERGED')
|
|
56
|
+
throw new Error('Cannot close a PR already merged');
|
|
57
|
+
if (facts?.state === 'CLOSED')
|
|
58
|
+
return;
|
|
59
|
+
if (!this.authority.enqueueExternalActionOnce)
|
|
60
|
+
throw new Error('Atomic external action queue is required');
|
|
61
|
+
await this.authority.enqueueExternalActionOnce({
|
|
62
|
+
idempotencyKey: 'close_pull_request',
|
|
63
|
+
operation: 'close_pull_request',
|
|
64
|
+
payload: { operation: 'close_pull_request' },
|
|
65
|
+
priority: 200,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
async recordExecutionProcess(record) {
|
|
69
|
+
await this.authority.recordExecutionProcess(record);
|
|
70
|
+
}
|
|
71
|
+
async loadExecutionProcess(jobId, taskId, executionId) {
|
|
72
|
+
return this.authority.loadExecutionProcess(jobId, taskId, executionId);
|
|
73
|
+
}
|
|
74
|
+
async finishExecutionProcess(jobId, taskId, executionId) {
|
|
75
|
+
await this.authority.finishExecutionProcess(jobId, taskId, executionId);
|
|
76
|
+
}
|
|
77
|
+
async applyPullRequestFacts(facts) {
|
|
78
|
+
await this.authority.savePullRequestFacts(structuredClone(facts));
|
|
79
|
+
}
|
|
80
|
+
async headSha() {
|
|
81
|
+
return (await this.pullRequestHead())?.sha ?? null;
|
|
82
|
+
}
|
|
83
|
+
async registerBlockerDependent(jobId, dependent) {
|
|
84
|
+
if (!this.relatedStore)
|
|
85
|
+
throw new Error('Related local store factory is required for blocker dependencies');
|
|
86
|
+
await registerBlockerDependency({
|
|
87
|
+
blockerStore: this,
|
|
88
|
+
blockerJobId: jobId,
|
|
89
|
+
dependentStore: this.relatedStore(dependent.repository, dependent.prNumber),
|
|
90
|
+
dependentJobId: dependent.jobId,
|
|
91
|
+
dependentTaskId: dependent.taskId,
|
|
92
|
+
resolutionMode: dependent.resolutionMode,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
async resolveBlockerDependents(jobId, resolution) {
|
|
96
|
+
if (!this.relatedStore)
|
|
97
|
+
throw new Error('Related local store factory is required for blocker dependencies');
|
|
98
|
+
return resolveBlockerDependencies({
|
|
99
|
+
blockerStore: this,
|
|
100
|
+
blockerJobId: jobId,
|
|
101
|
+
resolution,
|
|
102
|
+
dependentStore: (repository, prNumber) => this.relatedStore(repository, prNumber),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
async comments() { return []; }
|
|
106
|
+
async pullRequestState() {
|
|
107
|
+
return this.noGitHub ? 'UNKNOWN' : (await this.authority.loadPullRequestFacts())?.state ?? 'UNKNOWN';
|
|
108
|
+
}
|
|
109
|
+
async pullRequestHead() {
|
|
110
|
+
return this.noGitHub ? null : (await this.authority.loadPullRequestFacts())?.head ?? null;
|
|
111
|
+
}
|
|
112
|
+
async hydrate(_job) {
|
|
113
|
+
// GitHub snapshots cannot overwrite or create authoritative lifecycle state.
|
|
114
|
+
}
|
|
115
|
+
async load(jobId) {
|
|
116
|
+
const job = await this.authority.loadJob(jobId);
|
|
117
|
+
if (!job)
|
|
118
|
+
throw new Error(`Local authoritative Job ${jobId} not found`);
|
|
119
|
+
return structuredClone(job);
|
|
120
|
+
}
|
|
121
|
+
async saveJob(job) {
|
|
122
|
+
const incoming = structuredClone(job);
|
|
123
|
+
const existing = await this.authority.loadJob(job.id);
|
|
124
|
+
if (!existing) {
|
|
125
|
+
await this.authority.saveJob(incoming);
|
|
126
|
+
Object.assign(job, structuredClone(incoming));
|
|
127
|
+
publishLocalCommit(incoming);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (!this.authority.mutateJob)
|
|
131
|
+
throw new Error('Atomic SQLite mutation is required for local lifecycle authority');
|
|
132
|
+
const updated = await this.authority.mutateJob(job.id, current => {
|
|
133
|
+
const children = mergeInboundJobSnapshot(current, incoming);
|
|
134
|
+
const preserveTerminal = ['COMPLETED', 'CANCELLED'].includes(current.state)
|
|
135
|
+
&& !['COMPLETED', 'CANCELLED'].includes(incoming.state);
|
|
136
|
+
const review = current.review && incoming.review
|
|
137
|
+
&& current.review.updatedAt > incoming.review.updatedAt
|
|
138
|
+
? current.review : incoming.review;
|
|
139
|
+
// Job-level scalar fields obey snapshot freshness. Graph transitions
|
|
140
|
+
// may commit review evidence after a preceding effect has already saved
|
|
141
|
+
// the Job; review itself is merged independently by review.updatedAt.
|
|
142
|
+
const scalarSource = incoming.updatedAt > current.updatedAt ? incoming : current;
|
|
143
|
+
return {
|
|
144
|
+
...current, ...scalarSource,
|
|
145
|
+
state: preserveTerminal ? current.state : scalarSource.state,
|
|
146
|
+
updatedAt: current.updatedAt > incoming.updatedAt ? current.updatedAt : incoming.updatedAt,
|
|
147
|
+
tasks: children.tasks,
|
|
148
|
+
...(children.plans ? { plans: children.plans } : {}),
|
|
149
|
+
...(review !== undefined ? { review } : {}),
|
|
150
|
+
};
|
|
151
|
+
});
|
|
152
|
+
Object.assign(job, structuredClone(updated));
|
|
153
|
+
publishLocalCommit(updated);
|
|
154
|
+
}
|
|
155
|
+
/** Claim a schedule slot under one SQLite IMMEDIATE transaction, across all processes. */
|
|
156
|
+
async claimScheduledSlot(jobId, bucket, now) {
|
|
157
|
+
if (!this.authority.mutateJob)
|
|
158
|
+
throw new Error('Atomic SQLite schedule claim is required');
|
|
159
|
+
let status = 'skipped-idempotent';
|
|
160
|
+
const result = await this.authority.mutateJob(jobId, current => {
|
|
161
|
+
const schedule = current.schedule;
|
|
162
|
+
if (!schedule)
|
|
163
|
+
throw new Error('Scheduled Job ' + jobId + ' has no schedule metadata');
|
|
164
|
+
const slot = new Date(schedule.nextRunAt);
|
|
165
|
+
const decision = decideScheduledRun({
|
|
166
|
+
slotDue: scheduleBucket(slot) === bucket && slot.getTime() <= now.getTime(),
|
|
167
|
+
slotUnclaimed: !schedule.lastScheduledSlot || new Date(schedule.lastScheduledSlot).getTime() < slot.getTime(),
|
|
168
|
+
jobActive: current.state === 'ACTIVE',
|
|
169
|
+
hasActiveTasks: current.tasks.some(task => ['QUEUED', 'RUNNING'].includes(task.state)),
|
|
170
|
+
concurrencySkip: schedule.concurrencyPolicy === 'skip',
|
|
171
|
+
concurrencyQueue: schedule.concurrencyPolicy === 'queue',
|
|
172
|
+
runMissed: schedule.missedRunPolicy === 'run',
|
|
173
|
+
});
|
|
174
|
+
const action = decision.accepted ? decision.actions[0]?.id : undefined;
|
|
175
|
+
if (action === 'schedule.skipActiveAdvance') {
|
|
176
|
+
schedule.lastScheduledSlot = slot.toISOString();
|
|
177
|
+
schedule.nextRunAt = nextScheduleRun(schedule, now).toISOString();
|
|
178
|
+
status = 'skipped-active';
|
|
179
|
+
}
|
|
180
|
+
else if (action === 'schedule.claimFromSlot' || action === 'schedule.claimFromNow') {
|
|
181
|
+
schedule.lastScheduledSlot = slot.toISOString();
|
|
182
|
+
schedule.currentRunSlot = slot.toISOString();
|
|
183
|
+
schedule.nextRunAt = nextScheduleRun(schedule, action === 'schedule.claimFromSlot' ? slot : now).toISOString();
|
|
184
|
+
status = 'completed';
|
|
185
|
+
}
|
|
186
|
+
if (status !== 'skipped-idempotent')
|
|
187
|
+
current.updatedAt = now.toISOString();
|
|
188
|
+
return current;
|
|
189
|
+
});
|
|
190
|
+
if (status !== 'skipped-idempotent')
|
|
191
|
+
publishLocalCommit(result);
|
|
192
|
+
return { job: result, status };
|
|
193
|
+
}
|
|
194
|
+
/** Persist completion only if this is still the authoritative claimed slot. */
|
|
195
|
+
async finishScheduledSlot(jobId, slot, now) {
|
|
196
|
+
if (!this.authority.mutateJob)
|
|
197
|
+
throw new Error('Atomic SQLite schedule completion is required');
|
|
198
|
+
const result = await this.authority.mutateJob(jobId, current => {
|
|
199
|
+
if (current.schedule?.currentRunSlot !== slot)
|
|
200
|
+
return current;
|
|
201
|
+
if (!current.schedule.lastRunAt || Date.parse(current.schedule.lastRunAt) < now.getTime()) {
|
|
202
|
+
current.schedule.lastRunAt = now.toISOString();
|
|
203
|
+
current.updatedAt = now.toISOString();
|
|
204
|
+
}
|
|
205
|
+
return current;
|
|
206
|
+
});
|
|
207
|
+
publishLocalCommit(result);
|
|
208
|
+
return result;
|
|
209
|
+
}
|
|
210
|
+
async appendPlan(plan) {
|
|
211
|
+
await this.mutate(plan.jobId, job => {
|
|
212
|
+
const existing = (job.plans ?? []).find(item => item.id === plan.id);
|
|
213
|
+
if (existing) {
|
|
214
|
+
if (samePlanDefinition(existing, plan))
|
|
215
|
+
return job;
|
|
216
|
+
throw new DurableDefinitionConflictError('plan', 'append_plan', plan.jobId, plan.id, plan, existing, planDefinitionDivergence(existing, plan));
|
|
217
|
+
}
|
|
218
|
+
job.plans = [...(job.plans ?? []), structuredClone(plan)].sort((a, b) => a.round - b.round);
|
|
219
|
+
return job;
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
async savePlan(plan) {
|
|
223
|
+
await this.mutate(plan.jobId, job => {
|
|
224
|
+
const plans = [...(job.plans ?? [])];
|
|
225
|
+
const index = plans.findIndex(item => item.id === plan.id);
|
|
226
|
+
if (index < 0)
|
|
227
|
+
plans.push(structuredClone(plan));
|
|
228
|
+
else if (plans[index].updatedAt <= plan.updatedAt)
|
|
229
|
+
plans[index] = structuredClone(plan);
|
|
230
|
+
job.plans = plans.sort((a, b) => a.round - b.round);
|
|
231
|
+
return job;
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
async appendTask(task) {
|
|
235
|
+
if (!task.jobId)
|
|
236
|
+
throw new Error('Local authoritative task requires jobId');
|
|
237
|
+
await this.mutate(task.jobId, job => {
|
|
238
|
+
const existing = job.tasks.find(item => item.id === task.id);
|
|
239
|
+
if (existing) {
|
|
240
|
+
if (sameTaskDefinition(existing, task))
|
|
241
|
+
return job;
|
|
242
|
+
throw new DurableDefinitionConflictError('task', 'append_task', task.jobId, task.id, task, existing, taskDefinitionDivergence(existing, task));
|
|
243
|
+
}
|
|
244
|
+
job.tasks.push(structuredClone(task));
|
|
245
|
+
return job;
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
async saveTask(task) {
|
|
249
|
+
if (!task.jobId)
|
|
250
|
+
throw new Error('Local authoritative task requires jobId');
|
|
251
|
+
await this.mutate(task.jobId, job => {
|
|
252
|
+
const index = job.tasks.findIndex(item => item.id === task.id);
|
|
253
|
+
if (index < 0)
|
|
254
|
+
throw new Error(`Local authoritative Task ${task.id} not found`);
|
|
255
|
+
const current = job.tasks[index];
|
|
256
|
+
if (current.attempt > task.attempt || current.updatedAt > task.updatedAt)
|
|
257
|
+
return job;
|
|
258
|
+
if (current.attempt === task.attempt && current.updatedAt === task.updatedAt
|
|
259
|
+
&& TASK_STATE_ORDER[current.state] >= TASK_STATE_ORDER[task.state])
|
|
260
|
+
return job;
|
|
261
|
+
job.tasks[index] = structuredClone(task);
|
|
262
|
+
return job;
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
async mutate(jobId, mutation) {
|
|
266
|
+
if (!this.authority.mutateJob)
|
|
267
|
+
throw new Error('Atomic SQLite mutation is required for local lifecycle authority');
|
|
268
|
+
const changed = await this.authority.mutateJob(jobId, mutation);
|
|
269
|
+
publishLocalCommit(changed);
|
|
270
|
+
}
|
|
271
|
+
async listAgents() {
|
|
272
|
+
return structuredClone(await this.authority.loadAgents() ?? []);
|
|
273
|
+
}
|
|
274
|
+
async saveAgent(agent) {
|
|
275
|
+
if (!this.authority.upsertAgent)
|
|
276
|
+
throw new Error('Atomic SQLite agent upsert is required');
|
|
277
|
+
await this.authority.upsertAgent(structuredClone(agent));
|
|
278
|
+
}
|
|
279
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { withLocalMergeLock } from './local-merge-lock.js';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
5
|
+
import { implementationComplete } from './runtime-status.js';
|
|
6
|
+
import { completionGateSatisfied } from './scheduler.js';
|
|
7
|
+
function git(cwd, ...args) {
|
|
8
|
+
return execFileSync('git', ['-C', cwd, ...args], {
|
|
9
|
+
encoding: 'utf8', timeout: 30000, maxBuffer: 1024 * 1024,
|
|
10
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
11
|
+
}).trim();
|
|
12
|
+
}
|
|
13
|
+
function ref(name) {
|
|
14
|
+
if (!/^refs\/heads\/[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(name) || name.includes('..') || name.endsWith('.lock'))
|
|
15
|
+
throw new Error('Local merge requires explicit refs/heads/ branch references');
|
|
16
|
+
return name;
|
|
17
|
+
}
|
|
18
|
+
export function localWorkingHead(repoPath) {
|
|
19
|
+
return git(repoPath, 'rev-parse', '--verify', 'HEAD');
|
|
20
|
+
}
|
|
21
|
+
export function localSourceHead(repoPath, sourceRef) {
|
|
22
|
+
return git(repoPath, 'rev-parse', '--verify', ref(sourceRef));
|
|
23
|
+
}
|
|
24
|
+
export function requiresLocalGitMerge(job) {
|
|
25
|
+
return Boolean(job.localMerge) || job.tasks.some(task => task.output?.kind === 'task_pr');
|
|
26
|
+
}
|
|
27
|
+
export function localMergeEligible(job) {
|
|
28
|
+
return job.state === 'ACTIVE' && job.autoMerge !== false && !job.schedule &&
|
|
29
|
+
implementationComplete(job) && completionGateSatisfied(job) &&
|
|
30
|
+
(job.reviewEnabled === false || job.review?.outcome === 'approved');
|
|
31
|
+
}
|
|
32
|
+
function checkedOutTarget(repoPath, targetRef) {
|
|
33
|
+
const records = git(repoPath, 'worktree', 'list', '--porcelain').split(/\n\n/);
|
|
34
|
+
const paths = records.filter(record => record.split('\n').includes('branch ' + targetRef))
|
|
35
|
+
.map(record => record.split('\n').find(line => line.startsWith('worktree '))?.slice(9))
|
|
36
|
+
.filter((path) => Boolean(path));
|
|
37
|
+
if (paths.length > 1)
|
|
38
|
+
throw new Error('Local merge target has multiple checked-out worktrees');
|
|
39
|
+
return paths[0] ?? null;
|
|
40
|
+
}
|
|
41
|
+
/** Merge a checked-out branch using Git porcelain so its worktree and index stay coherent. */
|
|
42
|
+
function mergeCheckedOutTarget(repoPath, targetRef, sourceSha, targetSha) {
|
|
43
|
+
const worktree = checkedOutTarget(repoPath, targetRef);
|
|
44
|
+
if (!worktree)
|
|
45
|
+
throw new Error('Target worktree disappeared before local merge');
|
|
46
|
+
if (git(worktree, 'symbolic-ref', 'HEAD') !== targetRef
|
|
47
|
+
|| git(worktree, 'rev-parse', 'HEAD') !== targetSha) {
|
|
48
|
+
throw new Error('Local merge target moved before delivery');
|
|
49
|
+
}
|
|
50
|
+
if (git(worktree, 'status', '--porcelain', '--untracked-files=all')) {
|
|
51
|
+
throw new Error('Local merge target worktree is not clean');
|
|
52
|
+
}
|
|
53
|
+
if (existsSync(resolve(worktree, git(worktree, 'rev-parse', '--git-path', 'MERGE_HEAD')))) {
|
|
54
|
+
throw new Error('Existing MERGE_HEAD is not owned by Relay; manual resolution required');
|
|
55
|
+
}
|
|
56
|
+
// No checkout, reset or force update. Git controls index and working tree,
|
|
57
|
+
// reports conflicts and leaves a recoverable merge state when interrupted.
|
|
58
|
+
try {
|
|
59
|
+
git(worktree, 'merge', '--no-ff', '--no-edit', '-m', 'Agents Relay local merge', sourceSha);
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
// Only a MERGE_HEAD matching our exact source SHA can be attributed to
|
|
63
|
+
// this delivery. Never abort a concurrent user's unrelated merge.
|
|
64
|
+
const mergeHead = resolve(worktree, git(worktree, 'rev-parse', '--git-path', 'MERGE_HEAD'));
|
|
65
|
+
if (existsSync(mergeHead)) {
|
|
66
|
+
try {
|
|
67
|
+
const mergeMessage = resolve(worktree, git(worktree, 'rev-parse', '--git-path', 'MERGE_MSG'));
|
|
68
|
+
if (readFileSync(mergeHead, 'utf8').trim() === sourceSha
|
|
69
|
+
&& existsSync(mergeMessage)
|
|
70
|
+
&& readFileSync(mergeMessage, 'utf8').startsWith('Agents Relay local merge')
|
|
71
|
+
&& git(worktree, 'rev-parse', 'HEAD') === targetSha) {
|
|
72
|
+
git(worktree, 'merge', '--abort');
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
catch { /* Leave unidentified merge state for manual resolution. */ }
|
|
76
|
+
}
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
const result = git(worktree, 'rev-parse', 'HEAD');
|
|
80
|
+
const parents = git(worktree, 'rev-list', '--parents', '-n', '1', result).split(' ');
|
|
81
|
+
if (parents[1] !== targetSha || parents[2] !== sourceSha) {
|
|
82
|
+
throw new Error('Unexpected local merge commit parentage; manual verification required');
|
|
83
|
+
}
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
/** Deliver to a Git ref without ever updating a checked-out ref behind Git's back. */
|
|
87
|
+
export function deliverLocalMerge(repoPath, request) {
|
|
88
|
+
const sourceRef = ref(request.sourceRef), targetRef = ref(request.targetRef);
|
|
89
|
+
if (sourceRef === targetRef)
|
|
90
|
+
throw new Error('Local merge source and target must differ');
|
|
91
|
+
const commonDir = resolve(repoPath, git(repoPath, 'rev-parse', '--git-common-dir'));
|
|
92
|
+
return withLocalMergeLock(commonDir, targetRef, () => {
|
|
93
|
+
const sourceSha = localSourceHead(repoPath, sourceRef);
|
|
94
|
+
if (request.sourceSha && sourceSha !== request.sourceSha)
|
|
95
|
+
throw new Error('Local merge source SHA changed after review');
|
|
96
|
+
const targetSha = git(repoPath, 'rev-parse', '--verify', targetRef);
|
|
97
|
+
try {
|
|
98
|
+
git(repoPath, 'merge-base', '--is-ancestor', sourceSha, targetSha);
|
|
99
|
+
return { sourceSha, mergedCommitSha: targetSha }; // Idempotent after acknowledged or crashed delivery.
|
|
100
|
+
}
|
|
101
|
+
catch { /* merge required */ }
|
|
102
|
+
if (checkedOutTarget(repoPath, targetRef)) {
|
|
103
|
+
return { sourceSha, mergedCommitSha: mergeCheckedOutTarget(repoPath, targetRef, sourceSha, targetSha) };
|
|
104
|
+
}
|
|
105
|
+
// Unchecked-out refs can be updated atomically without touching any worktree.
|
|
106
|
+
const tree = git(repoPath, 'merge-tree', '--write-tree', targetSha, sourceSha).split('\n')[0];
|
|
107
|
+
if (!/^[0-9a-f]{40,64}$/.test(tree))
|
|
108
|
+
throw new Error('Local merge-tree returned no valid tree');
|
|
109
|
+
const mergedCommitSha = git(repoPath, 'commit-tree', tree, '-p', targetSha, '-p', sourceSha, '-m', 'Agents Relay local merge');
|
|
110
|
+
git(repoPath, 'update-ref', targetRef, mergedCommitSha, targetSha);
|
|
111
|
+
return { sourceSha, mergedCommitSha };
|
|
112
|
+
});
|
|
113
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
/** Primary local store key is immutable Job ID, independent of a PR number. */
|
|
3
|
+
export function relayJobStorePath(directory, jobId) {
|
|
4
|
+
if (!jobId.trim() || jobId.length > 1024)
|
|
5
|
+
throw new Error('Invalid immutable Job ID');
|
|
6
|
+
return directory + '/job-' + createHash('sha256').update(jobId).digest('hex') + '.db';
|
|
7
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { InMemoryStore } from './store.js';
|
|
2
|
+
import { LocalAuthorityDurableStore } from './local-authority-store.js';
|
|
3
|
+
import { discoverLocalRelayStoreSnapshots, SQLiteRelayStore } from './relay-store.js';
|
|
4
|
+
/** Resolve exactly one SQLite-backed Job from its immutable ID, independent of PR binding. */
|
|
5
|
+
export function createJobIdRegistryResolver(directory, openStores) {
|
|
6
|
+
return async (jobId) => {
|
|
7
|
+
const snapshots = await discoverLocalRelayStoreSnapshots(directory);
|
|
8
|
+
const matches = snapshots.flatMap(snapshot => snapshot.jobs.filter(job => job.id === jobId).map(job => ({ snapshot, job })));
|
|
9
|
+
// Retain historical terminal snapshots without shadowing a uniquely live Job.
|
|
10
|
+
const live = matches.filter(item => !['COMPLETED', 'CANCELLED'].includes(item.job.state));
|
|
11
|
+
const selected = live.length === 1 ? live[0] : live.length === 0 && matches.length === 1 ? matches[0] : null;
|
|
12
|
+
if (!selected)
|
|
13
|
+
return null;
|
|
14
|
+
const { snapshot, job } = selected;
|
|
15
|
+
let relay = openStores.get(snapshot.path);
|
|
16
|
+
if (!relay) {
|
|
17
|
+
relay = new SQLiteRelayStore(snapshot.path);
|
|
18
|
+
openStores.set(snapshot.path, relay);
|
|
19
|
+
}
|
|
20
|
+
// The GitHub adapter cannot enter the lifecycle execution store.
|
|
21
|
+
return { job, relay, store: new LocalAuthorityDurableStore(relay, new InMemoryStore(), undefined, true) };
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { GhApiClient } from './github-client.js';
|
|
4
|
+
import { DisabledGitHubClient, noGitHubMode } from './no-github.js';
|
|
5
|
+
import { GitHubStore } from './store.js';
|
|
6
|
+
import { LocalAuthorityDurableStore } from './local-authority-store.js';
|
|
7
|
+
import { relayStorePath, relayJobStorePath, SQLiteRelayStore } from './relay-store.js';
|
|
8
|
+
/** Open the canonical V2 SQLite authority without making any GitHub request. */
|
|
9
|
+
export function openLocalManagedStore(repository, prNumber, options = {}) {
|
|
10
|
+
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) || repository.includes('..') || !Number.isInteger(prNumber) || prNumber < 0 || (prNumber === 0 && !options.jobId)) {
|
|
11
|
+
throw new Error('Local managed store requires a repository and either a positive PR number or immutable jobId');
|
|
12
|
+
}
|
|
13
|
+
const directory = options.directory ?? join(homedir(), '.agents-relay', 'stores');
|
|
14
|
+
const relay = new SQLiteRelayStore(prNumber === 0 ? relayJobStorePath(directory, options.jobId) : relayStorePath(directory, repository, prNumber));
|
|
15
|
+
const client = prNumber === 0 || options.noGitHub || noGitHubMode() ? new DisabledGitHubClient() : options.client ?? new GhApiClient();
|
|
16
|
+
const github = new GitHubStore(client, repository, prNumber, options.trustedAuthors ?? new Set());
|
|
17
|
+
return {
|
|
18
|
+
relay,
|
|
19
|
+
store: new LocalAuthorityDurableStore(relay, github, (relatedRepository, relatedPr) => openLocalManagedStore(relatedRepository, relatedPr, options).store, options.noGitHub || noGitHubMode()),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { transitionJobEvent } from './scheduler.js';
|
|
2
|
+
import { deliverLocalMerge, localMergeEligible, requiresLocalGitMerge } from './local-git-merge.js';
|
|
3
|
+
import { errorMessage } from './observability.js';
|
|
4
|
+
/** Graph-selected durable local merge and completion; no checked-out worktree is ever changed. */
|
|
5
|
+
export async function reconcileLocalGitCompletion(store, finalState, headSha, noGitHub, projectPathForJob) {
|
|
6
|
+
if (localMergeEligible(finalState) && !requiresLocalGitMerge(finalState)) {
|
|
7
|
+
await transitionJobEvent(finalState, 'local.completed', { localCompletionAllowed: true });
|
|
8
|
+
await store.saveJob(finalState);
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
if (localMergeEligible(finalState) &&
|
|
12
|
+
finalState.localMerge && (!finalState.reviewEnabled || finalState.review?.headSha === headSha)) {
|
|
13
|
+
const receipt = finalState.localMerge;
|
|
14
|
+
let activeReceipt = receipt;
|
|
15
|
+
if (receipt.status !== 'succeeded' &&
|
|
16
|
+
(!receipt.nextAttemptAt || Date.parse(receipt.nextAttemptAt) <= Date.now())) {
|
|
17
|
+
await transitionJobEvent(finalState, 'local.merge.requested', { localMergeReady: true }, async (actionId) => {
|
|
18
|
+
if (actionId !== 'effect.deliverLocalMerge')
|
|
19
|
+
throw new Error('Unsupported local merge Graph effect ' + actionId);
|
|
20
|
+
try {
|
|
21
|
+
if (receipt.sourceSha !== headSha && headSha) {
|
|
22
|
+
activeReceipt = { ...receipt, sourceSha: headSha, status: 'pending' };
|
|
23
|
+
finalState.localMerge = activeReceipt;
|
|
24
|
+
finalState.updatedAt = new Date().toISOString();
|
|
25
|
+
await store.saveJob(finalState);
|
|
26
|
+
}
|
|
27
|
+
if (!projectPathForJob)
|
|
28
|
+
throw new Error('Local Git merge requires an explicit project path');
|
|
29
|
+
const repoPath = await projectPathForJob(finalState);
|
|
30
|
+
const delivered = deliverLocalMerge(repoPath, { ...activeReceipt, sourceSha: activeReceipt.sourceSha ?? headSha ?? undefined });
|
|
31
|
+
finalState.localMerge = { ...activeReceipt, ...delivered, status: 'succeeded',
|
|
32
|
+
error: undefined, nextAttemptAt: undefined, updatedAt: new Date().toISOString() };
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
const attempts = (activeReceipt.attempts ?? 0) + 1;
|
|
36
|
+
finalState.localMerge = { ...activeReceipt, status: 'failed', attempts,
|
|
37
|
+
error: errorMessage(error),
|
|
38
|
+
nextAttemptAt: new Date(Date.now() + Math.min(300_000, 1000 * (2 ** Math.min(attempts, 8)))).toISOString(),
|
|
39
|
+
updatedAt: new Date().toISOString() };
|
|
40
|
+
}
|
|
41
|
+
finalState.updatedAt = new Date().toISOString();
|
|
42
|
+
await store.saveJob(finalState);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
if (finalState.localMerge?.status === 'succeeded') {
|
|
46
|
+
await transitionJobEvent(finalState, 'local.completed', { localCompletionAllowed: true });
|
|
47
|
+
await store.saveJob(finalState);
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return false;
|
|
52
|
+
}
|