sage-mem 2.1.3 → 2.1.5
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 +1 -1
- package/README.zh.md +1 -1
- package/lib/autodream/audit.js +5 -2
- package/lib/autodream/tools.js +14 -5
- package/lib/memory/archive.js +81 -35
- package/lib/memory/util.js +73 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
**Cross-session memory for DeepSeek Harness — plain markdown files, no database, no worker.**
|
|
14
14
|
|
|
15
|
-
**Current release: `2.1.
|
|
15
|
+
**Current release: `2.1.5`** — the npm badge above is the dynamic source of truth. **Contract baseline: `1.0.0`** — the first stable release, whose public commitments, the capability boundaries and the list of things it explicitly cannot do are frozen in the
|
|
16
16
|
[frozen contract](https://github.com/gezi-wen/sage-mem/blob/main/docs/1.0.0-contract.md).
|
|
17
17
|
|
|
18
18
|
If it is useful to you, a Star is the most direct feedback.
|
package/README.zh.md
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
> File-based cross-session memory for DeepSeek Harness — every memory is a plain markdown file you can open, edit and delete. No database, no worker, no port.
|
|
16
16
|
|
|
17
|
-
**当前发布版本:`2.1.
|
|
17
|
+
**当前发布版本:`2.1.5`** —— 顶部 npm 徽章是动态权威。**契约基线:`1.0.0`** —— 第一个稳定版,公开承诺、能力边界与「明确做不到」的清单冻结在
|
|
18
18
|
[契约冻结](https://github.com/gezi-wen/sage-mem/blob/main/docs/1.0.0-contract.md)。
|
|
19
19
|
|
|
20
20
|
如果它对你有用,给个 Star 是最直接的反馈。
|
package/lib/autodream/audit.js
CHANGED
|
@@ -33,15 +33,18 @@ import { warnLog } from '../memory/util.js'
|
|
|
33
33
|
const INDEX_FILE = 'MEMORY.md'
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
|
-
* 归档 / 恢复中途崩溃留下的**私有名残留**:`a.md.archiving-<hex>`。
|
|
36
|
+
* 归档 / 恢复中途崩溃留下的**私有名残留**:`a.md.archiving-<hex>` / `a.md.restoring-<hex>`。
|
|
37
37
|
*
|
|
38
38
|
* 为什么单列一类(2026-10-12 复审复核点出):`memory/archive.js` 把源 `rename` 到这个
|
|
39
39
|
* 私有名之后、复核完成之前若进程死掉,这份内容就留在了记忆目录里。它**不是** `.md`,
|
|
40
40
|
* 所以扫描 / 注入 / 星图 / 快照 / 列表都不认它(这本来是对的,免得半成品被当成一条记忆)——
|
|
41
41
|
* 但连体检也不认,就等于「可能装着用户唯一一份内容的东西」在界面上完全隐形,
|
|
42
42
|
* 而它躺在用户会同步、会备份的记忆根目录里。审计负责把它报出来(纯只读,只报不删)。
|
|
43
|
+
*
|
|
44
|
+
* 2026-10-31 外部审查 Q06:恢复也改用同一套「接管到私有名」的事务原语,所以残留后缀
|
|
45
|
+
* 多了一个 `.restoring-` —— 两种都必须报,只加新不留旧(归档残留照样报)。
|
|
43
46
|
*/
|
|
44
|
-
const STRAY_RE = /\.archiving-[0-9a-f]+$/i
|
|
47
|
+
const STRAY_RE = /\.(?:archiving|restoring)-[0-9a-f]+$/i
|
|
45
48
|
|
|
46
49
|
/**
|
|
47
50
|
* 审计**整个跳过**的文件(不读、不当被审对象)。
|
package/lib/autodream/tools.js
CHANGED
|
@@ -36,7 +36,7 @@ import { writeFileAtomic, warnLog } from '../memory/util.js'
|
|
|
36
36
|
import { buildConflict, listConflicts, readConflict, sameConflict, writeConflict } from '../memory/conflicts.js'
|
|
37
37
|
import { memoryPathOf } from '../memory/paths.js'
|
|
38
38
|
import { decideWriteIntent } from '../memory/write-gate.js'
|
|
39
|
-
import { inspectMemoryEntry, reservedIdentitySet } from '../memory/scan.js'
|
|
39
|
+
import { inspectMemoryEntry, readVerified, reservedIdentitySet } from '../memory/scan.js'
|
|
40
40
|
|
|
41
41
|
/** 单次 read_memory 返回的正文上限(字符)。超过就截断并明确告诉模型。 */
|
|
42
42
|
const READ_LIMIT = 20000
|
|
@@ -121,7 +121,8 @@ async function scanEntries(memoryDir, ctx) {
|
|
|
121
121
|
continue
|
|
122
122
|
}
|
|
123
123
|
try {
|
|
124
|
-
|
|
124
|
+
// 同上:清单也要从闸门检查过的那个句柄读(N03 —— 检查后换文件不能把外部标题带进来)。
|
|
125
|
+
const content = await readVerified(full, gate, 'utf8')
|
|
125
126
|
const meta = parseFrontmatterLite(content)
|
|
126
127
|
const body = content.replace(/^---\r?\n[\s\S]*?\r?\n---/, '')
|
|
127
128
|
const h1 = body.match(/^#\s+(.+)$/m)
|
|
@@ -538,9 +539,16 @@ export function createToolRunner(opts) {
|
|
|
538
539
|
const before = await hostStat()
|
|
539
540
|
let content
|
|
540
541
|
try {
|
|
541
|
-
|
|
542
|
+
/**
|
|
543
|
+
* 内容走**公共句柄读取入口**(外部审查 2026-10-31,N03):`inspectMemoryEntry` 是按路径
|
|
544
|
+
* `lstat`,`readFile(路径)` 是另一次按路径打开 —— 两次之间把普通文件换成符号链接 / 另一份
|
|
545
|
+
* 文件,读的就是没检查过的东西(审查用真符号链接复现出目录外内容)。`readVerified`
|
|
546
|
+
* 改成 open 句柄 + fstat 比对 `dev:ino` / 大小,只从那个句柄读;体积上限也在全文读入
|
|
547
|
+
* **之前**生效,不会先整份读进内存再判。
|
|
548
|
+
*/
|
|
549
|
+
content = await readVerified(full, gate, 'utf8')
|
|
542
550
|
} catch (err) {
|
|
543
|
-
//
|
|
551
|
+
// 扫到读之间被删是正常状态;其余读失败(含「检查后被换掉」)照旧抛给上层。
|
|
544
552
|
if (err?.code === 'ENOENT') return { ok: false, gate: { ok: false, code: 'missing' } }
|
|
545
553
|
throw err
|
|
546
554
|
}
|
|
@@ -982,10 +990,11 @@ export function createToolRunner(opts) {
|
|
|
982
990
|
return '错误:report_contradiction 必须给 note —— 一句话说明这两条到底哪里互斥。'
|
|
983
991
|
}
|
|
984
992
|
// 两份正文都存进记录:面板上人要对照着判,只留一边等于没得判。
|
|
993
|
+
// 同样走句柄读取入口(N03):记录里的正文会被人和模型看到,不能来自目录外。
|
|
985
994
|
const readBody = async (full) => {
|
|
986
995
|
const gate = await inspectMemoryEntry(full)
|
|
987
996
|
if (!gate.ok) return null
|
|
988
|
-
return await
|
|
997
|
+
return await readVerified(full, gate, 'utf8').catch(() => null)
|
|
989
998
|
}
|
|
990
999
|
const onDisk = await readBody(oldFull)
|
|
991
1000
|
const incoming = await readBody(newFull)
|
package/lib/memory/archive.js
CHANGED
|
@@ -17,10 +17,10 @@
|
|
|
17
17
|
|
|
18
18
|
import { ARCHIVE_DIR_NAME, addArchiveMeta, inspectDir, inspectMemoryEntry, localStamp, readArchivedMeta, stripArchiveMeta } from './scan.js'
|
|
19
19
|
import { isReserved, nameKey, safeName } from './naming.js'
|
|
20
|
-
import { writeFileAtomic, writeFileExclusive } from './util.js'
|
|
20
|
+
import { retireTaken, returnSource, takeOverSource, writeFileAtomic, writeFileExclusive } from './util.js'
|
|
21
21
|
import { isLocked } from './lock.js'
|
|
22
22
|
import { ensureIndexEntry, dropIndexEntry } from './index-file.js'
|
|
23
|
-
import { link, mkdir, readFile, rename, rm, stat
|
|
23
|
+
import { link, mkdir, readFile, rename, rm, stat } from 'node:fs/promises'
|
|
24
24
|
import { basename, join } from 'node:path'
|
|
25
25
|
import { randomUUID } from 'node:crypto'
|
|
26
26
|
|
|
@@ -107,7 +107,9 @@ export async function archiveMemory(memoryDir, name, reason, at = localStamp())
|
|
|
107
107
|
throw err
|
|
108
108
|
}
|
|
109
109
|
/**
|
|
110
|
-
* ⚠️
|
|
110
|
+
* ⚠️ 收走原件这一步交给 **`takeOverSource`**(`rename` 到私有名 → 逐字节复核 → 还回 /
|
|
111
|
+
* 退役),不是 `unlink`(复审 R06 场景①;2026-10-31 外部审查 Q06 起归档与恢复共用
|
|
112
|
+
* 同一份事务移动原语,见 `memory/util.js`)。
|
|
111
113
|
*
|
|
112
114
|
* 从上面那次 `inspectMemoryEntry(src)` 到现在,中间隔着读文件 + 写归档副本两次 IO;
|
|
113
115
|
* 这期间若有人(面板保存、另一个进程、模型工具)把 `a.md` 写成了新版本,
|
|
@@ -125,18 +127,14 @@ export async function archiveMemory(memoryDir, name, reason, at = localStamp())
|
|
|
125
127
|
* 于是**新版本被当成旧版本收走**,顶层与归档件里都不再有它。`ino` 在拿不到 inode 的卷上
|
|
126
128
|
* 还会退化成 `null === null` 恒真,把判据进一步削弱成「体积 + 时间」。
|
|
127
129
|
* 内容比对是精确判据,而且成本可忽略(归档本来就要把这份内容读进内存)。
|
|
128
|
-
* · 与读到的字节完全相同 →
|
|
129
|
-
* · 不同(或读不回来)→
|
|
130
|
+
* · 与读到的字节完全相同 → 就是当初那一份,归档完成,退役私有名;
|
|
131
|
+
* · 不同(或读不回来)→ 是别人的新版本,原语用 `link()` 还回原名,放弃本次归档。
|
|
130
132
|
*
|
|
131
133
|
* 崩在中途的代价说清楚:私有名文件会留在记忆目录里(不是 `.md`,扫描与注入都不认它),
|
|
132
|
-
* 而归档副本已经写好 ——
|
|
134
|
+
* 而归档副本已经写好 —— 不会出现「一份都没有」;体检会把 `.archiving-` 残留报出来。
|
|
133
135
|
*/
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
() => null,
|
|
137
|
-
(err) => err,
|
|
138
|
-
)
|
|
139
|
-
if (moveErr) {
|
|
136
|
+
const takenRes = await takeOverSource(src, sourceBytes, 'archiving')
|
|
137
|
+
if (!takenRes.ok) {
|
|
140
138
|
/**
|
|
141
139
|
* ⚠️ `rename` 失败**不等于**「源不见了」(复审复核实测,真机 Windows):
|
|
142
140
|
* 另一进程只读占用(编辑器 / 杀软 / 同步工具)时 `rename` 报 **EBUSY** 而不是 ENOENT,
|
|
@@ -144,45 +142,37 @@ export async function archiveMemory(memoryDir, name, reason, at = localStamp())
|
|
|
144
142
|
* 「错误:没有这个文件」—— 诊断是错的,人会去别处找。按真实错误码分流。
|
|
145
143
|
*/
|
|
146
144
|
await rm(dst, { force: true }).catch(() => {})
|
|
147
|
-
if (
|
|
145
|
+
if (takenRes.code === 'not-found') {
|
|
148
146
|
return { ok: false, code: 'not-found', error: `sage-mem: file not found: ${safe}(归档途中源文件消失了)` }
|
|
149
147
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
148
|
+
if (takenRes.code === 'move-failed') {
|
|
149
|
+
const moveErr = takenRes.cause
|
|
150
|
+
return {
|
|
151
|
+
ok: false,
|
|
152
|
+
code: 'move-failed',
|
|
153
|
+
error: `sage-mem: 归档 ${safe} 时移不动它(${moveErr?.code ?? moveErr}:${moveErr?.message ?? moveErr})。文件仍在原处、一个字节没动,本次归档未完成 —— 可稍后重试(若是被其它程序占用,关掉它再来)。`,
|
|
154
|
+
}
|
|
154
155
|
}
|
|
155
|
-
|
|
156
|
-
const currentBytes = await readFile(taken).catch(() => null)
|
|
157
|
-
const sameOne = currentBytes !== null && Buffer.compare(currentBytes, sourceBytes) === 0
|
|
158
|
-
if (!sameOne) {
|
|
159
|
-
// 移走的是**别人**的新版本:还回去,一个字节都不动。
|
|
160
|
-
const restored = await link(taken, src).then(
|
|
161
|
-
() => true,
|
|
162
|
-
() => false,
|
|
163
|
-
)
|
|
164
|
-
await rm(dst, { force: true }).catch(() => {})
|
|
165
|
-
if (!restored) {
|
|
156
|
+
if (takenRes.keptAt) {
|
|
166
157
|
/**
|
|
167
158
|
* ⚠️ 归还失败时**绝不能删 `taken`**:它是这份新版本**唯一**的落身处
|
|
168
159
|
* (原名此刻已被别人占着,link 才报的 EEXIST)。删掉它就是真的丢数据。
|
|
169
|
-
*
|
|
170
|
-
* 后缀,不是 `.md`,扫描 / 注入 /
|
|
160
|
+
* 原语把它原样留下,这里把路径如实写进错误里让人去捞 —— 名字带 `.archiving-`
|
|
161
|
+
* 后缀,不是 `.md`,扫描 / 注入 / 星图都不会把它当成一条记忆,体检会报出来。
|
|
171
162
|
*/
|
|
172
163
|
return {
|
|
173
164
|
ok: false,
|
|
174
165
|
code: 'changed',
|
|
175
|
-
error: `sage-mem: ${safe} 在归档过程中被改写,已放弃本次归档;归还时原名已被占用,**内容仍保留在 ${basename(
|
|
166
|
+
error: `sage-mem: ${safe} 在归档过程中被改写,已放弃本次归档;归还时原名已被占用,**内容仍保留在 ${basename(takenRes.keptAt)}**,请手工改回 ${safe}`,
|
|
176
167
|
}
|
|
177
168
|
}
|
|
178
|
-
await rm(taken, { force: true }).catch(() => {})
|
|
179
169
|
return {
|
|
180
170
|
ok: false,
|
|
181
171
|
code: 'changed',
|
|
182
172
|
error: `sage-mem: ${safe} 在归档过程中被改写,已放弃本次归档(新版本保留在原处,请重试)`,
|
|
183
173
|
}
|
|
184
174
|
}
|
|
185
|
-
await
|
|
175
|
+
await retireTaken(takenRes.taken)
|
|
186
176
|
/**
|
|
187
177
|
* **从索引里摘掉**(与恢复时补索引对称):索引是「活动记忆的清单」,
|
|
188
178
|
* 记忆归档了就该从清单里退出 —— 不摘的话审计会报「索引悬空」。
|
|
@@ -245,7 +235,54 @@ export async function restoreMemory(memoryDir, name) {
|
|
|
245
235
|
if (dstGate.code !== 'missing') {
|
|
246
236
|
return { ok: false, code: dstGate.code, error: `sage-mem: refusing to restore over ${safe}: ${dstGate.error}` }
|
|
247
237
|
}
|
|
248
|
-
const
|
|
238
|
+
const sourceBytes = await readFile(src)
|
|
239
|
+
const content = sourceBytes.toString('utf8')
|
|
240
|
+
/**
|
|
241
|
+
* **事务接管源 + 目标排他落位**(2026-10-31 外部审查 Q06)。
|
|
242
|
+
*
|
|
243
|
+
* 旧写法是「先把 A 排他落位到顶层,再 `unlink(归档源)`」:落位确实保护了目标
|
|
244
|
+
* (复审 R06 场景②),但最后那步按名删除**没有绑定读取时的源版本** —— 落位与 unlink
|
|
245
|
+
* 之间另一路把归档源改写成 B,`unlink(名字)` 删的就是 B,新版本从两个位置一起消失,
|
|
246
|
+
* 而函数报 `ok:true`。
|
|
247
|
+
*
|
|
248
|
+
* 现在顺序反过来,用与归档同一份原语(`memory/util.js` 的 `takeOverSource`):
|
|
249
|
+
* ① 先把源原子 `rename` 成私有名,并逐字节核对它仍是当初读到的那一份;
|
|
250
|
+
* ② 内容对不上 → 原语把 B 还回原名,本次放弃(一个字节都不丢);
|
|
251
|
+
* ③ 排他落位目标(`writeFileExclusive`:目标冒出来就 EEXIST,绝不覆盖);
|
|
252
|
+
* ④ 落位失败 → 把接管的那份**原样还回**归档区;还不动时留在私有名上并点名;
|
|
253
|
+
* ⑤ 落位成功后才退役**私有副本** —— 不碰「当前叫 archive/a.md 的那个文件」,
|
|
254
|
+
* 所以落位之后被写进来的 B 会老老实实留在归档区。
|
|
255
|
+
*
|
|
256
|
+
* 崩在中途的代价:`.restoring-` 私有副本会留在归档区(不是 `.md`,扫描 / 注入都不认),
|
|
257
|
+
* 体检会把它报出来 —— 至少有一份可定位、可恢复的原文。
|
|
258
|
+
*/
|
|
259
|
+
const takenRes = await takeOverSource(src, sourceBytes, 'restoring')
|
|
260
|
+
if (!takenRes.ok) {
|
|
261
|
+
if (takenRes.code === 'not-found') {
|
|
262
|
+
return { ok: false, code: 'not-archived', error: `sage-mem: not archived: ${safe}(恢复途中源文件消失了)` }
|
|
263
|
+
}
|
|
264
|
+
if (takenRes.code === 'move-failed') {
|
|
265
|
+
const moveErr = takenRes.cause
|
|
266
|
+
return {
|
|
267
|
+
ok: false,
|
|
268
|
+
code: 'move-failed',
|
|
269
|
+
error: `sage-mem: 恢复 ${safe} 时移不动它(${moveErr?.code ?? moveErr}:${moveErr?.message ?? moveErr})。文件仍在 archive/ 里、一个字节没动 —— 可稍后重试(若是被其它程序占用,关掉它再来)。`,
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (takenRes.keptAt) {
|
|
273
|
+
// 核对时发现是别人的新版本,而原名已被再次占住 —— 新版本留在私有名上,点名给人捞。
|
|
274
|
+
return {
|
|
275
|
+
ok: false,
|
|
276
|
+
code: 'changed',
|
|
277
|
+
error: `sage-mem: ${safe} 在恢复过程中被改写,已放弃本次恢复;归还时原名已被占用,**内容(新版本)仍保留在 archive/${basename(takenRes.keptAt)}**,请手工改回 ${safe} 再试。`,
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return {
|
|
281
|
+
ok: false,
|
|
282
|
+
code: 'changed',
|
|
283
|
+
error: `sage-mem: ${safe} 在恢复过程中被改写,已放弃本次恢复(新版本保留在归档区原处,请重试)`,
|
|
284
|
+
}
|
|
285
|
+
}
|
|
249
286
|
/**
|
|
250
287
|
* **排他落位**(复审 R06 场景②):上面那句 `dstGate` 检查通过 ≠ 目标此刻仍不存在 ——
|
|
251
288
|
* 检查与写入之间,用户完全可能在顶层新建一条同名记忆(实测用 IPC 固定交错即可复现)。
|
|
@@ -256,12 +293,21 @@ export async function restoreMemory(memoryDir, name) {
|
|
|
256
293
|
try {
|
|
257
294
|
await writeFileExclusive(dst, stripArchiveMeta(content))
|
|
258
295
|
} catch (err) {
|
|
296
|
+
// 落位没成:接管的归档件必须先还回去 —— 它此刻是这个名字下唯一的一份内容。
|
|
297
|
+
const back = await returnSource(takenRes.taken, src)
|
|
298
|
+
if (!back.ok) {
|
|
299
|
+
return {
|
|
300
|
+
ok: false,
|
|
301
|
+
code: 'restore-failed',
|
|
302
|
+
error: `sage-mem: 恢复 ${safe} 落位失败(${err?.code ?? err}),且归还归档区原名时被占用 —— **内容仍保留在 archive/${basename(back.keptAt)}**,请手工改回 ${safe} 再试。`,
|
|
303
|
+
}
|
|
304
|
+
}
|
|
259
305
|
if (err?.code === 'EEXIST') {
|
|
260
306
|
return { ok: false, code: 'target-exists', error: `sage-mem: already exists, refusing to overwrite: ${safe}` }
|
|
261
307
|
}
|
|
262
308
|
throw err
|
|
263
309
|
}
|
|
264
|
-
await
|
|
310
|
+
await retireTaken(takenRes.taken)
|
|
265
311
|
/**
|
|
266
312
|
* **注册回索引**:移回文件不等于这条记忆又存在了 —— 索引里没有它,
|
|
267
313
|
* 面板与检索都看不见,体检会报「漏索引」(真实事故见 lib/memory/index-file.js 的头注)。
|
package/lib/memory/util.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* sage-mem 记忆层 —— 通用小工具:统一日志出口与原子写。
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { randomUUID } from 'node:crypto'
|
|
6
|
+
import { link, open, readFile, rename, rm, unlink, writeFile } from 'node:fs/promises'
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* 统一告警出口。所有非 ENOENT 的失败都必须落到日志,绝不静默
|
|
@@ -124,6 +125,77 @@ export async function writeFileExclusive(target, content) {
|
|
|
124
125
|
return { exclusive }
|
|
125
126
|
}
|
|
126
127
|
|
|
128
|
+
/**
|
|
129
|
+
* **事务式接管源文件**:把源 `rename` 到只有本次调用知道的私有名,再逐字节核对
|
|
130
|
+
* 它是不是调用方读到的那些字节。
|
|
131
|
+
*
|
|
132
|
+
* 为什么需要它(2026-10-31 外部审查 Q06 / 复审 R06):`unlink(名字)` / `rm(名字)`
|
|
133
|
+
* 只认名字 —— 从「读到内容」到「动手删」之间隔着若干次 IO,别人完全可能在这段时间里
|
|
134
|
+
* 把那个名字换成新版本,按名删除就会把**从未被收走过的新版本**从唯一的位置抹掉。
|
|
135
|
+
* `rename` 是原子的:先把源移到私有名上,再对**移走的那份实物**核对内容,
|
|
136
|
+
* 「名字」与「身份」就解耦了(与 `autodream/gate.js`「抢占过期锁 + 复核 + 还回」同一套手法)。
|
|
137
|
+
*
|
|
138
|
+
* ⚠️ 判据必须是**内容**,不能是 `dev:ino + size + mtime`(复审复核实测:原地截断改写 +
|
|
139
|
+
* 体积刚好相同 + 回写 mtime 会让三元组全部命中,新版本被当成旧版本收走)。内容比对
|
|
140
|
+
* 成本可忽略 —— 归档 / 恢复本来就要把这份内容读进内存。
|
|
141
|
+
*
|
|
142
|
+
* 失败时**绝不直接丢掉**这份内容:
|
|
143
|
+
* - 内容对不上 → 用 `link()` 还回原名;原名已被别人占住时,把它留在私有名上并带回路径;
|
|
144
|
+
* - 移不动 → 源还在原处、一个字节没动(按真实错误码分流,别谎称「源不存在」)。
|
|
145
|
+
*
|
|
146
|
+
* @param {string} from — 源路径
|
|
147
|
+
* @param {Buffer} expected — 调用方读到的字节
|
|
148
|
+
* @param {string} tag — 私有名后缀(`archiving` / `restoring`);体检按它认崩溃残留
|
|
149
|
+
* @returns {Promise<
|
|
150
|
+
* {ok:true, taken:string} |
|
|
151
|
+
* {ok:false, code:'not-found'|'move-failed'|'changed', cause?:Error, keptAt?:string}
|
|
152
|
+
* >}
|
|
153
|
+
*/
|
|
154
|
+
export async function takeOverSource(from, expected, tag) {
|
|
155
|
+
const taken = `${from}.${tag}-${randomUUID().replace(/-/g, '')}`
|
|
156
|
+
const moveErr = await rename(from, taken).then(
|
|
157
|
+
() => null,
|
|
158
|
+
(err) => err,
|
|
159
|
+
)
|
|
160
|
+
if (moveErr) {
|
|
161
|
+
return { ok: false, code: moveErr?.code === 'ENOENT' ? 'not-found' : 'move-failed', cause: moveErr }
|
|
162
|
+
}
|
|
163
|
+
const current = await readFile(taken).catch(() => null)
|
|
164
|
+
if (current !== null && Buffer.compare(current, expected) === 0) return { ok: true, taken }
|
|
165
|
+
// 移走的是**别人**的新版本:还回去,一个字节都不动。
|
|
166
|
+
const back = await returnSource(taken, from)
|
|
167
|
+
return { ok: false, code: 'changed', ...(back.ok ? {} : { keptAt: back.keptAt }) }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* 把 `takeOverSource` 接管的那份**原样**还回原名。
|
|
172
|
+
*
|
|
173
|
+
* 还成功时删掉私有副本;原名已被别人占住(`link` 报 EEXIST)或其它原因还不动时,
|
|
174
|
+
* **绝不删它** —— 它是这份内容唯一的落身处,留在私有名上让人去捞。
|
|
175
|
+
*
|
|
176
|
+
* @returns {Promise<{ok:true}|{ok:false,keptAt:string}>}
|
|
177
|
+
*/
|
|
178
|
+
export async function returnSource(taken, to) {
|
|
179
|
+
const linked = await link(taken, to).then(
|
|
180
|
+
() => true,
|
|
181
|
+
() => false,
|
|
182
|
+
)
|
|
183
|
+
if (!linked) return { ok: false, keptAt: taken }
|
|
184
|
+
await rm(taken, { force: true }).catch(() => {})
|
|
185
|
+
return { ok: true }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* 退役一份已经被**成功接管并落位**的私有副本。
|
|
190
|
+
*
|
|
191
|
+
* 调用者必须已经确认落位目标拿到了同一份内容:这里删的是私有名,不是任何可能
|
|
192
|
+
* 已经被别人改过的公开名字 —— 「目标落位后归档源又被写成 B」时,B 住在公开名上,
|
|
193
|
+
* 这里一个字节都碰不到它。
|
|
194
|
+
*/
|
|
195
|
+
export async function retireTaken(taken) {
|
|
196
|
+
await rm(taken, { force: true }).catch(() => {})
|
|
197
|
+
}
|
|
198
|
+
|
|
127
199
|
/**
|
|
128
200
|
* 造一个**进程内串行队列**:同一个队列上的任务一个接一个跑,绝不并发。
|
|
129
201
|
*
|
package/package.json
CHANGED