aura-basetools 0.15.0 → 0.16.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 CHANGED
@@ -1,4 +1,4 @@
1
- # aura-basetools 0.15.0
1
+ # aura-basetools 0.16.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.15.0
24
+ npm i -g aura-basetools@0.16.0
25
25
  aura --version
26
26
  ```
27
27
 
@@ -91,6 +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` checks files were not changed externally (`TX_STALE`) and can run `verify`; `tx rollback` restores all of them.
94
95
  - **Addressing is structural.** `target` / `lines` / `old` / `expect` / handles replace "copy the literal text exactly"; failure receipts include ready-to-use retry addresses.
95
96
  - **The core is DSH-free.** `aura-basetools` has no runtime dependencies and no DSH imports; the DSH adapter is a separate optional package.
96
97
 
@@ -135,7 +136,7 @@ MIT.
135
136
  ### 安装
136
137
 
137
138
  ```bash
138
- npm i -g aura-basetools@0.15.0
139
+ npm i -g aura-basetools@0.16.0
139
140
  aura --version
140
141
  ```
141
142
 
@@ -205,6 +206,7 @@ npx -y --package aura-basetools aura basetools mcp
205
206
  - **依据过期就拒绝。** 区域级陈旧闸门在“授权读取之后该区域被改过”时拒绝,并指出重读哪几行;`expect` 让目标原文显式化。
206
207
  - **写完要验,不靠相信。** 每次写盘走临时文件、flush、rename,再做逐字节回读;多目标先全量规划,任一失败则一个字节都不写。
207
208
  - **变更可以自证。** `edit` / `write` 接受显式 `verify` 命令;非零退出或超时会把整个事务回滚,回执带命令输出与回滚状态。不自动发现仓库脚本。
209
+ - **变更可以跨调用。** `tx begin` 开事务;`edit` / `write --tx <id>` 追加;`tx commit` 先检查文件没被外部改过(`TX_STALE`),可带 `verify`;`tx rollback` 恢复全部。
208
210
  - **寻址是结构化的。** `target` / `lines` / `old` / `expect` / 句柄替代“把原文一字不差抄对”;失败回执带可直接用的重试地址。
209
211
  - **核心不依赖 DSH。** `aura-basetools` 零运行时依赖、无 DSH import;DSH 适配器是独立可选包。
210
212
 
package/bin/aura.mjs CHANGED
@@ -6,6 +6,7 @@
6
6
  * aura basetools edit --file F (--hunks '<json>' | --hunks-file P) [--dry-run] [--verify "命令"] [--create <文本|@文件>]
7
7
  * aura basetools read --targets '[...]|@spec.json' 一次读多个目标(与 --file 互斥)
8
8
  * aura basetools edit --targets '[...]|@spec.json' 一次改多个文件(与 --file 互斥;任一规划失败 ⇒ 零写入)
9
+ * aura basetools tx begin | commit --id <id> [--verify "命令"] | rollback --id <id> | status --id <id>
9
10
  * aura basetools mcp
10
11
  * aura --help | --version
11
12
  *
