aura-basetools 0.0.0-stage → 0.7.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 +26 -2
- package/THIRD-PARTY-NOTICES.md +9 -0
- package/bin/aura-mcp.mjs +424 -0
- package/bin/aura.mjs +396 -0
- package/lib/fs-provider.mjs +355 -0
- package/lib/observation-store.mjs +236 -0
- package/lib/ops.mjs +498 -0
- package/lib/transaction-store.mjs +350 -0
- package/lib/workspace-lock.mjs +130 -0
- package/locator-lab/anchor-core.mjs +586 -0
- package/locator-lab/args.mjs +491 -0
- package/locator-lab/handle-core.mjs +207 -0
- package/locator-lab/locator-api.mjs +921 -0
- package/locator-lab/read-core.mjs +3013 -0
- package/locator-lab/repo-map.mjs +768 -0
- package/locator-lab/struct-core.mjs +3026 -0
- package/locator-lab/write-path.mjs +3446 -0
- package/package.json +50 -4
- package/test/standalone-test.mjs +1575 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* transaction-store.mjs —— 写事务日志、回滚与 undo。
|
|
3
|
+
*
|
|
4
|
+
* 一次写事务在 `~/.aura/transactions/<workspace-key>/<id>/` 下留下:
|
|
5
|
+
* · `meta.json` —— 事务状态与每个文件的原始 mode/版本/备份名;
|
|
6
|
+
* · `originals/<n>.bin` —— 写前原始字节(逐字节,含 BOM/行尾)。
|
|
7
|
+
*
|
|
8
|
+
* 事务的生命周期:
|
|
9
|
+
* pending → committed(写成功,可 undo)
|
|
10
|
+
* → rolled_back(写失败,已恢复原字节)
|
|
11
|
+
* → recovery_required(写失败且恢复也失败;meta 里留着可手工恢复的路径)
|
|
12
|
+
* committed → undone(undo 成功)
|
|
13
|
+
*
|
|
14
|
+
* 回滚/undo 前会比一次 `afterVersion`:文件在事务之后又被别人改过就拒绝覆盖(`--force` 才强制)。
|
|
15
|
+
* 这不是安全边界;它只保证遵守本协议的进程不会静默覆盖并发写。
|
|
16
|
+
*/
|
|
17
|
+
import fs from 'node:fs'
|
|
18
|
+
import os from 'node:os'
|
|
19
|
+
import path from 'node:path'
|
|
20
|
+
import crypto from 'node:crypto'
|
|
21
|
+
|
|
22
|
+
const KEEP = 20
|
|
23
|
+
|
|
24
|
+
function cacheRoot() {
|
|
25
|
+
return process.env.AURA_CACHE_DIR ?? path.join(os.homedir(), '.aura')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function fnvWorkspace(root) {
|
|
29
|
+
return crypto.createHash('sha256').update(path.resolve(root)).digest('hex').slice(0, 16)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function transactionsRoot(root) {
|
|
33
|
+
return path.join(cacheRoot(), 'transactions', fnvWorkspace(root))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function transactionId() {
|
|
37
|
+
return `${Date.now().toString(36)}-${crypto.randomBytes(4).toString('hex')}`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function ensureDir(dir) {
|
|
41
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
42
|
+
try { fs.chmodSync(dir, 0o700) } catch { /* Windows / 非 owner */ }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function writeTextAtomic(file, text, mode = 0o600) {
|
|
46
|
+
ensureDir(path.dirname(file))
|
|
47
|
+
const tmp = `${file}.tmp-${process.pid}-${crypto.randomBytes(3).toString('hex')}`
|
|
48
|
+
try {
|
|
49
|
+
fs.writeFileSync(tmp, text, { mode })
|
|
50
|
+
try { fs.chmodSync(tmp, mode) } catch { /* Windows */ }
|
|
51
|
+
fs.renameSync(tmp, file)
|
|
52
|
+
try { fs.chmodSync(file, mode) } catch { /* Windows */ }
|
|
53
|
+
} catch (error) {
|
|
54
|
+
try { fs.rmSync(tmp, { force: true }) } catch { /* 清理失败不影响正确性 */ }
|
|
55
|
+
throw error
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function writeBytesAtomic(file, bytes, mode = 0o600) {
|
|
60
|
+
ensureDir(path.dirname(file))
|
|
61
|
+
const tmp = `${file}.tmp-${process.pid}-${crypto.randomBytes(3).toString('hex')}`
|
|
62
|
+
let fd
|
|
63
|
+
try {
|
|
64
|
+
fd = fs.openSync(tmp, 'wx', mode)
|
|
65
|
+
let offset = 0
|
|
66
|
+
while (offset < bytes.length) offset += fs.writeSync(fd, bytes, offset, bytes.length - offset)
|
|
67
|
+
try { fs.fsyncSync(fd) } catch { /* 尽力而为 */ }
|
|
68
|
+
fs.closeSync(fd)
|
|
69
|
+
fd = undefined
|
|
70
|
+
try { fs.chmodSync(tmp, mode) } catch { /* Windows */ }
|
|
71
|
+
fs.renameSync(tmp, file)
|
|
72
|
+
try { fs.chmodSync(file, mode) } catch { /* Windows */ }
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (fd !== undefined) {
|
|
75
|
+
try { fs.closeSync(fd) } catch { /* 不覆盖原始错误 */ }
|
|
76
|
+
}
|
|
77
|
+
try { fs.rmSync(tmp, { force: true }) } catch { /* 清理失败不影响正确性 */ }
|
|
78
|
+
throw error
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function normalizeTarget(provider, target) {
|
|
83
|
+
const resolved = typeof provider.resolve === 'function' ? provider.resolve(target) : target
|
|
84
|
+
return path.resolve(resolved)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function realPathOf(display) {
|
|
88
|
+
try {
|
|
89
|
+
return fs.realpathSync.native(display)
|
|
90
|
+
} catch {
|
|
91
|
+
return display
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function loadMeta(dir) {
|
|
96
|
+
return JSON.parse(fs.readFileSync(path.join(dir, 'meta.json'), 'utf8'))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function saveMeta(meta) {
|
|
100
|
+
writeTextAtomic(path.join(meta.dir, 'meta.json'), JSON.stringify(meta, null, 1))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function fileSummary(entry) {
|
|
104
|
+
return {
|
|
105
|
+
path: entry.path,
|
|
106
|
+
realPath: entry.realPath,
|
|
107
|
+
existed: entry.existed,
|
|
108
|
+
mode: entry.mode,
|
|
109
|
+
written: entry.written,
|
|
110
|
+
afterVersion: entry.afterVersion ?? null,
|
|
111
|
+
backup: entry.backup ?? null,
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 创建一个 pending 事务。此时还没有任何文件被记录;第一次 writeText 前才记原始字节。
|
|
117
|
+
*
|
|
118
|
+
* @param root - 工作区根
|
|
119
|
+
* @param tool - 产生它的工具名(仅记录)
|
|
120
|
+
*/
|
|
121
|
+
export function createTransaction(root, tool = 'aura_edit') {
|
|
122
|
+
const id = transactionId()
|
|
123
|
+
const dir = path.join(transactionsRoot(root), id)
|
|
124
|
+
ensureDir(dir)
|
|
125
|
+
const meta = {
|
|
126
|
+
id,
|
|
127
|
+
tool,
|
|
128
|
+
root: path.resolve(root),
|
|
129
|
+
createdAt: new Date().toISOString(),
|
|
130
|
+
status: 'pending',
|
|
131
|
+
files: [],
|
|
132
|
+
dir,
|
|
133
|
+
}
|
|
134
|
+
saveMeta(meta)
|
|
135
|
+
const entries = new Map()
|
|
136
|
+
let closed = false
|
|
137
|
+
|
|
138
|
+
const entryKey = (provider, target) => normalizeTarget(provider, target)
|
|
139
|
+
|
|
140
|
+
const record = async (provider, target) => {
|
|
141
|
+
const key = entryKey(provider, target)
|
|
142
|
+
if (entries.has(key)) return entries.get(key)
|
|
143
|
+
const display = key
|
|
144
|
+
let lst
|
|
145
|
+
try {
|
|
146
|
+
lst = fs.lstatSync(display)
|
|
147
|
+
} catch (error) {
|
|
148
|
+
if (error?.code !== 'ENOENT') throw error
|
|
149
|
+
const entry = { path: display, realPath: display, existed: false, mode: null, backup: null, written: false }
|
|
150
|
+
entry.metaIndex = meta.files.length
|
|
151
|
+
entries.set(key, entry)
|
|
152
|
+
meta.files.push(fileSummary(entry))
|
|
153
|
+
saveMeta(meta)
|
|
154
|
+
return entry
|
|
155
|
+
}
|
|
156
|
+
const realPath = lst.isSymbolicLink() ? realPathOf(display) : display
|
|
157
|
+
const st = fs.statSync(realPath)
|
|
158
|
+
if (!st.isFile()) throw Object.assign(new Error(`cannot journal "${display}": not a regular file`), { code: 'FS_NOT_REGULAR_FILE' })
|
|
159
|
+
const index = entries.size
|
|
160
|
+
const backup = path.join('originals', `${index}.bin`)
|
|
161
|
+
writeBytesAtomic(path.join(dir, backup), fs.readFileSync(realPath), 0o600)
|
|
162
|
+
const entry = {
|
|
163
|
+
path: display,
|
|
164
|
+
realPath,
|
|
165
|
+
existed: true,
|
|
166
|
+
mode: st.mode & 0o7777,
|
|
167
|
+
backup,
|
|
168
|
+
written: false,
|
|
169
|
+
}
|
|
170
|
+
entry.metaIndex = meta.files.length
|
|
171
|
+
entries.set(key, entry)
|
|
172
|
+
meta.files.push(fileSummary(entry))
|
|
173
|
+
saveMeta(meta)
|
|
174
|
+
return entry
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const tx = {
|
|
178
|
+
id,
|
|
179
|
+
dir,
|
|
180
|
+
meta,
|
|
181
|
+
entries,
|
|
182
|
+
/** 把 provider 的 writeText 包一层:第一次写前记录原始字节,写后记录 afterVersion。 */
|
|
183
|
+
proxy(provider) {
|
|
184
|
+
const proxy = Object.create(provider)
|
|
185
|
+
proxy.writeText = async (target, content, expected) => {
|
|
186
|
+
const entry = await record(provider, target)
|
|
187
|
+
const result = await provider.writeText(target, content, expected)
|
|
188
|
+
entry.written = true
|
|
189
|
+
entry.afterVersion = result?.version ?? null
|
|
190
|
+
const summary = meta.files[entry.metaIndex]
|
|
191
|
+
if (summary !== undefined) {
|
|
192
|
+
summary.written = true
|
|
193
|
+
summary.afterVersion = entry.afterVersion
|
|
194
|
+
}
|
|
195
|
+
saveMeta(meta)
|
|
196
|
+
return result
|
|
197
|
+
}
|
|
198
|
+
return proxy
|
|
199
|
+
},
|
|
200
|
+
commit() {
|
|
201
|
+
if (closed) return
|
|
202
|
+
meta.status = 'committed'
|
|
203
|
+
meta.committedAt = new Date().toISOString()
|
|
204
|
+
// 只有 meta 真正落盘后才 closed;否则调用方还能 rollback 已经写出的文件。
|
|
205
|
+
saveMeta(meta)
|
|
206
|
+
closed = true
|
|
207
|
+
prune(root)
|
|
208
|
+
},
|
|
209
|
+
summary() {
|
|
210
|
+
return { id, dir, status: meta.status, files: meta.files.length }
|
|
211
|
+
},
|
|
212
|
+
async rollback(provider, options = {}) {
|
|
213
|
+
if (closed) return { status: meta.status, restored: 0, conflicts: [] }
|
|
214
|
+
closed = true
|
|
215
|
+
const force = options.force === true
|
|
216
|
+
const conflicts = []
|
|
217
|
+
let restored = 0
|
|
218
|
+
for (const entry of entries.values()) {
|
|
219
|
+
if (!entry.written) continue
|
|
220
|
+
let current
|
|
221
|
+
try { current = provider.stat(entry.path) } catch { current = undefined }
|
|
222
|
+
if (!force && entry.afterVersion !== null && current?.version !== entry.afterVersion) {
|
|
223
|
+
conflicts.push({ path: entry.path, reason: 'changed after this transaction', expected: entry.afterVersion, actual: current?.version ?? null })
|
|
224
|
+
continue
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
if (entry.existed) {
|
|
228
|
+
writeBytesAtomic(entry.realPath, fs.readFileSync(path.join(dir, entry.backup)), entry.mode ?? 0o600)
|
|
229
|
+
} else {
|
|
230
|
+
fs.rmSync(entry.realPath, { force: true })
|
|
231
|
+
}
|
|
232
|
+
restored += 1
|
|
233
|
+
} catch (error) {
|
|
234
|
+
conflicts.push({ path: entry.path, reason: String(error?.message ?? error) })
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
meta.status = conflicts.length === 0 ? 'rolled_back' : 'recovery_required'
|
|
238
|
+
meta.rolledBackAt = new Date().toISOString()
|
|
239
|
+
meta.conflicts = conflicts
|
|
240
|
+
saveMeta(meta)
|
|
241
|
+
return { status: meta.status, restored, conflicts }
|
|
242
|
+
},
|
|
243
|
+
}
|
|
244
|
+
return tx
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function readAllMeta(root) {
|
|
248
|
+
const dir = transactionsRoot(root)
|
|
249
|
+
let names
|
|
250
|
+
try {
|
|
251
|
+
names = fs.readdirSync(dir, { withFileTypes: true })
|
|
252
|
+
} catch {
|
|
253
|
+
return []
|
|
254
|
+
}
|
|
255
|
+
const out = []
|
|
256
|
+
for (const entry of names) {
|
|
257
|
+
if (!entry.isDirectory()) continue
|
|
258
|
+
try {
|
|
259
|
+
out.push(loadMeta(path.join(dir, entry.name)))
|
|
260
|
+
} catch { /* 坏事务不拖垮列表 */ }
|
|
261
|
+
}
|
|
262
|
+
return out.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)))
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** 取最近一条可 undo 的 committed 事务。 */
|
|
266
|
+
export function latestCommitted(root) {
|
|
267
|
+
return readAllMeta(root).find((meta) => meta.status === 'committed') ?? null
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function listTransactions(root) {
|
|
271
|
+
return readAllMeta(root).map((meta) => ({
|
|
272
|
+
id: meta.id,
|
|
273
|
+
tool: meta.tool,
|
|
274
|
+
createdAt: meta.createdAt,
|
|
275
|
+
status: meta.status,
|
|
276
|
+
files: Array.isArray(meta.files) ? meta.files.length : 0,
|
|
277
|
+
dir: meta.dir,
|
|
278
|
+
}))
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function prune(root, keep = KEEP) {
|
|
282
|
+
const metas = readAllMeta(root).filter((meta) => meta.status !== 'pending')
|
|
283
|
+
for (const meta of metas.slice(keep)) {
|
|
284
|
+
try { fs.rmSync(meta.dir, { recursive: true, force: true }) } catch { /* 旧日志删不掉不阻断新事务 */ }
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* 撤销一条 committed 事务。
|
|
290
|
+
*
|
|
291
|
+
* 任何文件在当前时间点的版本与事务提交后的版本不一致 ⇒ 默认拒绝,避免覆盖后续编辑;
|
|
292
|
+
* `force: true` 才强制恢复旧字节。
|
|
293
|
+
*/
|
|
294
|
+
export async function undoTransaction(root, provider, options = {}) {
|
|
295
|
+
const metas = readAllMeta(root)
|
|
296
|
+
const meta = options.id ? metas.find((m) => m.id === options.id) : metas.find((m) => m.status === 'committed')
|
|
297
|
+
if (meta === undefined) {
|
|
298
|
+
throw Object.assign(new Error(options.id ? `transaction not found: ${options.id}` : 'no committed transaction to undo'), {
|
|
299
|
+
code: 'TRANSACTION_NOT_FOUND',
|
|
300
|
+
})
|
|
301
|
+
}
|
|
302
|
+
if (meta.status !== 'committed') {
|
|
303
|
+
throw Object.assign(new Error(`transaction ${meta.id} is ${meta.status}, not committed`), {
|
|
304
|
+
code: 'TRANSACTION_NOT_UNDOABLE',
|
|
305
|
+
status: meta.status,
|
|
306
|
+
})
|
|
307
|
+
}
|
|
308
|
+
const force = options.force === true
|
|
309
|
+
/**
|
|
310
|
+
* 先做一次**只读预检**:任何文件在事务后又被改过 ⇒ 整个 undo 拒绝。
|
|
311
|
+
* 旧顺序会先把还能恢复的文件写回去,再发现某个文件冲突——那就是一次部分 undo;
|
|
312
|
+
* 对一个以“不会静默改错”为卖点的工具,部分恢复比拒绝更坏。
|
|
313
|
+
*/
|
|
314
|
+
const plans = []
|
|
315
|
+
const conflicts = []
|
|
316
|
+
for (const entry of meta.files) {
|
|
317
|
+
if (!entry.written) continue
|
|
318
|
+
let current
|
|
319
|
+
try { current = provider.stat(entry.path) } catch { current = undefined }
|
|
320
|
+
if (!force && entry.afterVersion !== null && current?.version !== entry.afterVersion) {
|
|
321
|
+
conflicts.push({ path: entry.path, reason: 'changed since this transaction', expected: entry.afterVersion, actual: current?.version ?? null })
|
|
322
|
+
}
|
|
323
|
+
plans.push(entry)
|
|
324
|
+
}
|
|
325
|
+
if (conflicts.length > 0) {
|
|
326
|
+
throw Object.assign(new Error(`transaction ${meta.id} cannot be undone: ${conflicts.length} file(s) changed after it`), {
|
|
327
|
+
code: 'TRANSACTION_STALE',
|
|
328
|
+
transaction: meta.id,
|
|
329
|
+
conflicts,
|
|
330
|
+
})
|
|
331
|
+
}
|
|
332
|
+
let restored = 0
|
|
333
|
+
for (const entry of plans) {
|
|
334
|
+
try {
|
|
335
|
+
if (entry.existed) {
|
|
336
|
+
writeBytesAtomic(entry.realPath, fs.readFileSync(path.join(meta.dir, entry.backup)), entry.mode ?? 0o600)
|
|
337
|
+
} else {
|
|
338
|
+
fs.rmSync(entry.realPath, { force: true })
|
|
339
|
+
}
|
|
340
|
+
restored += 1
|
|
341
|
+
} catch (error) {
|
|
342
|
+
conflicts.push({ path: entry.path, reason: String(error?.message ?? error) })
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
meta.status = conflicts.length === 0 ? 'undone' : 'recovery_required'
|
|
346
|
+
meta.undoneAt = new Date().toISOString()
|
|
347
|
+
meta.undoConflicts = conflicts
|
|
348
|
+
saveMeta(meta)
|
|
349
|
+
return { transaction: meta.id, status: meta.status, restored, conflicts, dir: meta.dir }
|
|
350
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* workspace-lock.mjs —— 同一工作区的写事务互斥锁(best effort,跨进程)。
|
|
3
|
+
*
|
|
4
|
+
* 这不是文件系统 CAS,也不是安全边界;它只保证**遵守本协议的 Aura 进程**不会同时写同一个工作区。
|
|
5
|
+
* 锁文件放在 `~/.aura/locks/<workspace-key>.lock`(`AURA_CACHE_DIR` 可覆盖),内容只有
|
|
6
|
+
* `{ pid, createdAt, root }`。
|
|
7
|
+
*
|
|
8
|
+
* 语义:
|
|
9
|
+
* · `wx` 创建锁文件;已存在时等持有者结束或超时;
|
|
10
|
+
* · 持有者进程已不存在、或锁龄超过 staleMs ⇒ 视为陈旧锁并删除;
|
|
11
|
+
* · `AURA_NO_LOCK=1` 完全跳过(测试/单进程场景);
|
|
12
|
+
* · `AURA_LOCK_TIMEOUT_MS` / `AURA_LOCK_STALE_MS` 可调。
|
|
13
|
+
*
|
|
14
|
+
* 锁不防止不遵守协议的外部编辑器;删除/恢复仍由事务日志和版本校验兜底。
|
|
15
|
+
*/
|
|
16
|
+
import fs from 'node:fs'
|
|
17
|
+
import os from 'node:os'
|
|
18
|
+
import path from 'node:path'
|
|
19
|
+
import crypto from 'node:crypto'
|
|
20
|
+
|
|
21
|
+
const DEFAULT_TIMEOUT_MS = 5000
|
|
22
|
+
const DEFAULT_STALE_MS = 10 * 60 * 1000
|
|
23
|
+
const RETRY_MS = 50
|
|
24
|
+
|
|
25
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
26
|
+
|
|
27
|
+
function cacheRoot() {
|
|
28
|
+
return process.env.AURA_CACHE_DIR ?? path.join(os.homedir(), '.aura')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function workspacePath(root) {
|
|
32
|
+
const resolved = path.resolve(root)
|
|
33
|
+
try {
|
|
34
|
+
return fs.realpathSync.native(resolved)
|
|
35
|
+
} catch {
|
|
36
|
+
return resolved
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function workspaceKey(root) {
|
|
41
|
+
return crypto.createHash('sha256').update(workspacePath(root)).digest('hex').slice(0, 16)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function lockPath(root) {
|
|
45
|
+
return path.join(cacheRoot(), 'locks', `${workspaceKey(root)}.lock`)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function readLock(file) {
|
|
49
|
+
try {
|
|
50
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
51
|
+
} catch {
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function processAlive(pid) {
|
|
57
|
+
if (!Number.isInteger(pid) || pid <= 0) return false
|
|
58
|
+
try {
|
|
59
|
+
process.kill(pid, 0)
|
|
60
|
+
return true
|
|
61
|
+
} catch (error) {
|
|
62
|
+
// EPERM 表示进程存在但当前用户无权发信号;按存活处理更安全。
|
|
63
|
+
return error?.code === 'EPERM'
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 在 workspace 锁内执行一次写事务。
|
|
69
|
+
*
|
|
70
|
+
* @param root - 工作区根(provider.root)
|
|
71
|
+
* @param fn - async () => T
|
|
72
|
+
* @param options.timeoutMs - 等锁上限
|
|
73
|
+
* @param options.staleMs - 锁龄超过它即视为陈旧
|
|
74
|
+
* @returns fn 的结果
|
|
75
|
+
*/
|
|
76
|
+
export async function withWorkspaceLock(root, fn, options = {}) {
|
|
77
|
+
if (process.env.AURA_NO_LOCK === '1') return fn()
|
|
78
|
+
const timeoutMs = Number(options.timeoutMs ?? process.env.AURA_LOCK_TIMEOUT_MS) > 0
|
|
79
|
+
? Number(options.timeoutMs ?? process.env.AURA_LOCK_TIMEOUT_MS)
|
|
80
|
+
: DEFAULT_TIMEOUT_MS
|
|
81
|
+
const staleMs = Number(options.staleMs ?? process.env.AURA_LOCK_STALE_MS) > 0
|
|
82
|
+
? Number(options.staleMs ?? process.env.AURA_LOCK_STALE_MS)
|
|
83
|
+
: DEFAULT_STALE_MS
|
|
84
|
+
const file = lockPath(root)
|
|
85
|
+
const started = Date.now()
|
|
86
|
+
let held = false
|
|
87
|
+
|
|
88
|
+
while (!held) {
|
|
89
|
+
try {
|
|
90
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
|
|
91
|
+
const fd = fs.openSync(file, 'wx', 0o600)
|
|
92
|
+
try {
|
|
93
|
+
fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, createdAt: Date.now(), root: workspacePath(root) }))
|
|
94
|
+
fs.fsyncSync(fd)
|
|
95
|
+
} finally {
|
|
96
|
+
fs.closeSync(fd)
|
|
97
|
+
}
|
|
98
|
+
held = true
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (error?.code !== 'EEXIST') throw error
|
|
101
|
+
const holder = readLock(file)
|
|
102
|
+
const age = Date.now() - Number(holder?.createdAt ?? 0)
|
|
103
|
+
const stale = holder === null || age > staleMs || !processAlive(holder.pid)
|
|
104
|
+
if (stale) {
|
|
105
|
+
try { fs.rmSync(file, { force: true }) } catch { /* 下一轮再试 */ }
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
if (Date.now() - started > timeoutMs) {
|
|
109
|
+
throw Object.assign(new Error(`workspace is locked by pid ${holder.pid}`), {
|
|
110
|
+
code: 'WORKSPACE_LOCKED',
|
|
111
|
+
holder,
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
await sleep(RETRY_MS)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
return await fn()
|
|
120
|
+
} finally {
|
|
121
|
+
try {
|
|
122
|
+
const holder = readLock(file)
|
|
123
|
+
if (holder?.pid === process.pid) fs.rmSync(file, { force: true })
|
|
124
|
+
} catch { /* 锁释放失败不覆盖本次结果;陈旧锁会按 staleMs 回收 */ }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function lockFileFor(root) {
|
|
129
|
+
return lockPath(root)
|
|
130
|
+
}
|