aura-basetools 0.17.0 → 0.18.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/README.md +5 -5
- package/lib/ops.mjs +6 -6
- package/lib/transaction-store.mjs +113 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# aura-basetools 0.
|
|
1
|
+
# aura-basetools 0.18.0
|
|
2
2
|
|
|
3
3
|
Byte-exact file tools for coding agents: read by intent, edit by address, and write whole files without corrupting bytes. One engine shared by a standalone CLI and an MCP server.
|
|
4
4
|
|
|
@@ -21,7 +21,7 @@ The engine is intentionally not a general file manager: it reads and edits UTF-8
|
|
|
21
21
|
### Install
|
|
22
22
|
|
|
23
23
|
```bash
|
|
24
|
-
npm i -g aura-basetools@0.
|
|
24
|
+
npm i -g aura-basetools@0.18.0
|
|
25
25
|
aura --version
|
|
26
26
|
```
|
|
27
27
|
|
|
@@ -91,7 +91,7 @@ Compared with the DSH built-in `dsh-tool-fs` tools, the differences are delibera
|
|
|
91
91
|
- **Edits refuse stale bases.** A region-level gate rejects an edit when the addressed region changed after the read that authorized it, and points at the lines to re-read; `expect` makes the intended text explicit.
|
|
92
92
|
- **Writes are verified, not assumed.** Every write goes through a temp file, flush, rename, and a byte-for-byte read-back; multi-target edits plan first and write nothing if any target fails.
|
|
93
93
|
- **Changes can prove themselves.** `edit` / `write` accept an explicit `verify` command; a non-zero exit or timeout rolls the transaction back and returns the command output plus the rollback state. No auto-discovery of repo scripts.
|
|
94
|
-
- **Changes can span calls.** `tx begin` opens a transaction; `edit` / `write` with `--tx <id>` append to it; `tx commit`
|
|
94
|
+
- **Changes can span calls, and concurrent changes merge.** `tx begin` opens a transaction; `edit` / `write` with `--tx <id>` append to it; `tx commit` three-way-merges non-overlapping external edits, refuses overlapping ones (`TX_CONFLICT`) without writing, and can run `verify`; `tx rollback` restores all of them. MCP exposes the same lifecycle as `aura_basetools_tx` plus the `transaction` field on edit/write.
|
|
95
95
|
- **Addressing is structural.** `target` / `lines` / `old` / `expect` / handles replace "copy the literal text exactly"; failure receipts include ready-to-use retry addresses.
|
|
96
96
|
- **The core is DSH-free.** `aura-basetools` has no runtime dependencies and no DSH imports; the DSH adapter is a separate optional package.
|
|
97
97
|
|
|
@@ -136,7 +136,7 @@ MIT.
|
|
|
136
136
|
### 安装
|
|
137
137
|
|
|
138
138
|
```bash
|
|
139
|
-
npm i -g aura-basetools@0.
|
|
139
|
+
npm i -g aura-basetools@0.18.0
|
|
140
140
|
aura --version
|
|
141
141
|
```
|
|
142
142
|
|
|
@@ -206,7 +206,7 @@ npx -y --package aura-basetools aura basetools mcp
|
|
|
206
206
|
- **依据过期就拒绝。** 区域级陈旧闸门在“授权读取之后该区域被改过”时拒绝,并指出重读哪几行;`expect` 让目标原文显式化。
|
|
207
207
|
- **写完要验,不靠相信。** 每次写盘走临时文件、flush、rename,再做逐字节回读;多目标先全量规划,任一失败则一个字节都不写。
|
|
208
208
|
- **变更可以自证。** `edit` / `write` 接受显式 `verify` 命令;非零退出或超时会把整个事务回滚,回执带命令输出与回滚状态。不自动发现仓库脚本。
|
|
209
|
-
-
|
|
209
|
+
- **变更可以跨调用,也可以并发合并。** `tx begin` 开事务;`edit` / `write --tx <id>` 追加;`tx commit` 对外部非重叠改动做三方合并,重叠则 `TX_CONFLICT` 且零写入,可带 `verify`;`tx rollback` 恢复全部。MCP 用同一个生命周期:`aura_basetools_tx` + edit/write 的 `transaction` 字段。
|
|
210
210
|
- **寻址是结构化的。** `target` / `lines` / `old` / `expect` / 句柄替代“把原文一字不差抄对”;失败回执带可直接用的重试地址。
|
|
211
211
|
- **核心不依赖 DSH。** `aura-basetools` 零运行时依赖、无 DSH import;DSH 适配器是独立可选包。
|
|
212
212
|
|
package/lib/ops.mjs
CHANGED
|
@@ -728,13 +728,13 @@ export async function commitTransactionEnvelope(provider, { id, verify } = {}) {
|
|
|
728
728
|
try {
|
|
729
729
|
return await withWorkspaceLock(provider.root, async () => {
|
|
730
730
|
const tx = openTransaction(provider.root, id)
|
|
731
|
-
const
|
|
732
|
-
if (conflicts.length > 0) {
|
|
731
|
+
const merge = tx.mergeOnCommit(provider)
|
|
732
|
+
if (merge.conflicts.length > 0) {
|
|
733
733
|
return {
|
|
734
734
|
ok: false,
|
|
735
|
-
code: '
|
|
736
|
-
error: `transaction ${id} has ${conflicts.length} file(s)
|
|
737
|
-
conflicts,
|
|
735
|
+
code: 'TX_CONFLICT',
|
|
736
|
+
error: `transaction ${id} has ${merge.conflicts.length} file(s) that cannot be merged; nothing was written`,
|
|
737
|
+
conflicts: merge.conflicts,
|
|
738
738
|
transaction: tx.summary(),
|
|
739
739
|
}
|
|
740
740
|
}
|
|
@@ -754,7 +754,7 @@ export async function commitTransactionEnvelope(provider, { id, verify } = {}) {
|
|
|
754
754
|
}
|
|
755
755
|
}
|
|
756
756
|
tx.commit()
|
|
757
|
-
return { ok: true, transaction: tx.summary(), ...(verification === undefined ? {} : { verification }) }
|
|
757
|
+
return { ok: true, transaction: tx.summary(), merges: merge.merged, ...(verification === undefined ? {} : { verification }) }
|
|
758
758
|
})
|
|
759
759
|
} catch (error) {
|
|
760
760
|
return transactionError(error)
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* 这不是安全边界;它只保证遵守本协议的进程不会静默覆盖并发写。
|
|
15
15
|
*/
|
|
16
16
|
import fs from 'node:fs'
|
|
17
|
+
import { spawnSync } from 'node:child_process'
|
|
17
18
|
import os from 'node:os'
|
|
18
19
|
import path from 'node:path'
|
|
19
20
|
import crypto from 'node:crypto'
|
|
@@ -108,6 +109,8 @@ function fileSummary(entry) {
|
|
|
108
109
|
written: entry.written,
|
|
109
110
|
afterVersion: entry.afterVersion ?? null,
|
|
110
111
|
backup: entry.backup ?? null,
|
|
112
|
+
writtenBackup: entry.writtenBackup ?? null,
|
|
113
|
+
rollbackBackup: entry.rollbackBackup ?? null,
|
|
111
114
|
}
|
|
112
115
|
}
|
|
113
116
|
|
|
@@ -117,6 +120,53 @@ function fileSummary(entry) {
|
|
|
117
120
|
* @param root - 工作区根
|
|
118
121
|
* @param tool - 产生它的工具名(仅记录)
|
|
119
122
|
*/
|
|
123
|
+
function utf8Text(buf) {
|
|
124
|
+
const text = buf.toString('utf8')
|
|
125
|
+
if (!Buffer.from(text, 'utf8').equals(buf)) return null
|
|
126
|
+
if (text.includes('\u0000')) return null
|
|
127
|
+
return text
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 2-way fast path + `git merge-file` 的三方合并。
|
|
132
|
+
*
|
|
133
|
+
* 文字层合并,不碰二进制;一方没变时直接取另一方,避免为无关文件启动 git。
|
|
134
|
+
* Git 不可用或冲突时返回 conflict,调用方拒绝提交,不写任何文件。
|
|
135
|
+
*/
|
|
136
|
+
function mergeText(baseBuf, oursBuf, theirsBuf, tmpDir) {
|
|
137
|
+
const base = utf8Text(baseBuf)
|
|
138
|
+
const ours = utf8Text(oursBuf)
|
|
139
|
+
const theirs = utf8Text(theirsBuf)
|
|
140
|
+
if (base === null || ours === null || theirs === null) return { ok: false, reason: 'not-utf8' }
|
|
141
|
+
if (ours === base) return { ok: true, bytes: theirsBuf }
|
|
142
|
+
if (theirs === base) return { ok: true, bytes: oursBuf }
|
|
143
|
+
ensureDir(tmpDir)
|
|
144
|
+
const stem = `${process.pid}-${crypto.randomBytes(4).toString('hex')}`
|
|
145
|
+
const baseFile = path.join(tmpDir, `${stem}.base`)
|
|
146
|
+
const oursFile = path.join(tmpDir, `${stem}.ours`)
|
|
147
|
+
const theirsFile = path.join(tmpDir, `${stem}.theirs`)
|
|
148
|
+
try {
|
|
149
|
+
fs.writeFileSync(baseFile, baseBuf)
|
|
150
|
+
fs.writeFileSync(oursFile, oursBuf)
|
|
151
|
+
fs.writeFileSync(theirsFile, theirsBuf)
|
|
152
|
+
const res = spawnSync('git', ['merge-file', '-p', '--diff3', oursFile, baseFile, theirsFile], {
|
|
153
|
+
encoding: null,
|
|
154
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
155
|
+
windowsHide: true,
|
|
156
|
+
})
|
|
157
|
+
if (res.error?.code === 'ENOENT') return { ok: false, reason: 'git-unavailable' }
|
|
158
|
+
if (res.status === 0) return { ok: true, bytes: Buffer.from(res.stdout) }
|
|
159
|
+
if (res.status === 1) {
|
|
160
|
+
return { ok: false, reason: 'overlap', conflictText: Buffer.from(res.stdout).toString('utf8').slice(0, 4000) }
|
|
161
|
+
}
|
|
162
|
+
return { ok: false, reason: 'git-error', exitCode: res.status, stderr: String(res.stderr ?? '').slice(0, 500) }
|
|
163
|
+
} finally {
|
|
164
|
+
for (const file of [baseFile, oursFile, theirsFile]) {
|
|
165
|
+
try { fs.rmSync(file, { force: true }) } catch { /* 临时文件清不掉不影响判定 */ }
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
120
170
|
function makeTransaction(root, meta, dir) {
|
|
121
171
|
const entries = new Map()
|
|
122
172
|
for (let i = 0; i < meta.files.length; i += 1) {
|
|
@@ -177,16 +227,77 @@ function makeTransaction(root, meta, dir) {
|
|
|
177
227
|
const result = await provider.writeText(target, content, expected)
|
|
178
228
|
entry.written = true
|
|
179
229
|
entry.afterVersion = result?.version ?? null
|
|
230
|
+
const writtenPath = path.join('written', `${entry.metaIndex}.bin`)
|
|
231
|
+
writeBytesAtomic(path.join(dir, writtenPath), fs.readFileSync(entry.realPath), 0o600)
|
|
232
|
+
entry.writtenBackup = writtenPath
|
|
180
233
|
const summary = meta.files[entry.metaIndex]
|
|
181
234
|
if (summary !== undefined) {
|
|
182
235
|
summary.written = true
|
|
183
236
|
summary.afterVersion = entry.afterVersion
|
|
237
|
+
summary.writtenBackup = entry.writtenBackup
|
|
184
238
|
}
|
|
185
239
|
saveMeta(meta)
|
|
186
240
|
return result
|
|
187
241
|
}
|
|
188
242
|
return proxy
|
|
189
243
|
},
|
|
244
|
+
/**
|
|
245
|
+
* 提交前的并发合并:事务写过的文件若被外部改过,尝试把我们的 diff 三方合并到当前内容。
|
|
246
|
+
* **先规划所有文件,全部可合并才写;任一冲突 ⇒ 零写入**,返回 conflicts 供调用方拒绝。
|
|
247
|
+
*/
|
|
248
|
+
mergeOnCommit(provider) {
|
|
249
|
+
const plans = []
|
|
250
|
+
const conflicts = []
|
|
251
|
+
for (const entry of entries.values()) {
|
|
252
|
+
if (!entry.written) continue
|
|
253
|
+
let current
|
|
254
|
+
try { current = provider.stat(entry.path) } catch { current = undefined }
|
|
255
|
+
if (entry.afterVersion !== null && current?.version === entry.afterVersion) continue
|
|
256
|
+
if (!entry.writtenBackup || !entry.backup) {
|
|
257
|
+
conflicts.push({ path: entry.path, reason: 'no-merge-base' })
|
|
258
|
+
continue
|
|
259
|
+
}
|
|
260
|
+
let baseBytes
|
|
261
|
+
let oursBytes
|
|
262
|
+
let theirsBytes
|
|
263
|
+
try {
|
|
264
|
+
baseBytes = fs.readFileSync(path.join(dir, entry.backup))
|
|
265
|
+
oursBytes = fs.readFileSync(path.join(dir, entry.writtenBackup))
|
|
266
|
+
theirsBytes = fs.readFileSync(entry.realPath)
|
|
267
|
+
} catch (error) {
|
|
268
|
+
conflicts.push({ path: entry.path, reason: String(error?.message ?? error) })
|
|
269
|
+
continue
|
|
270
|
+
}
|
|
271
|
+
const merged = mergeText(baseBytes, oursBytes, theirsBytes, path.join(dir, 'merge-tmp'))
|
|
272
|
+
if (!merged.ok) {
|
|
273
|
+
conflicts.push({ path: entry.path, reason: merged.reason, ...(merged.conflictText === undefined ? {} : { conflictText: merged.conflictText }), ...(merged.exitCode === undefined ? {} : { exitCode: merged.exitCode }) })
|
|
274
|
+
continue
|
|
275
|
+
}
|
|
276
|
+
plans.push({ entry, theirsBytes, bytes: merged.bytes })
|
|
277
|
+
}
|
|
278
|
+
if (conflicts.length > 0) return { merged: [], conflicts }
|
|
279
|
+
const merged = []
|
|
280
|
+
for (const plan of plans) {
|
|
281
|
+
const { entry } = plan
|
|
282
|
+
const rollbackBackup = path.join('rollback', `${entry.metaIndex}.bin`)
|
|
283
|
+
writeBytesAtomic(path.join(dir, rollbackBackup), plan.theirsBytes, 0o600)
|
|
284
|
+
entry.rollbackBackup = rollbackBackup
|
|
285
|
+
writeBytesAtomic(entry.realPath, plan.bytes, entry.mode ?? 0o600)
|
|
286
|
+
const writtenBackup = path.join('written', `${entry.metaIndex}.bin`)
|
|
287
|
+
writeBytesAtomic(path.join(dir, writtenBackup), plan.bytes, 0o600)
|
|
288
|
+
entry.writtenBackup = writtenBackup
|
|
289
|
+
entry.afterVersion = provider.stat(entry.path)?.version ?? null
|
|
290
|
+
const summary = meta.files[entry.metaIndex]
|
|
291
|
+
if (summary !== undefined) {
|
|
292
|
+
summary.afterVersion = entry.afterVersion
|
|
293
|
+
summary.writtenBackup = entry.writtenBackup
|
|
294
|
+
summary.rollbackBackup = entry.rollbackBackup
|
|
295
|
+
}
|
|
296
|
+
merged.push({ path: entry.path, merged: true })
|
|
297
|
+
}
|
|
298
|
+
if (merged.length > 0) saveMeta(meta)
|
|
299
|
+
return { merged, conflicts: [] }
|
|
300
|
+
},
|
|
190
301
|
/** 交叉调用提交前的乐观校验:事务写过的文件必须仍停在 afterVersion。 */
|
|
191
302
|
assertFresh(provider) {
|
|
192
303
|
const conflicts = []
|
|
@@ -227,7 +338,8 @@ function makeTransaction(root, meta, dir) {
|
|
|
227
338
|
}
|
|
228
339
|
try {
|
|
229
340
|
if (entry.existed) {
|
|
230
|
-
|
|
341
|
+
const backup = entry.rollbackBackup ?? entry.backup
|
|
342
|
+
writeBytesAtomic(entry.realPath, fs.readFileSync(path.join(dir, backup)), entry.mode ?? 0o600)
|
|
231
343
|
} else {
|
|
232
344
|
fs.rmSync(entry.realPath, { force: true })
|
|
233
345
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aura-basetools",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Byte-exact read, edit and write tools for coding agents. Standalone CLI and MCP server; no DSH required. 面向 coding agent 的逐字节读写工具;独立 CLI / MCP,可选 DSH 适配器。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|