@@ -25,6 +26,10 @@ import {
25
26
  // 多目标(fs-contract-v2)的引擎绑定:与单目标同一处(`lib/ops.mjs`),bin 里不另找引擎。
26
27
  readManyWith,
27
28
  editManyEnvelope,
29
+ beginTransactionEnvelope,
30
+ commitTransactionEnvelope,
31
+ rollbackTransactionEnvelope,
32
+ statusTransactionEnvelope,
28
33
  capabilitiesEnvelope,
29
34
  writeEnvelope,
30
35
  assertReadTargets,
@@ -86,13 +91,14 @@ const COMMAND_FLAGS = {
86
91
  bool: ['pretty'],
87
92
  },
88
93
  edit: {
89
- value: ['cwd', 'file', 'targets', 'hunks', 'hunks-file', 'create', 'target', 'lines', 'start-line', 'end-line', 'line-cue', 'anchor', 'old', 'context', 'handle', 'line', 'position', 'replace', 'expect', 'near', 'eol', 'verify'],
94
+ value: ['cwd', 'file', 'targets', 'hunks', 'hunks-file', 'create', 'target', 'lines', 'start-line', 'end-line', 'line-cue', 'anchor', 'old', 'context', 'handle', 'line', 'position', 'replace', 'expect', 'near', 'eol', 'verify', 'tx'],
90
95
  bool: ['pretty', 'dry-run', 'plan', 'replace-all', 'require-observation'],
91
96
  },
92
97
  write: {
93
- value: ['cwd', 'file', 'content', 'eol', 'verify'],
98
+ value: ['cwd', 'file', 'content', 'eol', 'verify', 'tx'],
94
99
  bool: ['pretty', 'dry-run', 'plan', 'no-overwrite', 'allow-unobserved'],
95
100
  },
101
+ tx: { value: ['cwd', 'id', 'verify'], bool: ['pretty', 'json'] },
96
102
  doctor: { value: ['cwd'], bool: ['pretty', 'json'] },
97
103
  capabilities: { value: ['cwd'], bool: ['pretty'] },
98
104
  mcp: { value: ['cwd'], bool: ['pretty'] },
@@ -109,7 +115,9 @@ function validateCommandFlags(command, tokens) {
109
115
  if (spec === undefined) return undefined
110
116
  const valueFlags = new Set(spec.value)
111
117
  const boolFlags = new Set(spec.bool)
112
- for (let i = 0; i < tokens.length; i += 1) {
118
+ // `tx` 的第一个裸位置参数是子命令(begin/commit/rollback/status);其余命令不允许位置参数。
119
+ const start = command === 'tx' && tokens[0] !== undefined && !tokens[0].startsWith('--') ? 1 : 0
120
+ for (let i = start; i < tokens.length; i += 1) {
113
121
  const token = tokens[i]
114
122
  if (typeof token !== 'string' || !token.startsWith('--')) {
115
123
  return `unexpected argument ${JSON.stringify(token)} — this command takes flags only`
@@ -177,6 +185,10 @@ const USAGE = [
177
185
  ' aura basetools edit --file <路径> --handle <h…> --line <段内行号> --replace <文本> # 只换区间内第 k 行(1-based,从区间首行数起)',
178
186
  ' aura basetools edit --file <路径> --handle <h…> --position before|after --replace <文本> # 在区间边界插入,区间本身不动',
179
187
  " (--line / --position 是从属键:只有与 --handle 同时给才成立,单独给不构成一次编辑)",
188
+ ' aura basetools tx begin 开一个跨调用事务(后续 edit/write 用 --tx <id> 加入)',
189
+ ' aura basetools tx commit --id <id> [--verify "命令"] 提交;verify 失败/文件被外部改过 ⇒ 拒绝并回滚',
190
+ ' aura basetools tx rollback --id <id> 回滚整个事务(按 afterVersion 校验)',
191
+ ' aura basetools tx status --id <id> 只读状态',
180
192
  ' aura basetools mcp 启动 MCP stdio 服务',
181
193
  ' aura basetools write --file <路径> --content <文本|@文件> [--no-overwrite] [--dry-run|--plan] [--verify "命令"] [--allow-unobserved] [--eol auto|preserve|lf|crlf]',
182
194
  ' (覆写默认要求先读过这一版;--allow-unobserved 才允许盲写)',
@@ -305,7 +317,7 @@ if (command === '--version' || command === '-v') emit({ ok: true, version: VERSI
305
317
  * 旧顶层动词只给**迁移提示**,不再作为别名执行——保留别名等于保留未来的撞名。
306
318
  * 只提示、不执行:`aura basetools read` 不会偷偷改文件。
307
319
  */
308
- const MOVED_COMMANDS = new Set(['read', 'outline', 'edit', 'write', 'mcp', 'doctor', 'capabilities'])
320
+ const MOVED_COMMANDS = new Set(['read', 'outline', 'edit', 'write', 'tx', 'mcp', 'doctor', 'capabilities'])
309
321
  if (!namespaced && MOVED_COMMANDS.has(command)) {
310
322
  emit(
311
323
  {
@@ -329,6 +341,29 @@ if (command === 'mcp') {
329
341
 
330
342
  const provider = openProvider(typeof flag('cwd') === 'string' ? flag('cwd') : process.cwd())
331
343
 
344
+ if (command === 'tx') {
345
+ const action = argv[1]
346
+ const id = typeof flag('id') === 'string' ? flag('id') : undefined
347
+ if (action === 'begin') {
348
+ const env = beginTransactionEnvelope(provider)
349
+ emit(env, env.ok ? 0 : 2)
350
+ }
351
+ if (action === 'commit') {
352
+ const verify = typeof flag('verify') === 'string' ? flag('verify') : undefined
353
+ const env = await commitTransactionEnvelope(provider, { id, verify })
354
+ emit(env, env.ok ? 0 : 2)
355
+ }
356
+ if (action === 'rollback') {
357
+ const env = await rollbackTransactionEnvelope(provider, { id })
358
+ emit(env, env.ok ? 0 : 2)
359
+ }
360
+ if (action === 'status') {
361
+ const env = statusTransactionEnvelope(provider, { id })
362
+ emit(env, env.ok ? 0 : 2)
363
+ }
364
+ emit({ ok: false, error: 'tx 需要 begin | commit | rollback | status(commit/rollback/status 还要 --id)', code: 'USAGE' }, 1)
365
+ }
366
+
332
367
  if (command === 'capabilities') {
333
368
  const env = { ok: true, version: VERSION, ...capabilitiesEnvelope() }
334
369
  process.stdout.write(`${JSON.stringify(env, null, has('pretty') ? 2 : 0)}\n`)
@@ -353,6 +388,7 @@ if (command === 'write') {
353
388
  allowUnobserved: has('allow-unobserved'),
354
389
  ...(typeof eol === 'string' ? { eol } : {}),
355
390
  ...(typeof flag('verify') === 'string' ? { verify: flag('verify') } : {}),
391
+ ...(typeof flag('tx') === 'string' ? { transaction: flag('tx') } : {}),
356
392
  })
357
393
  emit(env, env.ok ? 0 : 2)
358
394
  }
@@ -475,6 +511,7 @@ if (command === 'edit') {
475
511
  dryRun: has('dry-run') || has('plan') || spec.dryRun,
476
512
  ...(has('require-observation') ? { requireObservation: true } : {}),
477
513
  ...(typeof flag('verify') === 'string' ? { verify: flag('verify') } : {}),
514
+ ...(typeof flag('tx') === 'string' ? { transaction: flag('tx') } : {}),
478
515
  })
479
516
  emit(env, env.ok ? 0 : 2)
480
517
  }
@@ -588,6 +625,7 @@ if (command === 'edit') {
588
625
  create,
589
626
  ...(has('require-observation') ? { requireObservation: true } : {}),
590
627
  ...(typeof flag('verify') === 'string' ? { verify: flag('verify') } : {}),
628
+ ...(typeof flag('tx') === 'string' ? { transaction: flag('tx') } : {}),
591
629
  })
592
630
  emit(env, env.ok ? 0 : 2)
593
631
  }
package/lib/ops.mjs CHANGED
@@ -14,7 +14,7 @@ import { spawnSync } from 'node:child_process'
14
14
  import { fileURLToPath } from 'node:url'
15
15
  import { createNodeFsProvider, DEFAULT_MAX_TEXT_BYTES } from './fs-provider.mjs'
16
16
  import { withWorkspaceLock } from './workspace-lock.mjs'
17
- import { createTransaction } from './transaction-store.mjs'
17
+ import { createTransaction, getTransactionMeta, openTransaction } from './transaction-store.mjs'
18
18
  import { createDiskStore } from './observation-store.mjs'
19
19
 
20
20
  /**
@@ -111,7 +111,7 @@ export function capabilitiesEnvelope() {
111
111
  },
112
112
  },
113
113
  ],
114
- commands: ['basetools read', 'basetools edit', 'basetools write', 'basetools mcp', 'basetools doctor', 'basetools capabilities', '--version', '--help'],
114
+ commands: ['basetools read', 'basetools edit', 'basetools write', 'basetools tx', 'basetools mcp', 'basetools doctor', 'basetools capabilities', '--version', '--help'],
115
115
  langs,
116
116
  langsCount: langs.length,
117
117
  limits: {
@@ -519,6 +519,66 @@ function runVerification(command, cwd) {
519
519
  }
520
520
  }
521
521
 
522
+ async function editInTransaction(provider, request) {
523
+ const verifyCommand = typeof request.verify === 'string' && request.verify.trim().length > 0 ? request.verify.trim() : null
524
+ try {
525
+ return await withWorkspaceLock(provider.root, async () => {
526
+ const tx = openTransaction(provider.root, request.transaction)
527
+ const env = await editEnvelopeInner(tx.proxy(provider), request)
528
+ if (!env.ok) return env
529
+ let verification
530
+ if (verifyCommand !== null) {
531
+ verification = runVerification(verifyCommand, provider.root)
532
+ if (!verification.ok) {
533
+ const rollback = await tx.rollback(provider)
534
+ return {
535
+ ok: false,
536
+ code: 'PATCH_VERIFY_FAILED',
537
+ error: `edit verification failed (exit ${verification.exitCode ?? 'no-status'}${verification.timedOut ? ', timed out' : ''}): ${verifyCommand}`,
538
+ verification,
539
+ rollback,
540
+ transaction: tx.summary(),
541
+ }
542
+ }
543
+ }
544
+ return { ...env, transaction: tx.summary(), ...(verification === undefined ? {} : { verification }) }
545
+ })
546
+ } catch (error) {
547
+ return transactionError(error)
548
+ }
549
+ }
550
+
551
+ async function editManyInTransaction(provider, request) {
552
+ const verifyCommand = typeof request.verify === 'string' && request.verify.trim().length > 0 ? request.verify.trim() : null
553
+ try {
554
+ return await withWorkspaceLock(provider.root, async () => {
555
+ const tx = openTransaction(provider.root, request.transaction)
556
+ const batch = await editManyEnvelopeInner(tx.proxy(provider), request)
557
+ if (!batch.ok) return batch
558
+ let verification
559
+ if (verifyCommand !== null) {
560
+ verification = runVerification(verifyCommand, provider.root)
561
+ if (!verification.ok) {
562
+ const rollback = await tx.rollback(provider)
563
+ const finalBatch = writePath.rebuildAfterRollback(batch, rollback)
564
+ return {
565
+ ...finalBatch,
566
+ ok: false,
567
+ code: 'PATCH_VERIFY_FAILED',
568
+ error: `edit verification failed (exit ${verification.exitCode ?? 'no-status'}${verification.timedOut ? ', timed out' : ''}): ${verifyCommand}`,
569
+ verification,
570
+ rollback,
571
+ transaction: tx.summary(),
572
+ }
573
+ }
574
+ }
575
+ return { ...batch, transaction: tx.summary(), ...(verification === undefined ? {} : { verification }) }
576
+ })
577
+ } catch (error) {
578
+ return transactionError(error)
579
+ }
580
+ }
581
+
522
582
  /**
523
583
  * 写事务外壳:workspace 锁 + 事务日志 + 失败回滚。
524
584
  *
@@ -532,6 +592,7 @@ export async function editEnvelope(provider, request) {
532
592
  * 预演不建事务、不写盘,也不需要工作区锁;0.14.0 起事务日志只用于失败回滚。
533
593
  */
534
594
  if (request.dryRun === true) return editEnvelopeInner(provider, request)
595
+ if (typeof request.transaction === 'string' && request.transaction.length > 0) return editInTransaction(provider, request)
535
596
  const verifyCommand = typeof request.verify === 'string' && request.verify.trim().length > 0 ? request.verify.trim() : null
536
597
  try {
537
598
  return await withWorkspaceLock(provider.root, async () => {
@@ -583,6 +644,7 @@ export async function editManyEnvelope(provider, request) {
583
644
  if (provider.root === undefined) return editManyEnvelopeInner(provider, request)
584
645
  // 与单目标同一条:预演不建事务、不写盘,不能顶掉真正的最近 committed 事务。
585
646
  if (request.dryRun === true) return editManyEnvelopeInner(provider, request)
647
+ if (typeof request.transaction === 'string' && request.transaction.length > 0) return editManyInTransaction(provider, request)
586
648
  const verifyCommand = typeof request.verify === 'string' && request.verify.trim().length > 0 ? request.verify.trim() : null
587
649
  try {
588
650
  return await withWorkspaceLock(provider.root, async () => {
@@ -642,6 +704,85 @@ export async function editManyEnvelope(provider, request) {
642
704
  }
643
705
  }
644
706
 
707
+ /** 开一个跨调用事务;后续 `edit --tx <id>` 写在同一份日志里。 */
708
+ export function beginTransactionEnvelope(provider) {
709
+ if (provider.root === undefined) return { ok: false, error: 'transactions need a workspace root', code: 'TX_NO_ROOT' }
710
+ try {
711
+ const tx = createTransaction(provider.root, 'aura_basetools_tx')
712
+ return { ok: true, transaction: tx.summary() }
713
+ } catch (error) {
714
+ return transactionError(error)
715
+ }
716
+ }
717
+
718
+ /** 提交跨调用事务:先做 afterVersion 乐观校验,再跑可选 verify;失败自动 rollback。 */
719
+ export async function commitTransactionEnvelope(provider, { id, verify } = {}) {
720
+ if (provider.root === undefined) return { ok: false, error: 'transactions need a workspace root', code: 'TX_NO_ROOT' }
721
+ if (typeof id !== 'string' || id.length === 0) return { ok: false, error: 'commit needs a transaction id', code: 'TX_BAD_ID' }
722
+ const verifyCommand = typeof verify === 'string' && verify.trim().length > 0 ? verify.trim() : null
723
+ try {
724
+ return await withWorkspaceLock(provider.root, async () => {
725
+ const tx = openTransaction(provider.root, id)
726
+ const conflicts = tx.assertFresh(provider)
727
+ if (conflicts.length > 0) {
728
+ return {
729
+ ok: false,
730
+ code: 'TX_STALE',
731
+ error: `transaction ${id} has ${conflicts.length} file(s) changed since the transaction wrote them`,
732
+ conflicts,
733
+ transaction: tx.summary(),
734
+ }
735
+ }
736
+ let verification
737
+ if (verifyCommand !== null) {
738
+ verification = runVerification(verifyCommand, provider.root)
739
+ if (!verification.ok) {
740
+ const rollback = await tx.rollback(provider)
741
+ return {
742
+ ok: false,
743
+ code: 'PATCH_VERIFY_FAILED',
744
+ error: `transaction verification failed (exit ${verification.exitCode ?? 'no-status'}${verification.timedOut ? ', timed out' : ''}): ${verifyCommand}`,
745
+ verification,
746
+ rollback,
747
+ transaction: tx.summary(),
748
+ }
749
+ }
750
+ }
751
+ tx.commit()
752
+ return { ok: true, transaction: tx.summary(), ...(verification === undefined ? {} : { verification }) }
753
+ })
754
+ } catch (error) {
755
+ return transactionError(error)
756
+ }
757
+ }
758
+
759
+ /** 回滚跨调用事务:与内部失败回滚同一套 afterVersion 校验。 */
760
+ export async function rollbackTransactionEnvelope(provider, { id } = {}) {
761
+ if (provider.root === undefined) return { ok: false, error: 'transactions need a workspace root', code: 'TX_NO_ROOT' }
762
+ if (typeof id !== 'string' || id.length === 0) return { ok: false, error: 'rollback needs a transaction id', code: 'TX_BAD_ID' }
763
+ try {
764
+ return await withWorkspaceLock(provider.root, async () => {
765
+ const tx = openTransaction(provider.root, id)
766
+ const rollback = await tx.rollback(provider)
767
+ return { ok: rollback.status === 'rolled_back', rollback, transaction: tx.summary() }
768
+ })
769
+ } catch (error) {
770
+ return transactionError(error)
771
+ }
772
+ }
773
+
774
+ /** 只读事务状态。 */
775
+ export function statusTransactionEnvelope(provider, { id } = {}) {
776
+ if (provider.root === undefined) return { ok: false, error: 'transactions need a workspace root', code: 'TX_NO_ROOT' }
777
+ if (typeof id !== 'string' || id.length === 0) return { ok: false, error: 'status needs a transaction id', code: 'TX_BAD_ID' }
778
+ try {
779
+ const meta = getTransactionMeta(provider.root, id)
780
+ return { ok: true, transaction: { id: meta.id, status: meta.status, files: meta.files.length, written: meta.files.filter((f) => f.written).length, dir: meta.dir } }
781
+ } catch (error) {
782
+ return transactionError(error)
783
+ }
784
+ }
785
+
645
786
  /** 整文件写的紧凑回执:不把整份 old/new 文本塞进 applied(那是 edit 的形状,不是 write 的)。 */
646
787
  function compactWriteReceipt(env, content) {
647
788
  if (env === null || typeof env !== 'object' || env.ok !== true) return env
@@ -683,7 +824,7 @@ function compactWriteReceipt(env, content) {
683
824
  * `preserve` 按 content 原样写(与内置 Write 的“给什么写什么”对齐),`lf` / `crlf` 强制统一。
684
825
  * 新建文件永远按 content 原样写;显式传非 `auto` 的 eol 会被拒绝(不做静默忽略)。
685
826
  */
686
- export async function writeEnvelope(provider, { file, content, dryRun = false, noOverwrite = false, allowUnobserved = false, eol = 'auto', verify } = {}) {
827
+ export async function writeEnvelope(provider, { file, content, dryRun = false, noOverwrite = false, allowUnobserved = false, eol = 'auto', verify, transaction } = {}) {
687
828
  if (typeof file !== 'string' || file.length === 0) {
688
829
  return { ok: false, error: 'write needs a file path', code: 'WRITE_BAD_ARGS' }
689
830
  }
@@ -710,7 +851,7 @@ export async function writeEnvelope(provider, { file, content, dryRun = false, n
710
851
  if (dryRun) {
711
852
  return { ok: true, result: { path: displayPath, operation: 'create', bytes: Buffer.byteLength(content, 'utf8'), dryRun: true } }
712
853
  }
713
- return compactWriteReceipt(await editEnvelope(provider, { file, create: content, verify }), content)
854
+ return compactWriteReceipt(await editEnvelope(provider, { file, create: content, verify, transaction }), content)
714
855
  }
715
856
  return compactWriteReceipt(
716
857
  await editEnvelope(provider, {
@@ -722,6 +863,7 @@ export async function writeEnvelope(provider, { file, content, dryRun = false, n
722
863
  skipStaleGate: allowUnobserved === true,
723
864
  toolName: 'aura_basetools_write',
724
865
  verify,
866
+ transaction,
725
867
  }),
726
868
  content,
727
869
  )
@@ -117,22 +117,13 @@ function fileSummary(entry) {
117
117
  * @param root - 工作区根
118
118
  * @param tool - 产生它的工具名(仅记录)
119
119
  */
120
- export function createTransaction(root, tool = 'aura_basetools_edit') {
121
- const id = transactionId()
122
- const dir = path.join(transactionsRoot(root), id)
123
- ensureDir(dir)
124
- const meta = {
125
- id,
126
- tool,
127
- root: path.resolve(root),
128
- createdAt: new Date().toISOString(),
129
- status: 'pending',
130
- files: [],
131
- dir,
132
- }
133
- saveMeta(meta)
120
+ function makeTransaction(root, meta, dir) {
134
121
  const entries = new Map()
135
- let closed = false
122
+ for (let i = 0; i < meta.files.length; i += 1) {
123
+ const saved = meta.files[i]
124
+ entries.set(saved.path, { ...saved, metaIndex: i })
125
+ }
126
+ let closed = meta.status !== 'pending'
136
127
 
137
128
  const entryKey = (provider, target) => normalizeTarget(provider, target)
138
129
 
@@ -174,7 +165,7 @@ export function createTransaction(root, tool = 'aura_basetools_edit') {
174
165
  }
175
166
 
176
167
  const tx = {
177
- id,
168
+ id: meta.id,
178
169
  dir,
179
170
  meta,
180
171
  entries,
@@ -196,17 +187,29 @@ export function createTransaction(root, tool = 'aura_basetools_edit') {
196
187
  }
197
188
  return proxy
198
189
  },
190
+ /** 交叉调用提交前的乐观校验:事务写过的文件必须仍停在 afterVersion。 */
191
+ assertFresh(provider) {
192
+ const conflicts = []
193
+ for (const entry of entries.values()) {
194
+ if (!entry.written) continue
195
+ let current
196
+ try { current = provider.stat(entry.path) } catch { current = undefined }
197
+ if (entry.afterVersion !== null && current?.version !== entry.afterVersion) {
198
+ conflicts.push({ path: entry.path, expected: entry.afterVersion, actual: current?.version ?? null })
199
+ }
200
+ }
201
+ return conflicts
202
+ },
199
203
  commit() {
200
204
  if (closed) return
201
205
  meta.status = 'committed'
202
206
  meta.committedAt = new Date().toISOString()
203
- // 只有 meta 真正落盘后才 closed;否则调用方还能 rollback 已经写出的文件。
204
207
  saveMeta(meta)
205
208
  closed = true
206
209
  prune(root)
207
210
  },
208
211
  summary() {
209
- return { id, dir, status: meta.status, files: meta.files.length }
212
+ return { id: meta.id, dir, status: meta.status, files: meta.files.length, written: meta.files.filter((f) => f.written).length }
210
213
  },
211
214
  async rollback(provider, options = {}) {
212
215
  if (closed) return { status: meta.status, restored: 0, conflicts: [] }
@@ -243,6 +246,48 @@ export function createTransaction(root, tool = 'aura_basetools_edit') {
243
246
  return tx
244
247
  }
245
248
 
249
+ /**
250
+ * 创建一个 pending 事务。此时还没有任何文件被记录;第一次 writeText 前才记原始字节。
251
+ *
252
+ * @param root - 工作区根
253
+ * @param tool - 产生它的工具名(仅记录)
254
+ */
255
+ export function createTransaction(root, tool = 'aura_basetools_edit') {
256
+ const id = transactionId()
257
+ const dir = path.join(transactionsRoot(root), id)
258
+ ensureDir(dir)
259
+ const meta = {
260
+ id,
261
+ tool,
262
+ root: path.resolve(root),
263
+ createdAt: new Date().toISOString(),
264
+ status: 'pending',
265
+ files: [],
266
+ dir,
267
+ }
268
+ saveMeta(meta)
269
+ return makeTransaction(root, meta, dir)
270
+ }
271
+
272
+ /** 打开一个仍处于 pending 的跨调用事务。 */
273
+ export function openTransaction(root, id) {
274
+ const dir = path.join(transactionsRoot(root), id)
275
+ const meta = loadMeta(dir)
276
+ if (meta.status !== 'pending') {
277
+ throw Object.assign(new Error(`transaction ${id} is ${meta.status}, not open`), { code: 'TX_NOT_OPEN' })
278
+ }
279
+ return makeTransaction(root, meta, dir)
280
+ }
281
+
282
+ /** 读一个事务的元数据(状态/文件数);不存在时抛 TX_NOT_FOUND。 */
283
+ export function getTransactionMeta(root, id) {
284
+ try {
285
+ return loadMeta(path.join(transactionsRoot(root), id))
286
+ } catch {
287
+ throw Object.assign(new Error(`transaction not found: ${id}`), { code: 'TX_NOT_FOUND' })
288
+ }
289
+ }
290
+
246
291
  function readAllMeta(root) {
247
292
  const dir = transactionsRoot(root)
248
293
  let names
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aura-basetools",
3
- "version": "0.15.0",
3
+ "version": "0.16.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",