@searls/turbocommit 0.15.0 → 0.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -11
- package/cli.js +247 -6
- package/lib/agent.js +10 -6
- package/lib/git.js +9 -5
- package/lib/install.js +11 -4
- package/lib/rescue.js +308 -0
- package/lib/run.js +128 -13
- package/lib/session.js +40 -1
- package/lib/track.js +671 -48
- package/package.json +1 -1
package/lib/track.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
const crypto = require('crypto')
|
|
2
|
+
const { execFileSync } = require('child_process')
|
|
2
3
|
const fs = require('fs')
|
|
3
4
|
const path = require('path')
|
|
4
5
|
const { fileURLToPath } = require('url')
|
|
5
6
|
const { ensureDir } = require('./io')
|
|
6
7
|
const { turbocommitDir } = require('./session')
|
|
7
|
-
const { canonicalRoot, canonicalTrackedPath, changedPathsInRepository } = require('./git')
|
|
8
|
+
const { canonicalRoot, canonicalTrackedPath, changedPathsInRepository, gitCommonDir } = require('./git')
|
|
8
9
|
|
|
9
10
|
const BASH_SNAPSHOT_TTL_MS = 60 * 60 * 1000
|
|
10
11
|
|
|
@@ -23,6 +24,11 @@ function trackingPath (root, sessionId) {
|
|
|
23
24
|
return dir && path.join(dir, sessionId + '.jsonl')
|
|
24
25
|
}
|
|
25
26
|
|
|
27
|
+
function preclaimDir (root) {
|
|
28
|
+
const base = turbocommitDir(root)
|
|
29
|
+
return base && path.join(base, 'preclaims')
|
|
30
|
+
}
|
|
31
|
+
|
|
26
32
|
/**
|
|
27
33
|
* Keys to probe in tool_input for a file path (MCP tools, Write, Edit, etc.)
|
|
28
34
|
*/
|
|
@@ -114,10 +120,11 @@ function extractFilePaths (toolName, toolInput, cwd) {
|
|
|
114
120
|
}
|
|
115
121
|
|
|
116
122
|
/**
|
|
117
|
-
* PreToolUse handler.
|
|
118
|
-
*
|
|
123
|
+
* PreToolUse handler. Persists ownership before waiting for overlap recovery.
|
|
124
|
+
* Returns false when the caller must deny the tool rather than let it run
|
|
125
|
+
* without a durable ownership claim.
|
|
119
126
|
*/
|
|
120
|
-
function handleTrack (input, root) {
|
|
127
|
+
function handleTrack (input, root, opts = {}) {
|
|
121
128
|
const hookInput = typeof input === 'string' ? parseInput(input) : input
|
|
122
129
|
root = root || hookInput?.root
|
|
123
130
|
if (!root || !hookInput) return
|
|
@@ -130,6 +137,11 @@ function handleTrack (input, root) {
|
|
|
130
137
|
|
|
131
138
|
const toolInput = hookInput.toolInput || hookInput.tool_input || {}
|
|
132
139
|
|
|
140
|
+
// Skip Bash with no command (malformed input). All other non-Bash tools
|
|
141
|
+
// passed the PreToolUse matcher, so they're known modifying tools even if
|
|
142
|
+
// we can't extract a specific file path (e.g. MultiEdit nests paths in edits[]).
|
|
143
|
+
if (toolName === 'Bash' && typeof toolInput.command !== 'string') return
|
|
144
|
+
|
|
133
145
|
const cwd = hookInput.cwd || hookInput.raw?.cwd || root
|
|
134
146
|
const entry = { tool: toolName, t: Date.now(), cwd }
|
|
135
147
|
const rawFiles = extractRawFilePaths(toolName, toolInput)
|
|
@@ -137,21 +149,53 @@ function handleTrack (input, root) {
|
|
|
137
149
|
if (rawFiles.length > 0) entry.rawFiles = rawFiles
|
|
138
150
|
if (files.length > 0) entry.files = files
|
|
139
151
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
152
|
+
const toolUseId = hookInput.toolUseId || hookInput.tool_use_id
|
|
153
|
+
let pendingSnapshot = false
|
|
154
|
+
let preclaim = null
|
|
155
|
+
try {
|
|
156
|
+
if (toolName === 'Bash') {
|
|
157
|
+
entry.command = toolInput.command
|
|
158
|
+
savePendingBashSnapshot(root, sessionId, toolUseId, cwd)
|
|
159
|
+
pendingSnapshot = true
|
|
160
|
+
appendTracking(root, sessionId, entry)
|
|
161
|
+
} else {
|
|
162
|
+
preclaim = savePreclaim(root, sessionId, entry)
|
|
163
|
+
}
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (pendingSnapshot) removeBashSnapshot(root, sessionId, toolUseId)
|
|
166
|
+
if (preclaim) removePreclaim(preclaim)
|
|
167
|
+
throw error
|
|
144
168
|
}
|
|
145
169
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
170
|
+
const recoveryWaitMs = opts.recoveryWaitMs ?? 5000
|
|
171
|
+
if (pendingSnapshot) {
|
|
172
|
+
const release = acquireBashOverlapRecoveryLock(root, recoveryWaitMs)
|
|
173
|
+
if (!release) {
|
|
174
|
+
removeBashSnapshot(root, sessionId, toolUseId)
|
|
175
|
+
return false
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
pruneBashSnapshots(root)
|
|
179
|
+
initializeBashSnapshot(root, sessionId, toolUseId, cwd)
|
|
180
|
+
} catch (error) {
|
|
181
|
+
removeBashSnapshot(root, sessionId, toolUseId)
|
|
182
|
+
throw error
|
|
183
|
+
} finally {
|
|
184
|
+
release()
|
|
185
|
+
}
|
|
186
|
+
return true
|
|
187
|
+
}
|
|
150
188
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
189
|
+
if (!waitForBashOverlapRecovery(root, recoveryWaitMs)) {
|
|
190
|
+
removePreclaim(preclaim)
|
|
191
|
+
return false
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
appendTracking(root, sessionId, entry)
|
|
195
|
+
return true
|
|
196
|
+
} finally {
|
|
197
|
+
removePreclaim(preclaim)
|
|
198
|
+
}
|
|
155
199
|
}
|
|
156
200
|
|
|
157
201
|
function handlePostTrack (input, root) {
|
|
@@ -163,31 +207,46 @@ function handlePostTrack (input, root) {
|
|
|
163
207
|
const toolName = hookInput.toolName || hookInput.tool_name
|
|
164
208
|
if (!sessionId || toolName !== 'Bash') return
|
|
165
209
|
|
|
166
|
-
const cwd = hookInput.cwd || hookInput.raw?.cwd || root
|
|
167
210
|
const toolUseId = hookInput.toolUseId || hookInput.tool_use_id
|
|
168
|
-
const
|
|
169
|
-
if (!
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
211
|
+
const release = acquireBashOverlapRecoveryLock(root, 5000)
|
|
212
|
+
if (!release) return
|
|
213
|
+
try {
|
|
214
|
+
const snapshot = loadBashSnapshot(root, sessionId, toolUseId)
|
|
215
|
+
if (!snapshot || Number.isFinite(snapshot.endedAt)) return
|
|
216
|
+
finishBashSnapshot(root, snapshot, {
|
|
217
|
+
cwd: hookInput.cwd || hookInput.raw?.cwd || snapshot.cwd || root,
|
|
218
|
+
endedAt: Date.now()
|
|
219
|
+
})
|
|
220
|
+
} finally {
|
|
221
|
+
release()
|
|
222
|
+
}
|
|
223
|
+
}
|
|
175
224
|
|
|
225
|
+
function finishBashSnapshot (root, snapshot, { cwd, endedAt }) {
|
|
226
|
+
if (snapshot.pending === true || !Array.isArray(snapshot.before) || !Number.isFinite(snapshot.startedAt)) return false
|
|
227
|
+
const overlaps = overlappingBashSnapshots(root, snapshot, endedAt)
|
|
176
228
|
const before = new Set(snapshot.before.map(canonicalTrackedPath))
|
|
177
|
-
const
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
229
|
+
const changed = changedPathsInRepository(snapshot.root).map(canonicalTrackedPath)
|
|
230
|
+
const overlapping = overlaps.length > 0
|
|
231
|
+
const claimed = overlapping
|
|
232
|
+
? claimedPathsBySessions(root)
|
|
233
|
+
: claimedPathsBySessions(root, snapshot.sessionId)
|
|
234
|
+
const candidates = changed.filter(file => !before.has(file) && !claimed.has(file))
|
|
235
|
+
const files = overlapping ? [] : candidates
|
|
236
|
+
|
|
237
|
+
const entry = { tool: 'Bash', phase: 'post', t: endedAt, cwd }
|
|
238
|
+
if (overlapping) {
|
|
239
|
+
entry.overlapping = true
|
|
240
|
+
entry.overlapEventId = recordBashOverlap(root, snapshot, overlaps, candidates, endedAt)
|
|
241
|
+
}
|
|
186
242
|
if (files.length > 0) {
|
|
187
243
|
entry.rawFiles = files
|
|
188
244
|
entry.files = files
|
|
189
245
|
}
|
|
190
|
-
appendTracking(root, sessionId, entry)
|
|
246
|
+
appendTracking(root, snapshot.sessionId, entry)
|
|
247
|
+
snapshot.endedAt = endedAt
|
|
248
|
+
writeBashSnapshot(root, snapshot.sessionId, snapshot.toolUseId, snapshot)
|
|
249
|
+
return true
|
|
191
250
|
}
|
|
192
251
|
|
|
193
252
|
function bashSnapshotDir (root) {
|
|
@@ -199,23 +258,43 @@ function bashSnapshotPath (root, sessionId, toolUseId) {
|
|
|
199
258
|
const dir = bashSnapshotDir(root)
|
|
200
259
|
if (!dir) return null
|
|
201
260
|
const key = crypto.createHash('sha256')
|
|
202
|
-
.update(`${sessionId}\0${toolUseId || 'current'}`)
|
|
261
|
+
.update(`${canonicalRoot(root)}\0${sessionId}\0${toolUseId || 'current'}`)
|
|
203
262
|
.digest('hex')
|
|
204
263
|
return path.join(dir, key + '.json')
|
|
205
264
|
}
|
|
206
265
|
|
|
207
|
-
function
|
|
266
|
+
function savePendingBashSnapshot (root, sessionId, toolUseId, cwd) {
|
|
208
267
|
const checkout = canonicalRoot(root)
|
|
209
|
-
pruneBashSnapshots(root)
|
|
210
268
|
writeBashSnapshot(root, sessionId, toolUseId, {
|
|
211
269
|
root: checkout,
|
|
270
|
+
cwd: cwd || checkout,
|
|
212
271
|
sessionId,
|
|
213
272
|
toolUseId: toolUseId || null,
|
|
273
|
+
createdAt: Date.now(),
|
|
274
|
+
pending: true
|
|
275
|
+
})
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function initializeBashSnapshot (root, sessionId, toolUseId, cwd) {
|
|
279
|
+
const checkout = canonicalRoot(root)
|
|
280
|
+
const snapshot = loadBashSnapshot(root, sessionId, toolUseId)
|
|
281
|
+
if (!snapshot || snapshot.pending !== true) throw new Error('Bash ownership snapshot disappeared before initialization')
|
|
282
|
+
writeBashSnapshot(root, sessionId, toolUseId, {
|
|
283
|
+
...snapshot,
|
|
284
|
+
root: checkout,
|
|
285
|
+
cwd: cwd || checkout,
|
|
214
286
|
startedAt: Date.now(),
|
|
215
|
-
before: changedPathsInRepository(checkout)
|
|
287
|
+
before: changedPathsInRepository(checkout),
|
|
288
|
+
pending: false
|
|
216
289
|
})
|
|
217
290
|
}
|
|
218
291
|
|
|
292
|
+
function removeBashSnapshot (root, sessionId, toolUseId) {
|
|
293
|
+
try {
|
|
294
|
+
fs.unlinkSync(bashSnapshotPath(root, sessionId, toolUseId))
|
|
295
|
+
} catch {}
|
|
296
|
+
}
|
|
297
|
+
|
|
219
298
|
function writeBashSnapshot (root, sessionId, toolUseId, snapshot) {
|
|
220
299
|
const file = bashSnapshotPath(root, sessionId, toolUseId)
|
|
221
300
|
if (!file) return
|
|
@@ -237,21 +316,68 @@ function loadBashSnapshot (root, sessionId, toolUseId) {
|
|
|
237
316
|
}
|
|
238
317
|
}
|
|
239
318
|
|
|
240
|
-
function
|
|
319
|
+
function overlappingBashSnapshots (root, current, now) {
|
|
241
320
|
const dir = bashSnapshotDir(root)
|
|
242
321
|
let files
|
|
243
322
|
try {
|
|
244
323
|
files = fs.readdirSync(dir)
|
|
245
324
|
} catch {
|
|
246
|
-
return
|
|
325
|
+
return []
|
|
247
326
|
}
|
|
248
|
-
return files.
|
|
327
|
+
return files.flatMap(file => {
|
|
249
328
|
try {
|
|
250
329
|
const other = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'))
|
|
251
|
-
if (other.sessionId === current.sessionId
|
|
252
|
-
if (canonicalRoot(other.root) !== canonicalRoot(current.root)) return
|
|
330
|
+
if (other.sessionId === current.sessionId) return []
|
|
331
|
+
if (canonicalRoot(other.root) !== canonicalRoot(current.root)) return []
|
|
332
|
+
if (other.pending === true || !Number.isFinite(other.startedAt)) return []
|
|
253
333
|
const endedAt = Number.isFinite(other.endedAt) ? other.endedAt : now
|
|
254
|
-
return other.startedAt <= now && endedAt >= current.startedAt
|
|
334
|
+
return other.startedAt <= now && endedAt >= current.startedAt ? [other] : []
|
|
335
|
+
} catch {
|
|
336
|
+
return []
|
|
337
|
+
}
|
|
338
|
+
})
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function finalizeBashSnapshots (root, sessionId, endedAt = Date.now(), waitMs = 5000) {
|
|
342
|
+
const files = unfinishedBashSnapshotFiles(root, sessionId)
|
|
343
|
+
if (files.length === 0) return 0
|
|
344
|
+
const release = acquireBashOverlapRecoveryLock(root, waitMs)
|
|
345
|
+
if (!release) return null
|
|
346
|
+
try {
|
|
347
|
+
let finalized = 0
|
|
348
|
+
for (const file of files) {
|
|
349
|
+
try {
|
|
350
|
+
const snapshot = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
351
|
+
if (snapshot.sessionId !== sessionId || Number.isFinite(snapshot.endedAt) || snapshot.pending === true) continue
|
|
352
|
+
if (canonicalRoot(snapshot.root) !== canonicalRoot(root)) continue
|
|
353
|
+
if (finishBashSnapshot(root, snapshot, {
|
|
354
|
+
cwd: snapshot.cwd || snapshot.root || root,
|
|
355
|
+
endedAt
|
|
356
|
+
})) finalized++
|
|
357
|
+
} catch {}
|
|
358
|
+
}
|
|
359
|
+
return finalized
|
|
360
|
+
} finally {
|
|
361
|
+
release()
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function unfinishedBashSnapshotFiles (root, sessionId) {
|
|
366
|
+
const dir = bashSnapshotDir(root)
|
|
367
|
+
let files
|
|
368
|
+
try {
|
|
369
|
+
files = fs.readdirSync(dir)
|
|
370
|
+
} catch {
|
|
371
|
+
return []
|
|
372
|
+
}
|
|
373
|
+
const checkout = canonicalRoot(root)
|
|
374
|
+
return files.map(file => path.join(dir, file)).filter(file => {
|
|
375
|
+
try {
|
|
376
|
+
const snapshot = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
377
|
+
return snapshot.sessionId === sessionId &&
|
|
378
|
+
!Number.isFinite(snapshot.endedAt) &&
|
|
379
|
+
snapshot.pending !== true &&
|
|
380
|
+
canonicalRoot(snapshot.root) === checkout
|
|
255
381
|
} catch {
|
|
256
382
|
return false
|
|
257
383
|
}
|
|
@@ -266,27 +392,47 @@ function pruneBashSnapshots (root) {
|
|
|
266
392
|
} catch {
|
|
267
393
|
return
|
|
268
394
|
}
|
|
395
|
+
const checkout = canonicalRoot(root)
|
|
269
396
|
const cutoff = Date.now() - BASH_SNAPSHOT_TTL_MS
|
|
397
|
+
const expired = []
|
|
270
398
|
for (const file of files) {
|
|
271
399
|
try {
|
|
272
400
|
const fullPath = path.join(dir, file)
|
|
273
401
|
const snapshot = JSON.parse(fs.readFileSync(fullPath, 'utf8'))
|
|
274
|
-
if ((snapshot.
|
|
402
|
+
if (canonicalRoot(snapshot.root) !== checkout) continue
|
|
403
|
+
if (Number.isFinite(snapshot.endedAt) && snapshot.endedAt < cutoff) {
|
|
404
|
+
fs.unlinkSync(fullPath)
|
|
405
|
+
continue
|
|
406
|
+
}
|
|
407
|
+
const startedAt = Number.isFinite(snapshot.startedAt) ? snapshot.startedAt : snapshot.createdAt
|
|
408
|
+
if (!Number.isFinite(snapshot.endedAt) && Number.isFinite(startedAt) && startedAt < cutoff) {
|
|
409
|
+
expired.push({ fullPath, snapshot })
|
|
410
|
+
}
|
|
275
411
|
} catch {}
|
|
276
412
|
}
|
|
413
|
+
|
|
414
|
+
if (expired.length === 0) return
|
|
415
|
+
const expiredIds = new Set(expired.map(({ snapshot }) => bashSnapshotId(snapshot)))
|
|
416
|
+
const eventIds = bashOverlapComponents(unresolvedBashOverlapEvents(root))
|
|
417
|
+
.filter(component => [...component.snapshotIds].some(id => expiredIds.has(id)))
|
|
418
|
+
.flatMap(component => component.events.map(event => event.id))
|
|
419
|
+
if (eventIds.length > 0) invalidateBashOverlap(root, [...new Set(eventIds)], 'expired-snapshot')
|
|
420
|
+
for (const { fullPath } of expired) {
|
|
421
|
+
try { fs.unlinkSync(fullPath) } catch {}
|
|
422
|
+
}
|
|
277
423
|
}
|
|
278
424
|
|
|
279
|
-
function
|
|
425
|
+
function claimedPathsBySessions (root, excludedSessionId) {
|
|
280
426
|
const result = new Set()
|
|
281
427
|
const dir = trackingDir(root)
|
|
282
428
|
let files
|
|
283
429
|
try {
|
|
284
430
|
files = fs.readdirSync(dir)
|
|
285
431
|
} catch {
|
|
286
|
-
|
|
432
|
+
files = []
|
|
287
433
|
}
|
|
288
434
|
for (const file of files) {
|
|
289
|
-
if (file ===
|
|
435
|
+
if (file === excludedSessionId + '.jsonl' || !file.endsWith('.jsonl')) continue
|
|
290
436
|
try {
|
|
291
437
|
const entries = fs.readFileSync(path.join(dir, file), 'utf8').trim().split('\n')
|
|
292
438
|
for (const line of entries) {
|
|
@@ -296,9 +442,436 @@ function claimedPathsByOtherSessions (root, sessionId) {
|
|
|
296
442
|
}
|
|
297
443
|
} catch {}
|
|
298
444
|
}
|
|
445
|
+
const claimsDir = preclaimDir(root)
|
|
446
|
+
let claims
|
|
447
|
+
try {
|
|
448
|
+
claims = fs.readdirSync(claimsDir)
|
|
449
|
+
} catch {
|
|
450
|
+
claims = []
|
|
451
|
+
}
|
|
452
|
+
for (const file of claims) {
|
|
453
|
+
if (!file.endsWith('.json')) continue
|
|
454
|
+
try {
|
|
455
|
+
const claim = JSON.parse(fs.readFileSync(path.join(claimsDir, file), 'utf8'))
|
|
456
|
+
if (claim.sessionId === excludedSessionId || !Array.isArray(claim.entry?.files)) continue
|
|
457
|
+
for (const claimed of claim.entry.files) result.add(canonicalTrackedPath(claimed))
|
|
458
|
+
} catch {}
|
|
459
|
+
}
|
|
299
460
|
return result
|
|
300
461
|
}
|
|
301
462
|
|
|
463
|
+
function bashOverlapPath (root) {
|
|
464
|
+
const base = turbocommitDir(root)
|
|
465
|
+
if (!base) return null
|
|
466
|
+
const checkout = crypto.createHash('sha256').update(canonicalRoot(root)).digest('hex').slice(0, 20)
|
|
467
|
+
return path.join(base, `bash-overlaps-${checkout}.jsonl`)
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function bashOverlapRecoveryLockRef (root) {
|
|
471
|
+
if (!turbocommitDir(root)) return null
|
|
472
|
+
const checkout = crypto.createHash('sha256').update(canonicalRoot(root)).digest('hex').slice(0, 20)
|
|
473
|
+
return `refs/turbocommit/bash-overlap-locks/${checkout}`
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function bashSessionStopDir (root) {
|
|
477
|
+
const base = turbocommitDir(root)
|
|
478
|
+
return base && path.join(base, 'bash-stops')
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function bashSessionStopPath (root, sessionId) {
|
|
482
|
+
const dir = bashSessionStopDir(root)
|
|
483
|
+
if (!dir) return null
|
|
484
|
+
const key = crypto.createHash('sha256')
|
|
485
|
+
.update(`${canonicalRoot(root)}\0${sessionId}`)
|
|
486
|
+
.digest('hex')
|
|
487
|
+
return path.join(dir, key + '.json')
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function recordBashOverlap (root, current, overlaps, files, at) {
|
|
491
|
+
const event = {
|
|
492
|
+
type: 'overlap',
|
|
493
|
+
id: crypto.randomUUID(),
|
|
494
|
+
t: at,
|
|
495
|
+
root: canonicalRoot(current.root),
|
|
496
|
+
completedSessionId: current.sessionId,
|
|
497
|
+
completedAt: at,
|
|
498
|
+
snapshotIds: [current, ...overlaps].map(bashSnapshotId).sort(),
|
|
499
|
+
sessionIds: [...new Set([current, ...overlaps].map(snapshot => snapshot.sessionId))].sort(),
|
|
500
|
+
paths: files.map(file => ({ path: file, fingerprint: fingerprintTrackedPath(file) }))
|
|
501
|
+
}
|
|
502
|
+
appendBashOverlapEvent(root, event)
|
|
503
|
+
return event.id
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function bashSnapshotId (snapshot) {
|
|
507
|
+
return crypto.createHash('sha256')
|
|
508
|
+
.update(`${canonicalRoot(snapshot.root)}\0${snapshot.sessionId}\0${snapshot.toolUseId || 'current'}`)
|
|
509
|
+
.digest('hex')
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function fingerprintTrackedPath (file) {
|
|
513
|
+
try {
|
|
514
|
+
const stat = fs.lstatSync(file)
|
|
515
|
+
if (stat.isSymbolicLink()) {
|
|
516
|
+
return 'link:' + crypto.createHash('sha256').update(fs.readlinkSync(file)).digest('hex')
|
|
517
|
+
}
|
|
518
|
+
if (stat.isFile()) {
|
|
519
|
+
const executable = (stat.mode & 0o111) !== 0 ? 'x' : '-'
|
|
520
|
+
return `file:${executable}:` + crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex')
|
|
521
|
+
}
|
|
522
|
+
return `other:${stat.mode}:${stat.size}:${stat.mtimeMs}`
|
|
523
|
+
} catch (error) {
|
|
524
|
+
return error && error.code === 'ENOENT' ? 'missing' : null
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function recordBashSessionStop (root, sessionId, at = Date.now()) {
|
|
529
|
+
const overlapFile = bashOverlapPath(root)
|
|
530
|
+
if (!overlapFile || !fs.existsSync(overlapFile)) return
|
|
531
|
+
const file = sessionId && bashSessionStopPath(root, sessionId)
|
|
532
|
+
if (!file) return
|
|
533
|
+
ensureDir(path.dirname(file))
|
|
534
|
+
const temporary = file + `.${process.pid}.${crypto.randomUUID()}.tmp`
|
|
535
|
+
try {
|
|
536
|
+
fs.writeFileSync(temporary, JSON.stringify({ root: canonicalRoot(root), sessionId, t: at }) + '\n')
|
|
537
|
+
fs.renameSync(temporary, file)
|
|
538
|
+
if (!fs.existsSync(overlapFile)) {
|
|
539
|
+
try { fs.unlinkSync(file) } catch {}
|
|
540
|
+
}
|
|
541
|
+
} finally {
|
|
542
|
+
try { fs.unlinkSync(temporary) } catch {}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function appendBashOverlapEvent (root, event) {
|
|
547
|
+
const file = bashOverlapPath(root)
|
|
548
|
+
if (!file) return
|
|
549
|
+
ensureDir(path.dirname(file))
|
|
550
|
+
fs.appendFileSync(file, JSON.stringify(event) + '\n')
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function readBashOverlapEvents (root) {
|
|
554
|
+
try {
|
|
555
|
+
return fs.readFileSync(bashOverlapPath(root), 'utf8').trim().split('\n').map(line => {
|
|
556
|
+
try {
|
|
557
|
+
return JSON.parse(line)
|
|
558
|
+
} catch {
|
|
559
|
+
return null
|
|
560
|
+
}
|
|
561
|
+
}).filter(Boolean)
|
|
562
|
+
} catch {
|
|
563
|
+
return []
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function readBashSessionStops (root) {
|
|
568
|
+
const dir = bashSessionStopDir(root)
|
|
569
|
+
const checkout = canonicalRoot(root)
|
|
570
|
+
let files
|
|
571
|
+
try {
|
|
572
|
+
files = fs.readdirSync(dir)
|
|
573
|
+
} catch {
|
|
574
|
+
return []
|
|
575
|
+
}
|
|
576
|
+
return files.flatMap(file => {
|
|
577
|
+
try {
|
|
578
|
+
const stop = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'))
|
|
579
|
+
return stop.root === checkout && stop.sessionId && Number.isFinite(stop.t) ? [stop] : []
|
|
580
|
+
} catch {
|
|
581
|
+
return []
|
|
582
|
+
}
|
|
583
|
+
})
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function unresolvedBashOverlapEvents (root, events = readBashOverlapEvents(root)) {
|
|
587
|
+
const checkout = canonicalRoot(root)
|
|
588
|
+
const terminal = new Set(events
|
|
589
|
+
.filter(event => (event.type === 'resolved' || event.type === 'invalidated') && event.root === checkout)
|
|
590
|
+
.flatMap(event => event.eventIds || []))
|
|
591
|
+
return events.filter(event =>
|
|
592
|
+
event.type === 'overlap' && event.root === checkout && !terminal.has(event.id))
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function bashOverlapComponents (overlaps) {
|
|
596
|
+
const components = []
|
|
597
|
+
for (const event of overlaps) {
|
|
598
|
+
const matching = components.filter(component =>
|
|
599
|
+
event.snapshotIds.some(id => component.snapshotIds.has(id)))
|
|
600
|
+
const component = matching.shift() || { events: [], snapshotIds: new Set() }
|
|
601
|
+
component.events.push(event)
|
|
602
|
+
for (const id of event.snapshotIds) component.snapshotIds.add(id)
|
|
603
|
+
for (const merged of matching) {
|
|
604
|
+
component.events.push(...merged.events)
|
|
605
|
+
for (const id of merged.snapshotIds) component.snapshotIds.add(id)
|
|
606
|
+
components.splice(components.indexOf(merged), 1)
|
|
607
|
+
}
|
|
608
|
+
if (!components.includes(component)) components.push(component)
|
|
609
|
+
}
|
|
610
|
+
return components
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function readReadyBashOverlaps (root) {
|
|
614
|
+
const events = readBashOverlapEvents(root)
|
|
615
|
+
const stops = new Map()
|
|
616
|
+
for (const stop of readBashSessionStops(root)) {
|
|
617
|
+
stops.set(stop.sessionId, Math.max(stops.get(stop.sessionId) || 0, stop.t))
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const components = bashOverlapComponents(unresolvedBashOverlapEvents(root, events))
|
|
621
|
+
|
|
622
|
+
return components.flatMap(component => {
|
|
623
|
+
const sessionIds = new Set()
|
|
624
|
+
const completionTimes = new Map()
|
|
625
|
+
const paths = new Map()
|
|
626
|
+
for (const event of component.events.sort((a, b) => a.t - b.t)) {
|
|
627
|
+
for (const sessionId of event.sessionIds || []) sessionIds.add(sessionId)
|
|
628
|
+
if (event.completedSessionId && Number.isFinite(event.completedAt)) {
|
|
629
|
+
completionTimes.set(event.completedSessionId, Math.max(
|
|
630
|
+
completionTimes.get(event.completedSessionId) || 0,
|
|
631
|
+
event.completedAt
|
|
632
|
+
))
|
|
633
|
+
} else {
|
|
634
|
+
for (const sessionId of event.sessionIds || []) {
|
|
635
|
+
completionTimes.set(sessionId, Math.max(completionTimes.get(sessionId) || 0, event.t))
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
for (const item of event.paths || []) paths.set(item.path, item)
|
|
639
|
+
}
|
|
640
|
+
const ready = [...sessionIds].every(sessionId => {
|
|
641
|
+
const completedAt = completionTimes.get(sessionId)
|
|
642
|
+
return Number.isFinite(completedAt) && (stops.get(sessionId) || 0) >= completedAt
|
|
643
|
+
})
|
|
644
|
+
if (!ready) return []
|
|
645
|
+
return [{
|
|
646
|
+
eventIds: component.events.map(event => event.id),
|
|
647
|
+
sessionIds: [...sessionIds].sort(),
|
|
648
|
+
paths: [...paths.values()].sort((a, b) => a.path.localeCompare(b.path))
|
|
649
|
+
}]
|
|
650
|
+
})
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function compactBashOverlapEvents (root) {
|
|
654
|
+
const overlaps = unresolvedBashOverlapEvents(root)
|
|
655
|
+
const file = bashOverlapPath(root)
|
|
656
|
+
if (!file) return
|
|
657
|
+
if (overlaps.length === 0) {
|
|
658
|
+
try { fs.unlinkSync(file) } catch {}
|
|
659
|
+
for (const stop of readBashSessionStops(root)) {
|
|
660
|
+
try { fs.unlinkSync(bashSessionStopPath(root, stop.sessionId)) } catch {}
|
|
661
|
+
}
|
|
662
|
+
return
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const referencedSessions = new Set(overlaps.flatMap(event => event.sessionIds || []))
|
|
666
|
+
for (const stop of readBashSessionStops(root)) {
|
|
667
|
+
if (referencedSessions.has(stop.sessionId)) continue
|
|
668
|
+
try { fs.unlinkSync(bashSessionStopPath(root, stop.sessionId)) } catch {}
|
|
669
|
+
}
|
|
670
|
+
ensureDir(path.dirname(file))
|
|
671
|
+
const temporary = file + `.${process.pid}.${crypto.randomUUID()}.tmp`
|
|
672
|
+
try {
|
|
673
|
+
fs.writeFileSync(temporary, overlaps
|
|
674
|
+
.sort((a, b) => a.t - b.t)
|
|
675
|
+
.map(event => JSON.stringify(event)).join('\n') + '\n')
|
|
676
|
+
fs.renameSync(temporary, file)
|
|
677
|
+
} finally {
|
|
678
|
+
try { fs.unlinkSync(temporary) } catch {}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function resolveBashOverlap (root, eventIds) {
|
|
683
|
+
appendBashOverlapEvent(root, {
|
|
684
|
+
type: 'resolved',
|
|
685
|
+
root: canonicalRoot(root),
|
|
686
|
+
eventIds,
|
|
687
|
+
t: Date.now()
|
|
688
|
+
})
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function invalidateBashOverlap (root, eventIds, reason) {
|
|
692
|
+
appendBashOverlapEvent(root, {
|
|
693
|
+
type: 'invalidated',
|
|
694
|
+
root: canonicalRoot(root),
|
|
695
|
+
eventIds,
|
|
696
|
+
reason,
|
|
697
|
+
t: Date.now()
|
|
698
|
+
})
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function hasActiveBashSnapshot (root) {
|
|
702
|
+
const dir = bashSnapshotDir(root)
|
|
703
|
+
const checkout = canonicalRoot(root)
|
|
704
|
+
let files
|
|
705
|
+
try {
|
|
706
|
+
files = fs.readdirSync(dir)
|
|
707
|
+
} catch {
|
|
708
|
+
return false
|
|
709
|
+
}
|
|
710
|
+
return files.some(file => {
|
|
711
|
+
try {
|
|
712
|
+
const snapshot = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'))
|
|
713
|
+
return canonicalRoot(snapshot.root) === checkout && !Number.isFinite(snapshot.endedAt)
|
|
714
|
+
} catch {
|
|
715
|
+
return false
|
|
716
|
+
}
|
|
717
|
+
})
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function acquireBashOverlapRecoveryLock (root, waitMs = 0) {
|
|
721
|
+
// update-ref supplies the compare-and-swap that a lock file cannot. A stale
|
|
722
|
+
// owner can only be replaced if the ref still points to its exact token.
|
|
723
|
+
// Released token blobs become unreachable and are removed by normal Git GC.
|
|
724
|
+
const ref = bashOverlapRecoveryLockRef(root)
|
|
725
|
+
const gitDir = gitCommonDir(root)
|
|
726
|
+
if (!ref || !gitDir) return null
|
|
727
|
+
const deadline = Date.now() + waitMs
|
|
728
|
+
const owner = JSON.stringify({
|
|
729
|
+
pid: process.pid,
|
|
730
|
+
startIdentity: processStartIdentity(process.pid),
|
|
731
|
+
token: crypto.randomUUID()
|
|
732
|
+
})
|
|
733
|
+
const token = writeRecoveryLockOwner(gitDir, owner)
|
|
734
|
+
if (!token) return null
|
|
735
|
+
const sleeper = new Int32Array(new SharedArrayBuffer(4))
|
|
736
|
+
while (true) {
|
|
737
|
+
if (!fs.existsSync(root)) return null
|
|
738
|
+
const state = readRecoveryLockToken(gitDir, ref)
|
|
739
|
+
if (!state.ok) return null
|
|
740
|
+
const current = state.token
|
|
741
|
+
if ((!current && updateRecoveryLock(gitDir, ref, token, '0'.repeat(token.length))) ||
|
|
742
|
+
(current && !isRecoveryLockOwnerAlive(gitDir, current) && updateRecoveryLock(gitDir, ref, token, current))) {
|
|
743
|
+
return () => {
|
|
744
|
+
deleteRecoveryLock(gitDir, ref, token)
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
if (Date.now() >= deadline) return null
|
|
748
|
+
Atomics.wait(sleeper, 0, 0, Math.min(25, deadline - Date.now()))
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function waitForBashOverlapRecovery (root, waitMs) {
|
|
753
|
+
const ref = bashOverlapRecoveryLockRef(root)
|
|
754
|
+
const gitDir = gitCommonDir(root)
|
|
755
|
+
if (!ref || !gitDir) return false
|
|
756
|
+
const deadline = Date.now() + waitMs
|
|
757
|
+
const sleeper = new Int32Array(new SharedArrayBuffer(4))
|
|
758
|
+
while (true) {
|
|
759
|
+
if (!fs.existsSync(root)) return false
|
|
760
|
+
const state = readRecoveryLockToken(gitDir, ref)
|
|
761
|
+
if (!state.ok) return false
|
|
762
|
+
const current = state.token
|
|
763
|
+
if (state.ok && !current) return true
|
|
764
|
+
if (current && !isRecoveryLockOwnerAlive(gitDir, current) && deleteRecoveryLock(gitDir, ref, current)) continue
|
|
765
|
+
if (Date.now() >= deadline) return false
|
|
766
|
+
Atomics.wait(sleeper, 0, 0, Math.min(25, deadline - Date.now()))
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function writeRecoveryLockOwner (gitDir, owner) {
|
|
771
|
+
try {
|
|
772
|
+
return execFileSync('git', ['--git-dir', gitDir, 'hash-object', '-w', '--stdin'], {
|
|
773
|
+
cwd: stableGitCwd(gitDir),
|
|
774
|
+
input: owner,
|
|
775
|
+
encoding: 'utf8',
|
|
776
|
+
stdio: ['pipe', 'pipe', 'ignore']
|
|
777
|
+
}).trim() || null
|
|
778
|
+
} catch {
|
|
779
|
+
return null
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function readRecoveryLockToken (gitDir, ref) {
|
|
784
|
+
try {
|
|
785
|
+
const token = execFileSync('git', ['--git-dir', gitDir, 'rev-parse', '--verify', '--quiet', ref], {
|
|
786
|
+
cwd: stableGitCwd(gitDir),
|
|
787
|
+
encoding: 'utf8',
|
|
788
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
789
|
+
}).trim() || null
|
|
790
|
+
return { ok: true, token }
|
|
791
|
+
} catch (error) {
|
|
792
|
+
return { ok: error?.status === 1, token: null }
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function isRecoveryLockOwnerAlive (gitDir, token) {
|
|
797
|
+
let owner
|
|
798
|
+
try {
|
|
799
|
+
owner = execFileSync('git', ['--git-dir', gitDir, 'cat-file', 'blob', token], {
|
|
800
|
+
cwd: stableGitCwd(gitDir),
|
|
801
|
+
encoding: 'utf8',
|
|
802
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
803
|
+
})
|
|
804
|
+
} catch {
|
|
805
|
+
return false
|
|
806
|
+
}
|
|
807
|
+
const parsed = parseRecoveryLockOwner(owner)
|
|
808
|
+
const pid = parsed?.pid
|
|
809
|
+
if (!Number.isInteger(pid) || pid <= 0) return false
|
|
810
|
+
if (parsed.startIdentity) {
|
|
811
|
+
const currentStartIdentity = processStartIdentity(pid)
|
|
812
|
+
if (currentStartIdentity && currentStartIdentity !== parsed.startIdentity) return false
|
|
813
|
+
}
|
|
814
|
+
return isProcessAlive(pid)
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function updateRecoveryLock (gitDir, ref, token, expected) {
|
|
818
|
+
try {
|
|
819
|
+
execFileSync('git', ['--git-dir', gitDir, 'update-ref', ref, token, expected], {
|
|
820
|
+
cwd: stableGitCwd(gitDir),
|
|
821
|
+
stdio: 'ignore'
|
|
822
|
+
})
|
|
823
|
+
return true
|
|
824
|
+
} catch {
|
|
825
|
+
return false
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function deleteRecoveryLock (gitDir, ref, expected) {
|
|
830
|
+
try {
|
|
831
|
+
execFileSync('git', ['--git-dir', gitDir, 'update-ref', '-d', ref, expected], {
|
|
832
|
+
cwd: stableGitCwd(gitDir),
|
|
833
|
+
stdio: 'ignore'
|
|
834
|
+
})
|
|
835
|
+
return true
|
|
836
|
+
} catch {
|
|
837
|
+
return false
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function stableGitCwd (gitDir) {
|
|
842
|
+
const parent = path.dirname(gitDir)
|
|
843
|
+
return fs.existsSync(parent) ? parent : process.cwd()
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function parseRecoveryLockOwner (owner) {
|
|
847
|
+
try {
|
|
848
|
+
const parsed = JSON.parse(owner)
|
|
849
|
+
if (Number.isInteger(parsed?.pid)) return parsed
|
|
850
|
+
} catch {}
|
|
851
|
+
const pid = Number(owner.split(':', 1)[0])
|
|
852
|
+
return Number.isInteger(pid) ? { pid } : null
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function processStartIdentity (pid) {
|
|
856
|
+
try {
|
|
857
|
+
return execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], {
|
|
858
|
+
encoding: 'utf8',
|
|
859
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
860
|
+
}).trim() || null
|
|
861
|
+
} catch {
|
|
862
|
+
return null
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function isProcessAlive (pid) {
|
|
867
|
+
try {
|
|
868
|
+
process.kill(pid, 0)
|
|
869
|
+
return true
|
|
870
|
+
} catch (error) {
|
|
871
|
+
return error?.code !== 'ESRCH'
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
302
875
|
function appendTracking (root, sessionId, entry) {
|
|
303
876
|
const file = trackingPath(root, sessionId)
|
|
304
877
|
if (!file) return
|
|
@@ -306,6 +879,26 @@ function appendTracking (root, sessionId, entry) {
|
|
|
306
879
|
fs.appendFileSync(file, JSON.stringify(entry) + '\n')
|
|
307
880
|
}
|
|
308
881
|
|
|
882
|
+
function savePreclaim (root, sessionId, entry) {
|
|
883
|
+
const dir = preclaimDir(root)
|
|
884
|
+
if (!dir) throw new Error('Cannot persist path ownership outside a git repository')
|
|
885
|
+
ensureDir(dir)
|
|
886
|
+
const file = path.join(dir, `${crypto.randomUUID()}.json`)
|
|
887
|
+
const temporary = file + `.${process.pid}.tmp`
|
|
888
|
+
try {
|
|
889
|
+
fs.writeFileSync(temporary, JSON.stringify({ sessionId, entry }) + '\n')
|
|
890
|
+
fs.renameSync(temporary, file)
|
|
891
|
+
return file
|
|
892
|
+
} finally {
|
|
893
|
+
try { fs.unlinkSync(temporary) } catch {}
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function removePreclaim (file) {
|
|
898
|
+
if (!file) return
|
|
899
|
+
try { fs.unlinkSync(file) } catch {}
|
|
900
|
+
}
|
|
901
|
+
|
|
309
902
|
function parseInput (input) {
|
|
310
903
|
try {
|
|
311
904
|
return JSON.parse(input)
|
|
@@ -349,11 +942,41 @@ function cleanupTracking (root, sessionId) {
|
|
|
349
942
|
try {
|
|
350
943
|
fs.unlinkSync(trackingPath(root, sessionId))
|
|
351
944
|
} catch {}
|
|
945
|
+
const dir = preclaimDir(root)
|
|
946
|
+
let files
|
|
947
|
+
try {
|
|
948
|
+
files = fs.readdirSync(dir)
|
|
949
|
+
} catch {
|
|
950
|
+
return
|
|
951
|
+
}
|
|
952
|
+
for (const file of files) {
|
|
953
|
+
if (!file.endsWith('.json')) continue
|
|
954
|
+
try {
|
|
955
|
+
const fullPath = path.join(dir, file)
|
|
956
|
+
const claim = JSON.parse(fs.readFileSync(fullPath, 'utf8'))
|
|
957
|
+
if (claim.sessionId === sessionId) fs.unlinkSync(fullPath)
|
|
958
|
+
} catch {}
|
|
959
|
+
}
|
|
352
960
|
}
|
|
353
961
|
|
|
354
962
|
module.exports = {
|
|
355
963
|
handleTrack,
|
|
356
964
|
handlePostTrack,
|
|
965
|
+
finalizeBashSnapshots,
|
|
966
|
+
pruneBashSnapshots,
|
|
967
|
+
recordBashSessionStop,
|
|
968
|
+
readBashOverlapEvents,
|
|
969
|
+
readBashSessionStops,
|
|
970
|
+
readReadyBashOverlaps,
|
|
971
|
+
compactBashOverlapEvents,
|
|
972
|
+
resolveBashOverlap,
|
|
973
|
+
invalidateBashOverlap,
|
|
974
|
+
hasActiveBashSnapshot,
|
|
975
|
+
acquireBashOverlapRecoveryLock,
|
|
976
|
+
bashOverlapRecoveryLockRef,
|
|
977
|
+
bashSnapshotPath,
|
|
978
|
+
fingerprintTrackedPath,
|
|
979
|
+
claimedPathsBySessions,
|
|
357
980
|
hasTrackedModifications,
|
|
358
981
|
cleanupTracking,
|
|
359
982
|
extractFilePath,
|