dsh-memu-memory 1.2.1 → 1.2.2
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/AGENTS.md +1 -1
- package/README.md +12 -5
- package/README.zh.md +4 -2
- package/docs/contributing.md +16 -3
- package/docs/host-api.md +3 -1
- package/docs/memory-semantics.md +21 -2
- package/docs/troubleshooting.md +42 -4
- package/lib/backup.js +24 -2
- package/lib/binding-diagnostics.js +2 -2
- package/lib/client.js +1361 -146
- package/lib/consolidation-worker.js +30 -8
- package/lib/curator-evidence.js +131 -0
- package/lib/curator-policy.js +81 -0
- package/lib/curator.js +1252 -91
- package/lib/cycle.js +3 -0
- package/lib/embedding-download.js +39 -4
- package/lib/engine.js +6 -3
- package/lib/global-graph.js +282 -0
- package/lib/graph.js +51 -9
- package/lib/inbound.js +19 -0
- package/lib/inject.js +277 -17
- package/lib/lexicon.js +1115 -0
- package/lib/local-vector-service.js +8 -6
- package/lib/managed-runtime.js +33 -2
- package/lib/memory.js +282 -17
- package/lib/memu.js +2 -2
- package/lib/migrate.js +83 -2
- package/lib/private-links.js +587 -0
- package/lib/private.js +32 -3
- package/lib/prompts/global-curator.md +76 -19
- package/lib/prompts/private-consolidation.coding.md +4 -1
- package/lib/prompts/private-consolidation.md +3 -1
- package/lib/prompts/private-consolidation.mixed.md +3 -1
- package/lib/prompts/private-consolidation.rp.md +3 -1
- package/lib/prompts.js +1 -1
- package/lib/rpc.js +577 -9
- package/lib/state.js +4 -1
- package/lib/text-retrieval.js +661 -0
- package/lib/text-similarity.js +36 -18
- package/lib/tools.js +168 -10
- package/lib/webui/standalone.css +17 -0
- package/lib/webui/standalone.js +2 -0
- package/package.json +1 -1
- package/scripts/acceptance-local.mjs +8 -5
- package/scripts/curator-pair-calibrate.mjs +259 -0
- package/scripts/lexicon-calibrate.mjs +230 -0
- package/scripts/lexicon-dedupe.mjs +318 -0
- package/tests/absorb-review.test.mjs +353 -0
- package/tests/appendix-b2.test.mjs +2 -2
- package/tests/client-export.test.mjs +520 -0
- package/tests/client-render.test.mjs +19 -3
- package/tests/consolidation-fork.test.mjs +14 -12
- package/tests/consolidation-snapshot.test.mjs +39 -0
- package/tests/curator-absorb.test.mjs +443 -0
- package/tests/curator-calibrate.test.mjs +173 -0
- package/tests/curator-clusters.test.mjs +281 -0
- package/tests/curator-commit.test.mjs +52 -5
- package/tests/curator-evidence.test.mjs +102 -0
- package/tests/curator-link.test.mjs +316 -0
- package/tests/curator-pairing.test.mjs +252 -0
- package/tests/curator-probe.test.mjs +15 -6
- package/tests/curator-review-mode.test.mjs +314 -0
- package/tests/disclosure.test.mjs +2 -1
- package/tests/fixtures/curator-pairs/labeled.v1.jsonl +90 -0
- package/tests/fixtures/lexicon-stub-hooks.mjs +15 -0
- package/tests/fixtures/lexicon-stub.mjs +284 -0
- package/tests/graph-cascade.test.mjs +35 -2
- package/tests/graph.test.mjs +96 -0
- package/tests/help-page.test.mjs +415 -0
- package/tests/inbound.test.mjs +6 -3
- package/tests/inject-guide.test.mjs +28 -0
- package/tests/inject.test.mjs +157 -5
- package/tests/lexicon-calibrate.test.mjs +48 -0
- package/tests/lexicon-dedupe.test.mjs +171 -0
- package/tests/lexicon-frequency.test.mjs +179 -0
- package/tests/lexicon-inject.test.mjs +410 -0
- package/tests/lexicon-perf.test.mjs +436 -0
- package/tests/lexicon-ui.test.mjs +547 -0
- package/tests/lexicon.test.mjs +922 -0
- package/tests/managed-runtime.test.mjs +32 -0
- package/tests/private-links.test.mjs +445 -0
- package/tests/prompts.test.mjs +35 -0
- package/tests/recall-dedup.test.mjs +33 -4
- package/tests/tool-schema.test.mjs +1 -1
- package/tests/tools.test.mjs +112 -3
- package/tests/webui-auth.test.mjs +348 -2
- package/tests/webui.test.mjs +188 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/lexicon-dedupe.mjs — 历史重复词条迁移工具(docs/lexicon-design.md §10 P2)
|
|
3
|
+
//
|
|
4
|
+
// 背景:词条冲突检查(findScopeCollision)落地之前的历史数据可能存在「同一作用域
|
|
5
|
+
// 内同规范词多条」的重复。本工具扫描 data/lexicon/*.json,按 normalizedTerm +
|
|
6
|
+
// sameFactContext 作用域找重复组,dry-run(默认)只打印报告,--apply 才执行合并。
|
|
7
|
+
//
|
|
8
|
+
// 判定与核心写入侧(lib/lexicon.js findScopeCollision)同口径:
|
|
9
|
+
// * 组键 = normalizedTerm + IDENTITIES(channelId/speakerId/characterId/worldId/
|
|
10
|
+
// branchId),与 lib/context.js sameFactContext 完全一致;knownBy 是可见性边界
|
|
11
|
+
// (contextVisible),不算作用域。
|
|
12
|
+
// * archived 条目不参与:归档不占用冲突位置(与核心一致)。
|
|
13
|
+
// * pending 且带 revisionOf 的条目不参与:它是某个 active 词条的待审修订草稿,
|
|
14
|
+
// 与其目标同词同 scope 是设计内的,不是重复。
|
|
15
|
+
//
|
|
16
|
+
// 保留规则:status active > pending > rejected(未知 status 按 rejected 档);
|
|
17
|
+
// 同档 updatedAt 新者优先;再按 termId 字典序收尾(结果稳定、可复算)。
|
|
18
|
+
// 合并规则(写入前按核心 lib/lexicon.js 的 §1.2 上限裁剪):
|
|
19
|
+
// * meaning 取组内最长(clip 500);usage 保留保留条目的;
|
|
20
|
+
// * aliases / examples 并集去重(按规范化形式 / 原文),clip 8×80 / 5×200;
|
|
21
|
+
// * strength / confidence 取 max;evidence 合并去重,clip 10 条 × quote 200;
|
|
22
|
+
// * hitCounts 三计数求和(审计历史不丢),lastInjectedAt 取最新;
|
|
23
|
+
// * status / kind / sensitivity / visibility / context 沿用保留条目(context 含
|
|
24
|
+
// knownBy,不并集——并集会放宽可见边界,交集会无故收紧,都不是"合并"该做的事);
|
|
25
|
+
// * createdAt 取组内最早(首次出现的时间);updatedAt = 本次合并时间;
|
|
26
|
+
// * 被并条目删除前,其 id 记入保留条目的 mergedFrom: [](可审计);指向被并
|
|
27
|
+
// 条目的 rejected 指纹改指保留条目(拒绝记忆不丢)。
|
|
28
|
+
// active+sensitive 的词条永不触碰:所在组整体跳过并报告。
|
|
29
|
+
//
|
|
30
|
+
// 用法:node scripts/lexicon-dedupe.mjs [--apply] [--library <id>]
|
|
31
|
+
// --apply 执行合并(默认 dry-run 只报告)
|
|
32
|
+
// --library <id> 只处理指定库(默认扫 data/lexicon/ 下全部安全命名的文件)
|
|
33
|
+
import fs from 'node:fs'
|
|
34
|
+
import path from 'node:path'
|
|
35
|
+
import { pathToFileURL } from 'node:url'
|
|
36
|
+
import { dataDir, readJsonStrict, readFailure, writeJson, withFileLock, isSafeId, shortHash, stamp } from '../lib/paths.js'
|
|
37
|
+
|
|
38
|
+
// 与 lib/context.js IDENTITIES 同组:作用域身份字段(knownBy 不在内)。
|
|
39
|
+
const IDENTITIES = ['channelId', 'speakerId', 'characterId', 'worldId', 'branchId']
|
|
40
|
+
// 核心 §1.2 上限(与 lib/lexicon.js 常量一致;这里不复用模块私有常量,写死并注释同源)。
|
|
41
|
+
const CAPS = { meaning: 500, usage: 300, aliases: 8, alias: 80, examples: 5, example: 200, evidence: 10, evidenceQuote: 200 }
|
|
42
|
+
const STATUS_RANK = { active: 3, pending: 2, rejected: 1 }
|
|
43
|
+
const STATUS_LABEL = { active: 'active', pending: 'pending', rejected: 'rejected' }
|
|
44
|
+
|
|
45
|
+
function clip(text, max) {
|
|
46
|
+
const s = String(text ?? '')
|
|
47
|
+
return s.length > max ? s.slice(0, max) : s
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 严格读取(与 lib/lexicon.js readLexiconStrict 同口径的本地副本,不走读缓存)。 */
|
|
51
|
+
function strictReadLexicon(file) {
|
|
52
|
+
const failure = readFailure(file)
|
|
53
|
+
if (failure) throw new Error(`write-protected by an earlier read failure (${failure.code || failure.message})`)
|
|
54
|
+
const result = readJsonStrict(file)
|
|
55
|
+
if (result.missing) return null
|
|
56
|
+
if (!result.ok) throw new Error(`could not be read (${result.kind === 'corrupt' ? 'invalid JSON' : result.error?.code || 'read failed'})`)
|
|
57
|
+
const value = result.value
|
|
58
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
59
|
+
|| (value.version !== undefined && value.version !== 1)
|
|
60
|
+
|| !value.terms || typeof value.terms !== 'object' || Array.isArray(value.terms)) {
|
|
61
|
+
throw new Error('has an invalid data shape')
|
|
62
|
+
}
|
|
63
|
+
for (const [termId, entry] of Object.entries(value.terms)) {
|
|
64
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
65
|
+
throw new Error(`entry ${termId} is not an object`)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return value
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function scopeKeyOf(entry) {
|
|
72
|
+
return IDENTITIES.map(key => String(entry?.context?.[key] || '')).join('\u0001')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function scopeLabelOf(entry) {
|
|
76
|
+
const parts = IDENTITIES.filter(key => entry?.context?.[key]).map(key => `${key}=${entry.context[key]}`)
|
|
77
|
+
return parts.length ? parts.join(', ') : '(全场景)'
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 判定词条是否参与重复分组。返回 null 表示排除(排除原因用于报告)。
|
|
82
|
+
*/
|
|
83
|
+
function participation(entry) {
|
|
84
|
+
const status = entry.status || 'active'
|
|
85
|
+
if (status === 'archived') return 'archived(不占用冲突位置,跳过)'
|
|
86
|
+
if (status === 'pending' && entry.revisionOf) return 'pending revision 草稿(active 词条的待审修订,跳过)'
|
|
87
|
+
if (!String(entry.normalizedTerm || '')) return '缺少 normalizedTerm,跳过'
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 扫描一本词典,返回 duplicate 组列表(每组 ≥2 条可参与成员)。 */
|
|
92
|
+
export function scanDuplicateGroups(data) {
|
|
93
|
+
const groups = new Map()
|
|
94
|
+
for (const [termId, entry] of Object.entries(data.terms || {})) {
|
|
95
|
+
const excluded = participation(entry)
|
|
96
|
+
if (excluded) continue
|
|
97
|
+
const key = `${String(entry.normalizedTerm)}\u0000${scopeKeyOf(entry)}`
|
|
98
|
+
if (!groups.has(key)) groups.set(key, [])
|
|
99
|
+
groups.get(key).push({ termId, entry })
|
|
100
|
+
}
|
|
101
|
+
return [...groups.values()].filter(members => members.length >= 2)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 保留规则:active > pending > rejected → updatedAt 新者 → termId 字典序。 */
|
|
105
|
+
function keptFirst(a, b) {
|
|
106
|
+
const ra = STATUS_RANK[a.entry.status || 'active'] || 1
|
|
107
|
+
const rb = STATUS_RANK[b.entry.status || 'active'] || 1
|
|
108
|
+
if (ra !== rb) return rb - ra
|
|
109
|
+
const ta = String(a.entry.updatedAt || ''), tb = String(b.entry.updatedAt || '')
|
|
110
|
+
if (ta !== tb) return tb.localeCompare(ta)
|
|
111
|
+
return String(a.termId).localeCompare(String(b.termId))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 为一个组产出合并计划;含 active+sensitive 成员的组整组跳过。 */
|
|
115
|
+
export function planGroup(members) {
|
|
116
|
+
const sensitiveMember = members.find(m => (m.entry.status || 'active') === 'active' && m.entry.sensitivity === 'sensitive')
|
|
117
|
+
if (sensitiveMember) {
|
|
118
|
+
return { skip: true, reason: `含 active+sensitive 词条 ${sensitiveMember.termId},工具永不触碰,整组跳过` }
|
|
119
|
+
}
|
|
120
|
+
const ordered = [...members].sort(keptFirst)
|
|
121
|
+
const kept = ordered[0]
|
|
122
|
+
const removed = ordered.slice(1)
|
|
123
|
+
const keptEntry = kept.entry
|
|
124
|
+
const normalizedTerm = String(keptEntry.normalizedTerm)
|
|
125
|
+
|
|
126
|
+
// meaning 取组内最长;usage 沿用保留条目。
|
|
127
|
+
let meaning = String(keptEntry.meaning || '')
|
|
128
|
+
for (const { entry } of removed) {
|
|
129
|
+
if (String(entry.meaning || '').length > meaning.length) meaning = String(entry.meaning || '')
|
|
130
|
+
}
|
|
131
|
+
meaning = clip(meaning.trim(), CAPS.meaning)
|
|
132
|
+
|
|
133
|
+
// aliases/examples 并集去重(aliases 按规范化形式去重,examples 按原文去重)。
|
|
134
|
+
const aliases = []
|
|
135
|
+
const seenAlias = new Set()
|
|
136
|
+
for (const { entry } of ordered) {
|
|
137
|
+
for (const alias of (Array.isArray(entry.aliases) ? entry.aliases : [])) {
|
|
138
|
+
const text = clip(String(alias ?? '').trim(), CAPS.alias)
|
|
139
|
+
const normalized = text.normalize('NFKC').toLowerCase().replace(/[\s\p{P}\p{S}]+/gu, '')
|
|
140
|
+
if (!text || normalized === normalizedTerm || seenAlias.has(normalized)) continue
|
|
141
|
+
seenAlias.add(normalized)
|
|
142
|
+
aliases.push(text)
|
|
143
|
+
if (aliases.length >= CAPS.aliases) break
|
|
144
|
+
}
|
|
145
|
+
if (aliases.length >= CAPS.aliases) break
|
|
146
|
+
}
|
|
147
|
+
const examples = []
|
|
148
|
+
const seenExample = new Set()
|
|
149
|
+
for (const { entry } of ordered) {
|
|
150
|
+
for (const example of (Array.isArray(entry.examples) ? entry.examples : [])) {
|
|
151
|
+
const text = clip(String(example ?? '').trim(), CAPS.example)
|
|
152
|
+
if (!text || seenExample.has(text)) continue
|
|
153
|
+
seenExample.add(text)
|
|
154
|
+
examples.push(text)
|
|
155
|
+
if (examples.length >= CAPS.examples) break
|
|
156
|
+
}
|
|
157
|
+
if (examples.length >= CAPS.examples) break
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// strength/confidence 取 max;evidence 合并去重;三计数求和;lastInjectedAt 取最新。
|
|
161
|
+
const numOf = value => (Number.isFinite(Number(value)) ? Number(value) : null)
|
|
162
|
+
let strength = numOf(keptEntry.strength)
|
|
163
|
+
let confidence = numOf(keptEntry.confidence)
|
|
164
|
+
const hitCounts = { candidate: 0, injection: 0, query: 0 }
|
|
165
|
+
let lastInjectedAt = keptEntry.lastInjectedAt || null
|
|
166
|
+
let createdAt = String(keptEntry.createdAt || '')
|
|
167
|
+
const evidence = []
|
|
168
|
+
const seenEvidence = new Set()
|
|
169
|
+
for (const { entry } of ordered) {
|
|
170
|
+
strength = Math.max(strength ?? 0, numOf(entry.strength) ?? 0)
|
|
171
|
+
confidence = Math.max(confidence ?? 0, numOf(entry.confidence) ?? 0)
|
|
172
|
+
for (const key of Object.keys(hitCounts)) {
|
|
173
|
+
hitCounts[key] += Number(entry.hitCounts?.[key]) || 0
|
|
174
|
+
}
|
|
175
|
+
if (entry.lastInjectedAt && (!lastInjectedAt || String(entry.lastInjectedAt) > String(lastInjectedAt))) {
|
|
176
|
+
lastInjectedAt = entry.lastInjectedAt
|
|
177
|
+
}
|
|
178
|
+
const entryCreated = String(entry.createdAt || '')
|
|
179
|
+
if (entryCreated && (!createdAt || entryCreated < createdAt)) createdAt = entryCreated
|
|
180
|
+
for (const item of (Array.isArray(entry.evidence) ? entry.evidence : [])) {
|
|
181
|
+
if (!item || typeof item !== 'object') continue
|
|
182
|
+
const quote = clip(typeof item.quote === 'string' ? item.quote : '', CAPS.evidenceQuote)
|
|
183
|
+
const id = clip(typeof item.id === 'string' ? item.id : '', 160)
|
|
184
|
+
const kind = clip(typeof item.kind === 'string' ? item.kind : 'message', 40)
|
|
185
|
+
if (!quote.trim() && !id) continue
|
|
186
|
+
const sig = JSON.stringify([kind, id, quote])
|
|
187
|
+
if (seenEvidence.has(sig)) continue
|
|
188
|
+
seenEvidence.add(sig)
|
|
189
|
+
evidence.push({ kind, id, quote })
|
|
190
|
+
if (evidence.length >= CAPS.evidence) break
|
|
191
|
+
}
|
|
192
|
+
if (evidence.length >= CAPS.evidence) break
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const merged = {
|
|
196
|
+
...keptEntry,
|
|
197
|
+
meaning,
|
|
198
|
+
aliases,
|
|
199
|
+
normalizedAliases: aliases.map(alias => alias.normalize('NFKC').toLowerCase().replace(/[\s\p{P}\p{S}]+/gu, '')).filter(Boolean),
|
|
200
|
+
examples,
|
|
201
|
+
strength: strength ?? 0.6,
|
|
202
|
+
confidence: confidence ?? 0.8,
|
|
203
|
+
evidence,
|
|
204
|
+
hitCounts,
|
|
205
|
+
lastInjectedAt,
|
|
206
|
+
createdAt: createdAt || keptEntry.createdAt,
|
|
207
|
+
fingerprint: keptEntry.fingerprint || shortHash(normalizedTerm),
|
|
208
|
+
// 被并条目的 id 记入保留条目,历史可审计。
|
|
209
|
+
mergedFrom: [...(Array.isArray(keptEntry.mergedFrom) ? keptEntry.mergedFrom : []), ...removed.map(item => item.termId)],
|
|
210
|
+
}
|
|
211
|
+
return { skip: false, keptId: kept.termId, keptEntry, removeIds: removed.map(item => item.termId), merged }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** 把计划应用到词典对象上(CAS:调用方必须在 withFileLock 内对最新读取执行)。 */
|
|
215
|
+
export function applyPlans(data, plans, now = stamp()) {
|
|
216
|
+
for (const plan of plans) {
|
|
217
|
+
if (plan.skip) continue
|
|
218
|
+
for (const removeId of plan.removeIds) {
|
|
219
|
+
const removed = data.terms[removeId]
|
|
220
|
+
if (removed?.fingerprint && data.rejected && data.rejected[removed.fingerprint]?.termId === removeId) {
|
|
221
|
+
// 拒绝指纹改指保留条目:同一规范词的 model-proposal 依旧被拦。
|
|
222
|
+
data.rejected[removed.fingerprint].termId = plan.keptId
|
|
223
|
+
}
|
|
224
|
+
delete data.terms[removeId]
|
|
225
|
+
}
|
|
226
|
+
data.terms[plan.keptId] = { ...plan.merged, updatedAt: now }
|
|
227
|
+
}
|
|
228
|
+
data.revision = (Number(data.revision) || 0) + 1
|
|
229
|
+
data.version = 1
|
|
230
|
+
return data
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function listLibraryFiles(onlyId) {
|
|
234
|
+
const dir = path.join(dataDir(), 'lexicon')
|
|
235
|
+
if (onlyId) {
|
|
236
|
+
if (!isSafeId(onlyId)) throw new Error(`invalid library id: ${clip(onlyId, 40)}`)
|
|
237
|
+
const file = path.join(dir, `${onlyId}.json`)
|
|
238
|
+
if (!fs.existsSync(file)) throw new Error(`lexicon file not found: ${onlyId}.json`)
|
|
239
|
+
return [{ libraryId: onlyId, file }]
|
|
240
|
+
}
|
|
241
|
+
if (!fs.existsSync(dir)) return []
|
|
242
|
+
return fs.readdirSync(dir)
|
|
243
|
+
.filter(name => name.endsWith('.json') && isSafeId(name.slice(0, -'.json'.length)))
|
|
244
|
+
.sort()
|
|
245
|
+
.map(name => ({ libraryId: name.slice(0, -'.json'.length), file: path.join(dir, name) }))
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function reportLibrary({ libraryId, file }, apply) {
|
|
249
|
+
const data = strictReadLexicon(file)
|
|
250
|
+
if (!data) {
|
|
251
|
+
console.log(`${libraryId}: 无文件,跳过`)
|
|
252
|
+
return { groups: 0, merges: 0, skipped: 0 }
|
|
253
|
+
}
|
|
254
|
+
const groups = scanDuplicateGroups(data)
|
|
255
|
+
const plans = groups.map(members => ({ label: `${String(members[0].entry.normalizedTerm)} @ ${scopeLabelOf(members[0].entry)}`, plan: planGroup(members) }))
|
|
256
|
+
const mergePlans = plans.filter(item => !item.plan.skip)
|
|
257
|
+
console.log(`${libraryId}: revision ${data.revision ?? 0},词条 ${Object.keys(data.terms || {}).length} 条,重复组 ${groups.length} 组`)
|
|
258
|
+
for (const { label, plan } of plans) {
|
|
259
|
+
if (plan.skip) {
|
|
260
|
+
console.log(` [跳过] ${label}:${plan.reason}`)
|
|
261
|
+
continue
|
|
262
|
+
}
|
|
263
|
+
console.log(` [合并] ${label}:${plan.removeIds.length + 1} 条 → 保留 ${plan.keptId} (${plan.keptEntry.status || 'active'}, updatedAt ${plan.keptEntry.updatedAt || 'null'})`)
|
|
264
|
+
console.log(` 并入并删除: ${plan.removeIds.join(', ')}`)
|
|
265
|
+
console.log(` meaning 取最长(${plan.merged.meaning.length} 字), aliases 并集 ${plan.merged.aliases.length}, examples 并集 ${plan.merged.examples.length}, strength max ${plan.merged.strength}, evidence ${plan.merged.evidence.length} 条`)
|
|
266
|
+
console.log(` mergedFrom 记入: [${plan.merged.mergedFrom.join(', ')}]`)
|
|
267
|
+
}
|
|
268
|
+
if (groups.length === 0) console.log(' 无历史重复')
|
|
269
|
+
if (apply && mergePlans.length) {
|
|
270
|
+
withFileLock(file, () => {
|
|
271
|
+
// CAS:锁内重读最新落盘内容再套用计划,plan 基于「扫描时快照」之外的变化重算。
|
|
272
|
+
const fresh = strictReadLexicon(file)
|
|
273
|
+
if (!fresh) throw new Error('disappeared during apply')
|
|
274
|
+
const freshPlans = scanDuplicateGroups(fresh).map(members => planGroup(members))
|
|
275
|
+
applyPlans(fresh, freshPlans)
|
|
276
|
+
writeJson(file, fresh)
|
|
277
|
+
console.log(` [写入] revision → ${fresh.revision},词条 ${Object.keys(fresh.terms).length} 条`)
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
return { groups: groups.length, merges: apply ? mergePlans.length : 0, skipped: plans.filter(item => item.plan.skip).length }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function main() {
|
|
284
|
+
const args = process.argv.slice(2)
|
|
285
|
+
const apply = args.includes('--apply')
|
|
286
|
+
const libraryFlagIndex = args.indexOf('--library')
|
|
287
|
+
const onlyId = libraryFlagIndex >= 0 ? String(args[libraryFlagIndex + 1] || '').trim() : null
|
|
288
|
+
if (args.some(arg => arg === '--help' || arg === '-h')) {
|
|
289
|
+
console.log('用法: node scripts/lexicon-dedupe.mjs [--apply] [--library <id>]')
|
|
290
|
+
console.log(' 默认 dry-run 只打印报告;--apply 才执行合并。')
|
|
291
|
+
process.exit(0)
|
|
292
|
+
}
|
|
293
|
+
console.log(apply ? 'lexicon-dedupe: --apply(将执行合并)' : 'lexicon-dedupe: dry-run(加 --apply 执行合并)')
|
|
294
|
+
let files
|
|
295
|
+
try {
|
|
296
|
+
files = listLibraryFiles(onlyId)
|
|
297
|
+
} catch (error) {
|
|
298
|
+
console.error(`lexicon-dedupe: ${String(error?.message || error)}`)
|
|
299
|
+
process.exit(2)
|
|
300
|
+
}
|
|
301
|
+
let totals = { groups: 0, merges: 0, skipped: 0 }
|
|
302
|
+
for (const entry of files) {
|
|
303
|
+
try {
|
|
304
|
+
const result = reportLibrary(entry, apply)
|
|
305
|
+
totals.groups += result.groups
|
|
306
|
+
totals.merges += result.merges
|
|
307
|
+
totals.skipped += result.skipped
|
|
308
|
+
} catch (error) {
|
|
309
|
+
console.log(`${entry.libraryId}: 读取失败,跳过(${String(error?.message || error)})`)
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
console.log(`汇总: 重复组 ${totals.groups},${apply ? `已合并 ${totals.merges}` : 'dry-run 未写入'},含 active+sensitive 跳过 ${totals.skipped} 组`)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/* istanbul ignore next */
|
|
316
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
|
317
|
+
main()
|
|
318
|
+
}
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import os from 'node:os'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { registerHooks } from 'node:module'
|
|
7
|
+
|
|
8
|
+
// Package F2 (spec §4.11 / §6.7): the pendingAbsorb review path and undo.
|
|
9
|
+
//
|
|
10
|
+
// Covered here:
|
|
11
|
+
// 1. listReviews carries reviewRisk / reviewRiskReasons / pendingAbsorb;
|
|
12
|
+
// 2. accept: resolution must be replace (coexist throws), the duplicate gate
|
|
13
|
+
// excludes target+sources, the snapshot check catches a concurrently changed
|
|
14
|
+
// source ('review changed; reload and retry'), the vector store receives ONLY
|
|
15
|
+
// the proposal id (never the sources), and the single index transaction
|
|
16
|
+
// supersedes the target, archives the sources, rewires links and writes the
|
|
17
|
+
// snapshot while clearing pendingAbsorb/reviewReason/conflictingWith;
|
|
18
|
+
// 3. reject: the proposal is archived and target/sources stay untouched;
|
|
19
|
+
// 4. undo-absorb through the RPC: restores target/sources/rewired links in one
|
|
20
|
+
// transaction, archives the merged entry, refuses a second undo, refuses a
|
|
21
|
+
// libraryId, refuses an entry without a snapshot, and refuses when a member
|
|
22
|
+
// was edited after absorption.
|
|
23
|
+
//
|
|
24
|
+
// Same hermetic pattern as tests/curator-commit.test.mjs. A managed vector
|
|
25
|
+
// generation is active so the accept path's commit lands in the stub backend
|
|
26
|
+
// and can be counted.
|
|
27
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-absorb-review-'))
|
|
28
|
+
process.env.DSH_MEMU_HOME = home
|
|
29
|
+
process.env.DSH_MEMU_MEMORY_HOME = home
|
|
30
|
+
|
|
31
|
+
const backend = {
|
|
32
|
+
commits: [],
|
|
33
|
+
forgets: [],
|
|
34
|
+
}
|
|
35
|
+
globalThis.__curatorAbsorbReviewTest = {
|
|
36
|
+
async commit(args) {
|
|
37
|
+
const names = (args.payload?.recall_files || []).map((file) => file.name)
|
|
38
|
+
backend.commits.push({ names, route: args.route, db: args.db })
|
|
39
|
+
return { ok: true }
|
|
40
|
+
},
|
|
41
|
+
async forget(args) {
|
|
42
|
+
backend.forgets.push({ name: args.name, db: args.db })
|
|
43
|
+
return { deleted: 1, segments: 1 }
|
|
44
|
+
},
|
|
45
|
+
async retrieve() { return { files: [], segments: [], resources: [], raw: null } },
|
|
46
|
+
async listFiles() { return { recall_files: [], resources: [], raw: null } },
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const memuSource = 'const bridge = globalThis.__curatorAbsorbReviewTest;'
|
|
50
|
+
+ 'export const commit = (args) => bridge.commit(args), forget = (args) => bridge.forget(args),'
|
|
51
|
+
+ 'retrieve = (args) => bridge.retrieve(args), listFiles = (args) => bridge.listFiles(args);'
|
|
52
|
+
const hooks = registerHooks({
|
|
53
|
+
resolve(specifier, context, nextResolve) {
|
|
54
|
+
if (specifier === './memu.js') return { url: 'data:text/javascript,' + encodeURIComponent(memuSource), shortCircuit: true }
|
|
55
|
+
return nextResolve(specifier, context)
|
|
56
|
+
},
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const curator = await import('../lib/curator.js')
|
|
60
|
+
const memory = await import('../lib/memory.js')
|
|
61
|
+
const rpc = await import('../lib/rpc.js')
|
|
62
|
+
const state = await import('../lib/state.js')
|
|
63
|
+
const vm = await import('../lib/vector-migration.js')
|
|
64
|
+
const vp = await import('../lib/vector-profile.js')
|
|
65
|
+
const catalog = await import('../lib/embedding-catalog.js')
|
|
66
|
+
|
|
67
|
+
const BGE = 'bge-small-zh-v1.5-int8'
|
|
68
|
+
const baseEntry = catalog.getEmbeddingModel(BGE)
|
|
69
|
+
const altEntry = JSON.parse(JSON.stringify(baseEntry))
|
|
70
|
+
altEntry.id = 'bge-small-zh-v1.5-int8-alt'
|
|
71
|
+
altEntry.displayName = 'BGE alternative revision (absorb review fixture)'
|
|
72
|
+
altEntry.sources.modelscope.revision = 'c'.repeat(40)
|
|
73
|
+
altEntry.profileFingerprint = catalog.profileFingerprint(altEntry)
|
|
74
|
+
const models = { [BGE]: baseEntry, [altEntry.id]: altEntry }
|
|
75
|
+
vp.setCatalogEntryResolver((id) => models[id] ?? null)
|
|
76
|
+
|
|
77
|
+
state.updateSettings({ memoryBackend: 'api', vectorSource: 'local' })
|
|
78
|
+
|
|
79
|
+
test.after(async () => {
|
|
80
|
+
try { hooks.unregister?.() } catch {}
|
|
81
|
+
vp.resetCatalogEntryResolver()
|
|
82
|
+
try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
function seedGlobalIndex(entries = {}) {
|
|
86
|
+
state.saveGlobalIndex({ version: 1, entries: structuredClone(entries) })
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function activateVectorGeneration() {
|
|
90
|
+
const dbs = new Map()
|
|
91
|
+
const rowsOf = (db) => {
|
|
92
|
+
if (!dbs.has(db)) dbs.set(db, new Map())
|
|
93
|
+
return dbs.get(db)
|
|
94
|
+
}
|
|
95
|
+
const migrator = vm.createVectorMigrator({
|
|
96
|
+
async listLibraries() { return [] },
|
|
97
|
+
loadIndex(libraryId) {
|
|
98
|
+
return libraryId ? state.loadPrivateIndex(libraryId) : state.loadGlobalIndex()
|
|
99
|
+
},
|
|
100
|
+
async commit({ db, item }) { rowsOf(db).set(item.id, item.text) },
|
|
101
|
+
async forget({ db, itemId }) { rowsOf(db).delete(itemId) },
|
|
102
|
+
async listFiles({ db }) { return [...rowsOf(db).keys()] },
|
|
103
|
+
async smoke() { return { ok: true, checks: ['dimension=512'] } },
|
|
104
|
+
})
|
|
105
|
+
const switched = await migrator.switchProfile({ profile: BGE })
|
|
106
|
+
assert.ok(['switched', 'already-active'].includes(switched.status), 'an active generation must be available')
|
|
107
|
+
return { migrator, generation: switched.generation }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const CORE = '每周四凌晨两点到四点是固定的部署窗口,窗口内只允许灰度发布。'
|
|
111
|
+
const AT = '2026-01-01T00:00:00.000Z'
|
|
112
|
+
const A = 'gmem-review-a'
|
|
113
|
+
const B = 'gmem-review-b'
|
|
114
|
+
const C = 'gmem-review-c'
|
|
115
|
+
const D = 'gmem-review-d'
|
|
116
|
+
const E = 'gmem-review-e'
|
|
117
|
+
|
|
118
|
+
function baseEntryOf(id, title, content, extra = {}) {
|
|
119
|
+
return {
|
|
120
|
+
track: 'memory', domain: 'general', title, content,
|
|
121
|
+
confidence: 0.9, status: 'active',
|
|
122
|
+
createdAt: AT, updatedAt: AT,
|
|
123
|
+
evidence: [{ sessionId: 's0', quote: '部署窗口只在周四' }],
|
|
124
|
+
...extra,
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function absorbEvidence(ids) {
|
|
129
|
+
return ids.map((id, index) => ({ session_id: `s-rev-${index}`, quote: `来源 ${id} 的落地引用内容`, source_id: id }))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function seedCluster({ withLinks = false } = {}) {
|
|
133
|
+
const entries = {
|
|
134
|
+
[A]: baseEntryOf(A, '部署窗口约定', CORE + '全量发布必须在监控确认通过之后才能执行。'),
|
|
135
|
+
[B]: baseEntryOf(B, '部署窗口细节', CORE + '灰度发布按服务分组逐步放量,异常时立即回滚。'),
|
|
136
|
+
[C]: baseEntryOf(C, '部署窗口约束', CORE + '窗口外禁止任何生产变更。'),
|
|
137
|
+
}
|
|
138
|
+
if (withLinks) {
|
|
139
|
+
entries[D] = baseEntryOf(D, '簇外条目', '另一个话题的内容,与吸收无关。', {
|
|
140
|
+
links: [{ id: 'lnk-in-9', opId: 'op9', to: A, kind: 'related', note: '这条入边在吸收后要改指合并稿。', confidence: 0.7, evidence: [], createdAt: AT }],
|
|
141
|
+
})
|
|
142
|
+
entries[A].links = [{ id: 'lnk-out-9', opId: 'op8', to: E, kind: 'refines', note: 'target 指向簇外的边要并入合并稿。', confidence: 0.7, evidence: [], createdAt: AT }]
|
|
143
|
+
entries[E] = baseEntryOf(E, '簇外条目2', '被 target 指向的条目。')
|
|
144
|
+
}
|
|
145
|
+
seedGlobalIndex(entries)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// review-all 让 absorb 计划一律落 pendingAbsorb 提案(spec §4.10 表格第三列)。
|
|
149
|
+
state.updateSettings({ curatorReviewMode: 'review-all' })
|
|
150
|
+
|
|
151
|
+
const ABSORB_PLAN = {
|
|
152
|
+
memories: [{
|
|
153
|
+
op: 'absorb', target_id: A, source_ids: [B, C],
|
|
154
|
+
confidence: 0.9,
|
|
155
|
+
evidence: absorbEvidence([A, B, C]),
|
|
156
|
+
}],
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function pendingProposalOf() {
|
|
160
|
+
const queue = await rpc.dispatch({}, 'listReviews', {})
|
|
161
|
+
const item = queue.items.find((row) => row.pendingAbsorb)
|
|
162
|
+
assert.ok(item, 'the absorb proposal must be in the review queue')
|
|
163
|
+
return item
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
test('listReviews exposes reviewRisk, reviewRiskReasons and pendingAbsorb for the queue UI', async () => {
|
|
167
|
+
seedCluster()
|
|
168
|
+
await activateVectorGeneration()
|
|
169
|
+
const result = await curator.commitCuratorPlans(structuredClone(ABSORB_PLAN), { job: { step: '' } })
|
|
170
|
+
assert.equal(result.pending, 1)
|
|
171
|
+
const item = await pendingProposalOf()
|
|
172
|
+
assert.equal(item.pendingAbsorb.targetId, A)
|
|
173
|
+
assert.deepEqual([...item.pendingAbsorb.sourceIds].sort(), [B, C].sort())
|
|
174
|
+
assert.ok(item.pendingAbsorb.opId)
|
|
175
|
+
assert.equal(item.pendingAbsorb.expectedUpdatedAt[A], AT)
|
|
176
|
+
assert.equal(typeof item.reviewRisk, 'number', 'reviewRisk rides along on every queue row')
|
|
177
|
+
assert.ok(Array.isArray(item.reviewRiskReasons))
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
test('accept turns the proposal active, archives the sources, commits only the proposal id to vectors and rewires links', async () => {
|
|
181
|
+
seedCluster({ withLinks: true })
|
|
182
|
+
await activateVectorGeneration()
|
|
183
|
+
await curator.commitCuratorPlans(structuredClone(ABSORB_PLAN), { job: { step: '' } })
|
|
184
|
+
const item = await pendingProposalOf()
|
|
185
|
+
const proposalId = item.id
|
|
186
|
+
|
|
187
|
+
const resolved = await rpc.dispatch({}, 'resolveReview', { id: proposalId, action: 'accept' })
|
|
188
|
+
assert.equal(resolved.status, 'active')
|
|
189
|
+
|
|
190
|
+
const index = state.loadGlobalIndex()
|
|
191
|
+
const merged = index.entries[proposalId]
|
|
192
|
+
assert.equal(merged.status, 'active')
|
|
193
|
+
// §3.3/§4.11:accept 写 absorbedFrom/absorbedAt/absorbOpId/representativeId,
|
|
194
|
+
// 并清 pendingAbsorb/reviewReason/conflictingWith。
|
|
195
|
+
assert.deepEqual(merged.absorbedFrom, [A, B, C].sort())
|
|
196
|
+
assert.ok(merged.absorbOpId)
|
|
197
|
+
assert.ok(merged.representativeId)
|
|
198
|
+
assert.ok(merged.absorbAt || merged.updatedAt)
|
|
199
|
+
assert.equal(merged.auditTrail?.at(-1)?.op, 'absorb')
|
|
200
|
+
assert.equal(index.entries[A].auditTrail?.at(-1)?.op, 'absorb')
|
|
201
|
+
assert.equal(index.entries[B].auditTrail?.at(-1)?.op, 'absorb')
|
|
202
|
+
assert.equal(merged.pendingAbsorb, undefined)
|
|
203
|
+
assert.equal(merged.reviewReason, undefined)
|
|
204
|
+
assert.equal(merged.conflictingWith, undefined)
|
|
205
|
+
assert.ok(merged.absorbSnapshot, 'the accept transaction writes the undo snapshot')
|
|
206
|
+
assert.ok(merged.vector, 'the accepted entry carries the vector stamp')
|
|
207
|
+
|
|
208
|
+
// 向量库只收到提案 id 这一行;sources 一行都没有。
|
|
209
|
+
const lastCommit = backend.commits.at(-1)
|
|
210
|
+
assert.deepEqual(lastCommit.names, [proposalId])
|
|
211
|
+
|
|
212
|
+
// target superseded;sources archived。
|
|
213
|
+
assert.equal(index.entries[A].status, 'superseded')
|
|
214
|
+
assert.equal(index.entries[A].absorbedInto, proposalId)
|
|
215
|
+
assert.equal(index.entries[B].status, 'archived')
|
|
216
|
+
assert.equal(index.entries[B].absorbedBy, proposalId)
|
|
217
|
+
assert.equal(index.entries[C].status, 'archived')
|
|
218
|
+
// links:入边改指 merged,出边并入 merged。
|
|
219
|
+
assert.equal(index.entries[D].links[0].to, proposalId)
|
|
220
|
+
assert.equal(merged.links[0].to, E)
|
|
221
|
+
// 快照保留吸收前状态。
|
|
222
|
+
assert.deepEqual(merged.absorbSnapshot.rewired.map((row) => row.id), [D])
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
test('reject archives the proposal and leaves target/sources untouched', async () => {
|
|
226
|
+
seedCluster()
|
|
227
|
+
await activateVectorGeneration()
|
|
228
|
+
await curator.commitCuratorPlans(structuredClone(ABSORB_PLAN), { job: { step: '' } })
|
|
229
|
+
const item = await pendingProposalOf()
|
|
230
|
+
await rpc.dispatch({}, 'resolveReview', { id: item.id, action: 'reject' })
|
|
231
|
+
const index = state.loadGlobalIndex()
|
|
232
|
+
assert.equal(index.entries[item.id].status, 'archived')
|
|
233
|
+
assert.equal(index.entries[A].status, 'active', 'the target stays active on reject')
|
|
234
|
+
assert.equal(index.entries[B].status, 'active')
|
|
235
|
+
assert.equal(index.entries[C].status, 'active')
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
test('accept with a coexist resolution is refused: a merged entry cannot coexist with its own sources', async () => {
|
|
239
|
+
seedCluster()
|
|
240
|
+
await activateVectorGeneration()
|
|
241
|
+
await curator.commitCuratorPlans(structuredClone(ABSORB_PLAN), { job: { step: '' } })
|
|
242
|
+
const item = await pendingProposalOf()
|
|
243
|
+
await assert.rejects(
|
|
244
|
+
() => rpc.dispatch({}, 'resolveReview', { id: item.id, action: 'accept', resolution: 'coexist', reason: '条件不同的并存说明文字' }),
|
|
245
|
+
/replace/,
|
|
246
|
+
)
|
|
247
|
+
assert.equal(state.loadGlobalIndex().entries[item.id].status, 'pending_review', 'the proposal stays pending')
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
test('accept with a concurrently changed source throws "review changed" and keeps the proposal pending', async () => {
|
|
251
|
+
seedCluster()
|
|
252
|
+
await activateVectorGeneration()
|
|
253
|
+
await curator.commitCuratorPlans(structuredClone(ABSORB_PLAN), { job: { step: '' } })
|
|
254
|
+
const item = await pendingProposalOf()
|
|
255
|
+
// 并发写:source 的 updatedAt 变了,快照校验必须拦下这次接受。
|
|
256
|
+
state.updateGlobalIndex((fresh) => {
|
|
257
|
+
fresh.entries[B] = { ...fresh.entries[B], content: '来源被并发修改。', updatedAt: '2026-03-03T00:00:00.000Z' }
|
|
258
|
+
})
|
|
259
|
+
await assert.rejects(
|
|
260
|
+
() => rpc.dispatch({}, 'resolveReview', { id: item.id, action: 'accept' }),
|
|
261
|
+
/review changed; reload and retry/,
|
|
262
|
+
)
|
|
263
|
+
const index = state.loadGlobalIndex()
|
|
264
|
+
assert.equal(index.entries[item.id].status, 'pending_review', 'the proposal stays pending')
|
|
265
|
+
assert.equal(index.entries[A].status, 'active', 'the target is not superseded by a failed accept')
|
|
266
|
+
assert.equal(index.entries[B].status, 'active')
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
test('undo-absorb restores target, sources and rewired links, archives the merged entry, and refuses a second undo', async () => {
|
|
270
|
+
seedCluster({ withLinks: true })
|
|
271
|
+
await activateVectorGeneration()
|
|
272
|
+
await curator.commitCuratorPlans(structuredClone(ABSORB_PLAN), { job: { step: '' } })
|
|
273
|
+
const item = await pendingProposalOf()
|
|
274
|
+
await rpc.dispatch({}, 'resolveReview', { id: item.id, action: 'accept' })
|
|
275
|
+
const mergedId = item.id
|
|
276
|
+
|
|
277
|
+
const undone = await rpc.dispatch({}, 'resolveReview', { id: mergedId, action: 'undo-absorb' })
|
|
278
|
+
assert.equal(undone.status, 'archived')
|
|
279
|
+
|
|
280
|
+
const index = state.loadGlobalIndex()
|
|
281
|
+
const merged = index.entries[mergedId]
|
|
282
|
+
assert.equal(merged.status, 'archived', 'the merged entry is archived, not deleted')
|
|
283
|
+
assert.ok(merged.absorbUndoneAt)
|
|
284
|
+
assert.ok(merged.content, 'the merged body is kept')
|
|
285
|
+
assert.equal(merged.auditTrail.at(-1).op, 'undo-absorb')
|
|
286
|
+
assert.equal(index.entries[A].auditTrail.at(-1).op, 'undo-absorb')
|
|
287
|
+
assert.equal(index.entries[B].auditTrail.at(-1).op, 'undo-absorb')
|
|
288
|
+
// target 恢复。
|
|
289
|
+
const target = index.entries[A]
|
|
290
|
+
assert.equal(target.status, 'active')
|
|
291
|
+
assert.equal(target.supersededBy, undefined)
|
|
292
|
+
assert.equal(target.absorbedInto, undefined)
|
|
293
|
+
assert.equal(target.validTo, undefined)
|
|
294
|
+
// sources 恢复。
|
|
295
|
+
for (const id of [B, C]) {
|
|
296
|
+
const source = index.entries[id]
|
|
297
|
+
assert.equal(source.status, 'active')
|
|
298
|
+
assert.equal(source.absorbedBy, undefined)
|
|
299
|
+
assert.equal(source.absorbedAt, undefined)
|
|
300
|
+
assert.equal(source.validTo, undefined)
|
|
301
|
+
}
|
|
302
|
+
// rewired 边恢复原状。
|
|
303
|
+
assert.equal(index.entries[D].links[0].to, A)
|
|
304
|
+
|
|
305
|
+
// 二次撤销:merged 已不是 active,直接报错。
|
|
306
|
+
await assert.rejects(
|
|
307
|
+
() => rpc.dispatch({}, 'resolveReview', { id: mergedId, action: 'undo-absorb' }),
|
|
308
|
+
/active merged entry with an absorb snapshot/,
|
|
309
|
+
)
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
test('undo-absorb refuses a libraryId payload, an entry without a snapshot, and members edited after absorption', async () => {
|
|
313
|
+
seedCluster({ withLinks: true })
|
|
314
|
+
await activateVectorGeneration()
|
|
315
|
+
await curator.commitCuratorPlans(structuredClone(ABSORB_PLAN), { job: { step: '' } })
|
|
316
|
+
const item = await pendingProposalOf()
|
|
317
|
+
await rpc.dispatch({}, 'resolveReview', { id: item.id, action: 'accept' })
|
|
318
|
+
const mergedId = item.id
|
|
319
|
+
|
|
320
|
+
// 带 libraryId 的 undo 是私有复核语义错位,必须报错(spec §6.7 RPC)。
|
|
321
|
+
await assert.rejects(
|
|
322
|
+
() => rpc.dispatch({}, 'resolveReview', { id: mergedId, action: 'undo-absorb', libraryId: 'some-lib' }),
|
|
323
|
+
/libraryId/,
|
|
324
|
+
)
|
|
325
|
+
// 没有 absorbSnapshot 的条目不能 undo。
|
|
326
|
+
await assert.rejects(
|
|
327
|
+
() => rpc.dispatch({}, 'resolveReview', { id: A, action: 'undo-absorb' }),
|
|
328
|
+
/absorb snapshot/,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
// 吸收后 target 又被人工改过(updatedAt > absorbedAt)→ 拒绝整体撤销。
|
|
332
|
+
// (absorbedAt 是真实的当前时间,人工改动的时间必须落在它之后。)
|
|
333
|
+
state.updateGlobalIndex((fresh) => {
|
|
334
|
+
fresh.entries[A] = { ...fresh.entries[A], content: '人工修正后的部署窗口约定。', updatedAt: '2030-01-01T00:00:00.000Z' }
|
|
335
|
+
})
|
|
336
|
+
await assert.rejects(
|
|
337
|
+
() => rpc.dispatch({}, 'resolveReview', { id: mergedId, action: 'undo-absorb' }),
|
|
338
|
+
/absorb undo refused/,
|
|
339
|
+
)
|
|
340
|
+
const index = state.loadGlobalIndex()
|
|
341
|
+
assert.equal(index.entries[mergedId].status, 'active', 'the refused undo leaves the merge in place')
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
test('the review wrapper still records the review in the activity log', async () => {
|
|
345
|
+
seedCluster()
|
|
346
|
+
await activateVectorGeneration()
|
|
347
|
+
await curator.commitCuratorPlans(structuredClone(ABSORB_PLAN), { job: { step: '' } })
|
|
348
|
+
const item = await pendingProposalOf()
|
|
349
|
+
await rpc.dispatch({}, 'resolveReview', { id: item.id, action: 'reject' })
|
|
350
|
+
const event = [...await import('../lib/activity-log.js').then((m) => m.listEvents({ kind: 'write' }))]
|
|
351
|
+
.find((row) => row.detail?.id === item.id && row.message.includes('复核'))
|
|
352
|
+
assert.ok(event, 'the resolveReview wrapper logs the decision')
|
|
353
|
+
})
|