nawabari 0.1.0
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/LICENSE +21 -0
- package/README.md +120 -0
- package/dist/cli.d.ts +9 -0
- package/dist/cli.js +298 -0
- package/dist/cli.js.map +1 -0
- package/dist/domain/doctor.d.ts +20 -0
- package/dist/domain/doctor.js +167 -0
- package/dist/domain/doctor.js.map +1 -0
- package/dist/domain/errors.d.ts +29 -0
- package/dist/domain/errors.js +42 -0
- package/dist/domain/errors.js.map +1 -0
- package/dist/domain/session-backend.d.ts +24 -0
- package/dist/domain/session-backend.js +206 -0
- package/dist/domain/session-backend.js.map +1 -0
- package/dist/domain/session.d.ts +89 -0
- package/dist/domain/session.js +47 -0
- package/dist/domain/session.js.map +1 -0
- package/dist/errors.d.ts +8 -0
- package/dist/errors.js +14 -0
- package/dist/errors.js.map +1 -0
- package/dist/git.d.ts +33 -0
- package/dist/git.js +157 -0
- package/dist/git.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/presentation.d.ts +9 -0
- package/dist/presentation.js +61 -0
- package/dist/presentation.js.map +1 -0
- package/dist/registry/atomic.d.ts +20 -0
- package/dist/registry/atomic.js +128 -0
- package/dist/registry/atomic.js.map +1 -0
- package/dist/registry/errors.d.ts +8 -0
- package/dist/registry/errors.js +30 -0
- package/dist/registry/errors.js.map +1 -0
- package/dist/registry/lock.d.ts +60 -0
- package/dist/registry/lock.js +727 -0
- package/dist/registry/lock.js.map +1 -0
- package/dist/registry/store.d.ts +38 -0
- package/dist/registry/store.js +156 -0
- package/dist/registry/store.js.map +1 -0
- package/dist/registry/types.d.ts +18 -0
- package/dist/registry/types.js +3 -0
- package/dist/registry/types.js.map +1 -0
- package/dist/registry.d.ts +5 -0
- package/dist/registry.js +6 -0
- package/dist/registry.js.map +1 -0
- package/dist/session-id.d.ts +7 -0
- package/dist/session-id.js +32 -0
- package/dist/session-id.js.map +1 -0
- package/dist/session-registry.d.ts +169 -0
- package/dist/session-registry.js +1190 -0
- package/dist/session-registry.js.map +1 -0
- package/package.json +57 -0
|
@@ -0,0 +1,1190 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { defaultGit, listGitWorktrees, normalizeBranchId, readCurrentBranch, resolveRepositoryContext, resolveWorktreeIdentity, } from "./git.js";
|
|
4
|
+
import { SessionRegistryError } from "./errors.js";
|
|
5
|
+
import { generateSessionId, isSessionId } from "./session-id.js";
|
|
6
|
+
import { RegistryLockError, RepositoryLock } from "./registry/lock.js";
|
|
7
|
+
export const REGISTRY_SCHEMA_VERSION = 1;
|
|
8
|
+
export const REGISTRY_DIRECTORY_NAME = "nawabari";
|
|
9
|
+
export const REGISTRY_FILE_NAME = "session-registry.json";
|
|
10
|
+
export const REGISTRY_LOCK_FILE_NAME = "session-registry.lock";
|
|
11
|
+
export const DEFAULT_STALE_AFTER_MS = 24 * 60 * 60 * 1_000;
|
|
12
|
+
const DEFAULT_LOCK_METADATA_GRACE_MS = 1_000;
|
|
13
|
+
const ACTIVE_STATES = new Set(["new", "active", "closing", "stale"]);
|
|
14
|
+
const CURRENT_SESSION_STATES = new Set(["new", "active", "closing"]);
|
|
15
|
+
const SESSION_STATES = new Set(["new", "active", "closing", "closed", "stale"]);
|
|
16
|
+
const ISO_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u;
|
|
17
|
+
const MAX_ID_GENERATION_ATTEMPTS = 8;
|
|
18
|
+
export class SessionRegistry {
|
|
19
|
+
repository;
|
|
20
|
+
paths;
|
|
21
|
+
git;
|
|
22
|
+
clock;
|
|
23
|
+
idGenerator;
|
|
24
|
+
lockTimeoutMs;
|
|
25
|
+
defaultBranchName;
|
|
26
|
+
protectedBranchNames;
|
|
27
|
+
protectedWorktreePaths;
|
|
28
|
+
worktreeRoot;
|
|
29
|
+
staleAfterMs;
|
|
30
|
+
lockStaleAfterMs;
|
|
31
|
+
lockMetadataGraceMs;
|
|
32
|
+
lock;
|
|
33
|
+
constructor(options = {}) {
|
|
34
|
+
this.repository = options.repository ?? resolveRepositoryContext({ cwd: options.cwd, git: options.git });
|
|
35
|
+
this.git = options.git ?? defaultGit;
|
|
36
|
+
this.clock = options.clock ?? (() => new Date());
|
|
37
|
+
this.idGenerator = options.idGenerator ?? generateSessionId;
|
|
38
|
+
this.lockTimeoutMs = options.lockTimeoutMs ?? 5_000;
|
|
39
|
+
this.defaultBranchName = options.defaultBranchName;
|
|
40
|
+
this.protectedBranchNames = Object.freeze([...(options.protectedBranchNames ?? [])]);
|
|
41
|
+
this.protectedWorktreePaths = Object.freeze([...(options.protectedWorktreePaths ?? [])]);
|
|
42
|
+
this.worktreeRoot = path.resolve(options.worktreeRoot ?? path.dirname(this.repository.worktreePath));
|
|
43
|
+
this.staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
|
|
44
|
+
this.lockStaleAfterMs = options.lockStaleAfterMs ?? this.lockTimeoutMs;
|
|
45
|
+
this.lockMetadataGraceMs = options.lockMetadataGraceMs ?? DEFAULT_LOCK_METADATA_GRACE_MS;
|
|
46
|
+
if (!Number.isSafeInteger(this.lockTimeoutMs) || this.lockTimeoutMs < 0) {
|
|
47
|
+
throw new RangeError("lockTimeoutMs must be a non-negative safe integer");
|
|
48
|
+
}
|
|
49
|
+
if (!Number.isSafeInteger(this.staleAfterMs) || this.staleAfterMs < 0) {
|
|
50
|
+
throw new RangeError("staleAfterMs must be a non-negative safe integer");
|
|
51
|
+
}
|
|
52
|
+
if (!Number.isSafeInteger(this.lockStaleAfterMs) || this.lockStaleAfterMs < 0) {
|
|
53
|
+
throw new RangeError("lockStaleAfterMs must be a non-negative safe integer");
|
|
54
|
+
}
|
|
55
|
+
if (!Number.isSafeInteger(this.lockMetadataGraceMs) || this.lockMetadataGraceMs < 0) {
|
|
56
|
+
throw new RangeError("lockMetadataGraceMs must be a non-negative safe integer");
|
|
57
|
+
}
|
|
58
|
+
const directory = path.join(this.repository.commonGitDirectory, REGISTRY_DIRECTORY_NAME);
|
|
59
|
+
this.paths = Object.freeze({
|
|
60
|
+
directory,
|
|
61
|
+
registry: path.join(directory, REGISTRY_FILE_NAME),
|
|
62
|
+
lock: path.join(directory, REGISTRY_LOCK_FILE_NAME),
|
|
63
|
+
});
|
|
64
|
+
this.lock = new RepositoryLock({
|
|
65
|
+
lockPath: this.paths.lock,
|
|
66
|
+
staleAfterMs: this.lockStaleAfterMs,
|
|
67
|
+
acquireTimeoutMs: this.lockTimeoutMs,
|
|
68
|
+
metadataGraceMs: this.lockMetadataGraceMs,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
read() {
|
|
72
|
+
return this.readUnsafe().map(cloneSessionRecord);
|
|
73
|
+
}
|
|
74
|
+
list() {
|
|
75
|
+
return this.read();
|
|
76
|
+
}
|
|
77
|
+
get(sessionId) {
|
|
78
|
+
assertSessionId(sessionId);
|
|
79
|
+
const record = this.readUnsafe().find((candidate) => candidate.sessionId === sessionId);
|
|
80
|
+
return record === undefined ? undefined : cloneSessionRecord(record);
|
|
81
|
+
}
|
|
82
|
+
create(options = {}) {
|
|
83
|
+
const resources = this.resolveCreationResources(options);
|
|
84
|
+
return this.mutate((records) => {
|
|
85
|
+
for (let attempt = 0; attempt < MAX_ID_GENERATION_ATTEMPTS; attempt += 1) {
|
|
86
|
+
const sessionId = this.idGenerator();
|
|
87
|
+
assertSessionId(sessionId);
|
|
88
|
+
if (records.some((record) => record.sessionId === sessionId)) {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const timestamp = toTimestamp(this.clock());
|
|
92
|
+
const record = freezeSessionRecord({
|
|
93
|
+
schemaVersion: REGISTRY_SCHEMA_VERSION,
|
|
94
|
+
sessionId,
|
|
95
|
+
repositoryId: this.repository.repositoryId,
|
|
96
|
+
worktreeId: resources.worktreeId,
|
|
97
|
+
worktreePath: resources.worktreePath,
|
|
98
|
+
branchId: resources.branchId,
|
|
99
|
+
branchName: resources.branchName,
|
|
100
|
+
state: "active",
|
|
101
|
+
createdAt: timestamp,
|
|
102
|
+
updatedAt: timestamp,
|
|
103
|
+
...(options.label === undefined ? {} : { label: validateLabel(options.label) }),
|
|
104
|
+
});
|
|
105
|
+
assertNoOwnershipConflict(records, record);
|
|
106
|
+
return { records: [...records, record], result: cloneSessionRecord(record) };
|
|
107
|
+
}
|
|
108
|
+
throw new SessionRegistryError("SESSION_ID_COLLISION", `Could not generate a unique session ID after ${MAX_ID_GENERATION_ATTEMPTS} attempts`, { attempts: MAX_ID_GENERATION_ATTEMPTS });
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
createSession(options = {}) {
|
|
112
|
+
return this.create(options);
|
|
113
|
+
}
|
|
114
|
+
/** Provision one isolated Git worktree and commit its ownership atomically. */
|
|
115
|
+
provision(options = {}) {
|
|
116
|
+
return this.withLock(() => {
|
|
117
|
+
const records = this.readUnsafe();
|
|
118
|
+
const sessionId = generateUniqueSessionId(records, this.idGenerator);
|
|
119
|
+
const resources = this.resolveProvisioningResources(options, sessionId);
|
|
120
|
+
const timestamp = toTimestamp(this.clock());
|
|
121
|
+
const record = freezeSessionRecord({
|
|
122
|
+
schemaVersion: REGISTRY_SCHEMA_VERSION,
|
|
123
|
+
sessionId,
|
|
124
|
+
repositoryId: this.repository.repositoryId,
|
|
125
|
+
worktreeId: resources.worktreePath,
|
|
126
|
+
worktreePath: resources.worktreePath,
|
|
127
|
+
branchId: resources.branchId,
|
|
128
|
+
branchName: resources.branchName,
|
|
129
|
+
state: "active",
|
|
130
|
+
createdAt: timestamp,
|
|
131
|
+
updatedAt: timestamp,
|
|
132
|
+
...(options.label === undefined ? {} : { label: validateLabel(options.label) }),
|
|
133
|
+
});
|
|
134
|
+
assertNoOwnershipConflict(records, record);
|
|
135
|
+
assertGitResourcesAvailable(this.git, this.repository.worktreePath, resources);
|
|
136
|
+
let gitProvisioned = false;
|
|
137
|
+
try {
|
|
138
|
+
this.git.run(["worktree", "add", "--quiet", "-b", resources.branchName, resources.worktreePath, resources.baseRef], this.repository.worktreePath);
|
|
139
|
+
gitProvisioned = true;
|
|
140
|
+
this.writeUnsafe([...records, record]);
|
|
141
|
+
return cloneSessionRecord(record);
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
if (gitProvisioned) {
|
|
145
|
+
rollbackProvisionedResources(this.git, this.repository.worktreePath, resources);
|
|
146
|
+
}
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
provisionSession(options = {}) {
|
|
152
|
+
return this.provision(options);
|
|
153
|
+
}
|
|
154
|
+
createProvisionedSession(options = {}) {
|
|
155
|
+
return this.provision(options);
|
|
156
|
+
}
|
|
157
|
+
register(record) {
|
|
158
|
+
const validated = validateSessionRecord(record, this.repository.repositoryId);
|
|
159
|
+
return this.mutate((records) => {
|
|
160
|
+
if (records.some((candidate) => candidate.sessionId === validated.sessionId)) {
|
|
161
|
+
throw new SessionRegistryError("DUPLICATE_SESSION_ID", `Session ID already exists: ${validated.sessionId}`, {
|
|
162
|
+
sessionId: validated.sessionId,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
assertNoOwnershipConflict(records, validated);
|
|
166
|
+
return { records: [...records, validated], result: cloneSessionRecord(validated) };
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
registerSession(record) {
|
|
170
|
+
return this.register(record);
|
|
171
|
+
}
|
|
172
|
+
resolveCurrentSession() {
|
|
173
|
+
const records = this.readUnsafe();
|
|
174
|
+
const matches = records.filter((record) => CURRENT_SESSION_STATES.has(record.state) && record.worktreeId === this.repository.worktreePath);
|
|
175
|
+
if (matches.length === 1) {
|
|
176
|
+
return cloneSessionRecord(matches[0]);
|
|
177
|
+
}
|
|
178
|
+
if (matches.length > 1) {
|
|
179
|
+
throw new SessionRegistryError("DUPLICATE_WORKTREE_OWNERSHIP", `Multiple active sessions claim the current worktree: ${this.repository.worktreePath}`, { worktree: this.repository.worktreePath });
|
|
180
|
+
}
|
|
181
|
+
throw new SessionRegistryError("SESSION_NOT_FOUND", `No active session owns the current worktree: ${this.repository.worktreePath}`, { worktree: this.repository.worktreePath });
|
|
182
|
+
}
|
|
183
|
+
currentSession() {
|
|
184
|
+
return this.resolveCurrentSession();
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Evaluate the current worktree as a Nawabari mutation context without taking
|
|
188
|
+
* the registry lock or changing Git or registry state.
|
|
189
|
+
*/
|
|
190
|
+
guard(options = {}) {
|
|
191
|
+
const requestedSessionId = options.sessionId ?? null;
|
|
192
|
+
const base = {
|
|
193
|
+
repositoryId: this.repository.repositoryId,
|
|
194
|
+
worktreePath: this.repository.worktreePath,
|
|
195
|
+
branchName: null,
|
|
196
|
+
sessionId: null,
|
|
197
|
+
ownerSessionId: null,
|
|
198
|
+
requestedSessionId,
|
|
199
|
+
state: null,
|
|
200
|
+
};
|
|
201
|
+
try {
|
|
202
|
+
if (requestedSessionId !== null && !isSessionId(requestedSessionId)) {
|
|
203
|
+
return deniedGuard("INVALID_SESSION_ID", base, { sessionId: requestedSessionId });
|
|
204
|
+
}
|
|
205
|
+
const worktrees = listGitWorktrees(this.git, this.repository.worktreePath);
|
|
206
|
+
if (worktrees.length === 0) {
|
|
207
|
+
return deniedGuard("WORKTREE_IDENTITY_AMBIGUOUS", base, { worktree: this.repository.worktreePath });
|
|
208
|
+
}
|
|
209
|
+
const identity = resolveWorktreeIdentity({
|
|
210
|
+
repository: this.repository,
|
|
211
|
+
git: this.git,
|
|
212
|
+
});
|
|
213
|
+
const identityBase = { ...base, branchName: identity.branchName };
|
|
214
|
+
const listedCurrentWorktree = worktrees.find((worktree) => samePath(worktree.worktreePath, this.repository.worktreePath));
|
|
215
|
+
if (listedCurrentWorktree?.branchName !== identity.branchName) {
|
|
216
|
+
return deniedGuard("WORKTREE_IDENTITY_AMBIGUOUS", identityBase, {
|
|
217
|
+
worktree: this.repository.worktreePath,
|
|
218
|
+
listedBranch: listedCurrentWorktree?.branchName ?? "<detached>",
|
|
219
|
+
resolvedBranch: identity.branchName,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
const records = this.readUnsafe();
|
|
223
|
+
const currentRecords = records.filter((record) => record.worktreeId === this.repository.worktreePath && record.state !== "closed");
|
|
224
|
+
if (currentRecords.length > 1) {
|
|
225
|
+
return deniedGuard("DUPLICATE_WORKTREE_OWNERSHIP", identityBase, {
|
|
226
|
+
worktree: this.repository.worktreePath,
|
|
227
|
+
sessionId: requestedSessionId ?? "<unspecified>",
|
|
228
|
+
}, currentRecords[0]?.sessionId ?? null, currentRecords[0]?.state ?? null);
|
|
229
|
+
}
|
|
230
|
+
const currentRecord = currentRecords.find((record) => record.state === "active");
|
|
231
|
+
if (currentRecord !== undefined) {
|
|
232
|
+
identityBase.sessionId = currentRecord.sessionId;
|
|
233
|
+
identityBase.ownerSessionId = currentRecord.sessionId;
|
|
234
|
+
identityBase.state = currentRecord.state;
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
const transitionalRecord = currentRecords[0];
|
|
238
|
+
if (transitionalRecord !== undefined) {
|
|
239
|
+
identityBase.sessionId = transitionalRecord.sessionId;
|
|
240
|
+
identityBase.ownerSessionId = transitionalRecord.sessionId;
|
|
241
|
+
identityBase.state = transitionalRecord.state;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (this.protectedWorktree(this.repository.worktreePath, worktrees)) {
|
|
245
|
+
return deniedGuard("PROTECTED_WORKTREE", identityBase, { worktree: this.repository.worktreePath }, identityBase.ownerSessionId, identityBase.state);
|
|
246
|
+
}
|
|
247
|
+
if (this.protectedBranch(identity.branchName, worktrees)) {
|
|
248
|
+
return deniedGuard("PROTECTED_BRANCH", identityBase, { branch: identity.branchName }, identityBase.ownerSessionId, identityBase.state);
|
|
249
|
+
}
|
|
250
|
+
if (currentRecord === undefined) {
|
|
251
|
+
const requestedRecord = requestedSessionId === null ? undefined : records.find((record) => record.sessionId === requestedSessionId);
|
|
252
|
+
return deniedGuard("SESSION_NOT_FOUND", identityBase, {
|
|
253
|
+
worktree: this.repository.worktreePath,
|
|
254
|
+
...(requestedRecord === undefined ? {} : { state: requestedRecord.state }),
|
|
255
|
+
}, identityBase.ownerSessionId, identityBase.state);
|
|
256
|
+
}
|
|
257
|
+
if (currentRecord.branchId !== identity.branchId || currentRecord.branchName !== identity.branchName) {
|
|
258
|
+
return deniedGuard("OWNERSHIP_MISMATCH", identityBase, {
|
|
259
|
+
worktree: this.repository.worktreePath,
|
|
260
|
+
expectedBranch: currentRecord.branchName,
|
|
261
|
+
actualBranch: identity.branchName,
|
|
262
|
+
}, currentRecord.sessionId, currentRecord.state);
|
|
263
|
+
}
|
|
264
|
+
if (requestedSessionId !== null) {
|
|
265
|
+
const requestedRecord = records.find((record) => record.sessionId === requestedSessionId);
|
|
266
|
+
if (requestedRecord === undefined || requestedRecord.state !== "active") {
|
|
267
|
+
return deniedGuard("SESSION_NOT_FOUND", identityBase, { sessionId: requestedSessionId }, currentRecord.sessionId, currentRecord.state);
|
|
268
|
+
}
|
|
269
|
+
if (requestedRecord.sessionId !== currentRecord.sessionId) {
|
|
270
|
+
return deniedGuard("DUPLICATE_WORKTREE_OWNERSHIP", identityBase, {
|
|
271
|
+
worktree: this.repository.worktreePath,
|
|
272
|
+
sessionId: requestedSessionId,
|
|
273
|
+
ownerSessionId: currentRecord.sessionId,
|
|
274
|
+
}, currentRecord.sessionId, currentRecord.state);
|
|
275
|
+
}
|
|
276
|
+
if (requestedRecord.branchId !== identity.branchId) {
|
|
277
|
+
return deniedGuard("OWNERSHIP_MISMATCH", identityBase, {
|
|
278
|
+
sessionId: requestedSessionId,
|
|
279
|
+
expectedBranch: requestedRecord.branchName,
|
|
280
|
+
actualBranch: identity.branchName,
|
|
281
|
+
}, currentRecord.sessionId, currentRecord.state);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return Object.freeze({
|
|
285
|
+
allowed: true,
|
|
286
|
+
code: "ALLOWED",
|
|
287
|
+
...identityBase,
|
|
288
|
+
details: {},
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
catch (error) {
|
|
292
|
+
if (error instanceof SessionRegistryError) {
|
|
293
|
+
return deniedGuard(error.code, base, error.details, base.ownerSessionId, base.state);
|
|
294
|
+
}
|
|
295
|
+
throw error;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
/** Close one session only after its ownership and recoverability are proven safe. */
|
|
299
|
+
close(sessionIdOrOptions) {
|
|
300
|
+
return this.withLock(() => {
|
|
301
|
+
const sessionId = typeof sessionIdOrOptions === "object" && sessionIdOrOptions !== null
|
|
302
|
+
? (sessionIdOrOptions.sessionId ?? sessionIdOrOptions.session_id)
|
|
303
|
+
: sessionIdOrOptions;
|
|
304
|
+
const selectedSessionId = sessionId ?? this.resolveCurrentSession().sessionId;
|
|
305
|
+
assertSessionId(selectedSessionId);
|
|
306
|
+
return this.closeUnsafe(selectedSessionId);
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
closeSession(sessionIdOrOptions) {
|
|
310
|
+
return this.close(sessionIdOrOptions);
|
|
311
|
+
}
|
|
312
|
+
/** Detect stale sessions, optionally applying only cleanup that passes close preflight. */
|
|
313
|
+
garbageCollect(options = {}) {
|
|
314
|
+
const apply = options.apply ?? false;
|
|
315
|
+
const staleAfterMs = options.staleAfterMs ?? this.staleAfterMs;
|
|
316
|
+
assertStaleAfterMs(staleAfterMs);
|
|
317
|
+
return this.withLock(() => {
|
|
318
|
+
let records = [...this.readUnsafe()];
|
|
319
|
+
const now = toTimestamp(this.clock());
|
|
320
|
+
const worktrees = listGitWorktrees(this.git, this.repository.worktreePath);
|
|
321
|
+
const candidates = records
|
|
322
|
+
.filter((record) => isStaleCandidate(record, now, staleAfterMs, worktrees))
|
|
323
|
+
.map(cloneSessionRecord);
|
|
324
|
+
if (!apply || candidates.length === 0) {
|
|
325
|
+
return {
|
|
326
|
+
apply,
|
|
327
|
+
candidates,
|
|
328
|
+
cleaned: [],
|
|
329
|
+
blocked: [],
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
const cleaned = [];
|
|
333
|
+
const blocked = [];
|
|
334
|
+
for (const candidate of candidates) {
|
|
335
|
+
const current = records.find((record) => record.sessionId === candidate.sessionId);
|
|
336
|
+
if (current === undefined || current.state === "closed")
|
|
337
|
+
continue;
|
|
338
|
+
if (current.state !== "stale" && current.state !== "closing") {
|
|
339
|
+
const staleRecord = transitionSessionState(current, "stale", this.clock);
|
|
340
|
+
records = replaceRecord(records, staleRecord);
|
|
341
|
+
validateRecords(records, this.repository.repositoryId);
|
|
342
|
+
this.writeUnsafe(records);
|
|
343
|
+
}
|
|
344
|
+
try {
|
|
345
|
+
const result = this.closeUnsafe(candidate.sessionId);
|
|
346
|
+
cleaned.push(result.session);
|
|
347
|
+
}
|
|
348
|
+
catch (error) {
|
|
349
|
+
if (!(error instanceof SessionRegistryError))
|
|
350
|
+
throw error;
|
|
351
|
+
blocked.push({
|
|
352
|
+
sessionId: candidate.sessionId,
|
|
353
|
+
code: error.code,
|
|
354
|
+
message: error.message,
|
|
355
|
+
details: error.details,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
records = [...this.readUnsafe()];
|
|
359
|
+
}
|
|
360
|
+
return {
|
|
361
|
+
apply,
|
|
362
|
+
candidates,
|
|
363
|
+
cleaned: cleaned.map(cloneSessionRecord),
|
|
364
|
+
blocked,
|
|
365
|
+
};
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
gc(options = {}) {
|
|
369
|
+
return this.garbageCollect(options);
|
|
370
|
+
}
|
|
371
|
+
closeUnsafe(sessionId) {
|
|
372
|
+
const records = this.readUnsafe();
|
|
373
|
+
const record = records.find((candidate) => candidate.sessionId === sessionId);
|
|
374
|
+
if (record === undefined) {
|
|
375
|
+
throw new SessionRegistryError("SESSION_NOT_FOUND", `Session was not found: ${sessionId}`, {
|
|
376
|
+
sessionId,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
if (record.state === "closed") {
|
|
380
|
+
return {
|
|
381
|
+
session: cloneSessionRecord(record),
|
|
382
|
+
worktreeRemoved: false,
|
|
383
|
+
branchRemoved: false,
|
|
384
|
+
idempotent: true,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
const resources = this.inspectCleanupResources(record);
|
|
388
|
+
const closingRecord = record.state === "closing" ? record : transitionSessionState(record, "closing", this.clock);
|
|
389
|
+
let closingRecords = replaceRecord(records, closingRecord);
|
|
390
|
+
validateRecords(closingRecords, this.repository.repositoryId);
|
|
391
|
+
this.writeUnsafe(closingRecords);
|
|
392
|
+
let worktreeRemoved = false;
|
|
393
|
+
let branchRemoved = false;
|
|
394
|
+
if (resources.worktreePresent && resources.removeWorktree) {
|
|
395
|
+
removeSessionWorktree(this.git, resources.gitCwd, record.worktreePath);
|
|
396
|
+
worktreeRemoved = true;
|
|
397
|
+
}
|
|
398
|
+
if (resources.branchPresent && resources.removeBranch) {
|
|
399
|
+
removeSessionBranch(this.git, resources.gitCwd, record.branchName);
|
|
400
|
+
branchRemoved = true;
|
|
401
|
+
}
|
|
402
|
+
const closedRecord = transitionSessionState(closingRecord, "closed", this.clock);
|
|
403
|
+
closingRecords = replaceRecord(closingRecords, closedRecord);
|
|
404
|
+
validateRecords(closingRecords, this.repository.repositoryId);
|
|
405
|
+
this.writeUnsafe(closingRecords);
|
|
406
|
+
return {
|
|
407
|
+
session: cloneSessionRecord(closedRecord),
|
|
408
|
+
worktreeRemoved,
|
|
409
|
+
branchRemoved,
|
|
410
|
+
idempotent: false,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
inspectCleanupResources(record) {
|
|
414
|
+
const git = this.git;
|
|
415
|
+
const worktrees = listGitWorktrees(git, this.repository.worktreePath);
|
|
416
|
+
const gitCwd = worktrees.find((worktree) => !samePath(worktree.worktreePath, record.worktreePath))?.worktreePath ??
|
|
417
|
+
this.repository.worktreePath;
|
|
418
|
+
const registeredWorktree = worktrees.find((worktree) => samePath(worktree.worktreePath, record.worktreePath));
|
|
419
|
+
const branchWorktree = worktrees.find((worktree) => worktree.branchName === record.branchName);
|
|
420
|
+
if (registeredWorktree !== undefined && registeredWorktree.branchName !== record.branchName) {
|
|
421
|
+
throw ownershipMismatch(record, "The registered worktree branch does not match the session branch", {
|
|
422
|
+
actualBranch: registeredWorktree.branchName ?? "<detached>",
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
if (branchWorktree !== undefined && !samePath(branchWorktree.worktreePath, record.worktreePath)) {
|
|
426
|
+
throw ownershipMismatch(record, "The session branch is checked out by another worktree", {
|
|
427
|
+
actualWorktree: branchWorktree.worktreePath,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
const pathEntry = lstatIfPresent(record.worktreePath);
|
|
431
|
+
if (registeredWorktree === undefined && pathEntry !== undefined) {
|
|
432
|
+
throw ownershipMismatch(record, "The session worktree path exists but is not a Git worktree");
|
|
433
|
+
}
|
|
434
|
+
if (pathEntry !== undefined && (pathEntry.isSymbolicLink() || !pathEntry.isDirectory())) {
|
|
435
|
+
throw ownershipMismatch(record, "The session worktree path is not a directory worktree");
|
|
436
|
+
}
|
|
437
|
+
const worktreePresent = registeredWorktree !== undefined;
|
|
438
|
+
const branchPresent = localBranchExists(git, this.repository.worktreePath, record.branchId);
|
|
439
|
+
if (worktreePresent) {
|
|
440
|
+
if (!branchPresent || registeredWorktree?.branchName !== record.branchName) {
|
|
441
|
+
throw ownershipMismatch(record, "The registered worktree no longer has the owned local branch");
|
|
442
|
+
}
|
|
443
|
+
assertWorktreeClean(git, record.worktreePath);
|
|
444
|
+
}
|
|
445
|
+
const worktreeProtection = this.protectedWorktree(record.worktreePath, worktrees);
|
|
446
|
+
const branchProtection = this.protectedBranch(record.branchName, worktrees);
|
|
447
|
+
const removeBranch = branchPresent && !branchProtection && this.branchIsReachableFromIntegration(record);
|
|
448
|
+
return {
|
|
449
|
+
gitCwd,
|
|
450
|
+
worktreePresent,
|
|
451
|
+
branchPresent,
|
|
452
|
+
removeWorktree: worktreePresent && !worktreeProtection,
|
|
453
|
+
removeBranch,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
protectedWorktree(worktreePath, worktrees) {
|
|
457
|
+
const defaultWorktreePath = worktrees[0]?.worktreePath ?? this.repository.worktreePath;
|
|
458
|
+
const configured = this.protectedWorktreePaths.map((candidate) => resolvePotentialWorktreePath(candidate, this.repository.worktreePath));
|
|
459
|
+
return (samePath(worktreePath, defaultWorktreePath) || configured.some((candidate) => samePath(worktreePath, candidate)));
|
|
460
|
+
}
|
|
461
|
+
protectedBranch(branchName, worktrees) {
|
|
462
|
+
const defaultBranchName = resolveDefaultBranchName(this.git, this.repository.worktreePath, worktrees, this.defaultBranchName);
|
|
463
|
+
const protectedBranchIds = [
|
|
464
|
+
...(defaultBranchName === undefined ? [] : [normalizeBranchId(defaultBranchName)]),
|
|
465
|
+
...this.protectedBranchNames.map((candidate) => normalizeBranchId(candidate)),
|
|
466
|
+
];
|
|
467
|
+
return protectedBranchIds.includes(normalizeBranchId(branchName));
|
|
468
|
+
}
|
|
469
|
+
branchIsReachableFromIntegration(record) {
|
|
470
|
+
const git = this.git;
|
|
471
|
+
const worktrees = listGitWorktrees(git, this.repository.worktreePath);
|
|
472
|
+
const defaultBranchName = resolveDefaultBranchName(git, this.repository.worktreePath, worktrees, this.defaultBranchName);
|
|
473
|
+
if (defaultBranchName === undefined) {
|
|
474
|
+
throw new SessionRegistryError("RECOVERABLE_COMMITS", `Cannot prove that commits on ${record.branchName} are safely retained: no integration branch is known`, { branch: record.branchName });
|
|
475
|
+
}
|
|
476
|
+
if (normalizeBranchId(defaultBranchName) === record.branchId)
|
|
477
|
+
return true;
|
|
478
|
+
try {
|
|
479
|
+
git.run(["merge-base", "--is-ancestor", record.branchId, normalizeBranchId(defaultBranchName)], this.repository.worktreePath);
|
|
480
|
+
return true;
|
|
481
|
+
}
|
|
482
|
+
catch (error) {
|
|
483
|
+
if (error instanceof SessionRegistryError && error.code === "GIT_COMMAND_FAILED") {
|
|
484
|
+
throw new SessionRegistryError("RECOVERABLE_COMMITS", `Commits on ${record.branchName} are not proven reachable from ${defaultBranchName}`, { branch: record.branchName, integrationBranch: defaultBranchName }, error);
|
|
485
|
+
}
|
|
486
|
+
throw error;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
resolveProvisioningResources(options, sessionId) {
|
|
490
|
+
const git = this.git;
|
|
491
|
+
const worktrees = listGitWorktrees(git, this.repository.worktreePath);
|
|
492
|
+
const requestedWorktreePath = resolveProvisionedWorktreePath(options.worktreePath ??
|
|
493
|
+
path.join(this.worktreeRoot, `${path.basename(this.repository.worktreePath)}-${sessionId}`), this.repository.worktreePath);
|
|
494
|
+
const defaultWorktreePath = worktrees[0]?.worktreePath ?? this.repository.worktreePath;
|
|
495
|
+
const configuredProtectedWorktrees = [
|
|
496
|
+
...this.protectedWorktreePaths,
|
|
497
|
+
...(options.protectedWorktreePaths ?? []),
|
|
498
|
+
].map((candidate) => resolvePotentialWorktreePath(candidate, this.repository.worktreePath));
|
|
499
|
+
if (samePath(requestedWorktreePath, defaultWorktreePath) ||
|
|
500
|
+
samePath(requestedWorktreePath, this.repository.worktreePath) ||
|
|
501
|
+
configuredProtectedWorktrees.some((candidate) => samePath(requestedWorktreePath, candidate))) {
|
|
502
|
+
throw new SessionRegistryError("PROTECTED_WORKTREE", `The integration worktree cannot be used as a session worktree: ${requestedWorktreePath}`, { worktree: requestedWorktreePath });
|
|
503
|
+
}
|
|
504
|
+
const branchName = options.branchName ?? `nawabari/session/${sessionId}`;
|
|
505
|
+
const branchId = normalizeBranchId(branchName);
|
|
506
|
+
const shortBranchName = branchId.slice("refs/heads/".length);
|
|
507
|
+
const defaultBranchName = resolveDefaultBranchName(git, this.repository.worktreePath, worktrees, options.defaultBranchName ?? this.defaultBranchName);
|
|
508
|
+
const protectedBranchIds = new Set();
|
|
509
|
+
if (defaultBranchName !== undefined)
|
|
510
|
+
protectedBranchIds.add(normalizeBranchId(defaultBranchName));
|
|
511
|
+
for (const protectedBranch of [...this.protectedBranchNames, ...(options.protectedBranchNames ?? [])]) {
|
|
512
|
+
protectedBranchIds.add(normalizeBranchId(protectedBranch));
|
|
513
|
+
}
|
|
514
|
+
if (protectedBranchIds.has(branchId)) {
|
|
515
|
+
throw new SessionRegistryError("PROTECTED_BRANCH", `Protected branch cannot be used by a session: ${shortBranchName}`, {
|
|
516
|
+
branch: shortBranchName,
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
const baseRef = resolveBaseRef(git, this.repository.worktreePath, options.baseRef ?? "HEAD");
|
|
520
|
+
return {
|
|
521
|
+
worktreePath: requestedWorktreePath,
|
|
522
|
+
branchId,
|
|
523
|
+
branchName: shortBranchName,
|
|
524
|
+
baseRef,
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
resolveCreationResources(options) {
|
|
528
|
+
const worktreePath = canonicalWorktreePath(options.worktreePath ?? this.repository.worktreePath);
|
|
529
|
+
const branchName = options.branchName ??
|
|
530
|
+
(worktreePath === this.repository.worktreePath ? readCurrentBranch(this.git, worktreePath) : undefined);
|
|
531
|
+
if (branchName === undefined) {
|
|
532
|
+
throw new SessionRegistryError("WORKTREE_IDENTITY_AMBIGUOUS", `A branch identity is required for a worktree other than the current worktree: ${worktreePath}`, { worktree: worktreePath });
|
|
533
|
+
}
|
|
534
|
+
const branchId = normalizeBranchId(branchName);
|
|
535
|
+
return {
|
|
536
|
+
worktreeId: worktreePath,
|
|
537
|
+
worktreePath,
|
|
538
|
+
branchId,
|
|
539
|
+
branchName: branchId.slice("refs/heads/".length),
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
readUnsafe() {
|
|
543
|
+
let contents;
|
|
544
|
+
try {
|
|
545
|
+
contents = fs.readFileSync(this.paths.registry, "utf8");
|
|
546
|
+
}
|
|
547
|
+
catch (error) {
|
|
548
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
549
|
+
return [];
|
|
550
|
+
}
|
|
551
|
+
throw new SessionRegistryError("REGISTRY_IO_FAILURE", `Could not read ${this.paths.registry}`, {
|
|
552
|
+
path: this.paths.registry,
|
|
553
|
+
}, error);
|
|
554
|
+
}
|
|
555
|
+
let parsed;
|
|
556
|
+
try {
|
|
557
|
+
parsed = JSON.parse(contents);
|
|
558
|
+
}
|
|
559
|
+
catch (error) {
|
|
560
|
+
throw new SessionRegistryError("REGISTRY_CORRUPT", `Registry is not valid JSON: ${this.paths.registry}`, {
|
|
561
|
+
path: this.paths.registry,
|
|
562
|
+
}, error);
|
|
563
|
+
}
|
|
564
|
+
return parseRegistry(parsed, this.repository.repositoryId);
|
|
565
|
+
}
|
|
566
|
+
writeUnsafe(records) {
|
|
567
|
+
const registry = {
|
|
568
|
+
schema_version: REGISTRY_SCHEMA_VERSION,
|
|
569
|
+
repository_id: this.repository.repositoryId,
|
|
570
|
+
sessions: records.map((record) => toPersistedSessionRecord(record, this.repository.repositoryId)),
|
|
571
|
+
};
|
|
572
|
+
const contents = `${JSON.stringify(registry, null, 2)}\n`;
|
|
573
|
+
const temporaryPath = `${this.paths.registry}.tmp-${process.pid}-${generateSessionId()}`;
|
|
574
|
+
let descriptor;
|
|
575
|
+
try {
|
|
576
|
+
fs.mkdirSync(this.paths.directory, { recursive: true, mode: 0o700 });
|
|
577
|
+
descriptor = fs.openSync(temporaryPath, "wx", 0o600);
|
|
578
|
+
fs.writeFileSync(descriptor, contents, "utf8");
|
|
579
|
+
fs.fsyncSync(descriptor);
|
|
580
|
+
fs.closeSync(descriptor);
|
|
581
|
+
descriptor = undefined;
|
|
582
|
+
fs.renameSync(temporaryPath, this.paths.registry);
|
|
583
|
+
syncDirectory(this.paths.directory);
|
|
584
|
+
}
|
|
585
|
+
catch (error) {
|
|
586
|
+
if (descriptor !== undefined) {
|
|
587
|
+
closeQuietly(descriptor);
|
|
588
|
+
}
|
|
589
|
+
unlinkQuietly(temporaryPath);
|
|
590
|
+
throw new SessionRegistryError("REGISTRY_IO_FAILURE", `Could not atomically write ${this.paths.registry}`, {
|
|
591
|
+
path: this.paths.registry,
|
|
592
|
+
}, error);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
mutate(mutation) {
|
|
596
|
+
return this.withLock(() => {
|
|
597
|
+
const records = this.readUnsafe();
|
|
598
|
+
const { records: nextRecords, result } = mutation(records);
|
|
599
|
+
validateRecords(nextRecords, this.repository.repositoryId);
|
|
600
|
+
this.writeUnsafe(nextRecords);
|
|
601
|
+
return result;
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
withLock(operation) {
|
|
605
|
+
let lease;
|
|
606
|
+
try {
|
|
607
|
+
lease = this.lock.acquireSync();
|
|
608
|
+
}
|
|
609
|
+
catch (error) {
|
|
610
|
+
throw toSessionRegistryLockError(error, this.paths.lock);
|
|
611
|
+
}
|
|
612
|
+
let result;
|
|
613
|
+
let operationFailed = false;
|
|
614
|
+
let operationError;
|
|
615
|
+
try {
|
|
616
|
+
result = operation();
|
|
617
|
+
}
|
|
618
|
+
catch (error) {
|
|
619
|
+
operationFailed = true;
|
|
620
|
+
operationError = error;
|
|
621
|
+
}
|
|
622
|
+
let releaseError;
|
|
623
|
+
try {
|
|
624
|
+
lease.release();
|
|
625
|
+
}
|
|
626
|
+
catch (error) {
|
|
627
|
+
releaseError = toSessionRegistryLockError(error, this.paths.lock);
|
|
628
|
+
}
|
|
629
|
+
if (operationFailed) {
|
|
630
|
+
throw operationError;
|
|
631
|
+
}
|
|
632
|
+
if (releaseError !== undefined) {
|
|
633
|
+
throw releaseError;
|
|
634
|
+
}
|
|
635
|
+
return result;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
function assertStaleAfterMs(value) {
|
|
639
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
640
|
+
throw new RangeError("staleAfterMs must be a non-negative safe integer");
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
function isStaleCandidate(record, now, staleAfterMs, worktrees) {
|
|
644
|
+
if (record.state === "closed")
|
|
645
|
+
return false;
|
|
646
|
+
if (record.state === "stale" || record.state === "closing")
|
|
647
|
+
return true;
|
|
648
|
+
const age = Date.parse(now) - Date.parse(record.updatedAt);
|
|
649
|
+
const worktreePresent = worktrees.some((worktree) => samePath(worktree.worktreePath, record.worktreePath));
|
|
650
|
+
const pathEntryPresent = lstatIfPresent(record.worktreePath) !== undefined;
|
|
651
|
+
return age >= staleAfterMs || (!worktreePresent && !pathEntryPresent);
|
|
652
|
+
}
|
|
653
|
+
function replaceRecord(records, replacement) {
|
|
654
|
+
return records.map((record) => (record.sessionId === replacement.sessionId ? replacement : record));
|
|
655
|
+
}
|
|
656
|
+
function transitionSessionState(record, state, clock) {
|
|
657
|
+
const clockTimestamp = toTimestamp(clock());
|
|
658
|
+
const updatedAt = new Date(Math.max(Date.parse(record.updatedAt), Date.parse(clockTimestamp))).toISOString();
|
|
659
|
+
return freezeSessionRecord({ ...record, state, updatedAt });
|
|
660
|
+
}
|
|
661
|
+
function ownershipMismatch(record, message, details = {}) {
|
|
662
|
+
return new SessionRegistryError("OWNERSHIP_MISMATCH", message, {
|
|
663
|
+
sessionId: record.sessionId,
|
|
664
|
+
worktree: record.worktreePath,
|
|
665
|
+
branch: record.branchName,
|
|
666
|
+
...details,
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
function deniedGuard(code, base, details, ownerSessionId = base.ownerSessionId, state = base.state) {
|
|
670
|
+
return Object.freeze({
|
|
671
|
+
allowed: false,
|
|
672
|
+
code,
|
|
673
|
+
...base,
|
|
674
|
+
sessionId: ownerSessionId,
|
|
675
|
+
ownerSessionId,
|
|
676
|
+
state,
|
|
677
|
+
details: { ...details },
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
function assertWorktreeClean(git, worktreePath) {
|
|
681
|
+
// Ignore build/cache artifacts owned by the repository's .gitignore. Git's
|
|
682
|
+
// default status still reports tracked edits and recoverable untracked files.
|
|
683
|
+
const status = git.run(["status", "--porcelain=v1", "--untracked-files=all", "--ignored=no"], worktreePath);
|
|
684
|
+
if (status.length > 0) {
|
|
685
|
+
throw new SessionRegistryError("DIRTY_WORKTREE", `Worktree contains recoverable changes: ${worktreePath}`, {
|
|
686
|
+
worktree: worktreePath,
|
|
687
|
+
status,
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
function removeSessionWorktree(git, cwd, worktreePath) {
|
|
692
|
+
const force = !fs.existsSync(worktreePath);
|
|
693
|
+
git.run(["worktree", "remove", ...(force ? ["--force"] : []), worktreePath], cwd);
|
|
694
|
+
}
|
|
695
|
+
function removeSessionBranch(git, cwd, branchName) {
|
|
696
|
+
git.run(["branch", "-d", "--", branchName], cwd);
|
|
697
|
+
}
|
|
698
|
+
function generateUniqueSessionId(records, idGenerator) {
|
|
699
|
+
for (let attempt = 0; attempt < MAX_ID_GENERATION_ATTEMPTS; attempt += 1) {
|
|
700
|
+
const sessionId = idGenerator();
|
|
701
|
+
assertSessionId(sessionId);
|
|
702
|
+
if (!records.some((record) => record.sessionId === sessionId))
|
|
703
|
+
return sessionId;
|
|
704
|
+
}
|
|
705
|
+
throw new SessionRegistryError("SESSION_ID_COLLISION", `Could not generate a unique session ID after ${MAX_ID_GENERATION_ATTEMPTS} attempts`, { attempts: MAX_ID_GENERATION_ATTEMPTS });
|
|
706
|
+
}
|
|
707
|
+
function resolveProvisionedWorktreePath(candidate, baseDirectory) {
|
|
708
|
+
const resolved = resolvePotentialWorktreePath(candidate, baseDirectory);
|
|
709
|
+
if (resolved === path.parse(resolved).root) {
|
|
710
|
+
throw new SessionRegistryError("INVALID_WORKTREE_PATH", `A filesystem root cannot be a session worktree: ${resolved}`, {
|
|
711
|
+
worktree: resolved,
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
const entry = lstatIfPresent(resolved);
|
|
715
|
+
if (entry !== undefined) {
|
|
716
|
+
if (!entry.isDirectory() || entry.isSymbolicLink()) {
|
|
717
|
+
throw new SessionRegistryError("INVALID_WORKTREE_PATH", `Worktree path is not a directory: ${resolved}`, {
|
|
718
|
+
worktree: resolved,
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
return resolved;
|
|
722
|
+
}
|
|
723
|
+
const parent = path.dirname(resolved);
|
|
724
|
+
try {
|
|
725
|
+
if (!fs.statSync(parent).isDirectory())
|
|
726
|
+
throw new Error("worktree parent is not a directory");
|
|
727
|
+
return resolved;
|
|
728
|
+
}
|
|
729
|
+
catch (error) {
|
|
730
|
+
throw new SessionRegistryError("INVALID_WORKTREE_PATH", `Worktree parent does not exist: ${parent}`, { worktree: resolved }, error);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
function resolvePotentialWorktreePath(candidate, baseDirectory) {
|
|
734
|
+
if (candidate.includes("\u0000") || candidate.trim().length === 0) {
|
|
735
|
+
throw new SessionRegistryError("INVALID_WORKTREE_PATH", `Invalid worktree path: ${candidate}`, {
|
|
736
|
+
worktree: candidate,
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
const resolved = path.resolve(baseDirectory, candidate);
|
|
740
|
+
assertNoSymlinkPath(resolved);
|
|
741
|
+
try {
|
|
742
|
+
return fs.realpathSync.native(resolved);
|
|
743
|
+
}
|
|
744
|
+
catch {
|
|
745
|
+
const parent = path.dirname(resolved);
|
|
746
|
+
try {
|
|
747
|
+
return path.join(fs.realpathSync.native(parent), path.basename(resolved));
|
|
748
|
+
}
|
|
749
|
+
catch {
|
|
750
|
+
return resolved;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
function assertNoSymlinkPath(candidate) {
|
|
755
|
+
const root = path.parse(candidate).root;
|
|
756
|
+
let current = root;
|
|
757
|
+
for (const component of path.relative(root, candidate).split(path.sep).filter(Boolean)) {
|
|
758
|
+
current = path.join(current, component);
|
|
759
|
+
let entry;
|
|
760
|
+
try {
|
|
761
|
+
entry = fs.lstatSync(current);
|
|
762
|
+
}
|
|
763
|
+
catch (error) {
|
|
764
|
+
if (isNodeError(error) && error.code === "ENOENT")
|
|
765
|
+
break;
|
|
766
|
+
throw new SessionRegistryError("INVALID_WORKTREE_PATH", `Could not inspect worktree path: ${candidate}`, { worktree: candidate }, error);
|
|
767
|
+
}
|
|
768
|
+
if (entry.isSymbolicLink()) {
|
|
769
|
+
throw new SessionRegistryError("INVALID_WORKTREE_PATH", `Worktree path contains a symbolic link: ${candidate}`, {
|
|
770
|
+
worktree: candidate,
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
function samePath(left, right) {
|
|
776
|
+
return path.resolve(left) === path.resolve(right);
|
|
777
|
+
}
|
|
778
|
+
function resolveDefaultBranchName(git, cwd, worktrees, configured) {
|
|
779
|
+
if (configured !== undefined)
|
|
780
|
+
return configured;
|
|
781
|
+
const integrationBranch = worktrees[0]?.branchName;
|
|
782
|
+
if (integrationBranch !== null && integrationBranch !== undefined)
|
|
783
|
+
return integrationBranch;
|
|
784
|
+
try {
|
|
785
|
+
const configuredDefault = git.run(["config", "--get", "init.defaultBranch"], cwd);
|
|
786
|
+
if (configuredDefault.length > 0)
|
|
787
|
+
return configuredDefault;
|
|
788
|
+
}
|
|
789
|
+
catch {
|
|
790
|
+
// A repository without init.defaultBranch is valid; continue to the local HEAD fallback.
|
|
791
|
+
}
|
|
792
|
+
try {
|
|
793
|
+
const remoteHead = git.run(["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"], cwd);
|
|
794
|
+
if (remoteHead.startsWith("origin/"))
|
|
795
|
+
return remoteHead.slice("origin/".length);
|
|
796
|
+
}
|
|
797
|
+
catch {
|
|
798
|
+
// A local repository may not have an origin or a symbolic remote HEAD.
|
|
799
|
+
}
|
|
800
|
+
return undefined;
|
|
801
|
+
}
|
|
802
|
+
function localBranchExists(git, cwd, branchId) {
|
|
803
|
+
try {
|
|
804
|
+
git.run(["show-ref", "--verify", "--quiet", branchId], cwd);
|
|
805
|
+
return true;
|
|
806
|
+
}
|
|
807
|
+
catch (error) {
|
|
808
|
+
if (error instanceof SessionRegistryError && error.code === "GIT_COMMAND_FAILED")
|
|
809
|
+
return false;
|
|
810
|
+
throw error;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
function assertGitResourcesAvailable(git, cwd, resources) {
|
|
814
|
+
const worktrees = listGitWorktrees(git, cwd);
|
|
815
|
+
if (worktrees.some((worktree) => samePath(worktree.worktreePath, resources.worktreePath))) {
|
|
816
|
+
throw new SessionRegistryError("WORKTREE_ALREADY_EXISTS", `Worktree path already exists: ${resources.worktreePath}`, {
|
|
817
|
+
worktree: resources.worktreePath,
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
const worktreeEntry = lstatIfPresent(resources.worktreePath);
|
|
821
|
+
if (worktreeEntry?.isSymbolicLink()) {
|
|
822
|
+
throw new SessionRegistryError("INVALID_WORKTREE_PATH", `Worktree path is a symbolic link: ${resources.worktreePath}`, {
|
|
823
|
+
worktree: resources.worktreePath,
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
if (worktreeEntry !== undefined) {
|
|
827
|
+
throw new SessionRegistryError("WORKTREE_ALREADY_EXISTS", `Worktree path already exists: ${resources.worktreePath}`, {
|
|
828
|
+
worktree: resources.worktreePath,
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
if (localBranchExists(git, cwd, resources.branchId)) {
|
|
832
|
+
throw new SessionRegistryError("BRANCH_ALREADY_EXISTS", `Local branch already exists: ${resources.branchName}`, {
|
|
833
|
+
branch: resources.branchName,
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
function resolveBaseRef(git, cwd, candidate) {
|
|
838
|
+
if (candidate.trim().length === 0 ||
|
|
839
|
+
candidate !== candidate.trim() ||
|
|
840
|
+
candidate.startsWith("-") ||
|
|
841
|
+
candidate.includes("\u0000")) {
|
|
842
|
+
throw new SessionRegistryError("INVALID_BASE_REF", `Invalid base ref: ${candidate}`, { baseRef: candidate });
|
|
843
|
+
}
|
|
844
|
+
try {
|
|
845
|
+
git.run(["rev-parse", "--verify", `${candidate}^{commit}`], cwd);
|
|
846
|
+
return candidate;
|
|
847
|
+
}
|
|
848
|
+
catch (error) {
|
|
849
|
+
if (error instanceof SessionRegistryError && error.code === "GIT_COMMAND_FAILED") {
|
|
850
|
+
throw new SessionRegistryError("INVALID_BASE_REF", `Base ref does not resolve to a commit: ${candidate}`, {
|
|
851
|
+
baseRef: candidate,
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
throw error;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
function rollbackProvisionedResources(git, cwd, resources) {
|
|
858
|
+
try {
|
|
859
|
+
git.run(["worktree", "remove", "--force", "--", resources.worktreePath], cwd);
|
|
860
|
+
}
|
|
861
|
+
catch {
|
|
862
|
+
// Best effort: the registry must remain unclaimed even if Git cleanup fails.
|
|
863
|
+
}
|
|
864
|
+
try {
|
|
865
|
+
git.run(["branch", "-D", "--", resources.branchName], cwd);
|
|
866
|
+
}
|
|
867
|
+
catch {
|
|
868
|
+
// Best effort: never replace the original provisioning or registry error.
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
function lstatIfPresent(candidate) {
|
|
872
|
+
try {
|
|
873
|
+
return fs.lstatSync(candidate);
|
|
874
|
+
}
|
|
875
|
+
catch (error) {
|
|
876
|
+
if (isNodeError(error) && error.code === "ENOENT")
|
|
877
|
+
return undefined;
|
|
878
|
+
throw new SessionRegistryError("INVALID_WORKTREE_PATH", `Could not inspect worktree path: ${candidate}`, { worktree: candidate }, error);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
export function toPersistedSessionRecord(record, expectedRepositoryId = record.repositoryId) {
|
|
882
|
+
const validated = validateSessionRecord(record, expectedRepositoryId);
|
|
883
|
+
return {
|
|
884
|
+
schema_version: validated.schemaVersion,
|
|
885
|
+
session_id: validated.sessionId,
|
|
886
|
+
repository_id: validated.repositoryId,
|
|
887
|
+
worktree_id: validated.worktreeId,
|
|
888
|
+
worktree_path: validated.worktreePath,
|
|
889
|
+
branch_id: validated.branchId,
|
|
890
|
+
branch_name: validated.branchName,
|
|
891
|
+
state: validated.state,
|
|
892
|
+
created_at: validated.createdAt,
|
|
893
|
+
updated_at: validated.updatedAt,
|
|
894
|
+
...(validated.label === undefined ? {} : { label: validated.label }),
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
function parseRegistry(value, expectedRepositoryId) {
|
|
898
|
+
if (!isRecord(value)) {
|
|
899
|
+
throw new SessionRegistryError("REGISTRY_CORRUPT", "Registry root must be an object");
|
|
900
|
+
}
|
|
901
|
+
if (value.schema_version !== REGISTRY_SCHEMA_VERSION) {
|
|
902
|
+
if (typeof value.schema_version === "number") {
|
|
903
|
+
throw new SessionRegistryError("UNSUPPORTED_SCHEMA_VERSION", `Unsupported registry schema version: ${value.schema_version}`, {
|
|
904
|
+
schemaVersion: value.schema_version,
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
throw new SessionRegistryError("REGISTRY_CORRUPT", "Registry schema_version must be a number");
|
|
908
|
+
}
|
|
909
|
+
assertExactKeys(value, ["schema_version", "repository_id", "sessions"]);
|
|
910
|
+
if (typeof value.repository_id !== "string" || value.repository_id !== expectedRepositoryId) {
|
|
911
|
+
throw new SessionRegistryError("REGISTRY_REPOSITORY_MISMATCH", "Registry repository identity does not match the current repository", { expectedRepositoryId, actualRepositoryId: stringifyDetail(value.repository_id) });
|
|
912
|
+
}
|
|
913
|
+
if (!Array.isArray(value.sessions)) {
|
|
914
|
+
throw new SessionRegistryError("REGISTRY_CORRUPT", "Registry sessions must be an array");
|
|
915
|
+
}
|
|
916
|
+
const records = value.sessions.map((candidate, index) => parseSessionRecord(candidate, index, expectedRepositoryId));
|
|
917
|
+
validateRecords(records, expectedRepositoryId);
|
|
918
|
+
return records;
|
|
919
|
+
}
|
|
920
|
+
function parseSessionRecord(value, index, expectedRepositoryId) {
|
|
921
|
+
if (!isRecord(value)) {
|
|
922
|
+
throw invalidRecord(index, "record must be an object");
|
|
923
|
+
}
|
|
924
|
+
assertExactKeys(value, [
|
|
925
|
+
"schema_version",
|
|
926
|
+
"session_id",
|
|
927
|
+
"repository_id",
|
|
928
|
+
"worktree_id",
|
|
929
|
+
"worktree_path",
|
|
930
|
+
"branch_id",
|
|
931
|
+
"branch_name",
|
|
932
|
+
"state",
|
|
933
|
+
"created_at",
|
|
934
|
+
"updated_at",
|
|
935
|
+
], ["label"], index);
|
|
936
|
+
if (value.schema_version !== REGISTRY_SCHEMA_VERSION) {
|
|
937
|
+
if (typeof value.schema_version === "number") {
|
|
938
|
+
throw new SessionRegistryError("UNSUPPORTED_SCHEMA_VERSION", `Unsupported session schema version: ${value.schema_version}`, {
|
|
939
|
+
schemaVersion: value.schema_version,
|
|
940
|
+
index,
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
throw invalidRecord(index, "schema_version must be a number");
|
|
944
|
+
}
|
|
945
|
+
const record = {
|
|
946
|
+
schemaVersion: REGISTRY_SCHEMA_VERSION,
|
|
947
|
+
sessionId: requireString(value.session_id, index, "session_id"),
|
|
948
|
+
repositoryId: requireString(value.repository_id, index, "repository_id"),
|
|
949
|
+
worktreeId: requireString(value.worktree_id, index, "worktree_id"),
|
|
950
|
+
worktreePath: requireString(value.worktree_path, index, "worktree_path"),
|
|
951
|
+
branchId: requireString(value.branch_id, index, "branch_id"),
|
|
952
|
+
branchName: requireString(value.branch_name, index, "branch_name"),
|
|
953
|
+
state: requireState(value.state, index),
|
|
954
|
+
createdAt: requireString(value.created_at, index, "created_at"),
|
|
955
|
+
updatedAt: requireString(value.updated_at, index, "updated_at"),
|
|
956
|
+
...(value.label === undefined ? {} : { label: requireString(value.label, index, "label") }),
|
|
957
|
+
};
|
|
958
|
+
return validateSessionRecord(record, expectedRepositoryId, index);
|
|
959
|
+
}
|
|
960
|
+
function validateSessionRecord(record, expectedRepositoryId, index) {
|
|
961
|
+
const position = index === undefined ? "" : ` at index ${index}`;
|
|
962
|
+
if (record.schemaVersion !== REGISTRY_SCHEMA_VERSION) {
|
|
963
|
+
throw new SessionRegistryError("UNSUPPORTED_SCHEMA_VERSION", `Unsupported session schema version${position}`, {
|
|
964
|
+
schemaVersion: record.schemaVersion,
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
if (!isSessionId(record.sessionId)) {
|
|
968
|
+
throw new SessionRegistryError("INVALID_SESSION_ID", `Invalid session ID${position}: ${record.sessionId}`, {
|
|
969
|
+
sessionId: record.sessionId,
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
if (record.repositoryId !== expectedRepositoryId) {
|
|
973
|
+
throw new SessionRegistryError("REGISTRY_REPOSITORY_MISMATCH", `Session repository identity does not match${position}`, {
|
|
974
|
+
expectedRepositoryId,
|
|
975
|
+
actualRepositoryId: record.repositoryId,
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
if (!isAbsolutePath(record.repositoryId) ||
|
|
979
|
+
!isAbsolutePath(record.worktreeId) ||
|
|
980
|
+
!isAbsolutePath(record.worktreePath)) {
|
|
981
|
+
throw invalidRecord(index, "repository and worktree identities must be absolute paths");
|
|
982
|
+
}
|
|
983
|
+
if (record.worktreeId !== record.worktreePath) {
|
|
984
|
+
throw invalidRecord(index, "worktree_id must equal the canonical worktree_path");
|
|
985
|
+
}
|
|
986
|
+
if (normalizeBranchId(record.branchName) !== record.branchId) {
|
|
987
|
+
throw invalidRecord(index, "branch_id must be the canonical identity of branch_name");
|
|
988
|
+
}
|
|
989
|
+
if (record.branchName !== record.branchId.slice("refs/heads/".length)) {
|
|
990
|
+
throw invalidRecord(index, "branch_name must be the short name represented by branch_id");
|
|
991
|
+
}
|
|
992
|
+
if (!SESSION_STATES.has(record.state)) {
|
|
993
|
+
throw invalidRecord(index, `unsupported lifecycle state: ${record.state}`);
|
|
994
|
+
}
|
|
995
|
+
if (!isTimestamp(record.createdAt) || !isTimestamp(record.updatedAt)) {
|
|
996
|
+
throw invalidRecord(index, "created_at and updated_at must be canonical UTC timestamps");
|
|
997
|
+
}
|
|
998
|
+
if (Date.parse(record.updatedAt) < Date.parse(record.createdAt)) {
|
|
999
|
+
throw invalidRecord(index, "updated_at cannot precede created_at");
|
|
1000
|
+
}
|
|
1001
|
+
if (record.label !== undefined && (typeof record.label !== "string" || record.label.length === 0)) {
|
|
1002
|
+
throw invalidRecord(index, "label must be a non-empty string");
|
|
1003
|
+
}
|
|
1004
|
+
return freezeSessionRecord({ ...record });
|
|
1005
|
+
}
|
|
1006
|
+
function validateRecords(records, expectedRepositoryId) {
|
|
1007
|
+
const sessionIds = new Set();
|
|
1008
|
+
const worktreeOwners = new Map();
|
|
1009
|
+
const branchOwners = new Map();
|
|
1010
|
+
for (const [index, record] of records.entries()) {
|
|
1011
|
+
const validated = validateSessionRecord(record, expectedRepositoryId, index);
|
|
1012
|
+
if (sessionIds.has(validated.sessionId)) {
|
|
1013
|
+
throw new SessionRegistryError("DUPLICATE_SESSION_ID", `Duplicate session ID: ${validated.sessionId}`, {
|
|
1014
|
+
sessionId: validated.sessionId,
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
sessionIds.add(validated.sessionId);
|
|
1018
|
+
if (!ACTIVE_STATES.has(validated.state)) {
|
|
1019
|
+
continue;
|
|
1020
|
+
}
|
|
1021
|
+
const existingWorktreeOwner = worktreeOwners.get(validated.worktreeId);
|
|
1022
|
+
if (existingWorktreeOwner !== undefined) {
|
|
1023
|
+
throw new SessionRegistryError("DUPLICATE_WORKTREE_OWNERSHIP", `Worktree is claimed by multiple active sessions: ${validated.worktreePath}`, { worktree: validated.worktreePath, sessionId: validated.sessionId, ownerSessionId: existingWorktreeOwner });
|
|
1024
|
+
}
|
|
1025
|
+
worktreeOwners.set(validated.worktreeId, validated.sessionId);
|
|
1026
|
+
const existingBranchOwner = branchOwners.get(validated.branchId);
|
|
1027
|
+
if (existingBranchOwner !== undefined) {
|
|
1028
|
+
throw new SessionRegistryError("DUPLICATE_BRANCH_OWNERSHIP", `Branch is claimed by multiple active sessions: ${validated.branchId}`, { branch: validated.branchId, sessionId: validated.sessionId, ownerSessionId: existingBranchOwner });
|
|
1029
|
+
}
|
|
1030
|
+
branchOwners.set(validated.branchId, validated.sessionId);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
function assertNoOwnershipConflict(records, candidate) {
|
|
1034
|
+
if (!ACTIVE_STATES.has(candidate.state)) {
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
for (const record of records) {
|
|
1038
|
+
if (!ACTIVE_STATES.has(record.state)) {
|
|
1039
|
+
continue;
|
|
1040
|
+
}
|
|
1041
|
+
if (record.worktreeId === candidate.worktreeId) {
|
|
1042
|
+
throw new SessionRegistryError("DUPLICATE_WORKTREE_OWNERSHIP", `Worktree is already owned: ${candidate.worktreePath}`, {
|
|
1043
|
+
worktree: candidate.worktreePath,
|
|
1044
|
+
ownerSessionId: record.sessionId,
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
1047
|
+
if (record.branchId === candidate.branchId) {
|
|
1048
|
+
throw new SessionRegistryError("DUPLICATE_BRANCH_OWNERSHIP", `Branch is already owned: ${candidate.branchId}`, {
|
|
1049
|
+
branch: candidate.branchId,
|
|
1050
|
+
ownerSessionId: record.sessionId,
|
|
1051
|
+
});
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
function canonicalWorktreePath(candidate) {
|
|
1056
|
+
const resolved = path.resolve(candidate);
|
|
1057
|
+
try {
|
|
1058
|
+
const stat = fs.statSync(resolved);
|
|
1059
|
+
if (!stat.isDirectory()) {
|
|
1060
|
+
throw new Error("path is not a directory");
|
|
1061
|
+
}
|
|
1062
|
+
return fs.realpathSync.native(resolved);
|
|
1063
|
+
}
|
|
1064
|
+
catch (error) {
|
|
1065
|
+
throw new SessionRegistryError("WORKTREE_IDENTITY_AMBIGUOUS", `Could not resolve worktree identity: ${resolved}`, {
|
|
1066
|
+
worktree: resolved,
|
|
1067
|
+
}, error);
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
function assertSessionId(sessionId) {
|
|
1071
|
+
if (!isSessionId(sessionId)) {
|
|
1072
|
+
throw new SessionRegistryError("INVALID_SESSION_ID", `Invalid session ID: ${sessionId}`, { sessionId });
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
function validateLabel(label) {
|
|
1076
|
+
if (typeof label !== "string" || label.length === 0) {
|
|
1077
|
+
throw new SessionRegistryError("INVALID_SESSION_RECORD", "Session label must be a non-empty string");
|
|
1078
|
+
}
|
|
1079
|
+
return label;
|
|
1080
|
+
}
|
|
1081
|
+
function toTimestamp(date) {
|
|
1082
|
+
if (Number.isNaN(date.getTime())) {
|
|
1083
|
+
throw new SessionRegistryError("INVALID_SESSION_RECORD", "Session clock returned an invalid timestamp");
|
|
1084
|
+
}
|
|
1085
|
+
return date.toISOString();
|
|
1086
|
+
}
|
|
1087
|
+
function isTimestamp(value) {
|
|
1088
|
+
return (ISO_TIMESTAMP_PATTERN.test(value) && !Number.isNaN(Date.parse(value)) && new Date(value).toISOString() === value);
|
|
1089
|
+
}
|
|
1090
|
+
function isAbsolutePath(value) {
|
|
1091
|
+
return path.isAbsolute(value) && path.resolve(value) === value && !value.includes("\u0000");
|
|
1092
|
+
}
|
|
1093
|
+
function cloneSessionRecord(record) {
|
|
1094
|
+
return freezeSessionRecord({ ...record });
|
|
1095
|
+
}
|
|
1096
|
+
function freezeSessionRecord(record) {
|
|
1097
|
+
return Object.freeze(record);
|
|
1098
|
+
}
|
|
1099
|
+
function requireString(value, index, field) {
|
|
1100
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
1101
|
+
throw invalidRecord(index, `${field} must be a non-empty string`);
|
|
1102
|
+
}
|
|
1103
|
+
return value;
|
|
1104
|
+
}
|
|
1105
|
+
function requireState(value, index) {
|
|
1106
|
+
if (typeof value !== "string" || !SESSION_STATES.has(value)) {
|
|
1107
|
+
throw invalidRecord(index, `state is invalid: ${stringifyDetail(value)}`);
|
|
1108
|
+
}
|
|
1109
|
+
return value;
|
|
1110
|
+
}
|
|
1111
|
+
function invalidRecord(index, reason) {
|
|
1112
|
+
return new SessionRegistryError("INVALID_SESSION_RECORD", `Invalid session record${index === undefined ? "" : ` at index ${index}`}: ${reason}`, {
|
|
1113
|
+
...(index === undefined ? {} : { index }),
|
|
1114
|
+
});
|
|
1115
|
+
}
|
|
1116
|
+
function assertExactKeys(value, required, optional = [], index) {
|
|
1117
|
+
const allowed = new Set([...required, ...optional]);
|
|
1118
|
+
const keys = Object.keys(value);
|
|
1119
|
+
if (keys.length !== required.length + optional.filter((key) => Object.hasOwn(value, key)).length ||
|
|
1120
|
+
keys.some((key) => !allowed.has(key))) {
|
|
1121
|
+
throw invalidRecord(index, `unexpected or missing fields: ${keys.join(", ")}`);
|
|
1122
|
+
}
|
|
1123
|
+
for (const key of required) {
|
|
1124
|
+
if (!Object.hasOwn(value, key)) {
|
|
1125
|
+
throw invalidRecord(index, `missing field: ${key}`);
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
function isRecord(value) {
|
|
1130
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1131
|
+
}
|
|
1132
|
+
function isNodeError(error) {
|
|
1133
|
+
return error instanceof Error && "code" in error && typeof error.code === "string";
|
|
1134
|
+
}
|
|
1135
|
+
function stringifyDetail(value) {
|
|
1136
|
+
return typeof value === "string" || typeof value === "number" || typeof value === "boolean"
|
|
1137
|
+
? String(value)
|
|
1138
|
+
: "<invalid>";
|
|
1139
|
+
}
|
|
1140
|
+
function toSessionRegistryLockError(error, lockPath) {
|
|
1141
|
+
if (error instanceof SessionRegistryError) {
|
|
1142
|
+
return error;
|
|
1143
|
+
}
|
|
1144
|
+
if (error instanceof RegistryLockError) {
|
|
1145
|
+
const details = { path: lockPath };
|
|
1146
|
+
for (const [key, value] of Object.entries(error.details)) {
|
|
1147
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
1148
|
+
details[key] = value;
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
const code = error.code === "LOCK_BUSY" || error.code === "LOCK_STALE" || error.code === "LOCK_INVALID"
|
|
1152
|
+
? "REGISTRY_LOCK_TIMEOUT"
|
|
1153
|
+
: "REGISTRY_IO_FAILURE";
|
|
1154
|
+
return new SessionRegistryError(code, error.message, details, error);
|
|
1155
|
+
}
|
|
1156
|
+
return new SessionRegistryError("REGISTRY_IO_FAILURE", `Could not operate on ${lockPath}`, { path: lockPath }, error);
|
|
1157
|
+
}
|
|
1158
|
+
function closeQuietly(descriptor) {
|
|
1159
|
+
try {
|
|
1160
|
+
fs.closeSync(descriptor);
|
|
1161
|
+
}
|
|
1162
|
+
catch {
|
|
1163
|
+
// The original operation's error is more useful than a best-effort close error.
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
function unlinkQuietly(filePath) {
|
|
1167
|
+
try {
|
|
1168
|
+
fs.unlinkSync(filePath);
|
|
1169
|
+
}
|
|
1170
|
+
catch {
|
|
1171
|
+
// A missing temporary file is already the desired state.
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
function syncDirectory(directory) {
|
|
1175
|
+
let descriptor;
|
|
1176
|
+
try {
|
|
1177
|
+
descriptor = fs.openSync(directory, "r");
|
|
1178
|
+
fs.fsyncSync(descriptor);
|
|
1179
|
+
}
|
|
1180
|
+
catch {
|
|
1181
|
+
// Directory fsync is not available on every supported filesystem. The
|
|
1182
|
+
// file itself was fsynced before rename, so continue on that limitation.
|
|
1183
|
+
}
|
|
1184
|
+
finally {
|
|
1185
|
+
if (descriptor !== undefined) {
|
|
1186
|
+
closeQuietly(descriptor);
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
//# sourceMappingURL=session-registry.js.map
|