engineering-memory 1.11.3 → 1.11.5
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/dispatcher/sections.mjs +2 -0
- package/package.json +1 -1
- package/runtime/dist/src/config.js +2 -0
- package/runtime/dist/src/git/git-inspector.js +41 -2
- package/runtime/dist/src/git/verification-gate.js +40 -1
- package/runtime/dist/src/index.js +15 -1
- package/runtime/dist/src/mcp/onboarding-tools.js +39 -1
- package/runtime/dist/src/mcp/server.js +2 -0
- package/runtime/dist/src/mcp/tool-definitions.js +15 -12
- package/runtime/dist/src/mcp/worktree-tools.js +392 -0
- package/runtime/dist/src/project/project-intake.js +9 -2
- package/runtime/dist/src/runtime/api-client.js +4 -0
- package/runtime/dist/src/runtime/branch-preferences.js +53 -0
- package/runtime/dist/src/runtime/bridge-service.js +407 -17
- package/runtime/dist/src/runtime/task-branch-store.js +1 -1
- package/runtime/dist/src/runtime/worktree-policy.js +37 -0
- package/runtime/dist/src/runtime/worktree-pool.js +847 -0
- package/runtime/dist/src/utilities/local-mutex.js +37 -0
- package/skill/SKILL.md +1 -1
- package/skill/references/lifecycle.md +14 -6
- package/skill/references/project-onboarding.md +4 -0
- package/skill/references/questionnaires.md +11 -1
|
@@ -0,0 +1,847 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, readdir } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
import * as z from 'zod/v4';
|
|
6
|
+
import { GitInspector } from '../git/git-inspector.js';
|
|
7
|
+
import { atomicWrite, assertManagedPath, canonicalPath, ensureManagedDirectory, pathExists, readJson, removeFile, safeSegment, } from '../utilities/files.js';
|
|
8
|
+
import { sha256 } from '../utilities/hash.js';
|
|
9
|
+
import { localMutex } from '../utilities/local-mutex.js';
|
|
10
|
+
import { NativeCommandRunner } from '../utilities/process.js';
|
|
11
|
+
import { BridgeRecoveryError } from './recovery-error.js';
|
|
12
|
+
const allocationSchema = z.object({
|
|
13
|
+
projectId: z.string().min(1),
|
|
14
|
+
repoFingerprint: z.string().regex(/^[a-f0-9]{64}$/),
|
|
15
|
+
commonDir: z.string().min(1),
|
|
16
|
+
repoRoot: z.string().min(1),
|
|
17
|
+
slot: z.number().int().min(1).nullable(),
|
|
18
|
+
managed: z.boolean(),
|
|
19
|
+
externalTaskId: z.string().min(1),
|
|
20
|
+
taskId: z.string().optional(),
|
|
21
|
+
branch: z.string().nullable(),
|
|
22
|
+
baseCommit: z
|
|
23
|
+
.string()
|
|
24
|
+
.regex(/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/)
|
|
25
|
+
.nullable(),
|
|
26
|
+
owner: z
|
|
27
|
+
.object({
|
|
28
|
+
id: z.string().uuid(),
|
|
29
|
+
pid: z.number().int().positive(),
|
|
30
|
+
generation: z.string().uuid(),
|
|
31
|
+
startedAt: z.string().optional(),
|
|
32
|
+
})
|
|
33
|
+
.nullable(),
|
|
34
|
+
generation: z.string().uuid(),
|
|
35
|
+
lastActivityAt: z.number().finite(),
|
|
36
|
+
phase: z.enum(['creating', 'working', 'paused', 'delivery', 'released', 'quarantined']),
|
|
37
|
+
pendingDelivery: z.boolean(),
|
|
38
|
+
reason: z.string().optional(),
|
|
39
|
+
});
|
|
40
|
+
const registrySchema = z.object({
|
|
41
|
+
schemaVersion: z.literal(1),
|
|
42
|
+
projects: z.record(z.string(), z.object({ folder: z.string().regex(/^[a-zA-Z0-9._-]+$/), entries: z.array(allocationSchema) })),
|
|
43
|
+
});
|
|
44
|
+
export class WorktreePool {
|
|
45
|
+
stateRoot;
|
|
46
|
+
ownerId;
|
|
47
|
+
runner;
|
|
48
|
+
git;
|
|
49
|
+
now;
|
|
50
|
+
alive;
|
|
51
|
+
registryPath;
|
|
52
|
+
startTimes = new Map();
|
|
53
|
+
ownStart;
|
|
54
|
+
documents;
|
|
55
|
+
before = new Map();
|
|
56
|
+
pendingWork;
|
|
57
|
+
constructor(stateRoot, options = {}) {
|
|
58
|
+
this.stateRoot = stateRoot;
|
|
59
|
+
this.ownerId = options.ownerId ?? randomUUID();
|
|
60
|
+
this.runner = options.runner ?? new NativeCommandRunner();
|
|
61
|
+
this.git = new GitInspector(this.runner);
|
|
62
|
+
this.now = options.now ?? Date.now;
|
|
63
|
+
this.alive = options.processAlive ?? processAlive;
|
|
64
|
+
this.documents = options.documentsRoot;
|
|
65
|
+
this.pendingWork = options.pendingWork ?? (async () => false);
|
|
66
|
+
this.registryPath = join(stateRoot, 'worktree-pool.json');
|
|
67
|
+
}
|
|
68
|
+
async allocate(input, policy) {
|
|
69
|
+
if (input.branch) {
|
|
70
|
+
try {
|
|
71
|
+
await this.git.validateBranch(input.repoRoot, input.branch);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
throw new BridgeRecoveryError('Choose a valid literal Git branch name of at most 255 characters. Retry task.branch with the corrected name; no allocation was recorded.', 'task.branch');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const commonDir = await this.commonDirectory(input.repoRoot);
|
|
78
|
+
return this.transaction(async (registry) => {
|
|
79
|
+
if (!registry.projects[input.projectId]) {
|
|
80
|
+
const managedRoot = key(join(await this.documentsDirectory(), 'engineeringmemory'));
|
|
81
|
+
const orphan = (await this.gitPaths(input.repoRoot)).find((p) => key(p.path).startsWith(managedRoot + (process.platform === 'win32' ? '\\' : '/')));
|
|
82
|
+
if (orphan)
|
|
83
|
+
throw refuse('Existing managed Git worktrees have no project index. Run worktree.reconcile before allocating more directories.');
|
|
84
|
+
}
|
|
85
|
+
const project = await this.project(registry, input.projectId, input.projectName);
|
|
86
|
+
let entry = project.entries.find((e) => e.externalTaskId === input.externalTaskId && e.phase !== 'released');
|
|
87
|
+
if (entry) {
|
|
88
|
+
this.requireSameClone(entry, commonDir);
|
|
89
|
+
if (entry.branch !== input.branch)
|
|
90
|
+
throw refuse('This task already owns a different branch. Resume its recorded worktree.');
|
|
91
|
+
if (entry.phase === 'quarantined' && input.resume) {
|
|
92
|
+
if (entry.owner && (await this.ownerAlive(entry.owner)))
|
|
93
|
+
throw refuse('Stop or pause the original owner before resuming a recovered registry entry.');
|
|
94
|
+
await this.assertIdentity(entry);
|
|
95
|
+
entry.phase = 'paused';
|
|
96
|
+
entry.pendingDelivery = true;
|
|
97
|
+
delete entry.reason;
|
|
98
|
+
}
|
|
99
|
+
await this.requireTakeover(entry);
|
|
100
|
+
await this.repairCreating(entry);
|
|
101
|
+
if (!(await pathExists(entry.repoRoot))) {
|
|
102
|
+
if (!entry.managed || (!input.resume && entry.phase !== 'creating'))
|
|
103
|
+
throw refuse('The task worktree is missing. Resume with its recorded branch, or reconcile the moved Git worktree.');
|
|
104
|
+
const paths = await this.gitPaths(input.repoRoot);
|
|
105
|
+
if (paths.some((p) => p.branch === entry.branch && key(p.path) !== key(entry.repoRoot)))
|
|
106
|
+
throw refuse('The recorded branch is checked out at another location. Reconcile the moved worktree before resuming.');
|
|
107
|
+
if (paths.some((p) => key(p.path) === key(entry.repoRoot))) {
|
|
108
|
+
await assertManagedPath(await this.documentsDirectory(), entry.repoRoot, true);
|
|
109
|
+
if (await pathExists(entry.repoRoot))
|
|
110
|
+
throw refuse('The missing worktree reappeared; reconcile before recovery.');
|
|
111
|
+
await this.command(input.repoRoot, ['worktree', 'remove', entry.repoRoot]);
|
|
112
|
+
}
|
|
113
|
+
await this.git.createWorktree(input.repoRoot, entry.branch, entry.repoRoot, entry.baseCommit ?? undefined);
|
|
114
|
+
}
|
|
115
|
+
await this.assertIdentity(entry);
|
|
116
|
+
return await this.claim(registry, entry);
|
|
117
|
+
}
|
|
118
|
+
if (input.keepCurrent) {
|
|
119
|
+
const current = await this.git.sourceIdentity(input.repoRoot);
|
|
120
|
+
if ((current?.sourceCommit ?? null) !== input.baseCommit)
|
|
121
|
+
throw refuse('HEAD changed after the current-branch choice. Review a fresh task.branch decision.');
|
|
122
|
+
const root = await this.git.findRoot(input.repoRoot);
|
|
123
|
+
entry = project.entries.find((e) => key(e.repoRoot) === key(root));
|
|
124
|
+
if (entry && entry.phase !== 'released')
|
|
125
|
+
throw refuse('This checkout is reserved for another task. Choose a new branch in an isolated worktree.');
|
|
126
|
+
const reservation = await this.git.branchStore(root).read();
|
|
127
|
+
if (reservation && reservation.decision.externalTaskId !== input.externalTaskId)
|
|
128
|
+
throw refuse('A legacy task reservation owns this checkout. Resume it or choose an isolated worktree.');
|
|
129
|
+
entry = await this.entry(input, commonDir, root, null, false);
|
|
130
|
+
project.entries = project.entries.filter((e) => key(e.repoRoot) !== key(root));
|
|
131
|
+
project.entries.push(entry);
|
|
132
|
+
await this.save(registry);
|
|
133
|
+
await this.reserve(entry);
|
|
134
|
+
entry.phase = 'working';
|
|
135
|
+
await this.save(registry);
|
|
136
|
+
return structuredClone(entry);
|
|
137
|
+
}
|
|
138
|
+
if (!input.branch)
|
|
139
|
+
throw refuse('A managed worktree requires the chosen new task branch.');
|
|
140
|
+
if (!input.baseCommit)
|
|
141
|
+
throw new BridgeRecoveryError('This repository has no committed base yet. For its initial scaffold, choose keepCurrent through task.branch in the unowned prepared repository; subsequent new branches use isolated worktrees.', 'task.branch');
|
|
142
|
+
if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(input.baseCommit) ||
|
|
143
|
+
(await this.git.sourceIdentity(input.repoRoot, input.baseCommit))?.sourceCommit !==
|
|
144
|
+
input.baseCommit)
|
|
145
|
+
throw new BridgeRecoveryError('The chosen base commit is unavailable in this clone. Make a fresh task.branch source choice; no allocation was recorded.', 'task.branch');
|
|
146
|
+
if (!input.resume && (await this.git.branchExists(input.repoRoot, input.branch)))
|
|
147
|
+
throw refuse('That branch already exists. Resume its recorded task or choose a new branch name; the selected base was not substituted.');
|
|
148
|
+
const checkout = (await this.gitPaths(input.repoRoot)).find((p) => p.branch === input.branch);
|
|
149
|
+
if (checkout)
|
|
150
|
+
throw refuse('This branch already has a checkout. Resume its task instead of creating or resetting the branch.');
|
|
151
|
+
let reusable;
|
|
152
|
+
for (const candidate of project.entries
|
|
153
|
+
.filter((e) => e.managed && e.commonDir === commonDir)
|
|
154
|
+
.sort((a, b) => a.slot - b.slot)) {
|
|
155
|
+
if (candidate.pendingDelivery ||
|
|
156
|
+
['creating', 'quarantined'].includes(candidate.phase) ||
|
|
157
|
+
(candidate.owner && (await this.ownerAlive(candidate.owner))))
|
|
158
|
+
continue;
|
|
159
|
+
const view = await this.view(candidate, policy);
|
|
160
|
+
if (view.activity === 'available') {
|
|
161
|
+
reusable = candidate;
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
entry = reusable;
|
|
166
|
+
if (entry) {
|
|
167
|
+
const old = structuredClone(entry);
|
|
168
|
+
await this.releaseReservation(old);
|
|
169
|
+
const next = await this.entry(input, commonDir, old.repoRoot, old.slot, true);
|
|
170
|
+
project.entries[project.entries.indexOf(entry)] = next;
|
|
171
|
+
entry = next;
|
|
172
|
+
await this.save(registry);
|
|
173
|
+
await this.reserve(entry);
|
|
174
|
+
await this.checkout(entry);
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
if (project.entries.filter((e) => e.managed).length >= policy.maxWorktrees)
|
|
178
|
+
throw refuse('The managed worktree limit is full. worktree.list explains protected directories; finish delivery or safely release a clean task. No files were deleted.');
|
|
179
|
+
let slot = 1;
|
|
180
|
+
const root = join(await this.documentsDirectory(), 'engineeringmemory', project.folder, 'worktrees');
|
|
181
|
+
await ensureManagedDirectory(await this.documentsDirectory(), root);
|
|
182
|
+
while (project.entries.some((e) => e.slot === slot) ||
|
|
183
|
+
(await pathExists(join(root, project.folder + '-worktree' + slot))))
|
|
184
|
+
slot++;
|
|
185
|
+
const path = join(root, project.folder + '-worktree' + slot);
|
|
186
|
+
await assertManagedPath(root, path, true);
|
|
187
|
+
entry = await this.entry(input, commonDir, path, slot, true);
|
|
188
|
+
project.entries.push(entry);
|
|
189
|
+
await this.save(registry);
|
|
190
|
+
await this.git.createWorktree(input.repoRoot, input.branch, path, input.baseCommit);
|
|
191
|
+
}
|
|
192
|
+
await this.reserve(entry);
|
|
193
|
+
entry.phase = 'working';
|
|
194
|
+
await this.save(registry);
|
|
195
|
+
return structuredClone(entry);
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
async list(projectId, policy) {
|
|
199
|
+
return this.transaction(async (registry) => {
|
|
200
|
+
const result = [];
|
|
201
|
+
for (const entry of registry.projects[projectId]?.entries ?? [])
|
|
202
|
+
result.push(await this.view(entry, policy));
|
|
203
|
+
return result;
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
async forTask(taskId) {
|
|
207
|
+
return this.transaction(async (registry) => structuredClone(Object.values(registry.projects)
|
|
208
|
+
.flatMap((p) => p.entries)
|
|
209
|
+
.find((e) => e.taskId === taskId && e.phase !== 'released') ?? null));
|
|
210
|
+
}
|
|
211
|
+
async forPath(projectId, repoRoot) {
|
|
212
|
+
return this.transaction(async (registry) => structuredClone(registry.projects[projectId]?.entries.find((e) => key(e.repoRoot) === key(repoRoot)) ??
|
|
213
|
+
null));
|
|
214
|
+
}
|
|
215
|
+
async validateCommit(projectId, taskId, repoRoot, generation) {
|
|
216
|
+
return this.transaction(async (registry) => {
|
|
217
|
+
const entry = registry.projects[projectId]?.entries.find((e) => key(e.repoRoot) === key(repoRoot));
|
|
218
|
+
if (!entry && !generation)
|
|
219
|
+
return;
|
|
220
|
+
if (!entry ||
|
|
221
|
+
entry.taskId !== taskId ||
|
|
222
|
+
entry.generation !== generation ||
|
|
223
|
+
entry.phase !== 'delivery')
|
|
224
|
+
throw refuse('The verification belongs to a previous or paused worktree owner. Resume and verify this task before committing.');
|
|
225
|
+
await this.assertIdentity(entry);
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
async find(projectId, externalTaskId, repoRoot) {
|
|
229
|
+
const commonDir = await this.commonDirectory(repoRoot);
|
|
230
|
+
return this.transaction(async (registry) => {
|
|
231
|
+
const entry = registry.projects[projectId]?.entries.find((e) => e.externalTaskId === externalTaskId && e.phase !== 'released');
|
|
232
|
+
if (entry)
|
|
233
|
+
this.requireSameClone(entry, commonDir);
|
|
234
|
+
return structuredClone(entry ?? null);
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
async assertOwnership(projectId, repoRoot, generation, touch = true) {
|
|
238
|
+
return this.transaction(async (registry) => {
|
|
239
|
+
const entry = this.owned(registry, projectId, repoRoot, generation);
|
|
240
|
+
await this.assertIdentity(entry);
|
|
241
|
+
if (touch) {
|
|
242
|
+
entry.lastActivityAt = this.now();
|
|
243
|
+
await this.save(registry);
|
|
244
|
+
}
|
|
245
|
+
return structuredClone(entry);
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
async bind(projectId, repoRoot, generation, taskId) {
|
|
249
|
+
return this.transaction(async (registry) => {
|
|
250
|
+
const entry = this.owned(registry, projectId, repoRoot, generation);
|
|
251
|
+
if (entry.taskId && entry.taskId !== taskId)
|
|
252
|
+
throw refuse('This worktree is already bound to a different task.');
|
|
253
|
+
entry.taskId = taskId;
|
|
254
|
+
await this.git.branchStore(repoRoot).bind(projectId, entry.externalTaskId, taskId);
|
|
255
|
+
entry.phase = 'working';
|
|
256
|
+
entry.lastActivityAt = this.now();
|
|
257
|
+
await this.save(registry);
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
async pause(projectId, repoRoot, generation) {
|
|
261
|
+
return this.transaction(async (registry) => {
|
|
262
|
+
const entry = this.owned(registry, projectId, repoRoot, generation);
|
|
263
|
+
entry.phase = 'paused';
|
|
264
|
+
entry.owner = null;
|
|
265
|
+
entry.generation = randomUUID();
|
|
266
|
+
await this.save(registry);
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
async delivery(projectId, repoRoot, generation) {
|
|
270
|
+
return this.transaction(async (registry) => {
|
|
271
|
+
const entry = this.owned(registry, projectId, repoRoot, generation);
|
|
272
|
+
entry.phase = 'delivery';
|
|
273
|
+
entry.pendingDelivery = true;
|
|
274
|
+
await this.save(registry);
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
async release(projectId, repoRoot, generation, delivered) {
|
|
278
|
+
return this.transaction(async (registry) => {
|
|
279
|
+
const entry = this.owned(registry, projectId, repoRoot, generation);
|
|
280
|
+
const reasons = await this.fileProtection(entry);
|
|
281
|
+
if (reasons.length)
|
|
282
|
+
throw refuse('The worktree is protected: ' +
|
|
283
|
+
reasons.join(', ') +
|
|
284
|
+
'. Finish or preserve the work before release.');
|
|
285
|
+
if (entry.pendingDelivery && !delivered)
|
|
286
|
+
throw refuse('Delivery is pending. Complete delivery before safely releasing this worktree.');
|
|
287
|
+
await this.releaseReservation(entry);
|
|
288
|
+
entry.pendingDelivery = false;
|
|
289
|
+
entry.phase = 'released';
|
|
290
|
+
entry.owner = null;
|
|
291
|
+
entry.generation = randomUUID();
|
|
292
|
+
await this.save(registry);
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
async reconcile(projectId, repoRoot, policy) {
|
|
296
|
+
await this.restoreRegistry();
|
|
297
|
+
const commonDir = await this.commonDirectory(repoRoot);
|
|
298
|
+
await this.transaction(async (registry) => {
|
|
299
|
+
let project = registry.projects[projectId];
|
|
300
|
+
if (!project) {
|
|
301
|
+
const locations = await this.gitPaths(repoRoot);
|
|
302
|
+
for (const location of locations) {
|
|
303
|
+
if (!(await pathExists(location.path)))
|
|
304
|
+
continue;
|
|
305
|
+
const reservation = await this.git.branchStore(location.path).read();
|
|
306
|
+
if (reservation?.decision.projectId !== projectId)
|
|
307
|
+
continue;
|
|
308
|
+
const folder = dirname(dirname(location.path)).split(/[\\/]/).pop();
|
|
309
|
+
const expected = join(await this.documentsDirectory(), 'engineeringmemory', folder, 'worktrees');
|
|
310
|
+
if (key(dirname(location.path)) !== key(expected))
|
|
311
|
+
continue;
|
|
312
|
+
registry.projects[projectId] = { folder, entries: [] };
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
const recoveredProject = registry.projects[projectId];
|
|
317
|
+
if (!recoveredProject)
|
|
318
|
+
return;
|
|
319
|
+
project = recoveredProject;
|
|
320
|
+
const locations = await this.gitPaths(repoRoot);
|
|
321
|
+
for (const entry of project.entries.filter((e) => e.commonDir === commonDir)) {
|
|
322
|
+
if (entry.owner && entry.owner.id !== this.ownerId && (await this.ownerAlive(entry.owner)))
|
|
323
|
+
continue;
|
|
324
|
+
if (await pathExists(entry.repoRoot))
|
|
325
|
+
continue;
|
|
326
|
+
const moved = locations.find((p) => p.branch === entry.branch && key(p.path) !== key(entry.repoRoot));
|
|
327
|
+
if (moved && (await pathExists(moved.path))) {
|
|
328
|
+
const reservation = await this.git.branchStore(moved.path).read();
|
|
329
|
+
if (reservation?.decision.projectId === projectId &&
|
|
330
|
+
reservation.decision.externalTaskId === entry.externalTaskId) {
|
|
331
|
+
entry.repoRoot = await canonicalPath(moved.path);
|
|
332
|
+
entry.managed = false; // a user-moved directory is never returned to the automatic pool
|
|
333
|
+
entry.slot = null;
|
|
334
|
+
entry.owner = null;
|
|
335
|
+
entry.generation = randomUUID();
|
|
336
|
+
entry.phase = 'paused';
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
for (const location of locations) {
|
|
341
|
+
if (project.entries.some((e) => key(e.repoRoot) === key(location.path)))
|
|
342
|
+
continue;
|
|
343
|
+
const parent = dirname(location.path);
|
|
344
|
+
if (dirname(parent)
|
|
345
|
+
.split(/[\\\\/]/)
|
|
346
|
+
.pop() !== project.folder ||
|
|
347
|
+
parent.split(/[\\\\/]/).pop() !== 'worktrees')
|
|
348
|
+
continue;
|
|
349
|
+
const reservation = await this.git.branchStore(location.path).read();
|
|
350
|
+
if (reservation?.decision.projectId !== projectId)
|
|
351
|
+
continue;
|
|
352
|
+
const source = await this.git.sourceIdentity(location.path);
|
|
353
|
+
const slotText = location.path
|
|
354
|
+
.split(/[\\\\/]/)
|
|
355
|
+
.pop()
|
|
356
|
+
?.match(/-worktree([0-9]+)$/)?.[1];
|
|
357
|
+
if (!source || !slotText)
|
|
358
|
+
continue;
|
|
359
|
+
project.entries.push({
|
|
360
|
+
projectId,
|
|
361
|
+
repoFingerprint: await this.git.fingerprint(repoRoot),
|
|
362
|
+
commonDir,
|
|
363
|
+
repoRoot: await canonicalPath(location.path),
|
|
364
|
+
slot: Number(slotText),
|
|
365
|
+
managed: true,
|
|
366
|
+
externalTaskId: reservation.decision.externalTaskId,
|
|
367
|
+
taskId: reservation.decision.taskId,
|
|
368
|
+
branch: reservation.decision.branch,
|
|
369
|
+
baseCommit: source.sourceCommit,
|
|
370
|
+
owner: null,
|
|
371
|
+
generation: randomUUID(),
|
|
372
|
+
lastActivityAt: 0,
|
|
373
|
+
phase: 'quarantined',
|
|
374
|
+
pendingDelivery: true,
|
|
375
|
+
reason: 'Recovered a Git reservation missing from the registry. Restore its original task source and delivery state before reuse.',
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
for (const entry of project.entries.filter((e) => e.commonDir === commonDir)) {
|
|
379
|
+
if (entry.owner && (await this.ownerAlive(entry.owner)) && entry.owner.id !== this.ownerId)
|
|
380
|
+
continue;
|
|
381
|
+
try {
|
|
382
|
+
await this.repairCreating(entry);
|
|
383
|
+
if (await pathExists(entry.repoRoot))
|
|
384
|
+
await this.assertIdentity(entry);
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
entry.phase = 'quarantined';
|
|
388
|
+
entry.reason =
|
|
389
|
+
'Git and registry disagree. Inspect worktree.list and repair the recorded Git worktree without deleting files.';
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
await this.save(registry);
|
|
393
|
+
});
|
|
394
|
+
return this.list(projectId, policy);
|
|
395
|
+
}
|
|
396
|
+
async entry(input, commonDir, repoRoot, slot, managed) {
|
|
397
|
+
const generation = randomUUID();
|
|
398
|
+
return {
|
|
399
|
+
projectId: input.projectId,
|
|
400
|
+
repoFingerprint: input.repoFingerprint,
|
|
401
|
+
commonDir,
|
|
402
|
+
repoRoot,
|
|
403
|
+
slot,
|
|
404
|
+
managed,
|
|
405
|
+
externalTaskId: input.externalTaskId,
|
|
406
|
+
branch: input.branch,
|
|
407
|
+
baseCommit: input.baseCommit,
|
|
408
|
+
generation,
|
|
409
|
+
owner: {
|
|
410
|
+
id: this.ownerId,
|
|
411
|
+
pid: process.pid,
|
|
412
|
+
generation,
|
|
413
|
+
...((await this.ownerStart()) ? { startedAt: (await this.ownerStart()) } : {}),
|
|
414
|
+
},
|
|
415
|
+
lastActivityAt: this.now(),
|
|
416
|
+
phase: 'creating',
|
|
417
|
+
pendingDelivery: false,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
async claim(registry, entry) {
|
|
421
|
+
if (entry.owner?.id !== this.ownerId) {
|
|
422
|
+
entry.generation = randomUUID();
|
|
423
|
+
entry.owner = {
|
|
424
|
+
id: this.ownerId,
|
|
425
|
+
pid: process.pid,
|
|
426
|
+
generation: entry.generation,
|
|
427
|
+
...((await this.ownerStart()) ? { startedAt: (await this.ownerStart()) } : {}),
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
if (entry.phase === 'paused')
|
|
431
|
+
entry.phase = 'working';
|
|
432
|
+
entry.lastActivityAt = this.now();
|
|
433
|
+
await this.reserve(entry);
|
|
434
|
+
await this.save(registry);
|
|
435
|
+
return structuredClone(entry);
|
|
436
|
+
}
|
|
437
|
+
requireSameClone(entry, commonDir) {
|
|
438
|
+
if (entry.commonDir !== commonDir)
|
|
439
|
+
throw new BridgeRecoveryError('This task already has a recorded worktree in another clone. Resume with repoRoot ' +
|
|
440
|
+
entry.repoRoot +
|
|
441
|
+
'; no second checkout was allocated.', entry.taskId ? 'session.resume' : 'task.branch');
|
|
442
|
+
}
|
|
443
|
+
async requireTakeover(entry) {
|
|
444
|
+
if (entry.phase === 'quarantined')
|
|
445
|
+
throw refuse(entry.reason ?? 'The worktree requires reconciliation.');
|
|
446
|
+
if (entry.owner && entry.owner.id !== this.ownerId && (await this.ownerAlive(entry.owner)))
|
|
447
|
+
throw refuse('Another live client owns this task. Pause it there before resuming here; inactivity alone does not transfer ownership.');
|
|
448
|
+
}
|
|
449
|
+
owned(registry, projectId, repoRoot, generation) {
|
|
450
|
+
const entry = registry.projects[projectId]?.entries.find((e) => key(e.repoRoot) === key(repoRoot));
|
|
451
|
+
if (!entry ||
|
|
452
|
+
!entry.owner ||
|
|
453
|
+
entry.generation !== generation ||
|
|
454
|
+
entry.owner.id !== this.ownerId ||
|
|
455
|
+
['paused', 'released', 'quarantined'].includes(entry.phase))
|
|
456
|
+
throw refuse('Worktree ownership has changed or was paused. Resume the task and use its returned repoRoot before any further write.');
|
|
457
|
+
return entry;
|
|
458
|
+
}
|
|
459
|
+
async reserve(entry) {
|
|
460
|
+
await this.git.branchStore(entry.repoRoot).reserve({
|
|
461
|
+
projectId: entry.projectId,
|
|
462
|
+
externalTaskId: entry.externalTaskId,
|
|
463
|
+
branch: entry.branch,
|
|
464
|
+
...(entry.taskId ? { taskId: entry.taskId } : {}),
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
async releaseReservation(entry) {
|
|
468
|
+
const store = this.git.branchStore(entry.repoRoot);
|
|
469
|
+
if (entry.taskId)
|
|
470
|
+
await store.release(entry.taskId);
|
|
471
|
+
else
|
|
472
|
+
await store.cancelUnopened(entry.projectId, entry.externalTaskId);
|
|
473
|
+
}
|
|
474
|
+
async checkout(entry) {
|
|
475
|
+
const existing = await this.git.sourceIdentity(entry.repoRoot, 'refs/heads/' + entry.branch);
|
|
476
|
+
const args = existing
|
|
477
|
+
? ['switch', '--no-overwrite-ignore', '--', entry.branch]
|
|
478
|
+
: ['switch', '--no-overwrite-ignore', '--no-track', '-c', entry.branch, entry.baseCommit];
|
|
479
|
+
const result = await this.runner.run('git', args, { cwd: entry.repoRoot });
|
|
480
|
+
if (result.exitCode !== 0)
|
|
481
|
+
throw refuse('Git could not select the task branch. Reconcile the interrupted allocation; no reset was attempted.');
|
|
482
|
+
}
|
|
483
|
+
async repairCreating(entry) {
|
|
484
|
+
if (entry.phase !== 'creating' || !(await pathExists(entry.repoRoot)))
|
|
485
|
+
return;
|
|
486
|
+
const common = await this.commonDirectory(entry.repoRoot);
|
|
487
|
+
if (common !== entry.commonDir)
|
|
488
|
+
throw refuse('The worktree directory belongs to another clone.');
|
|
489
|
+
if ((await this.git.currentBranch(entry.repoRoot)) !== entry.branch) {
|
|
490
|
+
if ((await this.fileProtection(entry, true)).length)
|
|
491
|
+
throw refuse('An interrupted checkout contains protected work.');
|
|
492
|
+
await this.checkout(entry);
|
|
493
|
+
}
|
|
494
|
+
await this.reserve(entry);
|
|
495
|
+
entry.phase = 'working';
|
|
496
|
+
}
|
|
497
|
+
async assertIdentity(entry, allowBranchChange = false) {
|
|
498
|
+
if (key(await this.git.findRoot(entry.repoRoot)) !== key(await canonicalPath(entry.repoRoot)) ||
|
|
499
|
+
(await this.commonDirectory(entry.repoRoot)) !== entry.commonDir ||
|
|
500
|
+
(!allowBranchChange && (await this.git.currentBranch(entry.repoRoot)) !== entry.branch))
|
|
501
|
+
throw refuse('The worktree Git identity changed. Reconcile it before continuing; the task was not moved or reset.');
|
|
502
|
+
const reservation = await this.git.branchStore(entry.repoRoot).read();
|
|
503
|
+
if (reservation &&
|
|
504
|
+
(reservation.decision.projectId !== entry.projectId ||
|
|
505
|
+
reservation.decision.externalTaskId !== entry.externalTaskId))
|
|
506
|
+
throw refuse('Another task or legacy client reserved this checkout. Resume the recorded task.');
|
|
507
|
+
}
|
|
508
|
+
async fileProtection(entry, allowBranchChange = false) {
|
|
509
|
+
if (!(await pathExists(entry.repoRoot)))
|
|
510
|
+
return ['worktree directory missing'];
|
|
511
|
+
try {
|
|
512
|
+
await this.assertIdentity(entry, allowBranchChange);
|
|
513
|
+
}
|
|
514
|
+
catch {
|
|
515
|
+
return ['Git identity or reservation changed or unreadable; reconcile this checkout'];
|
|
516
|
+
}
|
|
517
|
+
const status = await this.runner.run('git', ['status', '--porcelain=v1', '--untracked-files=all'], { cwd: entry.repoRoot });
|
|
518
|
+
const reasons = [];
|
|
519
|
+
const reservation = await this.git.branchStore(entry.repoRoot).read();
|
|
520
|
+
if (reservation &&
|
|
521
|
+
(reservation.decision.projectId !== entry.projectId ||
|
|
522
|
+
reservation.decision.externalTaskId !== entry.externalTaskId))
|
|
523
|
+
reasons.push('another client or legacy task reserved this checkout');
|
|
524
|
+
if (await this.pendingWork(entry))
|
|
525
|
+
reasons.push('task delivery or intent pending');
|
|
526
|
+
if (status.exitCode !== 0 || status.stdout.trim())
|
|
527
|
+
reasons.push('uncommitted, staged or untracked files');
|
|
528
|
+
const gitDir = await this.command(entry.repoRoot, ['rev-parse', '--absolute-git-dir']);
|
|
529
|
+
for (const marker of [
|
|
530
|
+
'MERGE_HEAD',
|
|
531
|
+
'CHERRY_PICK_HEAD',
|
|
532
|
+
'REVERT_HEAD',
|
|
533
|
+
'BISECT_LOG',
|
|
534
|
+
'rebase-merge',
|
|
535
|
+
'rebase-apply',
|
|
536
|
+
'index.lock',
|
|
537
|
+
'locked',
|
|
538
|
+
])
|
|
539
|
+
if (await pathExists(join(gitDir, marker)))
|
|
540
|
+
reasons.push('unfinished Git operation: ' + marker);
|
|
541
|
+
return reasons;
|
|
542
|
+
}
|
|
543
|
+
async view(entry, policy) {
|
|
544
|
+
const reasons = await this.fileProtection(entry);
|
|
545
|
+
if (entry.phase === 'creating' || entry.phase === 'quarantined')
|
|
546
|
+
reasons.push(entry.reason ?? 'allocation requires reconciliation');
|
|
547
|
+
if (entry.pendingDelivery)
|
|
548
|
+
reasons.push('delivery pending');
|
|
549
|
+
if (entry.owner && (await this.ownerAlive(entry.owner)))
|
|
550
|
+
reasons.push('owner process still running');
|
|
551
|
+
const age = this.now() - entry.lastActivityAt;
|
|
552
|
+
const active = entry.owner !== null &&
|
|
553
|
+
(await this.ownerAlive(entry.owner)) &&
|
|
554
|
+
age >= 0 &&
|
|
555
|
+
age < policy.inactivitySeconds * 1000 &&
|
|
556
|
+
entry.phase === 'working';
|
|
557
|
+
return {
|
|
558
|
+
...structuredClone(entry),
|
|
559
|
+
activity: active ? 'active' : reasons.length ? 'inactive_protected' : 'available',
|
|
560
|
+
reasons,
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
async project(registry, projectId, projectName) {
|
|
564
|
+
if (registry.projects[projectId])
|
|
565
|
+
return registry.projects[projectId];
|
|
566
|
+
let folder = safeSegment(projectName)
|
|
567
|
+
.replace(/[. ]+$/, '')
|
|
568
|
+
.slice(0, 70);
|
|
569
|
+
if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(folder))
|
|
570
|
+
folder = 'project-' + folder;
|
|
571
|
+
const root = join(await this.documentsDirectory(), 'engineeringmemory');
|
|
572
|
+
if (Object.values(registry.projects).some((p) => key(p.folder) === key(folder)) ||
|
|
573
|
+
(await pathExists(join(root, folder))))
|
|
574
|
+
folder += '-' + sha256(projectId).slice(0, 8);
|
|
575
|
+
if (Object.values(registry.projects).some((p) => key(p.folder) === key(folder)))
|
|
576
|
+
throw refuse('The managed project folder identity conflicts with another project.');
|
|
577
|
+
return (registry.projects[projectId] = { folder, entries: [] });
|
|
578
|
+
}
|
|
579
|
+
async commonDirectory(repoRoot) {
|
|
580
|
+
return key(await canonicalPath(await this.command(repoRoot, ['rev-parse', '--path-format=absolute', '--git-common-dir'])));
|
|
581
|
+
}
|
|
582
|
+
async command(repoRoot, args) {
|
|
583
|
+
const result = await this.runner.run('git', args, { cwd: repoRoot });
|
|
584
|
+
if (result.exitCode !== 0)
|
|
585
|
+
throw refuse('Git metadata could not be read. Reconcile the repository before allocating or resuming.');
|
|
586
|
+
return result.stdout.trim();
|
|
587
|
+
}
|
|
588
|
+
async gitPaths(repoRoot) {
|
|
589
|
+
const output = await this.command(repoRoot, ['worktree', 'list', '--porcelain', '-z']);
|
|
590
|
+
return output
|
|
591
|
+
.split('\0\0')
|
|
592
|
+
.filter(Boolean)
|
|
593
|
+
.map((block) => {
|
|
594
|
+
const fields = block.split('\0');
|
|
595
|
+
return {
|
|
596
|
+
path: fields.find((f) => f.startsWith('worktree '))?.slice(9) ?? '',
|
|
597
|
+
branch: fields.find((f) => f.startsWith('branch refs/heads/'))?.slice(18) ?? null,
|
|
598
|
+
};
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
ownerStart() {
|
|
602
|
+
return (this.ownStart ??= this.processStart(process.pid));
|
|
603
|
+
}
|
|
604
|
+
async ownerAlive(owner) {
|
|
605
|
+
if (!this.alive(owner.pid))
|
|
606
|
+
return false;
|
|
607
|
+
if (!owner.startedAt)
|
|
608
|
+
return true; // unknown legacy identity is protected, not guessed dead
|
|
609
|
+
const cached = this.startTimes.get(owner.pid);
|
|
610
|
+
let current = cached?.value;
|
|
611
|
+
const age = cached ? Date.now() - cached.checkedAt : Infinity;
|
|
612
|
+
if (!cached || age < 0 || age > 15000) {
|
|
613
|
+
current = await this.processStart(owner.pid);
|
|
614
|
+
this.startTimes.set(owner.pid, { checkedAt: Date.now(), value: current });
|
|
615
|
+
}
|
|
616
|
+
return !current || current === owner.startedAt;
|
|
617
|
+
}
|
|
618
|
+
async processStart(pid) {
|
|
619
|
+
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
620
|
+
return null;
|
|
621
|
+
try {
|
|
622
|
+
if (process.platform === 'linux') {
|
|
623
|
+
const stat = await readFile('/proc/' + pid + '/stat', 'utf8');
|
|
624
|
+
return stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19] ?? null;
|
|
625
|
+
}
|
|
626
|
+
const result = process.platform === 'win32'
|
|
627
|
+
? await this.runner.run('powershell.exe', [
|
|
628
|
+
'-NoProfile',
|
|
629
|
+
'-NonInteractive',
|
|
630
|
+
'-Command',
|
|
631
|
+
'[System.Diagnostics.Process]::GetProcessById(' +
|
|
632
|
+
pid +
|
|
633
|
+
').StartTime.ToUniversalTime().Ticks',
|
|
634
|
+
], {})
|
|
635
|
+
: await this.runner.run('ps', ['-p', String(pid), '-o', 'lstart='], {});
|
|
636
|
+
return result.exitCode === 0 && result.stdout.trim() ? result.stdout.trim() : null;
|
|
637
|
+
}
|
|
638
|
+
catch {
|
|
639
|
+
return null;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
async documentsDirectory() {
|
|
643
|
+
if (this.documents)
|
|
644
|
+
return resolve(this.documents);
|
|
645
|
+
if (process.platform === 'win32') {
|
|
646
|
+
const result = await this.runner.run('powershell.exe', [
|
|
647
|
+
'-NoProfile',
|
|
648
|
+
'-NonInteractive',
|
|
649
|
+
'-Command',
|
|
650
|
+
"[Environment]::GetFolderPath('MyDocuments')",
|
|
651
|
+
], {});
|
|
652
|
+
if (result.exitCode !== 0 || !result.stdout.trim())
|
|
653
|
+
throw refuse('The Windows Documents folder could not be resolved.');
|
|
654
|
+
this.documents = result.stdout.trim();
|
|
655
|
+
}
|
|
656
|
+
else if (process.platform === 'linux') {
|
|
657
|
+
const result = await this.runner.run('xdg-user-dir', ['DOCUMENTS'], {}).catch(() => null);
|
|
658
|
+
this.documents =
|
|
659
|
+
result?.exitCode === 0 && result.stdout.trim()
|
|
660
|
+
? result.stdout.trim()
|
|
661
|
+
: join(homedir(), 'Documents');
|
|
662
|
+
}
|
|
663
|
+
else
|
|
664
|
+
this.documents = join(homedir(), 'Documents');
|
|
665
|
+
await mkdir(this.documents, { recursive: true });
|
|
666
|
+
return resolve(this.documents);
|
|
667
|
+
}
|
|
668
|
+
async transaction(work) {
|
|
669
|
+
await mkdir(this.stateRoot, { recursive: true });
|
|
670
|
+
return localMutex(key(await canonicalPath(this.stateRoot)), async () => {
|
|
671
|
+
if (await pathExists(this.registryPath + '.pending'))
|
|
672
|
+
await this.restoreRecords();
|
|
673
|
+
let registry;
|
|
674
|
+
try {
|
|
675
|
+
registry = await readJson(this.registryPath, this.stateRoot);
|
|
676
|
+
}
|
|
677
|
+
catch {
|
|
678
|
+
throw refuse('The local registry is damaged. worktree.reconcile must restore a validated backup before allocation; existing files are protected.');
|
|
679
|
+
}
|
|
680
|
+
if (!registry) {
|
|
681
|
+
if ((await pathExists(this.registryPath + '.previous')) ||
|
|
682
|
+
(await pathExists(join(this.stateRoot, 'worktree-records'))))
|
|
683
|
+
throw refuse('The registry is missing but its backup exists. Restore it through worktree.reconcile before allocating.');
|
|
684
|
+
registry = { schemaVersion: 1, projects: {} };
|
|
685
|
+
}
|
|
686
|
+
await this.validateRegistry(registry);
|
|
687
|
+
this.before = new Map(Object.values(registry.projects).flatMap((project) => project.entries.map((entry) => [
|
|
688
|
+
key(entry.repoRoot),
|
|
689
|
+
JSON.stringify({ folder: project.folder, entry }),
|
|
690
|
+
])));
|
|
691
|
+
return work(registry);
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
async validateRegistry(registry) {
|
|
695
|
+
if (!registrySchema.safeParse(registry).success)
|
|
696
|
+
throw refuse('The local registry schema is invalid. Reconcile it before allocating.');
|
|
697
|
+
for (const project of Object.values(registry.projects))
|
|
698
|
+
if (!Array.isArray(project.entries) || typeof project.folder !== 'string')
|
|
699
|
+
throw refuse('The local project registry is invalid. Existing worktrees remain protected.');
|
|
700
|
+
for (const [projectId, project] of Object.entries(registry.projects)) {
|
|
701
|
+
const paths = new Set();
|
|
702
|
+
for (const entry of project.entries) {
|
|
703
|
+
if (entry.projectId !== projectId ||
|
|
704
|
+
paths.has(key(entry.repoRoot)) ||
|
|
705
|
+
(entry.owner && entry.owner.generation !== entry.generation))
|
|
706
|
+
throw refuse('The registry contains conflicting task ownership. Restore a validated backup with worktree.reconcile.');
|
|
707
|
+
paths.add(key(entry.repoRoot));
|
|
708
|
+
if (entry.managed) {
|
|
709
|
+
const documents = await this.documentsDirectory();
|
|
710
|
+
const expected = join(documents, 'engineeringmemory', project.folder, 'worktrees', project.folder + '-worktree' + entry.slot);
|
|
711
|
+
if (!entry.slot || key(entry.repoRoot) !== key(expected))
|
|
712
|
+
throw refuse('A managed worktree path does not match its project Documents directory. Reconcile before continuing.');
|
|
713
|
+
await assertManagedPath(documents, entry.repoRoot, true);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
async restoreRegistry() {
|
|
719
|
+
await mkdir(this.stateRoot, { recursive: true });
|
|
720
|
+
await localMutex(key(await canonicalPath(this.stateRoot)), async () => {
|
|
721
|
+
let current;
|
|
722
|
+
try {
|
|
723
|
+
current = await readJson(this.registryPath, this.stateRoot);
|
|
724
|
+
}
|
|
725
|
+
catch {
|
|
726
|
+
current = undefined;
|
|
727
|
+
}
|
|
728
|
+
if (registrySchema.safeParse(current).success &&
|
|
729
|
+
!(await pathExists(this.registryPath + '.pending')))
|
|
730
|
+
return;
|
|
731
|
+
if (await this.restoreRecords())
|
|
732
|
+
return;
|
|
733
|
+
let previous;
|
|
734
|
+
try {
|
|
735
|
+
previous = await readJson(this.registryPath + '.previous', this.stateRoot);
|
|
736
|
+
}
|
|
737
|
+
catch {
|
|
738
|
+
previous = undefined;
|
|
739
|
+
}
|
|
740
|
+
if (!current && !previous && !(await pathExists(this.registryPath)))
|
|
741
|
+
return;
|
|
742
|
+
const restored = registrySchema.safeParse(previous);
|
|
743
|
+
if (!restored.success)
|
|
744
|
+
throw refuse('No valid registry backup exists. Preserve the directories and restore the local registry backup; automatic allocation remains disabled.');
|
|
745
|
+
for (const project of Object.values(restored.data.projects))
|
|
746
|
+
for (const entry of project.entries) {
|
|
747
|
+
entry.phase = 'quarantined';
|
|
748
|
+
entry.reason =
|
|
749
|
+
'Registry recovered from backup. The original owner must be stopped and the task explicitly resumed after checking Git and delivery state.';
|
|
750
|
+
}
|
|
751
|
+
await atomicWrite(this.registryPath, JSON.stringify(restored.data) + '\n', this.stateRoot);
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
async restoreRecords() {
|
|
755
|
+
if (await pathExists(this.registryPath + '.pending')) {
|
|
756
|
+
let raw;
|
|
757
|
+
try {
|
|
758
|
+
raw = await readJson(this.registryPath + '.pending', this.stateRoot);
|
|
759
|
+
}
|
|
760
|
+
catch {
|
|
761
|
+
throw refuse('The pending allocation transaction is damaged. Restore its local backup before reconciliation; existing directories remain protected.');
|
|
762
|
+
}
|
|
763
|
+
if (raw !== 1) {
|
|
764
|
+
const parsed = registrySchema.safeParse(raw);
|
|
765
|
+
if (!parsed.success)
|
|
766
|
+
throw refuse('The pending allocation transaction is invalid. Restore its local backup before reconciliation.');
|
|
767
|
+
const registry = parsed.data;
|
|
768
|
+
await this.validateRegistry(registry);
|
|
769
|
+
await this.writeRecords(registry, true);
|
|
770
|
+
await atomicWrite(this.registryPath, JSON.stringify(registry) + '\n', this.stateRoot);
|
|
771
|
+
await removeFile(this.registryPath + '.pending', this.stateRoot);
|
|
772
|
+
return true;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
const root = join(this.stateRoot, 'worktree-records');
|
|
776
|
+
if (!(await pathExists(root)))
|
|
777
|
+
return false;
|
|
778
|
+
const registry = { schemaVersion: 1, projects: {} };
|
|
779
|
+
const files = (await readdir(root)).filter((name) => /^[a-f0-9]{64}\.json$/.test(name));
|
|
780
|
+
if (!files.length)
|
|
781
|
+
return false;
|
|
782
|
+
for (const name of files) {
|
|
783
|
+
const raw = await readJson(join(root, name), this.stateRoot);
|
|
784
|
+
if (!raw ||
|
|
785
|
+
!/^[a-zA-Z0-9._-]+$/.test(raw.folder) ||
|
|
786
|
+
!allocationSchema.safeParse(raw.entry).success ||
|
|
787
|
+
sha256(key(raw.entry.repoRoot)) + '.json' !== name)
|
|
788
|
+
throw refuse('An allocation recovery record is damaged. Restore that local record from backup before reconciliation; no directory is reusable from incomplete evidence.');
|
|
789
|
+
const project = (registry.projects[raw.entry.projectId] ??= {
|
|
790
|
+
folder: raw.folder,
|
|
791
|
+
entries: [],
|
|
792
|
+
});
|
|
793
|
+
if (project.folder !== raw.folder)
|
|
794
|
+
throw refuse('Recovery records disagree on the project folder. Restore the matching local backup.');
|
|
795
|
+
project.entries.push(raw.entry);
|
|
796
|
+
}
|
|
797
|
+
await this.validateRegistry(registry);
|
|
798
|
+
await atomicWrite(this.registryPath, JSON.stringify(registry) + '\n', this.stateRoot);
|
|
799
|
+
await removeFile(this.registryPath + '.pending', this.stateRoot);
|
|
800
|
+
return true;
|
|
801
|
+
}
|
|
802
|
+
async writeRecords(registry, replay = false) {
|
|
803
|
+
const root = join(this.stateRoot, 'worktree-records');
|
|
804
|
+
await ensureManagedDirectory(this.stateRoot, root);
|
|
805
|
+
const current = new Set();
|
|
806
|
+
for (const project of Object.values(registry.projects))
|
|
807
|
+
for (const entry of project.entries) {
|
|
808
|
+
const name = sha256(key(entry.repoRoot)) + '.json';
|
|
809
|
+
current.add(name);
|
|
810
|
+
const content = JSON.stringify({ folder: project.folder, entry });
|
|
811
|
+
if (replay || this.before.get(key(entry.repoRoot)) !== content)
|
|
812
|
+
await atomicWrite(join(root, name), content + '\n', this.stateRoot);
|
|
813
|
+
}
|
|
814
|
+
const previous = replay
|
|
815
|
+
? (await readdir(root)).filter((name) => /^[a-f0-9]{64}\.json$/.test(name))
|
|
816
|
+
: [...this.before.keys()].map((path) => sha256(path) + '.json');
|
|
817
|
+
for (const name of previous)
|
|
818
|
+
if (!current.has(name))
|
|
819
|
+
await removeFile(join(root, name), this.stateRoot);
|
|
820
|
+
}
|
|
821
|
+
async save(registry) {
|
|
822
|
+
await this.validateRegistry(registry);
|
|
823
|
+
await atomicWrite(this.registryPath + '.pending', JSON.stringify(registry) + '\n', this.stateRoot);
|
|
824
|
+
await this.writeRecords(registry);
|
|
825
|
+
if (await pathExists(this.registryPath))
|
|
826
|
+
await atomicWrite(this.registryPath + '.previous', await readFile(this.registryPath), this.stateRoot);
|
|
827
|
+
await atomicWrite(this.registryPath, JSON.stringify(registry) + '\n', this.stateRoot);
|
|
828
|
+
await removeFile(this.registryPath + '.pending', this.stateRoot);
|
|
829
|
+
this.before = new Map(Object.values(registry.projects).flatMap((p) => p.entries.map((e) => [key(e.repoRoot), JSON.stringify({ folder: p.folder, entry: e })])));
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
function key(path) {
|
|
833
|
+
return process.platform === 'win32' ? resolve(path).toLowerCase() : resolve(path);
|
|
834
|
+
}
|
|
835
|
+
function processAlive(pid) {
|
|
836
|
+
try {
|
|
837
|
+
process.kill(pid, 0);
|
|
838
|
+
return true;
|
|
839
|
+
}
|
|
840
|
+
catch (error) {
|
|
841
|
+
return error.code === 'EPERM';
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
function refuse(message) {
|
|
845
|
+
return new BridgeRecoveryError(message, 'worktree.reconcile');
|
|
846
|
+
}
|
|
847
|
+
//# sourceMappingURL=worktree-pool.js.map
|