engineering-memory 1.11.26 → 1.11.28
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/package.json +1 -1
- package/runtime/build.json +1 -1
- package/runtime/dist/src/config.js +1 -0
- package/runtime/dist/src/git/git-inspector.js +27 -8
- package/runtime/dist/src/localization/catalogue.generated.js +44 -0
- package/runtime/dist/src/mcp/delivery-tools.js +85 -0
- package/runtime/dist/src/mcp/tool-annotations.js +3 -0
- package/runtime/dist/src/mcp/tool-definitions.js +3 -0
- package/runtime/dist/src/providers/gitlab-token-page.js +226 -0
- package/runtime/dist/src/providers/gitlab.js +417 -0
- package/runtime/dist/src/runtime/active-context-store.js +19 -9
- package/runtime/dist/src/runtime/api-client.js +1 -0
- package/runtime/dist/src/runtime/bridge-service.js +157 -11
- package/runtime/dist/src/runtime/create-bridge-service.js +16 -2
- package/runtime/dist/src/runtime/merge-request-sync.js +281 -0
- package/runtime/dist/src/runtime/principal-state.js +4 -1
- package/runtime/dist/src/runtime/worktree-pool.js +9 -0
- package/skill/references/lifecycle.md +6 -2
|
@@ -23,6 +23,8 @@ import { sameHolder, suggestedBranchNames, taskStartChoice } from './task-start.
|
|
|
23
23
|
import { catalogueLanguages, copies, fetchLanguage, format } from './texts.js';
|
|
24
24
|
import * as z from 'zod/v4';
|
|
25
25
|
import { BridgeRecoveryError } from './recovery-error.js';
|
|
26
|
+
import { gitLabRemote } from '../providers/gitlab.js';
|
|
27
|
+
import { awaitsReviewRequest, reviewDeliveries, } from './merge-request-sync.js';
|
|
26
28
|
export const validationIds = [
|
|
27
29
|
'format',
|
|
28
30
|
'static_analysis',
|
|
@@ -987,6 +989,7 @@ export class BridgeService {
|
|
|
987
989
|
await this.live.taskStarted(backendTask.id);
|
|
988
990
|
}
|
|
989
991
|
const backendFresh = responseSource !== 'stale_cache';
|
|
992
|
+
const deliveryState = objectValue(backend.taskDelivery)?.state;
|
|
990
993
|
return asJsonValue({
|
|
991
994
|
backend: resumeBackendView(backend, backendFresh || !localJournal.projection),
|
|
992
995
|
requirements: backend.requirements ?? null,
|
|
@@ -999,7 +1002,12 @@ export class BridgeService {
|
|
|
999
1002
|
staleReason: backend.staleReason ?? null,
|
|
1000
1003
|
nextAction: contextRefreshAction(backend.staleReason, resumedBaseline),
|
|
1001
1004
|
}
|
|
1002
|
-
:
|
|
1005
|
+
: backendTask.status === 'closed' &&
|
|
1006
|
+
(deliveryState === 'unanswered' || deliveryState === 'answered')
|
|
1007
|
+
? {
|
|
1008
|
+
nextAction: 'This task is closed and its changes are not delivered yet. Its delivery continues as before: task.close in this folder asks the delivery question. If the user asks for a further change instead, call context.prepare_change with this sessionId and the paths the change touches: that reopens the task, and the next task.close asks the delivery question again for the new content.',
|
|
1009
|
+
}
|
|
1010
|
+
: {}),
|
|
1003
1011
|
repository: publicRepository(repository),
|
|
1004
1012
|
worktree: (await this.ownedAllocation(projectId, repository.repoRoot)) ?? null,
|
|
1005
1013
|
localJournal: backendFresh ? journalSummary(localJournal) : localJournal,
|
|
@@ -1165,6 +1173,9 @@ export class BridgeService {
|
|
|
1165
1173
|
const response = await this.dependencies.client.request(endpoints.contextPrepareChange, {
|
|
1166
1174
|
method: 'POST',
|
|
1167
1175
|
body,
|
|
1176
|
+
...(this.dependencies.clientVersion
|
|
1177
|
+
? { headers: { 'x-client-version': this.dependencies.clientVersion } }
|
|
1178
|
+
: {}),
|
|
1168
1179
|
...(input.transitionToWrite
|
|
1169
1180
|
? {}
|
|
1170
1181
|
: {
|
|
@@ -1174,6 +1185,8 @@ export class BridgeService {
|
|
|
1174
1185
|
});
|
|
1175
1186
|
const responseSource = this.dependencies.client.getResponseSource(response);
|
|
1176
1187
|
if (responseSource === 'stale_cache') {
|
|
1188
|
+
if (pointer.closedAt)
|
|
1189
|
+
throw refuse('This task is closed, and reopening it for a further change needs Engineering Memory, which cannot be reached now. Try again once it is reachable.', 'context.prepare_change');
|
|
1177
1190
|
validateOfflineLease(response.data, input.sessionId, changedPaths, baselineDiffHash);
|
|
1178
1191
|
await this.dependencies.activeContexts.setChangeBaseline(repository.repoFingerprint, input.sessionId, repository.git, changedPaths);
|
|
1179
1192
|
return asJsonValue({
|
|
@@ -1192,7 +1205,8 @@ export class BridgeService {
|
|
|
1192
1205
|
resumeConflicts: pointer.resumeConflicts.filter((value) => value !== 'backend_resume_is_stale'),
|
|
1193
1206
|
});
|
|
1194
1207
|
}
|
|
1195
|
-
const
|
|
1208
|
+
const preparedData = objectValue(response.data);
|
|
1209
|
+
const preparedTask = objectValue(preparedData?.task);
|
|
1196
1210
|
await this.dependencies.activeContexts.setChangeBaseline(repository.repoFingerprint, input.sessionId, repository.git, changedPaths, preparedTask &&
|
|
1197
1211
|
typeof preparedTask.id === 'string' &&
|
|
1198
1212
|
typeof preparedTask.lockVersion === 'number'
|
|
@@ -1201,8 +1215,16 @@ export class BridgeService {
|
|
|
1201
1215
|
taskVersion: numericTaskVersion(preparedTask.lockVersion),
|
|
1202
1216
|
lastSequence: numericSequence(preparedTask.lastSequence),
|
|
1203
1217
|
}
|
|
1204
|
-
: undefined);
|
|
1205
|
-
|
|
1218
|
+
: undefined, selfReviewRecords(preparedData?.selfReviewRequired));
|
|
1219
|
+
let preparedPointer = await this.dependencies.activeContexts.loadForSession(repository.repoFingerprint, input.sessionId);
|
|
1220
|
+
if (preparedPointer?.closedAt && preparedTask?.status === 'open') {
|
|
1221
|
+
const allocation = await this.ownedAllocation(preparedPointer.projectId, repository.repoRoot);
|
|
1222
|
+
if (allocation && this.dependencies.worktreePool)
|
|
1223
|
+
await this.dependencies.worktreePool.reopen(preparedPointer.projectId, repository.repoRoot, allocation.generation);
|
|
1224
|
+
const { closedAt: _closedAt, ...reopened } = preparedPointer;
|
|
1225
|
+
await this.dependencies.activeContexts.save(reopened);
|
|
1226
|
+
preparedPointer = reopened;
|
|
1227
|
+
}
|
|
1206
1228
|
if (preparedPointer) {
|
|
1207
1229
|
if (decision) {
|
|
1208
1230
|
await this.dependencies.repositories.git
|
|
@@ -1221,7 +1243,6 @@ export class BridgeService {
|
|
|
1221
1243
|
allocation.taskId = preparedPointer.taskId;
|
|
1222
1244
|
}
|
|
1223
1245
|
}
|
|
1224
|
-
const preparedData = objectValue(response.data);
|
|
1225
1246
|
await this.seedResumeSnapshot(preparedPointer, {
|
|
1226
1247
|
...objectOrEmpty(response.data),
|
|
1227
1248
|
...(preparedData?.lease ? { activeLease: preparedData.lease } : {}),
|
|
@@ -1910,10 +1931,18 @@ export class BridgeService {
|
|
|
1910
1931
|
}
|
|
1911
1932
|
async taskSelfReview(input) {
|
|
1912
1933
|
const reviewed = await this.execute(async () => {
|
|
1934
|
+
const checkout = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
|
|
1935
|
+
const pointer = await this.dependencies.activeContexts.loadForTask(checkout.repoFingerprint, input.taskId);
|
|
1936
|
+
const named = new Set(input.files.flatMap(({ rules }) => rules.map((rule) => rule.resourceId)));
|
|
1937
|
+
const leasePaths = pointer?.changeBaseline?.leasePaths ?? [];
|
|
1938
|
+
const unnamed = (pointer?.changeBaseline?.selfReviewRequired ?? []).filter(({ resourceId }) => !named.has(resourceId));
|
|
1939
|
+
if (unnamed.length > 0)
|
|
1940
|
+
throw refuse(`This self review leaves out records the change lease requires, so task.verify would refuse it: ${unnamed
|
|
1941
|
+
.map(({ resourceKey, title, paths }) => `${resourceKey} "${title}" (${paths.length === leasePaths.length ? 'every leased path' : paths.join(', ') || 'no single path'})`)
|
|
1942
|
+
.join('; ')}. Read the changed files against each of them and name it on a changed file it covers, or on any changed file when it covers no single path; then record task.self_review again. Nothing was recorded.`, 'task.self_review');
|
|
1913
1943
|
const files = await this.approvedDeviations(input);
|
|
1914
1944
|
assertSafeToPersist(cleanJson({ files }));
|
|
1915
|
-
const
|
|
1916
|
-
const repository = await this.taskRepository(checkout, await this.dependencies.activeContexts.loadForTask(checkout.repoFingerprint, input.taskId));
|
|
1945
|
+
const repository = await this.taskRepository(checkout, pointer);
|
|
1917
1946
|
if (this.deferDeliveries) {
|
|
1918
1947
|
return this.enqueueTaskReceipt('task.self_review', endpoints.taskSelfReview, input.taskId, repository.repoRoot, cleanJson({
|
|
1919
1948
|
diffHash: repository.git.diffHash,
|
|
@@ -3747,6 +3776,10 @@ export class BridgeService {
|
|
|
3747
3776
|
query.set('limit', String(input.limit));
|
|
3748
3777
|
const suffix = query.size > 0 ? `?${query.toString()}` : '';
|
|
3749
3778
|
const response = await this.dependencies.client.request(`${endpoints.workItemList(input.projectId)}${suffix}`);
|
|
3779
|
+
this.followMergeRequests(input.projectId, async () => {
|
|
3780
|
+
const here = await this.dependencies.repositories.resolveIdentity(process.cwd());
|
|
3781
|
+
return here.projectId === input.projectId ? here.repoRoot : null;
|
|
3782
|
+
});
|
|
3750
3783
|
return asJsonValue({
|
|
3751
3784
|
selectionRequired: true,
|
|
3752
3785
|
questionnaire: 'Select the project work item to run in this chat',
|
|
@@ -4136,7 +4169,7 @@ export class BridgeService {
|
|
|
4136
4169
|
const livePointers = authenticated && state === 'bound'
|
|
4137
4170
|
? this.dependencies.activeContexts.list(repository.repoFingerprint)
|
|
4138
4171
|
: Promise.resolve([]);
|
|
4139
|
-
const [client, liveTasksRaw, workItems, gitPreferences, worktrees, pendingQuestionnaires, moved, deliveries,] = await Promise.all([
|
|
4172
|
+
const [client, liveTasksRaw, workItems, gitPreferences, worktrees, pendingQuestionnaires, moved, deliveries, reviews,] = await Promise.all([
|
|
4140
4173
|
this.clientUpdate(authenticated),
|
|
4141
4174
|
livePointers,
|
|
4142
4175
|
authenticated && state === 'bound' && projectId
|
|
@@ -4160,8 +4193,14 @@ export class BridgeService {
|
|
|
4160
4193
|
authenticated && state === 'bound' && projectId
|
|
4161
4194
|
? this.openDeliveries(projectId)
|
|
4162
4195
|
: Promise.resolve(null),
|
|
4196
|
+
authenticated && state === 'bound' && projectId
|
|
4197
|
+
? this.reviewCandidates(projectId)
|
|
4198
|
+
: Promise.resolve(null),
|
|
4163
4199
|
]);
|
|
4164
4200
|
timer.mark('independent_lookups');
|
|
4201
|
+
if (projectId && reviews)
|
|
4202
|
+
this.followMergeRequests(projectId, repository.repoRoot, reviews);
|
|
4203
|
+
const awaiting = reviews?.filter(awaitsReviewRequest) ?? [];
|
|
4165
4204
|
const liveTasks = liveTasksRaw.map(describePointer);
|
|
4166
4205
|
const shipped = objectValue(objectValue(client)?.shippedKnowledge);
|
|
4167
4206
|
const incomplete = shipped && (shipped.failure !== null || Number(shipped.live) < Number(shipped.expected));
|
|
@@ -4208,6 +4247,22 @@ export class BridgeService {
|
|
|
4208
4247
|
? { deferredTaskStarts: pendingQuestionnaires.deferred }
|
|
4209
4248
|
: {}),
|
|
4210
4249
|
...(deliveries ? { openDeliveries: deliveries } : {}),
|
|
4250
|
+
...(awaiting.length
|
|
4251
|
+
? {
|
|
4252
|
+
awaitingReviewRequests: {
|
|
4253
|
+
total: awaiting.length,
|
|
4254
|
+
items: awaiting.slice(0, 5).map((delivery) => ({
|
|
4255
|
+
taskId: delivery.taskId,
|
|
4256
|
+
externalTaskId: delivery.externalTaskId ?? null,
|
|
4257
|
+
workItemKey: delivery.workItemKey ?? null,
|
|
4258
|
+
branch: delivery.branch ?? null,
|
|
4259
|
+
targetBranch: delivery.baseBranch ?? null,
|
|
4260
|
+
draft: delivery.choice === 'commit_push_draft_pr',
|
|
4261
|
+
})),
|
|
4262
|
+
nextAction: 'These delivered tasks chose a pull request, but none is recorded for them yet. Mention them to the user in one line. On a GitLab remote, task.open_review_request opens one from this computer when the user asks; a pull request opened anywhere else is recorded with task.review_request. A merge request someone opened by hand on a GitLab this computer follows is found by itself.',
|
|
4263
|
+
},
|
|
4264
|
+
}
|
|
4265
|
+
: {}),
|
|
4211
4266
|
client,
|
|
4212
4267
|
...moved,
|
|
4213
4268
|
...(incomplete && state === 'bound'
|
|
@@ -4519,9 +4574,9 @@ export class BridgeService {
|
|
|
4519
4574
|
const taskEntries = (await this.dependencies.outbox.list()).filter((entry) => taskIdFromDeliverySafe(entry) === input.taskId);
|
|
4520
4575
|
const pointer = await this.resolveCheckpointPointer(input, repoRoot);
|
|
4521
4576
|
if (pointer.closedAt && correction)
|
|
4522
|
-
throw refuse(`Task ${pointer.taskSlug} is closed, so it takes no more corrections.
|
|
4577
|
+
throw refuse(`Task ${pointer.taskSlug} is closed, so it takes no more corrections. When the correction asks for a further code change and the task's changes are not delivered yet, call context.prepare_change in its session first: that reopens the task, and the correction is then recorded on it. Otherwise record it as a proposal for the rule it concerns with memory.propose_revision, or in the task that next changes this code.`, 'memory.propose_revision');
|
|
4523
4578
|
if (pointer.closedAt)
|
|
4524
|
-
throw refuse(`Task ${pointer.taskSlug} is closed, so it takes no more checkpoints.
|
|
4579
|
+
throw refuse(`Task ${pointer.taskSlug} is closed, so it takes no more checkpoints. While its changes are not delivered, a further change continues it: call context.prepare_change in its session with the paths the change touches, which reopens it. Work after its delivery belongs to a new task.`, 'context.prepare_change');
|
|
4525
4580
|
const blockedEntry = taskEntries.find((entry) => entry.lastError && entry.lastError !== 'backend_unavailable');
|
|
4526
4581
|
if (blockedEntry && blockedEntry.idempotencyKey !== idempotencyKey) {
|
|
4527
4582
|
throw refuse('A blocked task delivery must be explicitly resolved before checkpointing', 'task.resolve_pending_delivery');
|
|
@@ -4643,6 +4698,76 @@ export class BridgeService {
|
|
|
4643
4698
|
async taskDeliveryCancel(input) {
|
|
4644
4699
|
return this.execute(() => this.mutateWithOutbox('task.delivery', endpoints.taskDeliveryCancel(input.projectId, input.taskId), { reason: input.reason }));
|
|
4645
4700
|
}
|
|
4701
|
+
async taskReviewRequest(input) {
|
|
4702
|
+
return this.execute(() => this.mutateWithOutbox('task.review_request', endpoints.taskReviewRequest(input.projectId, input.taskId), { url: input.url, state: input.state }));
|
|
4703
|
+
}
|
|
4704
|
+
async taskOpenReviewRequest(input) {
|
|
4705
|
+
return this.execute(async () => {
|
|
4706
|
+
const gitlab = this.gitlab();
|
|
4707
|
+
const repository = await this.dependencies.repositories.resolveIdentity(input.repoRoot ?? process.cwd());
|
|
4708
|
+
if (repository.projectId !== input.projectId)
|
|
4709
|
+
throw refuse("Call this in a clone of the task's project repository: this folder belongs to another project, or to none.", 'session.entry');
|
|
4710
|
+
const opened = await gitlab.requests.open({ ...input, repoRoot: repository.repoRoot });
|
|
4711
|
+
if ('recorded' in opened)
|
|
4712
|
+
return asJsonValue({ alreadyRecorded: opened.recorded });
|
|
4713
|
+
try {
|
|
4714
|
+
const report = await this.mutateWithOutbox('task.review_request', endpoints.taskReviewRequest(input.projectId, input.taskId), { url: opened.url, state: opened.state, source: 'machine' });
|
|
4715
|
+
return asJsonValue({ mergeRequest: opened, report });
|
|
4716
|
+
}
|
|
4717
|
+
catch (error) {
|
|
4718
|
+
return asJsonValue({
|
|
4719
|
+
mergeRequest: opened,
|
|
4720
|
+
reportRefused: error instanceof Error ? error.message : String(error),
|
|
4721
|
+
});
|
|
4722
|
+
}
|
|
4723
|
+
});
|
|
4724
|
+
}
|
|
4725
|
+
async gitlabToken(input = {}) {
|
|
4726
|
+
return this.execute(async () => {
|
|
4727
|
+
const gitlab = this.gitlab();
|
|
4728
|
+
if (!(await this.dependencies.credentials.get('access-token')))
|
|
4729
|
+
throw refuse('Sign in to Engineering Memory first: a GitLab token is kept for the signed-in account.', 'auth.signin_browser');
|
|
4730
|
+
await this.language(input.language);
|
|
4731
|
+
const git = this.dependencies.repositories.git;
|
|
4732
|
+
const pushUrl = await git
|
|
4733
|
+
.findRoot(input.repoRoot ?? process.cwd())
|
|
4734
|
+
.then(async (root) => (await git.pushDestination(root)).pushUrl)
|
|
4735
|
+
.catch(() => null);
|
|
4736
|
+
const remote = pushUrl ? gitLabRemote(pushUrl) : null;
|
|
4737
|
+
if (remote && 'refusal' in remote && remote.refusal === 'github')
|
|
4738
|
+
throw refuse('This repository is on GitHub. Engineering Memory opens merge requests only on GitLab so far; a GitHub pull request is opened with the tools you have and recorded with task.review_request.', 'task.review_request');
|
|
4739
|
+
const target = remote && !('refusal' in remote)
|
|
4740
|
+
? {
|
|
4741
|
+
host: remote.host,
|
|
4742
|
+
address: (await gitlab.tokens.address(remote.host)) ?? remote.address,
|
|
4743
|
+
}
|
|
4744
|
+
: { host: null, address: null };
|
|
4745
|
+
const link = await gitlab.page.open(target);
|
|
4746
|
+
return asJsonValue({
|
|
4747
|
+
...link,
|
|
4748
|
+
address: target.address,
|
|
4749
|
+
tokenSaved: target.address ? Boolean(await gitlab.tokens.token(target.address)) : false,
|
|
4750
|
+
nextAction: "Give this link to the user to open in a browser on this computer, and tell them to enter their GitLab address and personal access token there: the api scope lets Engineering Memory open merge requests, read_api only lets it follow them. Never open the link, fill in the page or ask for the token yourself, and if the user pastes a token into the chat, do not use it: ask them to revoke it and use the page. The page checks the token with GitLab, keeps it in this computer's credential store and closes after one save or removal, or after 10 minutes. When the user says it is saved, retry what needed it.",
|
|
4751
|
+
});
|
|
4752
|
+
});
|
|
4753
|
+
}
|
|
4754
|
+
gitlab() {
|
|
4755
|
+
if (!this.dependencies.gitlab)
|
|
4756
|
+
throw refuse('This bridge was started without GitLab support. Open the merge request in GitLab and record it with task.review_request.', 'task.review_request');
|
|
4757
|
+
return this.dependencies.gitlab;
|
|
4758
|
+
}
|
|
4759
|
+
followMergeRequests(projectId, repoRoot, deliveries) {
|
|
4760
|
+
this.dependencies.gitlab?.requests.schedule(projectId, repoRoot, deliveries);
|
|
4761
|
+
}
|
|
4762
|
+
async reviewCandidates(projectId) {
|
|
4763
|
+
try {
|
|
4764
|
+
const response = await this.dependencies.client.request(`${endpoints.projectDeliveries(projectId)}?state=review&limit=100`);
|
|
4765
|
+
return reviewDeliveries(response.data);
|
|
4766
|
+
}
|
|
4767
|
+
catch {
|
|
4768
|
+
return null;
|
|
4769
|
+
}
|
|
4770
|
+
}
|
|
4646
4771
|
async taskDeliveryRecord(input) {
|
|
4647
4772
|
return this.execute(async () => {
|
|
4648
4773
|
const response = await this.dependencies.client.request(endpoints.taskDelivery(input.projectId, input.taskId));
|
|
@@ -5669,7 +5794,7 @@ function deliveryQuestion(touchedContract, destination, publishApplicable, commi
|
|
|
5669
5794
|
alsoAsk: 'This task changed an endpoint contract. Ask whether to document the change for the linked project, and if so write what the other side must do differently with memory.propose_revision as an integration_note.',
|
|
5670
5795
|
}
|
|
5671
5796
|
: {}),
|
|
5672
|
-
afterPullRequest: 'Do not end the turn once
|
|
5797
|
+
afterPullRequest: 'Do not end the turn once the pull request is due. On a GitLab remote, task.open_review_request opens it from this computer and records it in one call; when it says no GitLab token is saved, gitlab.token gives the user a page on this computer to save their own. Anywhere else, open it with the tools you have and record it with task.review_request: its link, and draft or ready as it was opened. Then check whether it merges cleanly, report the conflicting files if it does not, and ask whether to resolve them before touching anything. When the user later says it became ready, was merged or was closed, record that with task.review_request too; a GitLab merge request this computer can read is also followed by itself.',
|
|
5673
5798
|
afterCommit,
|
|
5674
5799
|
});
|
|
5675
5800
|
}
|
|
@@ -5732,6 +5857,27 @@ function checkpointView(checkpoint) {
|
|
|
5732
5857
|
documents: documentViews(checkpoint.documents),
|
|
5733
5858
|
};
|
|
5734
5859
|
}
|
|
5860
|
+
function selfReviewRecords(value) {
|
|
5861
|
+
if (!Array.isArray(value))
|
|
5862
|
+
return undefined;
|
|
5863
|
+
return value.flatMap((entry) => {
|
|
5864
|
+
const record = objectValue(entry);
|
|
5865
|
+
return record &&
|
|
5866
|
+
typeof record.resourceId === 'string' &&
|
|
5867
|
+
typeof record.resourceKey === 'string' &&
|
|
5868
|
+
typeof record.title === 'string' &&
|
|
5869
|
+
Array.isArray(record.paths)
|
|
5870
|
+
? [
|
|
5871
|
+
{
|
|
5872
|
+
resourceId: record.resourceId,
|
|
5873
|
+
resourceKey: record.resourceKey,
|
|
5874
|
+
title: record.title,
|
|
5875
|
+
paths: record.paths.filter((path) => typeof path === 'string'),
|
|
5876
|
+
},
|
|
5877
|
+
]
|
|
5878
|
+
: [];
|
|
5879
|
+
});
|
|
5880
|
+
}
|
|
5735
5881
|
function resumeBackendView(backend, includeDocuments) {
|
|
5736
5882
|
const rest = Object.fromEntries(Object.entries(backend).filter(([key]) => key !== 'requirements'));
|
|
5737
5883
|
const task = objectValue(backend.task);
|
|
@@ -18,7 +18,10 @@ import { RepositoryDecisionStore } from './repository-decision-store.js';
|
|
|
18
18
|
import { ShadowNoticeStore } from './shadow-notice-store.js';
|
|
19
19
|
import { LanguageStore } from './language-store.js';
|
|
20
20
|
import { UpdateChoiceStore } from './update-choice-store.js';
|
|
21
|
-
import { PrincipalStateGuard } from './principal-state.js';
|
|
21
|
+
import { PrincipalStateGuard, principalFingerprint } from './principal-state.js';
|
|
22
|
+
import { MergeRequests } from './merge-request-sync.js';
|
|
23
|
+
import { GitLabClient, GitLabTokens } from '../providers/gitlab.js';
|
|
24
|
+
import { GitLabTokenPage } from '../providers/gitlab-token-page.js';
|
|
22
25
|
import { OnboardingStore } from './onboarding-store.js';
|
|
23
26
|
import { QuestionnaireStore } from './questionnaire-store.js';
|
|
24
27
|
import { sha256 } from '../utilities/hash.js';
|
|
@@ -78,8 +81,14 @@ export function createBridgeService(options = {}) {
|
|
|
78
81
|
knownReservations,
|
|
79
82
|
});
|
|
80
83
|
const gate = new VerificationGate(stateRoot, git, outbox, undefined, worktreePool);
|
|
81
|
-
const
|
|
84
|
+
const gitlabTokens = new GitLabTokens(stateRoot, credentials);
|
|
85
|
+
const principalState = new PrincipalStateGuard(stateRoot, credentials, cache, outbox, activeContexts, gate, gitlabTokens);
|
|
82
86
|
const languages = new LanguageStore(stateRoot);
|
|
87
|
+
const gitlab = new GitLabClient(config.requestTimeoutMs);
|
|
88
|
+
const signedInLanguage = async () => {
|
|
89
|
+
const accessToken = await credentials.get('access-token');
|
|
90
|
+
return languages.read(accessToken ? principalFingerprint(accessToken) : sha256('anonymous'));
|
|
91
|
+
};
|
|
83
92
|
return (service = new BridgeService({
|
|
84
93
|
live,
|
|
85
94
|
releaseNotes: new ReleaseNotes(stateRoot, client, credentials),
|
|
@@ -101,6 +110,11 @@ export function createBridgeService(options = {}) {
|
|
|
101
110
|
onboarding: new OnboardingStore(stateRoot),
|
|
102
111
|
clientVersion: options.clientVersion === undefined ? config.clientVersion : options.clientVersion,
|
|
103
112
|
principalState,
|
|
113
|
+
gitlab: {
|
|
114
|
+
tokens: gitlabTokens,
|
|
115
|
+
page: new GitLabTokenPage(gitlabTokens, gitlab, signedInLanguage),
|
|
116
|
+
requests: new MergeRequests(client, gitlabTokens, gitlab, git),
|
|
117
|
+
},
|
|
104
118
|
}));
|
|
105
119
|
}
|
|
106
120
|
//# sourceMappingURL=create-bridge-service.js.map
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import * as z from 'zod/v4';
|
|
3
|
+
import { endpoints } from '../config.js';
|
|
4
|
+
import { GitLabError, gitLabRemote, projectPath, reviewState, } from '../providers/gitlab.js';
|
|
5
|
+
import { BridgeRecoveryError } from './recovery-error.js';
|
|
6
|
+
export const reviewDeliverySchema = z.object({
|
|
7
|
+
taskId: z.string(),
|
|
8
|
+
externalTaskId: z.string().nullish(),
|
|
9
|
+
state: z.string(),
|
|
10
|
+
choice: z.string().nullish(),
|
|
11
|
+
baseBranch: z.string().nullish(),
|
|
12
|
+
deliveredCommit: z.string().nullish(),
|
|
13
|
+
pushed: z.boolean().nullish(),
|
|
14
|
+
branch: z.string().nullish(),
|
|
15
|
+
workItemKey: z.string().nullish(),
|
|
16
|
+
reviewRequest: z
|
|
17
|
+
.object({
|
|
18
|
+
provider: z.string(),
|
|
19
|
+
host: z.string(),
|
|
20
|
+
repository: z.string(),
|
|
21
|
+
number: z.number().int().positive(),
|
|
22
|
+
url: z.string(),
|
|
23
|
+
state: z.string(),
|
|
24
|
+
source: z.string().optional(),
|
|
25
|
+
})
|
|
26
|
+
.nullish(),
|
|
27
|
+
});
|
|
28
|
+
const pullRequestChoices = ['commit_push_draft_pr', 'commit_push_pr'];
|
|
29
|
+
export function awaitsReviewRequest(delivery) {
|
|
30
|
+
return (!delivery.reviewRequest &&
|
|
31
|
+
delivery.state === 'delivered' &&
|
|
32
|
+
delivery.pushed === true &&
|
|
33
|
+
pullRequestChoices.includes(delivery.choice ?? ''));
|
|
34
|
+
}
|
|
35
|
+
export function reviewDeliveries(data) {
|
|
36
|
+
const items = z.object({ items: z.array(z.unknown()) }).safeParse(data);
|
|
37
|
+
return (items.success ? items.data.items : []).flatMap((item) => {
|
|
38
|
+
const delivery = reviewDeliverySchema.safeParse(item);
|
|
39
|
+
return delivery.success ? [delivery.data] : [];
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
const manualWay = 'Opening it in GitLab yourself always works: record it afterwards with task.review_request and its link.';
|
|
43
|
+
export class MergeRequests {
|
|
44
|
+
client;
|
|
45
|
+
tokens;
|
|
46
|
+
gitlab;
|
|
47
|
+
git;
|
|
48
|
+
passes = new Map();
|
|
49
|
+
running = new Set();
|
|
50
|
+
constructor(client, tokens, gitlab, git) {
|
|
51
|
+
this.client = client;
|
|
52
|
+
this.tokens = tokens;
|
|
53
|
+
this.gitlab = gitlab;
|
|
54
|
+
this.git = git;
|
|
55
|
+
}
|
|
56
|
+
async open(input) {
|
|
57
|
+
const delivery = reviewDeliverySchema.parse((await this.client.request(endpoints.taskDelivery(input.projectId, input.taskId)))
|
|
58
|
+
.data);
|
|
59
|
+
if (delivery.reviewRequest)
|
|
60
|
+
return { recorded: { url: delivery.reviewRequest.url, state: delivery.reviewRequest.state } };
|
|
61
|
+
if (delivery.state === 'cancelled')
|
|
62
|
+
throw new BridgeRecoveryError('This delivery was concluded without delivering, so it gets no merge request.', 'task.delivery');
|
|
63
|
+
const branch = delivery.branch;
|
|
64
|
+
if (!branch)
|
|
65
|
+
throw new BridgeRecoveryError('This task has no branch, so there is nothing to open a merge request from.', 'task.review_request');
|
|
66
|
+
const chosen = pullRequestChoices.includes(delivery.choice ?? '');
|
|
67
|
+
const target = input.targetBranch ?? (chosen ? delivery.baseBranch : null);
|
|
68
|
+
if (!target)
|
|
69
|
+
throw new BridgeRecoveryError('The delivery answer did not choose a pull request, so it names no target branch. If the user wants a merge request, call again with the targetBranch they name.', 'task.open_review_request');
|
|
70
|
+
const draft = input.draft ?? delivery.choice === 'commit_push_draft_pr';
|
|
71
|
+
const pushUrl = await this.git.branchPushUrl(input.repoRoot, branch);
|
|
72
|
+
if (!pushUrl)
|
|
73
|
+
throw new BridgeRecoveryError(`This clone has no remote that ${branch} is pushed to. ${manualWay}`, 'task.review_request');
|
|
74
|
+
const remote = gitLabRemote(pushUrl);
|
|
75
|
+
if ('refusal' in remote)
|
|
76
|
+
throw new BridgeRecoveryError(remoteRefusal(remote.refusal, pushUrl), 'task.review_request');
|
|
77
|
+
const address = (await this.tokens.address(remote.host)) ?? remote.address;
|
|
78
|
+
const token = await this.tokens.token(address);
|
|
79
|
+
if (!token)
|
|
80
|
+
throw new BridgeRecoveryError(`No GitLab token is saved on this computer for ${address}. Call gitlab.token and give the user the link it returns; they save their own token there, and then this call works. ${manualWay}`, 'gitlab.token', { address });
|
|
81
|
+
const path = projectPath(address, remote.path);
|
|
82
|
+
try {
|
|
83
|
+
const project = await this.gitlab.project(address, token, path);
|
|
84
|
+
if (!project)
|
|
85
|
+
throw new BridgeRecoveryError(`GitLab at ${address} shows no project ${path} to this token. If GitLab runs under a path (like https://host/gitlab), save that address with gitlab.token; otherwise the token's user needs access to the project. ${manualWay}`, 'gitlab.token', { address, project: path });
|
|
86
|
+
const remoteHead = await this.gitlab.branch(address, token, path, branch);
|
|
87
|
+
if (!remoteHead)
|
|
88
|
+
throw new BridgeRecoveryError(`${branch} is not on GitLab yet. Push it, then call again.`, 'task.open_review_request');
|
|
89
|
+
const local = await this.git.branchCommit(input.repoRoot, branch).catch(() => null);
|
|
90
|
+
const delivered = local?.commit ?? delivery.deliveredCommit ?? null;
|
|
91
|
+
if (delivered &&
|
|
92
|
+
delivered !== remoteHead.commit &&
|
|
93
|
+
!(await this.gitlab.contains(address, token, path, branch, delivered)))
|
|
94
|
+
throw new BridgeRecoveryError(`${branch} on GitLab does not have ${delivered.slice(0, 12)} yet, so a merge request would lack it. Push the branch, then call again.`, 'task.open_review_request');
|
|
95
|
+
const source = { id: project.id, path };
|
|
96
|
+
const existing = (await this.gitlab.mergeRequests(address, token, source, branch, 'opened'))[0];
|
|
97
|
+
if (existing)
|
|
98
|
+
return opened(existing, true);
|
|
99
|
+
if (!(await this.gitlab.branch(address, token, path, target)))
|
|
100
|
+
throw new BridgeRecoveryError(`The target branch ${target} does not exist on GitLab. Ask the user which branch the merge request goes into and call again with targetBranch.`, 'task.open_review_request');
|
|
101
|
+
const title = ((draft ? 'Draft: ' : '') +
|
|
102
|
+
(input.title ??
|
|
103
|
+
defaultTitle(local?.subject || remoteHead.title || branch, delivery.workItemKey))).slice(0, 255);
|
|
104
|
+
try {
|
|
105
|
+
return opened(await this.gitlab.createMergeRequest(address, token, path, {
|
|
106
|
+
source: branch,
|
|
107
|
+
target,
|
|
108
|
+
title,
|
|
109
|
+
...(input.description ? { description: input.description } : {}),
|
|
110
|
+
}), false);
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (!(error instanceof GitLabError) ||
|
|
114
|
+
!['conflict', 'unknown_outcome', 'server_error'].includes(error.failure))
|
|
115
|
+
throw error;
|
|
116
|
+
const found = (await this.gitlab.mergeRequests(address, token, source, branch, 'opened'))[0];
|
|
117
|
+
if (found)
|
|
118
|
+
return opened(found, true);
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
if (error instanceof GitLabError)
|
|
124
|
+
throw gitLabRefusal(error);
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
schedule(projectId, repoRoot, deliveries) {
|
|
129
|
+
const now = Date.now();
|
|
130
|
+
const last = this.passes.get(projectId);
|
|
131
|
+
if (last !== undefined && now - last < 5 * 60 * 1000)
|
|
132
|
+
return;
|
|
133
|
+
this.passes.set(projectId, now);
|
|
134
|
+
const pass = this.read(projectId, repoRoot, deliveries).then(() => undefined, () => undefined);
|
|
135
|
+
this.running.add(pass);
|
|
136
|
+
void pass.finally(() => this.running.delete(pass));
|
|
137
|
+
}
|
|
138
|
+
async settle() {
|
|
139
|
+
while (this.running.size)
|
|
140
|
+
await Promise.all([...this.running]);
|
|
141
|
+
}
|
|
142
|
+
async read(projectId, root, given) {
|
|
143
|
+
if (!(await this.tokens.any()))
|
|
144
|
+
return { reported: 0 };
|
|
145
|
+
const repoRoot = typeof root === 'function' ? await root().catch(() => null) : root;
|
|
146
|
+
const deliveries = given ??
|
|
147
|
+
reviewDeliveries((await this.client.request(`${endpoints.projectDeliveries(projectId)}?state=review&limit=100`)).data);
|
|
148
|
+
const pass = { failed: new Set(), projects: new Map(), calls: 100 };
|
|
149
|
+
const queue = [...deliveries];
|
|
150
|
+
let reported = 0;
|
|
151
|
+
const worker = async () => {
|
|
152
|
+
for (let delivery = queue.shift(); delivery && pass.calls > 0; delivery = queue.shift()) {
|
|
153
|
+
const found = await this.follow(delivery, repoRoot, pass).catch(() => null);
|
|
154
|
+
if (found && (await this.report(projectId, delivery.taskId, found)))
|
|
155
|
+
reported++;
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
await Promise.all([worker(), worker(), worker(), worker()]);
|
|
159
|
+
return { reported };
|
|
160
|
+
}
|
|
161
|
+
async follow(delivery, repoRoot, pass) {
|
|
162
|
+
const known = delivery.reviewRequest;
|
|
163
|
+
if (known) {
|
|
164
|
+
if (known.provider !== 'gitlab' || !['draft', 'ready'].includes(known.state))
|
|
165
|
+
return null;
|
|
166
|
+
const link = await this.tokens.forLink(known.host, known.repository);
|
|
167
|
+
const token = link && !pass.failed.has(link.address) ? await this.tokens.token(link.address) : null;
|
|
168
|
+
if (!link || !token)
|
|
169
|
+
return null;
|
|
170
|
+
const request = await this.call(pass, link.address, () => this.gitlab.mergeRequest(link.address, token, link.project, known.number));
|
|
171
|
+
if (!request)
|
|
172
|
+
return null;
|
|
173
|
+
const state = reviewState(request);
|
|
174
|
+
return state !== known.state || known.source !== 'machine' ? { url: known.url, state } : null;
|
|
175
|
+
}
|
|
176
|
+
if (!repoRoot || !delivery.branch)
|
|
177
|
+
return null;
|
|
178
|
+
const pushUrl = await this.git.branchPushUrl(repoRoot, delivery.branch).catch(() => null);
|
|
179
|
+
const remote = pushUrl ? gitLabRemote(pushUrl) : null;
|
|
180
|
+
if (!remote || 'refusal' in remote)
|
|
181
|
+
return null;
|
|
182
|
+
const address = (await this.tokens.address(remote.host)) ?? remote.address;
|
|
183
|
+
const token = pass.failed.has(address) ? null : await this.tokens.token(address);
|
|
184
|
+
if (!token)
|
|
185
|
+
return null;
|
|
186
|
+
const path = projectPath(address, remote.path);
|
|
187
|
+
const key = `${address}\n${path}`;
|
|
188
|
+
if (!pass.projects.has(key))
|
|
189
|
+
pass.projects.set(key, await this.call(pass, address, () => this.gitlab.project(address, token, path)));
|
|
190
|
+
const project = pass.projects.get(key);
|
|
191
|
+
if (!project)
|
|
192
|
+
return null;
|
|
193
|
+
const requests = await this.call(pass, address, () => this.gitlab.mergeRequests(address, token, { id: project.id, path }, delivery.branch));
|
|
194
|
+
const rank = (request) => request.state === 'opened' || request.state === 'locked'
|
|
195
|
+
? 0
|
|
196
|
+
: request.state === 'merged'
|
|
197
|
+
? 1
|
|
198
|
+
: 2;
|
|
199
|
+
const chosen = (requests ?? [])
|
|
200
|
+
.filter((request) => rank(request) < 2)
|
|
201
|
+
.sort((left, right) => rank(left) - rank(right) ||
|
|
202
|
+
Number(right.sha === delivery.deliveredCommit) -
|
|
203
|
+
Number(left.sha === delivery.deliveredCommit))[0];
|
|
204
|
+
return chosen ? { url: chosen.web_url, state: reviewState(chosen) } : null;
|
|
205
|
+
}
|
|
206
|
+
async call(pass, address, request) {
|
|
207
|
+
if (pass.failed.has(address) || pass.calls <= 0)
|
|
208
|
+
return null;
|
|
209
|
+
pass.calls--;
|
|
210
|
+
try {
|
|
211
|
+
return await request();
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
if (!(error instanceof GitLabError))
|
|
215
|
+
throw error;
|
|
216
|
+
pass.failed.add(address);
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
async report(projectId, taskId, found) {
|
|
221
|
+
try {
|
|
222
|
+
await this.client.request(endpoints.taskReviewRequest(projectId, taskId), {
|
|
223
|
+
method: 'POST',
|
|
224
|
+
body: { url: found.url, state: found.state, source: 'machine' },
|
|
225
|
+
idempotencyKey: randomUUID(),
|
|
226
|
+
});
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
function opened(request, reused) {
|
|
235
|
+
return {
|
|
236
|
+
url: request.web_url,
|
|
237
|
+
state: reviewState(request),
|
|
238
|
+
sourceBranch: request.source_branch,
|
|
239
|
+
targetBranch: request.target_branch,
|
|
240
|
+
reused,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
export function defaultTitle(subject, workItemKey) {
|
|
244
|
+
const title = subject.trim().slice(0, 240) || 'Merge request';
|
|
245
|
+
return workItemKey && !title.toLowerCase().includes(workItemKey.toLowerCase())
|
|
246
|
+
? `${workItemKey}: ${title}`
|
|
247
|
+
: title;
|
|
248
|
+
}
|
|
249
|
+
function remoteRefusal(refusal, pushUrl) {
|
|
250
|
+
switch (refusal) {
|
|
251
|
+
case 'github':
|
|
252
|
+
return `The branch is pushed to GitHub (${pushUrl}). Engineering Memory opens merge requests only on GitLab so far: open the pull request with the tools you have and record it with task.review_request.`;
|
|
253
|
+
case 'ip':
|
|
254
|
+
return `The remote is reached by an IP address (${pushUrl}), and Engineering Memory records no link with an IP address in it. Open the merge request in GitLab and move the work item with work_item.update.`;
|
|
255
|
+
case 'local':
|
|
256
|
+
return `The remote is a folder on disk (${pushUrl}), not a GitLab server. ${manualWay}`;
|
|
257
|
+
default:
|
|
258
|
+
return `The remote address ${pushUrl} is not one Engineering Memory can read as a GitLab project. ${manualWay}`;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
function gitLabRefusal(error) {
|
|
262
|
+
const at = error.address;
|
|
263
|
+
const details = { address: at, failure: error.failure, status: error.status };
|
|
264
|
+
switch (error.failure) {
|
|
265
|
+
case 'token_rejected':
|
|
266
|
+
return new BridgeRecoveryError(`GitLab at ${at} no longer accepts the saved token (expired or revoked). Call gitlab.token so the user can save a new one. ${manualWay}`, 'gitlab.token', details);
|
|
267
|
+
case 'not_allowed':
|
|
268
|
+
return new BridgeRecoveryError(`GitLab at ${at} refused this with the saved token: it needs the api scope and a role that may open merge requests in the project (Developer or higher). Nothing was opened. ${manualWay}`, 'gitlab.token', details);
|
|
269
|
+
case 'unreachable':
|
|
270
|
+
return new BridgeRecoveryError(`This computer cannot reach ${at}. If GitLab is only reachable from the company network, the user connects (for example through the VPN) and you call again. Nothing was opened.`, 'task.open_review_request', details);
|
|
271
|
+
case 'untrusted_certificate':
|
|
272
|
+
return new BridgeRecoveryError(`This computer does not trust the certificate of ${at}. If the company uses its own certificate, the user sets NODE_EXTRA_CA_CERTS to its file and restarts the coding agent. Nothing was opened.`, 'task.open_review_request', details);
|
|
273
|
+
case 'unknown_outcome':
|
|
274
|
+
return new BridgeRecoveryError(`The answer from ${at} was lost after the merge request was sent, and it is not visible yet. Call again: an open merge request of the branch is reused, never opened twice.`, 'task.open_review_request', details);
|
|
275
|
+
case 'redirected':
|
|
276
|
+
return new BridgeRecoveryError(`${at} answered with a redirect, so the saved address is probably not GitLab's own. Call gitlab.token so the user can correct the address.`, 'gitlab.token', details);
|
|
277
|
+
default:
|
|
278
|
+
return new BridgeRecoveryError(`GitLab at ${at} answered ${error.failure.replace(/_/g, ' ')}${error.status ? ` (${error.status})` : ''}${error.detail ? `: ${error.detail}` : ''}. Nothing more was done. ${manualWay}`, 'task.open_review_request', details);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
//# sourceMappingURL=merge-request-sync.js.map
|
|
@@ -9,15 +9,17 @@ export class PrincipalStateGuard {
|
|
|
9
9
|
outbox;
|
|
10
10
|
activeContexts;
|
|
11
11
|
gate;
|
|
12
|
+
gitlabTokens;
|
|
12
13
|
ownerPath;
|
|
13
14
|
queue = Promise.resolve();
|
|
14
|
-
constructor(stateRoot, credentials, cache, outbox, activeContexts, gate) {
|
|
15
|
+
constructor(stateRoot, credentials, cache, outbox, activeContexts, gate, gitlabTokens) {
|
|
15
16
|
this.stateRoot = stateRoot;
|
|
16
17
|
this.credentials = credentials;
|
|
17
18
|
this.cache = cache;
|
|
18
19
|
this.outbox = outbox;
|
|
19
20
|
this.activeContexts = activeContexts;
|
|
20
21
|
this.gate = gate;
|
|
22
|
+
this.gitlabTokens = gitlabTokens;
|
|
21
23
|
this.ownerPath = join(stateRoot, 'principal-owner.json');
|
|
22
24
|
}
|
|
23
25
|
async ensure() {
|
|
@@ -55,6 +57,7 @@ export class PrincipalStateGuard {
|
|
|
55
57
|
await this.cache.clear();
|
|
56
58
|
await this.activeContexts.clear();
|
|
57
59
|
await this.gate.clear();
|
|
60
|
+
await this.gitlabTokens?.clear();
|
|
58
61
|
await removeFile(this.ownerPath, this.stateRoot);
|
|
59
62
|
}
|
|
60
63
|
async exclusive(action) {
|
|
@@ -588,6 +588,15 @@ export class WorktreePool {
|
|
|
588
588
|
await this.save(registry);
|
|
589
589
|
});
|
|
590
590
|
}
|
|
591
|
+
async reopen(projectId, repoRoot, generation) {
|
|
592
|
+
return this.transaction(async (registry) => {
|
|
593
|
+
const entry = this.owned(registry, projectId, repoRoot, generation);
|
|
594
|
+
entry.phase = 'working';
|
|
595
|
+
entry.pendingDelivery = false;
|
|
596
|
+
entry.lastActivityAt = this.now();
|
|
597
|
+
await this.save(registry);
|
|
598
|
+
});
|
|
599
|
+
}
|
|
591
600
|
async release(projectId, repoRoot, generation, deliveryOutcome) {
|
|
592
601
|
return this.transaction(async (registry) => {
|
|
593
602
|
const entry = await this.deliveryOwned(registry, projectId, repoRoot, generation);
|