aura-basetools 0.7.0 → 0.13.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 +223 -13
- package/bin/aura-mcp.mjs +169 -27
- package/bin/aura.mjs +257 -37
- package/lib/observation-store.mjs +150 -17
- package/lib/ops.mjs +183 -19
- package/lib/transaction-store.mjs +1 -1
- package/lib/workspace-lock.mjs +25 -4
- package/locator-lab/anchor-core.mjs +1 -1
- package/locator-lab/args.mjs +103 -6
- package/locator-lab/locator-api.mjs +401 -22
- package/locator-lab/read-core.mjs +634 -563
- package/locator-lab/repo-map.mjs +1 -1
- package/locator-lab/struct-core.mjs +119 -38
- package/locator-lab/write-path.mjs +663 -112
- package/package.json +8 -8
- package/test/argument-surface-test.mjs +119 -0
- package/test/doctor-test.mjs +73 -0
- package/test/observation-concurrency-test.mjs +90 -0
- package/test/standalone-test.mjs +202 -128
- package/test/write-test.mjs +294 -0
package/lib/ops.mjs
CHANGED
|
@@ -76,6 +76,54 @@ const readCore = await import(engineUrl('read-core.mjs'))
|
|
|
76
76
|
const repoMap = await import(engineUrl('repo-map.mjs'))
|
|
77
77
|
const engineArgs = await import(engineUrl('args.mjs'))
|
|
78
78
|
const writePath = await import(engineUrl('write-path.mjs'))
|
|
79
|
+
const structCore = await import(engineUrl('struct-core.mjs'))
|
|
80
|
+
const handleCore = await import(engineUrl('handle-core.mjs'))
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 0.9.9:运行时可查询的能力清单(与 build/capabilities.mjs 同一份源码常量)。
|
|
84
|
+
* 不发货额外 JSON:包内已有的引擎模块就是唯一来源。
|
|
85
|
+
*/
|
|
86
|
+
export function capabilitiesEnvelope() {
|
|
87
|
+
const langs = Object.keys(structCore.LANGS).sort()
|
|
88
|
+
return {
|
|
89
|
+
tools: [
|
|
90
|
+
{ name: 'aura_basetools_read', modes: ['auto', ...readCore.MODES] },
|
|
91
|
+
{
|
|
92
|
+
name: 'aura_basetools_edit',
|
|
93
|
+
addressKinds: [...new Set([...engineArgs.FLAT_ADDRESS_KEYS, 'old', 'context', 'position'])],
|
|
94
|
+
options: ['replace_all', 'require_observation'],
|
|
95
|
+
writeSafety: {
|
|
96
|
+
cli: ['transaction', 'undo', 'workspace-lock', 'stale-region-gate', 'byte-verify'],
|
|
97
|
+
mcp: ['transaction', 'undo', 'workspace-lock', 'stale-region-gate', 'byte-verify'],
|
|
98
|
+
dsh: ['stale-region-gate', 'byte-verify'],
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: 'aura_basetools_write',
|
|
103
|
+
operations: ['create', 'overwrite'],
|
|
104
|
+
options: ['eol'],
|
|
105
|
+
writeSafety: {
|
|
106
|
+
cli: ['transaction', 'undo', 'workspace-lock', 'stale-region-gate', 'observation-gate', 'byte-verify'],
|
|
107
|
+
mcp: ['transaction', 'undo', 'workspace-lock', 'stale-region-gate', 'observation-gate', 'byte-verify'],
|
|
108
|
+
// DSH 壳没有事务/undo/工作区锁;它经过引擎的区域闸、严格新鲜度与回读校验。
|
|
109
|
+
dsh: ['stale-region-gate', 'observation-gate', 'byte-verify'],
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
commands: ['basetools read', 'basetools edit', 'basetools write', 'basetools undo', 'basetools transactions', 'basetools mcp', 'basetools doctor', 'basetools capabilities', '--version', '--help'],
|
|
114
|
+
langs,
|
|
115
|
+
langsCount: langs.length,
|
|
116
|
+
limits: {
|
|
117
|
+
multiTargetsMax: engineArgs.MT_MAX_TARGETS,
|
|
118
|
+
multiTargetPool: engineArgs.MT_POOL,
|
|
119
|
+
defaultPruneBudget: readCore.DEFAULT_PRUNE_BUDGET,
|
|
120
|
+
envelopeAllowance: readCore.DEFAULT_ENVELOPE_ALLOWANCE,
|
|
121
|
+
contentBudget: readCore.CONTENT_BUDGET,
|
|
122
|
+
handlesPerReceipt: handleCore.HANDLE_MAX_PER_RECEIPT,
|
|
123
|
+
},
|
|
124
|
+
packaging: { corePackage: 'aura-basetools', adapterPackage: 'aura-basetools-dsh' },
|
|
125
|
+
}
|
|
126
|
+
}
|
|
79
127
|
|
|
80
128
|
/**
|
|
81
129
|
* 引擎公开面的转发:绑定转发,不是「把命名空间塞给调用方」——
|
|
@@ -94,6 +142,7 @@ export const asLinePair = engineArgs.asLinePair
|
|
|
94
142
|
export const gaveLineAddress = engineArgs.gaveLineAddress
|
|
95
143
|
export const inferReadMode = engineArgs.inferReadMode
|
|
96
144
|
export const lineRangeOf = engineArgs.lineRangeOf
|
|
145
|
+
export const normalizeReadRequest = engineArgs.normalizeReadRequest
|
|
97
146
|
export const flatHunk = engineArgs.flatHunk
|
|
98
147
|
export const applyEditToProvider = writePath.applyEditToProvider
|
|
99
148
|
|
|
@@ -117,6 +166,8 @@ export const assertReadTargets = engineArgs.assertReadTargets
|
|
|
117
166
|
export const assertEditTargets = engineArgs.assertEditTargets
|
|
118
167
|
export const assertTargetShape = engineArgs.assertTargetShape
|
|
119
168
|
export const assertKnownArgs = engineArgs.assertKnownArgs
|
|
169
|
+
export const MT_READ_ELEMENT_KEYS = engineArgs.MT_READ_ELEMENT_KEYS
|
|
170
|
+
export const MT_EDIT_ELEMENT_KEYS = engineArgs.MT_EDIT_ELEMENT_KEYS
|
|
120
171
|
|
|
121
172
|
// ── C55:非 UTF-8 文件 ⇒ 可执行的拒绝 ──────────────────────────────────────
|
|
122
173
|
|
|
@@ -262,7 +313,7 @@ export function readFileBytes(displayPath) {
|
|
|
262
313
|
return {
|
|
263
314
|
error: {
|
|
264
315
|
ok: false,
|
|
265
|
-
error: nonUtf8RefusalMessage('read', displayPath, '
|
|
316
|
+
error: nonUtf8RefusalMessage('read', displayPath, 'aura_basetools_read'),
|
|
266
317
|
code: typeof error?.code === 'string' && error.code.length > 0 ? error.code : 'FS_NOT_TEXT',
|
|
267
318
|
},
|
|
268
319
|
}
|
|
@@ -277,18 +328,22 @@ export function readFileBytes(displayPath) {
|
|
|
277
328
|
* 两个调用方(CLI `bin/aura.mjs`、MCP `bin/aura-mcp.mjs`)本来就 `await` 它,写法不变。
|
|
278
329
|
*/
|
|
279
330
|
export async function readEnvelope(provider, args) {
|
|
280
|
-
const { file, around, search, since,
|
|
331
|
+
const { file, around, search, since, context, kind, budget } = args
|
|
332
|
+
// 宿主面用 `max_chars`,引擎面用 `maxChars`;单目标与 targets 元素都必须认两种写法。
|
|
333
|
+
const maxChars = args.maxChars ?? args.max_chars
|
|
281
334
|
try {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
const mode = inferReadMode(args, lines)
|
|
335
|
+
/**
|
|
336
|
+
* 行区间 / 分页 / 模式推断收成一份(`normalizeReadRequest`):单目标与 `targets` 元素
|
|
337
|
+
* 走同一个判定器。第一遍只判形状(文件还没读),第二遍拿到总行数后做 offset 越界与截断。
|
|
338
|
+
*/
|
|
339
|
+
const requestShape = normalizeReadRequest(args)
|
|
340
|
+
if (requestShape.ok !== true) return requestShape
|
|
289
341
|
const displayPath = provider.resolve(file)
|
|
290
342
|
const info = provider.stat(displayPath)
|
|
291
343
|
if (info === undefined) return { ok: false, error: `cannot read "${file}": not found`, code: 'FS_NOT_FOUND' }
|
|
344
|
+
if (info.type === 'directory' && requestShape.paginate) {
|
|
345
|
+
return { ok: false, error: 'offset/limit applies to files; a directory is returned as a repo map — drop pagination', code: 'READ_BAD_ARGS' }
|
|
346
|
+
}
|
|
292
347
|
/**
|
|
293
348
|
* 目录:给一张仓库地图(T26)。判据只有“它是目录”这一条,不需要调用方多填参数。
|
|
294
349
|
*
|
|
@@ -311,15 +366,20 @@ export async function readEnvelope(provider, args) {
|
|
|
311
366
|
}
|
|
312
367
|
const got = readFileBytes(displayPath)
|
|
313
368
|
if (got.error !== undefined) return got.error
|
|
369
|
+
const request = normalizeReadRequest(args, got.source.split(/\r?\n/).length)
|
|
370
|
+
if (request.ok !== true) return request
|
|
314
371
|
const env = readWith(got.source, {
|
|
315
|
-
mode,
|
|
372
|
+
mode: request.mode,
|
|
316
373
|
around,
|
|
317
374
|
// 读侧只认数组:这里从收敛层的 {start,end} 转一次(之前 CLI/MCP 直接传 {start,end},
|
|
318
375
|
// 于是 `aura read --lines 1,2` 会报 READ_NO_RANGE —— 顺手修掉的既有 bug)。
|
|
319
|
-
lines: asLinePair(lines),
|
|
376
|
+
lines: asLinePair(request.lines),
|
|
320
377
|
search,
|
|
321
378
|
since,
|
|
322
379
|
maxChars,
|
|
380
|
+
...(context === undefined ? {} : { context }),
|
|
381
|
+
...(kind === undefined ? {} : { kind }),
|
|
382
|
+
...(budget === undefined ? {} : { budget }),
|
|
323
383
|
byteOrderMark: got.byteOrderMark,
|
|
324
384
|
filePath: displayPath,
|
|
325
385
|
})
|
|
@@ -332,7 +392,7 @@ export async function readEnvelope(provider, args) {
|
|
|
332
392
|
if (isNonUtf8TextFailure(error)) {
|
|
333
393
|
return {
|
|
334
394
|
ok: false,
|
|
335
|
-
error: nonUtf8RefusalMessage('read', resolvedOrRaw(provider, file), '
|
|
395
|
+
error: nonUtf8RefusalMessage('read', resolvedOrRaw(provider, file), 'aura_basetools_read'),
|
|
336
396
|
code: typeof error?.code === 'string' && error.code.length > 0 ? error.code : 'FS_NOT_TEXT',
|
|
337
397
|
...errorEvidence(error),
|
|
338
398
|
}
|
|
@@ -347,12 +407,16 @@ export async function readEnvelope(provider, args) {
|
|
|
347
407
|
}
|
|
348
408
|
|
|
349
409
|
/** 改一次(规划 → 陈旧闸门 → 逐字节写 + CAS → 回读校验 → 刷新观测)。 */
|
|
350
|
-
async function editEnvelopeInner(provider, { file, hunks, dryRun, create }) {
|
|
410
|
+
async function editEnvelopeInner(provider, { file, hunks, dryRun, create, requireObservation, wholeFile, eol, skipStaleGate, toolName = 'aura_basetools_edit' }) {
|
|
351
411
|
try {
|
|
352
412
|
const result = await applyEditToProvider(provider, {
|
|
353
413
|
displayPath: provider.resolve(file),
|
|
354
|
-
hunks,
|
|
414
|
+
hunks: hunks ?? [],
|
|
355
415
|
dryRun: dryRun === true,
|
|
416
|
+
...(requireObservation === true ? { requireObservation: true } : {}),
|
|
417
|
+
...(typeof wholeFile === 'string' ? { wholeFile } : {}),
|
|
418
|
+
...(typeof eol === 'string' ? { eol } : {}),
|
|
419
|
+
...(skipStaleGate === true ? { skipStaleGate: true } : {}),
|
|
356
420
|
...(typeof create === 'string' ? { create } : {}),
|
|
357
421
|
})
|
|
358
422
|
return { ok: true, result }
|
|
@@ -365,7 +429,7 @@ async function editEnvelopeInner(provider, { file, hunks, dryRun, create }) {
|
|
|
365
429
|
if (isNonUtf8TextFailure(error)) {
|
|
366
430
|
return {
|
|
367
431
|
ok: false,
|
|
368
|
-
error: nonUtf8RefusalMessage('patch', resolvedOrRaw(provider, file),
|
|
432
|
+
error: nonUtf8RefusalMessage('patch', resolvedOrRaw(provider, file), toolName),
|
|
369
433
|
code: typeof error?.code === 'string' && error.code.length > 0 ? error.code : 'FS_NOT_TEXT',
|
|
370
434
|
...errorEvidence(error),
|
|
371
435
|
}
|
|
@@ -424,10 +488,16 @@ function transactionError(error) {
|
|
|
424
488
|
export async function editEnvelope(provider, request) {
|
|
425
489
|
// 事务需要工作区根与真实文件系统;测试/嵌入式 provider 没有 root 时保持原来的引擎行为。
|
|
426
490
|
if (provider.root === undefined) return editEnvelopeInner(provider, request)
|
|
491
|
+
/**
|
|
492
|
+
* 预演不建事务:否则 `--dry-run --transaction` 会提交一条 0 文件的 committed 事务,
|
|
493
|
+
* 把 `aura undo`(最近一次 committed)顶掉——用户以为撤销了真实编辑,实际 restored:0。
|
|
494
|
+
* 预演也不写盘,不需要工作区锁。
|
|
495
|
+
*/
|
|
496
|
+
if (request.dryRun === true) return editEnvelopeInner(provider, request)
|
|
427
497
|
const includeTransaction = request.includeTransaction === true
|
|
428
498
|
try {
|
|
429
499
|
return await withWorkspaceLock(provider.root, async () => {
|
|
430
|
-
const tx = createTransaction(provider.root, '
|
|
500
|
+
const tx = createTransaction(provider.root, 'aura_basetools_edit')
|
|
431
501
|
const env = await editEnvelopeInner(tx.proxy(provider), request)
|
|
432
502
|
if (env.ok) {
|
|
433
503
|
try {
|
|
@@ -451,10 +521,12 @@ export async function editEnvelope(provider, request) {
|
|
|
451
521
|
/** 多目标写事务:与单目标共用锁/日志/回滚;批次返回 ok=false 时也回滚。 */
|
|
452
522
|
export async function editManyEnvelope(provider, request) {
|
|
453
523
|
if (provider.root === undefined) return editManyEnvelopeInner(provider, request)
|
|
524
|
+
// 与单目标同一条:预演不建事务、不写盘,不能顶掉真正的最近 committed 事务。
|
|
525
|
+
if (request.dryRun === true) return editManyEnvelopeInner(provider, request)
|
|
454
526
|
const includeTransaction = request.includeTransaction === true
|
|
455
527
|
try {
|
|
456
528
|
return await withWorkspaceLock(provider.root, async () => {
|
|
457
|
-
const tx = createTransaction(provider.root, '
|
|
529
|
+
const tx = createTransaction(provider.root, 'aura_basetools_edit')
|
|
458
530
|
try {
|
|
459
531
|
const batch = await editManyEnvelopeInner(tx.proxy(provider), request)
|
|
460
532
|
if (batch.ok) {
|
|
@@ -469,7 +541,12 @@ export async function editManyEnvelope(provider, request) {
|
|
|
469
541
|
return includeTransaction ? { ...batch, transaction: tx.summary() } : batch
|
|
470
542
|
}
|
|
471
543
|
const rollback = await tx.rollback(provider)
|
|
472
|
-
|
|
544
|
+
const finalBatch = writePath.rebuildAfterRollback(batch, rollback)
|
|
545
|
+
return {
|
|
546
|
+
...finalBatch,
|
|
547
|
+
ok: false,
|
|
548
|
+
...(includeTransaction ? { transaction: tx.summary() } : {}),
|
|
549
|
+
}
|
|
473
550
|
} catch (error) {
|
|
474
551
|
const rollback = await tx.rollback(provider)
|
|
475
552
|
error.transaction = tx.summary()
|
|
@@ -482,8 +559,95 @@ export async function editManyEnvelope(provider, request) {
|
|
|
482
559
|
}
|
|
483
560
|
}
|
|
484
561
|
|
|
485
|
-
|
|
562
|
+
/** 整文件写的紧凑回执:不把整份 old/new 文本塞进 applied(那是 edit 的形状,不是 write 的)。 */
|
|
563
|
+
function compactWriteReceipt(env, content) {
|
|
564
|
+
if (env === null || typeof env !== 'object' || env.ok !== true) return env
|
|
565
|
+
const result = env.result ?? {}
|
|
566
|
+
const dryRun = result.dryRun === true
|
|
567
|
+
/**
|
|
568
|
+
* 覆写预演没有落盘,也没有回读字节数;此时 `content` 的长度可能不等于最终字节数
|
|
569
|
+
* (整份写会把 content 的换行适配成文件已有风格,CRLF 每行多一个字节)。
|
|
570
|
+
* 宁可**不报** bytes,也不报一个预演后必然对不上的数。新建预演不需要适配,照报。
|
|
571
|
+
*/
|
|
572
|
+
const bytes = result.write_verify?.bytes ?? (dryRun && result.operation === 'create' ? Buffer.byteLength(content, 'utf8') : undefined)
|
|
573
|
+
return {
|
|
574
|
+
ok: true,
|
|
575
|
+
result: {
|
|
576
|
+
path: result.path,
|
|
577
|
+
operation: result.operation ?? 'write',
|
|
578
|
+
...(bytes === undefined ? {} : { bytes }),
|
|
579
|
+
dryRun,
|
|
580
|
+
...(result.write_verify === undefined ? {} : { write_verify: result.write_verify }),
|
|
581
|
+
...(result.unverified_region === true ? { unverified_region: true } : {}),
|
|
582
|
+
...(result.transaction === undefined ? {} : { transaction: result.transaction }),
|
|
583
|
+
},
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* 0.10.1:整文件写(内置 `Write` 的替代)。
|
|
589
|
+
*
|
|
590
|
+
* 新建与覆写都走 `editEnvelope`:同一把工作区锁、同一份事务日志、同一套回读校验。
|
|
591
|
+
* 覆写用“整文件行区间(含末尾那个空行)+ `eol: auto`”表达:行区间覆盖到文件末尾,
|
|
592
|
+
* 于是 content 自己的**有无尾换行**被精确保留,而 content 内的换行统一成文件已有的风格
|
|
593
|
+
* (与内置 Edit 对多行替换的处理一致)。已有的 BOM 也照旧保留。
|
|
594
|
+
*
|
|
595
|
+
* **覆写默认要求先读过这版内容**(`PATCH_UNOBSERVED`),与内置 `Write` 的 read-before-write
|
|
596
|
+
* 同一条;`allowUnobserved` 是显式逃生口(回执照实标 `unverified_region`)。
|
|
597
|
+
* 新建不受这条约束。
|
|
598
|
+
*
|
|
599
|
+
* `eol` 只对覆写有意义:`auto`(默认)把 content 的新行适配成文件已有风格,
|
|
600
|
+
* `preserve` 按 content 原样写(与内置 Write 的“给什么写什么”对齐),`lf` / `crlf` 强制统一。
|
|
601
|
+
* 新建文件永远按 content 原样写;显式传非 `auto` 的 eol 会被拒绝(不做静默忽略)。
|
|
602
|
+
*/
|
|
603
|
+
export async function writeEnvelope(provider, { file, content, dryRun = false, noOverwrite = false, includeTransaction = false, allowUnobserved = false, eol = 'auto' } = {}) {
|
|
604
|
+
if (typeof file !== 'string' || file.length === 0) {
|
|
605
|
+
return { ok: false, error: 'write needs a file path', code: 'WRITE_BAD_ARGS' }
|
|
606
|
+
}
|
|
607
|
+
if (typeof content !== 'string') {
|
|
608
|
+
return { ok: false, error: 'write needs `content` as a string', code: 'WRITE_BAD_ARGS' }
|
|
609
|
+
}
|
|
610
|
+
if (!['auto', 'preserve', 'lf', 'crlf'].includes(eol)) {
|
|
611
|
+
return { ok: false, error: 'write `eol` must be auto, preserve, lf, or crlf', code: 'WRITE_BAD_ARGS' }
|
|
612
|
+
}
|
|
486
613
|
try {
|
|
614
|
+
const displayPath = provider.resolve(file)
|
|
615
|
+
const info = provider.stat(displayPath)
|
|
616
|
+
if (info !== undefined && noOverwrite) {
|
|
617
|
+
return { ok: false, error: `cannot write "${displayPath}": it already exists (no-overwrite)`, code: 'WRITE_EXISTS' }
|
|
618
|
+
}
|
|
619
|
+
if (info === undefined) {
|
|
620
|
+
if (eol !== 'auto') {
|
|
621
|
+
return {
|
|
622
|
+
ok: false,
|
|
623
|
+
error: 'write `eol` applies when overwriting an existing file; a new file is written exactly as given — pass the endings you want in `content`',
|
|
624
|
+
code: 'WRITE_BAD_ARGS',
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
if (dryRun) {
|
|
628
|
+
return { ok: true, result: { path: displayPath, operation: 'create', bytes: Buffer.byteLength(content, 'utf8'), dryRun: true } }
|
|
629
|
+
}
|
|
630
|
+
return compactWriteReceipt(await editEnvelope(provider, { file, create: content, includeTransaction }), content)
|
|
631
|
+
}
|
|
632
|
+
return compactWriteReceipt(
|
|
633
|
+
await editEnvelope(provider, {
|
|
634
|
+
file,
|
|
635
|
+
wholeFile: content,
|
|
636
|
+
eol,
|
|
637
|
+
dryRun,
|
|
638
|
+
includeTransaction,
|
|
639
|
+
requireObservation: allowUnobserved !== true,
|
|
640
|
+
skipStaleGate: allowUnobserved === true,
|
|
641
|
+
toolName: 'aura_basetools_write',
|
|
642
|
+
}),
|
|
643
|
+
content,
|
|
644
|
+
)
|
|
645
|
+
} catch (error) {
|
|
646
|
+
return transactionError(error)
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
export async function undoEnvelope(provider, { id, force } = {}) { try {
|
|
487
651
|
return {
|
|
488
652
|
ok: true,
|
|
489
653
|
...(await withWorkspaceLock(provider.root, () => undoTransaction(provider.root, provider, { id, force: force === true }))),
|
|
@@ -118,7 +118,7 @@ function fileSummary(entry) {
|
|
|
118
118
|
* @param root - 工作区根
|
|
119
119
|
* @param tool - 产生它的工具名(仅记录)
|
|
120
120
|
*/
|
|
121
|
-
export function createTransaction(root, tool = '
|
|
121
|
+
export function createTransaction(root, tool = 'aura_basetools_edit') {
|
|
122
122
|
const id = transactionId()
|
|
123
123
|
const dir = path.join(transactionsRoot(root), id)
|
|
124
124
|
ensureDir(dir)
|
package/lib/workspace-lock.mjs
CHANGED
|
@@ -20,6 +20,8 @@ import crypto from 'node:crypto'
|
|
|
20
20
|
|
|
21
21
|
const DEFAULT_TIMEOUT_MS = 5000
|
|
22
22
|
const DEFAULT_STALE_MS = 10 * 60 * 1000
|
|
23
|
+
/** 空锁(已创建、PID 还没写进去)的宽限:短于这个年龄不判陈旧,避免抢走刚持有的锁。 */
|
|
24
|
+
const EMPTY_LOCK_GRACE_MS = 2000
|
|
23
25
|
const RETRY_MS = 50
|
|
24
26
|
|
|
25
27
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
@@ -99,16 +101,35 @@ export async function withWorkspaceLock(root, fn, options = {}) {
|
|
|
99
101
|
} catch (error) {
|
|
100
102
|
if (error?.code !== 'EEXIST') throw error
|
|
101
103
|
const holder = readLock(file)
|
|
102
|
-
|
|
103
|
-
|
|
104
|
+
/**
|
|
105
|
+
* **空锁不是陈旧锁**:`open(..., 'wx')` 与写入 PID 之间有窗口,另一个进程此时读到的
|
|
106
|
+
* 是空文件(`readLock` 返回 null)。旧判据把 `holder === null` 直接当陈旧 ⇒ 会删掉
|
|
107
|
+
* 一个刚被持有、还没写完元数据的锁,两个进程同时进入临界区。实测:4 个进程并发改
|
|
108
|
+
* 同一文件的不同行,偶发 1 个进程的陈旧闸门报错、两行修改丢失。
|
|
109
|
+
* 现在空锁给 2 秒宽限:只有 mtime 超过宽限(或超过 staleMs)才回收。
|
|
110
|
+
*/
|
|
111
|
+
let stale
|
|
112
|
+
if (holder === null) {
|
|
113
|
+
let mtime = 0
|
|
114
|
+
try {
|
|
115
|
+
mtime = fs.statSync(file).mtimeMs
|
|
116
|
+
} catch {
|
|
117
|
+
continue // 锁刚被释放,下一轮重试
|
|
118
|
+
}
|
|
119
|
+
stale = Date.now() - mtime > Math.min(staleMs, EMPTY_LOCK_GRACE_MS)
|
|
120
|
+
} else {
|
|
121
|
+
const age = Date.now() - Number(holder.createdAt ?? 0)
|
|
122
|
+
stale = age > staleMs || !processAlive(holder.pid)
|
|
123
|
+
}
|
|
104
124
|
if (stale) {
|
|
105
125
|
try { fs.rmSync(file, { force: true }) } catch { /* 下一轮再试 */ }
|
|
106
126
|
continue
|
|
107
127
|
}
|
|
108
128
|
if (Date.now() - started > timeoutMs) {
|
|
109
|
-
|
|
129
|
+
const holderLabel = holder === null ? 'an empty lock (pid not written yet)' : `pid ${holder.pid}`
|
|
130
|
+
throw Object.assign(new Error(`workspace is locked by ${holderLabel}`), {
|
|
110
131
|
code: 'WORKSPACE_LOCKED',
|
|
111
|
-
holder,
|
|
132
|
+
holder: holder ?? null,
|
|
112
133
|
})
|
|
113
134
|
}
|
|
114
135
|
await sleep(RETRY_MS)
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* 唯一的内部状态是 `indexFile` 手里那层**内容派生的记忆化缓存**(见该函数上方注释):
|
|
13
13
|
* 同一份内容不再被重复建索引,返回值不变。
|
|
14
14
|
* 文件读写、沙箱、观察策略、审批都归调用方——那是各框架自己的事,引擎不该知道。
|
|
15
|
-
* DSH 的 `
|
|
15
|
+
* DSH 的 `aura_basetools_edit` 工具是本引擎的一个适配器,见 `../aura-edit/index.js`。
|
|
16
16
|
*
|
|
17
17
|
* @module anchor-core
|
|
18
18
|
*/
|
package/locator-lab/args.mjs
CHANGED
|
@@ -63,23 +63,86 @@ export function flatHunk(source) {
|
|
|
63
63
|
const ADDRESS_KEYS = FLAT_ADDRESS_KEYS
|
|
64
64
|
if (!ADDRESS_KEYS.some((k) => source[k] !== undefined)) return null
|
|
65
65
|
const hunk = {}
|
|
66
|
-
for (const key of [...ADDRESS_KEYS, ...HANDLE_SHAPE_KEYS, 'replace', 'expect', 'near']) {
|
|
66
|
+
for (const key of [...ADDRESS_KEYS, ...HANDLE_SHAPE_KEYS, 'context', 'replace', 'expect', 'near', 'eol', 'replace_all']) {
|
|
67
67
|
|
|
68
68
|
const value = source[key]
|
|
69
69
|
if (value === undefined || value === null) continue
|
|
70
|
-
|
|
70
|
+
// `replace: ''` 是**删除**,是合法动作;其余空串仍然不是地址/断言。
|
|
71
|
+
if (typeof value === 'string' && value.length === 0 && key !== 'replace') continue
|
|
71
72
|
hunk[key] = value
|
|
72
73
|
}
|
|
73
74
|
return typeof hunk.replace === 'string' ? hunk : null
|
|
74
75
|
}
|
|
75
76
|
|
|
77
|
+
/**
|
|
78
|
+
* 单次读请求的**统一收敛**(单目标与 `targets` 元素共用)。
|
|
79
|
+
*
|
|
80
|
+
* 旧版 `readManyTarget` 手写字段转发,`offset` / `limit` / `context` / `kind` / `budget`
|
|
81
|
+
* 在多目标里被静默丢掉——同一个元素单独读与放进 `targets` 读会得到两种结果。
|
|
82
|
+
* 这里把“行区间 / 分页 / 模式推断 / 非法组合”收成一份,两个入口都调它。
|
|
83
|
+
*
|
|
84
|
+
* `totalLines` 只有在内容已读到时才给得出;第一遍(读文件前)传 undefined,用来尽早拒绝
|
|
85
|
+
* 形状错误;第二遍传真实行数,用来做 offset 越界与尾行截断。
|
|
86
|
+
*
|
|
87
|
+
* @returns `{ok:true, mode, lines, paginate, offset, limit}` 或
|
|
88
|
+
* `{ok:false, code:'READ_BAD_ARGS', error}`。
|
|
89
|
+
*/
|
|
90
|
+
export function normalizeReadRequest(args, totalLines = undefined) {
|
|
91
|
+
const lines = lineRangeOf(args)
|
|
92
|
+
if (lines === undefined && gaveLineAddress(args)) {
|
|
93
|
+
return { ok: false, code: 'READ_BAD_ARGS', error: LINE_ADDRESS_HINT }
|
|
94
|
+
}
|
|
95
|
+
const paginate = args.offset !== undefined || args.limit !== undefined
|
|
96
|
+
if (paginate && lines !== undefined) {
|
|
97
|
+
return { ok: false, code: 'READ_BAD_ARGS', error: 'use either lines/start_line+end_line or offset/limit, not both' }
|
|
98
|
+
}
|
|
99
|
+
if (paginate && args.mode !== undefined && args.mode !== null && args.mode !== 'auto') {
|
|
100
|
+
return { ok: false, code: 'READ_BAD_ARGS', error: `mode=${JSON.stringify(args.mode)} cannot be combined with offset/limit; drop the mode or use lines/start_line+end_line` }
|
|
101
|
+
}
|
|
102
|
+
const offset = paginate ? Number(args.offset ?? 0) : null
|
|
103
|
+
const limit = paginate ? Number(args.limit ?? 2000) : null
|
|
104
|
+
if (paginate && (!Number.isInteger(offset) || offset < 0 || !Number.isInteger(limit) || limit <= 0)) {
|
|
105
|
+
return { ok: false, code: 'READ_BAD_ARGS', error: 'offset must be an integer >= 0 and limit an integer > 0' }
|
|
106
|
+
}
|
|
107
|
+
let resolvedLines = lines
|
|
108
|
+
if (paginate && totalLines !== undefined) {
|
|
109
|
+
if (!Number.isInteger(totalLines) || totalLines < 1 || offset >= totalLines) {
|
|
110
|
+
return { ok: false, code: 'READ_BAD_ARGS', error: `offset ${offset} is outside 0..${Math.max(0, totalLines - 1)}` }
|
|
111
|
+
}
|
|
112
|
+
resolvedLines = { start: offset + 1, end: Math.min(offset + limit, totalLines) }
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
ok: true,
|
|
116
|
+
mode: paginate ? 'lines' : inferReadMode(args, lines),
|
|
117
|
+
lines: resolvedLines,
|
|
118
|
+
paginate,
|
|
119
|
+
offset,
|
|
120
|
+
limit,
|
|
121
|
+
}
|
|
122
|
+
}
|
|
76
123
|
/**
|
|
77
124
|
* 平铺形状的**地址键**({@link flatHunk} 与未知参数守卫共用同一份名单)。
|
|
78
125
|
*
|
|
79
126
|
* 导出它是为了不再出现第二份:守卫要拿它算“合法键集合”,而如果有两份名单,
|
|
80
127
|
* 新增一种地址写法时必有一处漏改——那正是本项目反复出现的形状。
|
|
81
128
|
*/
|
|
82
|
-
export const FLAT_ADDRESS_KEYS = ['target', 'lines', 'start_line', 'end_line', 'line_cue', 'anchor', 'handle']
|
|
129
|
+
export const FLAT_ADDRESS_KEYS = ['target', 'intent', 'lines', 'start_line', 'end_line', 'line_cue', 'anchor', 'handle', 'old']
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* `targets` 元素(单目标参数)的合法键。
|
|
133
|
+
*
|
|
134
|
+
* 与顶层工具键**分开**:`targets` 本身、批量级的 `include_transaction` 都不是元素参数;
|
|
135
|
+
* 放进元素里只会被静默丢掉。三面(CLI / MCP / DSH)共用这一份,未知键一律
|
|
136
|
+
* `ARGS_UNKNOWN_KEY`,不再只校验顶层。
|
|
137
|
+
*/
|
|
138
|
+
export const MT_READ_ELEMENT_KEYS = [
|
|
139
|
+
'file_path', 'mode', 'around', 'lines', 'start_line', 'end_line', 'search', 'context', 'since', 'kind', 'budget', 'max_chars', 'offset', 'limit',
|
|
140
|
+
]
|
|
141
|
+
export const MT_EDIT_ELEMENT_KEYS = [
|
|
142
|
+
'file_path', 'hunks', 'create', 'dry_run', 'require_observation',
|
|
143
|
+
'target', 'intent', 'lines', 'start_line', 'end_line', 'line_cue', 'anchor', 'old', 'context',
|
|
144
|
+
'replace', 'replace_all', 'eol', 'expect', 'near', 'handle', 'line', 'position',
|
|
145
|
+
]
|
|
83
146
|
|
|
84
147
|
/**
|
|
85
148
|
* 句柄形状的**从属键**(fs-contract-v2 / HD2):它们只在 `handle` 在场时有意义,
|
|
@@ -121,7 +184,7 @@ export function handleShapeVerdict(value) {
|
|
|
121
184
|
*
|
|
122
185
|
* ## 为什么必须有这道守卫(实测出来的)
|
|
123
186
|
*
|
|
124
|
-
* 真框架**不会**剥掉未声明的参数:实测 `
|
|
187
|
+
* 真框架**不会**剥掉未声明的参数:实测 `aura_basetools_read` 收到 `{file_path, mod: 'map'}` 时
|
|
125
188
|
* `args.mod` 原样到达 `execute`,而工具只读 `args.mode` ⇒ 回一个**默认模式的成功回执**,零反馈。
|
|
126
189
|
* 模型把 `mode` 写成 `mod`、`lines` 写成 `line`、`maxChars` 写成 `max_chars` 时,
|
|
127
190
|
* 它看到的是成功——比报错坏得多(本项目原则:宁可大声失败,不可静默换语义)。
|
|
@@ -134,7 +197,7 @@ export function handleShapeVerdict(value) {
|
|
|
134
197
|
* ## 实测代价(16757 次真实调用)
|
|
135
198
|
*
|
|
136
199
|
* 会被这道守卫拒的:`limit×13`、`offset×10`、`line_cue×2`、`start_mode×1`、`sandbox_permissions×1`
|
|
137
|
-
* (全在 `
|
|
200
|
+
* (全在 `aura_basetools_read` 侧;`aura_basetools_edit` 侧**一条都没有**)。
|
|
138
201
|
* 其中 `limit`与`offset` 本意是分页——而本工具的分页参数叫 `start_line`/`end_line`,
|
|
139
202
|
* 静默忽略的结果是“模型以为自己翻页了、实际拿到的是同一段”。报错是**修好**,不是收紧。
|
|
140
203
|
*
|
|
@@ -394,6 +457,23 @@ export function assertEditTargets(source, options = {}) {
|
|
|
394
457
|
{ code: 'ARGS_TARGETS_SHAPE', index, got: 'an object without a path' },
|
|
395
458
|
)
|
|
396
459
|
}
|
|
460
|
+
/**
|
|
461
|
+
* 元素级布尔参数在**参数面**就判类型:否则 `require_observation:"yes"` 会在
|
|
462
|
+
* `editTargetRequests` 里抛到编排层之外(dry-run 分支没有 try/catch,CLI 直接吐 Node 栈;
|
|
463
|
+
* 非 dry-run 还会先建一个事务再回滚)。参数没成形属于 `ARGS_*` 面,与 `USAGE/1` 同档。
|
|
464
|
+
*/
|
|
465
|
+
for (const key of ['require_observation', 'requireObservation']) {
|
|
466
|
+
if (element[key] !== undefined && typeof element[key] !== 'boolean') {
|
|
467
|
+
throw Object.assign(
|
|
468
|
+
new Error(`${toolName}: \`${targetKey}[${index}].${key}\` must be a boolean — got ${typeWordOf(element[key])}`),
|
|
469
|
+
{ code: 'ARGS_BAD_REQUIRE_OBSERVATION', index, key, got: typeWordOf(element[key]) },
|
|
470
|
+
)
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
// 元素级的未知键:与顶层同一个守卫、同一套报错(合法键由调用方按读/写给)。
|
|
474
|
+
if (Array.isArray(options.elementKeys)) {
|
|
475
|
+
assertKnownArgs(element, options.elementKeys, `${toolName} ${targetKey}[${index}]`)
|
|
476
|
+
}
|
|
397
477
|
})
|
|
398
478
|
return raw
|
|
399
479
|
}
|
|
@@ -413,7 +493,7 @@ export function assertEditTargets(source, options = {}) {
|
|
|
413
493
|
*
|
|
414
494
|
* 为什么不是另一份实现:`assertEditTargets` 里那条互斥判据(`targets` 与单目标形状同时给 ⇒
|
|
415
495
|
* 参数面拒绝)是本仓的**唯一一份**。读侧再写一遍就会漂开 —— 而漂开之后,同一个载荷
|
|
416
|
-
* 在 `
|
|
496
|
+
* 在 `aura_basetools_read` 被放行、在 `aura_basetools_edit` 被拒(或反过来),且两边都不会报错。
|
|
417
497
|
*
|
|
418
498
|
* 读侧元素内部(mode / lines / search / since / kind)本函数**一个字都不解读**:
|
|
419
499
|
* 它们由引擎 `readManyWith` 逐项走今天那套单目标判定器(契约 §2.1「同一个校验器」)。
|
|
@@ -486,6 +566,23 @@ export function editTargetRequests(targets) {
|
|
|
486
566
|
for (const key of ['create', 'identityKey', 'dryRun']) {
|
|
487
567
|
if (element[key] !== undefined) request[key] = element[key]
|
|
488
568
|
}
|
|
569
|
+
/**
|
|
570
|
+
* 单目标参数里的 `require_observation` 在这里翻成引擎的驼峰键。
|
|
571
|
+
*
|
|
572
|
+
* 旧版只转发 `create/identityKey/dryRun`,于是元素上写 `require_observation: true`
|
|
573
|
+
* 在参数面被收下、到引擎前被静默丢掉——与“顶层默认值”相反的行为,而且回执照样成功。
|
|
574
|
+
* 元素显式给了就压过顶层默认(与 `dryRun` 同一口径)。
|
|
575
|
+
*/
|
|
576
|
+
const requireObservation =
|
|
577
|
+
element.require_observation !== undefined ? element.require_observation : element.requireObservation
|
|
578
|
+
if (requireObservation !== undefined) {
|
|
579
|
+
if (typeof requireObservation !== 'boolean') {
|
|
580
|
+
throw Object.assign(new Error(`targets[${index}].require_observation must be a boolean`), {
|
|
581
|
+
code: 'ARGS_BAD_REQUIRE_OBSERVATION',
|
|
582
|
+
})
|
|
583
|
+
}
|
|
584
|
+
request.requireObservation = requireObservation
|
|
585
|
+
}
|
|
489
586
|
return request
|
|
490
587
|
})
|
|
491
588
|
}
|