@akira-tl/forgerelay 0.9.0 → 0.9.2

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.
@@ -1,7 +1,9 @@
1
- import { realpath, stat } from "node:fs/promises";
1
+ import { randomBytes } from "node:crypto";
2
+ import { mkdir, realpath, rm, stat } from "node:fs/promises";
2
3
  import { platform } from "node:os";
3
4
  import { basename, dirname, join, resolve } from "node:path";
4
5
  import { assertAllowedPath } from "../../mcp/filesystem/roots.js";
6
+ import { managedWorktreePath } from "./git-worktrees.js";
5
7
  import { git } from "./git.js";
6
8
  export async function inspectManagedWorktreeRecovery(session, config) {
7
9
  if (session.status !== "active" ||
@@ -72,6 +74,233 @@ export async function inspectManagedWorktreeRecovery(session, config) {
72
74
  backingBranch,
73
75
  };
74
76
  }
77
+ export async function prepareManagedWorktreeRepair(session, config) {
78
+ const recovery = await inspectManagedWorktreeRecovery(session, config);
79
+ if (!recovery) {
80
+ throw new Error(`Workspace ${session.id} is not an active managed-worktree Workspace.`);
81
+ }
82
+ if (recovery.classification !== "recoverable") {
83
+ return {
84
+ prepared: false,
85
+ recovery,
86
+ reason: "The Workspace is not in a provably recoverable managed-worktree state.",
87
+ };
88
+ }
89
+ if (!session.sourceRoot ||
90
+ !session.branch ||
91
+ !session.targetBranch ||
92
+ !session.baseRef ||
93
+ !session.baseSha ||
94
+ !session.branch.startsWith("forgerelay/")) {
95
+ return {
96
+ prepared: false,
97
+ recovery: manualRecovery(recovery),
98
+ reason: "Persisted managed-worktree ownership metadata is incomplete or cannot prove ForgeRelay branch ownership.",
99
+ };
100
+ }
101
+ const sourceRoot = assertAllowedPath(session.sourceRoot, config.allowedRoots);
102
+ const head = (await git(sourceRoot, ["rev-parse", `refs/heads/${session.branch}`])).stdout.trim();
103
+ const beforeRegistrations = await worktreeRegistrations(sourceRoot);
104
+ const persistedRegistrations = await matchingPathRegistrations(beforeRegistrations, session.root);
105
+ if (recovery.gitRegistration === "stale" &&
106
+ (persistedRegistrations.length !== 1 ||
107
+ persistedRegistrations[0]?.prunable !== true ||
108
+ persistedRegistrations[0]?.branch !== `refs/heads/${session.branch}` ||
109
+ (persistedRegistrations[0]?.head !== undefined && persistedRegistrations[0].head !== head))) {
110
+ return {
111
+ prepared: false,
112
+ recovery: manualRecovery(recovery),
113
+ reason: "The stale Git worktree registration does not prove ownership of the persisted ForgeRelay managed branch.",
114
+ };
115
+ }
116
+ const beforeConflicts = await conflictingBranchRegistrations(beforeRegistrations, session.branch, session.root);
117
+ if (beforeConflicts.length > 0) {
118
+ return {
119
+ prepared: false,
120
+ recovery: manualRecovery(recovery),
121
+ reason: `Managed branch ${session.branch} is already associated with another worktree candidate.`,
122
+ };
123
+ }
124
+ const root = await allocateManagedWorktreeRecoveryPath(sourceRoot, config);
125
+ try {
126
+ await git(sourceRoot, ["worktree", "add", "--force", root, session.branch]);
127
+ }
128
+ catch (error) {
129
+ await rm(root, { recursive: true, force: true });
130
+ const afterFailureConflicts = await conflictingBranchRegistrations(await worktreeRegistrations(sourceRoot), session.branch, session.root);
131
+ if (afterFailureConflicts.length > 0) {
132
+ return {
133
+ prepared: false,
134
+ recovery: manualRecovery(recovery),
135
+ reason: `Managed branch ${session.branch} acquired another worktree candidate while repair was starting.`,
136
+ };
137
+ }
138
+ throw new Error(`Git could not recreate managed-worktree backing from ${session.branch}: ${error instanceof Error ? error.message : String(error)}`);
139
+ }
140
+ const preparation = {
141
+ prepared: true,
142
+ previousRoot: session.root,
143
+ root,
144
+ sourceRoot,
145
+ branch: session.branch,
146
+ targetBranch: session.targetBranch,
147
+ baseRef: session.baseRef,
148
+ baseSha: session.baseSha,
149
+ head,
150
+ recovery,
151
+ };
152
+ try {
153
+ const afterRegistrations = await worktreeRegistrations(sourceRoot);
154
+ const afterConflicts = await conflictingBranchRegistrations(afterRegistrations, session.branch, session.root, root);
155
+ if (afterConflicts.length > 0) {
156
+ await rollbackManagedWorktreeRepair(preparation, config);
157
+ return {
158
+ prepared: false,
159
+ recovery: manualRecovery(recovery),
160
+ reason: `Managed branch ${session.branch} has ambiguous worktree ownership after repair preflight.`,
161
+ };
162
+ }
163
+ const rootKey = await registrationPathKey(root);
164
+ const repairedRegistration = await firstMatchingRegistration(afterRegistrations, `refs/heads/${session.branch}`, rootKey);
165
+ if (!repairedRegistration || repairedRegistration.prunable) {
166
+ throw new Error("Git did not register the newly created recovery backing as an active worktree.");
167
+ }
168
+ const actualBranch = (await git(root, ["symbolic-ref", "--quiet", "--short", "HEAD"])).stdout.trim();
169
+ if (actualBranch !== session.branch) {
170
+ throw new Error(`Recovery backing opened branch ${actualBranch} instead of ${session.branch}.`);
171
+ }
172
+ const repairedHead = (await git(root, ["rev-parse", "HEAD"])).stdout.trim();
173
+ const managedHead = (await git(sourceRoot, ["rev-parse", `refs/heads/${session.branch}`])).stdout.trim();
174
+ if (repairedHead !== managedHead) {
175
+ throw new Error("Recovery backing HEAD does not match the surviving managed branch.");
176
+ }
177
+ if ((await git(root, ["status", "--porcelain=v1"])).stdout.trim().length > 0) {
178
+ throw new Error("Recovery backing is not clean immediately after reconstruction.");
179
+ }
180
+ const candidateRecovery = await inspectManagedWorktreeRecovery({ ...session, root }, config);
181
+ if (!candidateRecovery || candidateRecovery.classification !== "healthy") {
182
+ await rollbackManagedWorktreeRepair({ ...preparation, head: repairedHead }, config);
183
+ return {
184
+ prepared: false,
185
+ recovery: candidateRecovery ? manualRecovery(candidateRecovery) : manualRecovery(recovery),
186
+ reason: "The reconstructed backing did not pass the managed-worktree recovery health check.",
187
+ };
188
+ }
189
+ return { ...preparation, head: repairedHead, recovery: candidateRecovery };
190
+ }
191
+ catch (error) {
192
+ try {
193
+ await rollbackManagedWorktreeRepair(preparation, config);
194
+ }
195
+ catch (rollbackError) {
196
+ throw new Error(`${error instanceof Error ? error.message : String(error)} Recovery rollback also failed; the temporary backing was preserved for inspection: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`);
197
+ }
198
+ throw error;
199
+ }
200
+ }
201
+ export async function rollbackManagedWorktreeRepair(preparation, config) {
202
+ const sourceRoot = assertAllowedPath(preparation.sourceRoot, config.allowedRoots);
203
+ const root = assertAllowedPath(preparation.root, [config.worktreeRoot]);
204
+ const actualBranch = (await git(root, ["symbolic-ref", "--quiet", "--short", "HEAD"])).stdout.trim();
205
+ if (actualBranch !== preparation.branch) {
206
+ throw new Error(`Cannot roll back recovery backing because it is on ${actualBranch} instead of ${preparation.branch}.`);
207
+ }
208
+ if ((await git(root, ["status", "--porcelain=v1"])).stdout.trim().length > 0) {
209
+ throw new Error("Cannot roll back recovery backing because it acquired working-tree changes.");
210
+ }
211
+ const head = (await git(root, ["rev-parse", "HEAD"])).stdout.trim();
212
+ if (head !== preparation.head) {
213
+ throw new Error("Cannot roll back recovery backing because its managed branch advanced after reconstruction.");
214
+ }
215
+ await git(sourceRoot, ["worktree", "remove", root]);
216
+ }
217
+ export async function cleanupManagedWorktreeState(session, config, options = {}) {
218
+ if (session.mode !== "worktree" || !session.managed || (session.status !== "active" && session.status !== "closed")) {
219
+ throw new Error(`Workspace ${session.id} is not an active or closed ForgeRelay-managed worktree Workspace.`);
220
+ }
221
+ let recoveryBefore;
222
+ try {
223
+ recoveryBefore = await inspectManagedWorktreeRecovery(session, config);
224
+ }
225
+ catch {
226
+ recoveryBefore = undefined;
227
+ }
228
+ if (!session.sourceRoot || !session.branch || !session.targetBranch || !session.branch.startsWith("forgerelay/")) {
229
+ return cleanupResult(session, recoveryBefore, false, false, "manual-intervention", "Persisted managed-worktree ownership metadata is incomplete or cannot prove ForgeRelay branch ownership.");
230
+ }
231
+ let backing;
232
+ try {
233
+ backing = await directoryState(session.root, [config.worktreeRoot]);
234
+ }
235
+ catch {
236
+ return cleanupResult(session, recoveryBefore, false, false, "manual-intervention", "The persisted managed-worktree backing path is outside the trusted ForgeRelay worktree root or otherwise unavailable.");
237
+ }
238
+ if (backing === "present") {
239
+ return cleanupResult(session, recoveryBefore, false, false, "nothing-to-clean", "The managed worktree backing is still present; cleanup will not mutate an active or residual physical backing.");
240
+ }
241
+ const source = await sourceState(session.sourceRoot, config.allowedRoots);
242
+ if (source !== "available") {
243
+ return cleanupResult(session, recoveryBefore, false, false, "manual-intervention", "The persisted source repository is unavailable, so cleanup cannot prove Git ownership safely.");
244
+ }
245
+ const sourceRoot = assertAllowedPath(session.sourceRoot, config.allowedRoots);
246
+ const branchRef = `refs/heads/${session.branch}`;
247
+ let registrations = await worktreeRegistrations(sourceRoot);
248
+ const persistedRegistrations = await matchingPathRegistrations(registrations, session.root);
249
+ if (persistedRegistrations.length > 1) {
250
+ return cleanupResult(session, recoveryBefore, false, false, "manual-intervention", "Multiple Git worktree registrations match the persisted backing; cleanup requires manual intervention.");
251
+ }
252
+ let registrationRemoved = false;
253
+ const persistedRegistration = persistedRegistrations[0];
254
+ if (persistedRegistration) {
255
+ if (!persistedRegistration.prunable) {
256
+ return cleanupResult(session, recoveryBefore, false, false, "manual-intervention", "The persisted backing still has an active Git worktree registration; cleanup will not remove it.");
257
+ }
258
+ if (persistedRegistration.branch !== branchRef) {
259
+ return cleanupResult(session, recoveryBefore, false, false, "manual-intervention", "The stale Git worktree registration no longer proves ownership of the persisted ForgeRelay managed branch.");
260
+ }
261
+ try {
262
+ await git(sourceRoot, ["worktree", "remove", persistedRegistration.path]);
263
+ registrationRemoved = true;
264
+ }
265
+ catch {
266
+ return cleanupResult(session, recoveryBefore, false, false, "manual-intervention", "Git did not confirm safe removal of the stale worktree registration; no cleanup mutation was forced.");
267
+ }
268
+ registrations = await worktreeRegistrations(sourceRoot);
269
+ }
270
+ if (session.status === "active") {
271
+ const recovery = await inspectManagedWorktreeRecovery(session, config);
272
+ return cleanupResult(session, recovery, registrationRemoved, false, registrationRemoved ? "cleaned" : "nothing-to-clean", registrationRemoved
273
+ ? "Stale Git worktree registration removed; the managed branch is preserved because the Workspace remains active."
274
+ : "No stale registration is safe to remove; the managed branch is preserved because the Workspace remains active.");
275
+ }
276
+ const managedBranch = await branchState(sourceRoot, session.branch);
277
+ if (managedBranch === "missing") {
278
+ return cleanupResult(session, undefined, registrationRemoved, false, registrationRemoved ? "cleaned" : "nothing-to-clean", registrationRemoved ? undefined : "No stale registration or managed branch remains to clean.");
279
+ }
280
+ if (registrations.some((registration) => registration.branch === branchRef)) {
281
+ return cleanupResult(session, undefined, registrationRemoved, false, "manual-intervention", "The managed branch is still associated with a Git worktree registration and was preserved.");
282
+ }
283
+ if (options.managedBranchOwnedByOtherWorkspace) {
284
+ return cleanupResult(session, undefined, registrationRemoved, false, "manual-intervention", "Another persistent Workspace still references the managed branch, so it was preserved.");
285
+ }
286
+ if (await branchState(sourceRoot, session.targetBranch) !== "present") {
287
+ return cleanupResult(session, undefined, registrationRemoved, false, "manual-intervention", "The intended target branch is missing, so the managed branch was preserved.");
288
+ }
289
+ const sourceBranch = await currentBranchName(sourceRoot);
290
+ if (sourceBranch !== session.targetBranch) {
291
+ return cleanupResult(session, undefined, registrationRemoved, false, "manual-intervention", "The source checkout is not on the persisted target branch, so the managed branch was preserved.");
292
+ }
293
+ if (!await branchIsAncestorOfTarget(sourceRoot, session.branch, session.targetBranch)) {
294
+ return cleanupResult(session, undefined, registrationRemoved, false, "manual-intervention", "The managed branch contains commits not integrated into the intended target branch and was preserved.");
295
+ }
296
+ try {
297
+ await git(sourceRoot, ["branch", "-d", session.branch]);
298
+ }
299
+ catch {
300
+ return cleanupResult(session, undefined, registrationRemoved, false, "manual-intervention", "Git did not confirm safe managed-branch deletion at cleanup time; the branch was preserved.");
301
+ }
302
+ return cleanupResult(session, undefined, registrationRemoved, true, "cleaned");
303
+ }
75
304
  async function directoryState(path, allowedRoots) {
76
305
  const allowedPath = assertAllowedPath(path, allowedRoots);
77
306
  try {
@@ -156,13 +385,113 @@ function parseWorktreeRegistrations(output) {
156
385
  const path = lines.find((line) => line.startsWith("worktree "))?.slice("worktree ".length);
157
386
  if (!path)
158
387
  return undefined;
388
+ const branch = lines.find((line) => line.startsWith("branch "))?.slice("branch ".length);
389
+ const head = lines.find((line) => line.startsWith("HEAD "))?.slice("HEAD ".length);
159
390
  return {
160
391
  path,
161
392
  prunable: lines.some((line) => line.startsWith("prunable ")),
393
+ ...(branch ? { branch } : {}),
394
+ ...(head ? { head } : {}),
162
395
  };
163
396
  })
164
397
  .filter((entry) => entry !== undefined);
165
398
  }
399
+ async function worktreeRegistrations(sourceRoot) {
400
+ return parseWorktreeRegistrations((await git(sourceRoot, ["worktree", "list", "--porcelain"])).stdout);
401
+ }
402
+ async function conflictingBranchRegistrations(registrations, branch, previousRoot, repairedRoot) {
403
+ const branchRef = `refs/heads/${branch}`;
404
+ const previousKey = await registrationPathKey(previousRoot);
405
+ const repairedKey = repairedRoot ? await registrationPathKey(repairedRoot) : undefined;
406
+ const conflicts = [];
407
+ for (const registration of registrations) {
408
+ if (registration.branch !== branchRef)
409
+ continue;
410
+ const key = await registrationPathKey(registration.path);
411
+ if (key === previousKey && registration.prunable)
412
+ continue;
413
+ if (repairedKey !== undefined && key === repairedKey && !registration.prunable)
414
+ continue;
415
+ conflicts.push(registration);
416
+ }
417
+ return conflicts;
418
+ }
419
+ async function matchingPathRegistrations(registrations, path) {
420
+ const expectedKey = await registrationPathKey(path);
421
+ const matches = [];
422
+ for (const registration of registrations) {
423
+ if (await registrationPathKey(registration.path) === expectedKey)
424
+ matches.push(registration);
425
+ }
426
+ return matches;
427
+ }
428
+ async function firstMatchingRegistration(registrations, branchRef, pathKeyValue) {
429
+ for (const registration of registrations) {
430
+ if (registration.branch !== branchRef)
431
+ continue;
432
+ if (await registrationPathKey(registration.path) === pathKeyValue)
433
+ return registration;
434
+ }
435
+ return undefined;
436
+ }
437
+ async function allocateManagedWorktreeRecoveryPath(sourceRoot, config) {
438
+ await mkdir(config.worktreeRoot, { recursive: true });
439
+ for (let attempt = 0; attempt < 16; attempt += 1) {
440
+ const path = managedWorktreePath({
441
+ worktreeRoot: config.worktreeRoot,
442
+ repoRoot: sourceRoot,
443
+ worktreeId: randomBytes(4).toString("hex"),
444
+ });
445
+ assertAllowedPath(path, [config.worktreeRoot]);
446
+ try {
447
+ await stat(path);
448
+ }
449
+ catch (error) {
450
+ if (isMissingPath(error))
451
+ return path;
452
+ throw error;
453
+ }
454
+ }
455
+ throw new Error("Could not allocate a unique managed-worktree recovery path.");
456
+ }
457
+ function manualRecovery(recovery) {
458
+ return recovery.classification === "manual-intervention"
459
+ ? recovery
460
+ : { ...recovery, classification: "manual-intervention" };
461
+ }
462
+ function cleanupResult(session, recovery, registrationRemoved, managedBranchRemoved, classification, reason) {
463
+ return {
464
+ classification,
465
+ cleaned: registrationRemoved || managedBranchRemoved,
466
+ registrationRemoved,
467
+ managedBranchRemoved,
468
+ status: session.status,
469
+ ...(recovery ? { recovery } : {}),
470
+ ...(reason ? { reason } : {}),
471
+ };
472
+ }
473
+ async function currentBranchName(cwd) {
474
+ try {
475
+ return (await git(cwd, ["symbolic-ref", "--quiet", "--short", "HEAD"])).stdout.trim() || undefined;
476
+ }
477
+ catch {
478
+ return undefined;
479
+ }
480
+ }
481
+ async function branchIsAncestorOfTarget(sourceRoot, branch, targetBranch) {
482
+ try {
483
+ await git(sourceRoot, [
484
+ "merge-base",
485
+ "--is-ancestor",
486
+ `refs/heads/${branch}`,
487
+ `refs/heads/${targetBranch}`,
488
+ ]);
489
+ return true;
490
+ }
491
+ catch {
492
+ return false;
493
+ }
494
+ }
166
495
  async function registrationPathKey(path) {
167
496
  const resolved = resolve(path);
168
497
  try {
@@ -2,6 +2,7 @@ import { stat } from "node:fs/promises";
2
2
  import { resolve } from "node:path";
3
3
  import { loadCapabilityGuides } from "../mcp/server/core/capabilities.js";
4
4
  import { createManagedWorktree, discardFreshManagedWorktree, } from "./git/git-worktrees.js";
5
+ import { cleanupManagedWorktreeState, inspectManagedWorktreeRecovery, prepareManagedWorktreeRepair, rollbackManagedWorktreeRepair, } from "./git/worktree-recovery.js";
5
6
  import { AccessDeniedError, assertAllowedPath } from "../mcp/filesystem/roots.js";
6
7
  import { loadSubagentProfiles } from "../subagents/profiles.js";
7
8
  import { BOOTSTRAP_CONTEXT_COMPONENTS, bootstrapContextFingerprints, resolveBootstrapContextComponents, } from "./bootstrap.js";
@@ -371,6 +372,98 @@ export class WorkspaceSessionService {
371
372
  throw error;
372
373
  }
373
374
  }
375
+ async runManagedWorktreeRecovery(workspaceId, operation) {
376
+ const store = this.store;
377
+ if (!store) {
378
+ throw new Error(`Workspace ${workspaceId} cannot use managed-worktree recovery without persistent Workspace state.`);
379
+ }
380
+ const session = store.getSession(workspaceId);
381
+ if (!session)
382
+ throw new Error(`Unknown workspaceId: ${workspaceId}. Call open_workspace first.`);
383
+ if (operation === "cleanup") {
384
+ let managedBranchOwnedByOtherWorkspace = false;
385
+ if (session.sourceRoot && session.branch) {
386
+ const sourceKey = canonicalPersistedWorkspacePath(session.sourceRoot);
387
+ for (const candidate of store.listSessions({ mode: "worktree" })) {
388
+ if (candidate.id === session.id ||
389
+ !candidate.managed ||
390
+ candidate.branch !== session.branch ||
391
+ !candidate.sourceRoot)
392
+ continue;
393
+ if (canonicalPersistedWorkspacePath(candidate.sourceRoot) === sourceKey) {
394
+ managedBranchOwnedByOtherWorkspace = true;
395
+ break;
396
+ }
397
+ }
398
+ }
399
+ return {
400
+ workspaceId: session.id,
401
+ ...await cleanupManagedWorktreeState(session, this.config, { managedBranchOwnedByOtherWorkspace }),
402
+ };
403
+ }
404
+ const recovery = await inspectManagedWorktreeRecovery(session, this.config);
405
+ if (!recovery) {
406
+ throw new Error(`Workspace ${session.id} is not an active managed-worktree Workspace.`);
407
+ }
408
+ if (operation === "status") {
409
+ return {
410
+ workspaceId: session.id,
411
+ repaired: false,
412
+ recovery,
413
+ };
414
+ }
415
+ const prepared = await prepareManagedWorktreeRepair(session, this.config);
416
+ if (!prepared.prepared) {
417
+ return {
418
+ workspaceId: session.id,
419
+ repaired: false,
420
+ recovery: prepared.recovery,
421
+ reason: prepared.reason,
422
+ };
423
+ }
424
+ const cachedWorkspace = this.workspaces.get(session.id);
425
+ try {
426
+ this.context.forgetWorkspaceResources(session.id);
427
+ this.workspaces.delete(session.id);
428
+ const candidateSession = {
429
+ ...session,
430
+ root: prepared.root,
431
+ };
432
+ const candidateWorkspace = this.workspaceFromSession(candidateSession, false);
433
+ await this.reusedWorkspaceContext(candidateWorkspace);
434
+ store.replaceWorktreeBacking({
435
+ id: session.id,
436
+ root: prepared.root,
437
+ sourceRoot: prepared.sourceRoot,
438
+ baseRef: prepared.baseRef,
439
+ baseSha: prepared.baseSha,
440
+ branch: prepared.branch,
441
+ targetBranch: prepared.targetBranch,
442
+ });
443
+ return {
444
+ workspaceId: session.id,
445
+ repaired: true,
446
+ previousRoot: prepared.previousRoot,
447
+ root: prepared.root,
448
+ branch: prepared.branch,
449
+ targetBranch: prepared.targetBranch,
450
+ recovery: prepared.recovery,
451
+ };
452
+ }
453
+ catch (error) {
454
+ this.context.forgetWorkspaceResources(session.id);
455
+ this.workspaces.delete(session.id);
456
+ if (cachedWorkspace)
457
+ this.workspaces.set(session.id, cachedWorkspace);
458
+ try {
459
+ await rollbackManagedWorktreeRepair(prepared, this.config);
460
+ }
461
+ catch (rollbackError) {
462
+ throw new Error(`${error instanceof Error ? error.message : String(error)} Recovery rollback also failed; the temporary backing was preserved for inspection: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`);
463
+ }
464
+ throw error;
465
+ }
466
+ }
374
467
  getWorkspaceSession(workspaceId) {
375
468
  const session = this.store?.getSession(workspaceId);
376
469
  if (session)