dsh-memu-memory 1.1.2 → 1.2.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/AGENTS.md +8 -6
- package/README.md +50 -5
- package/README.zh.md +49 -5
- package/docs/active-memory-ab.md +385 -0
- package/docs/binding-lifecycle.md +99 -0
- package/docs/contributing.md +6 -4
- package/docs/emotion-model-spec.md +281 -0
- package/docs/env-and-commands.md +13 -0
- package/docs/host-api.md +35 -3
- package/docs/memory-semantics.md +40 -11
- package/docs/recall-benchmark.md +238 -0
- package/docs/troubleshooting.md +395 -366
- package/lib/active-memory.js +165 -0
- package/lib/activity-log.js +190 -0
- package/lib/backup.js +1103 -0
- package/lib/binding-diagnostics.js +635 -0
- package/lib/client.js +728 -58
- package/lib/consolidation-ledger.js +347 -0
- package/lib/consolidation-receipt.js +284 -0
- package/lib/consolidation-snapshot.js +274 -0
- package/lib/consolidation-worker.js +115 -0
- package/lib/curator.js +81 -37
- package/lib/cycle.js +43 -6
- package/lib/embedding-catalog.js +763 -0
- package/lib/embedding-download.js +407 -0
- package/lib/embedding-runtime.js +710 -0
- package/lib/engine.js +2 -2
- package/lib/index.js +36 -0
- package/lib/inject.js +232 -103
- package/lib/local-embedding-entry.js +145 -0
- package/lib/local-embedding-manager.js +267 -0
- package/lib/local-embedding-protocol.js +173 -0
- package/lib/local-embedding-worker.js +652 -0
- package/lib/local-vector-service.js +283 -0
- package/lib/memory.js +599 -57
- package/lib/memu-config.js +10 -22
- package/lib/memu.js +53 -19
- package/lib/mood.js +268 -39
- package/lib/paths.js +24 -0
- package/lib/preset-scope.js +1 -1
- package/lib/private.js +450 -29
- package/lib/prompts/private-consolidation.coding.md +1 -0
- package/lib/prompts/private-consolidation.md +1 -0
- package/lib/prompts/private-consolidation.mixed.md +1 -0
- package/lib/prompts/private-consolidation.rp.md +1 -0
- package/lib/prompts.js +4 -4
- package/lib/remote-summary.js +51 -25
- package/lib/rpc.js +54 -5
- package/lib/state.js +137 -172
- package/lib/tools.js +58 -7
- package/lib/vector-migration.js +878 -0
- package/lib/vector-profile.js +968 -0
- package/package.json +4 -2
- package/scripts/acceptance-local.mjs +7 -5
- package/scripts/active-memory-ab-live.mjs +655 -0
- package/scripts/active-memory-ab.mjs +1302 -0
- package/scripts/chain-matrix.mjs +537 -0
- package/scripts/check-all.mjs +29 -1
- package/scripts/check-doc-drift.mjs +353 -0
- package/scripts/diagnose-bindings.mjs +176 -0
- package/scripts/local-runtime-install.mjs +142 -0
- package/scripts/local-runtime-verify.mjs +56 -0
- package/scripts/local-vector-verify.mjs +93 -0
- package/scripts/recall-bench.mjs +813 -0
- package/scripts/repetition-ab-scenarios.mjs +93 -0
- package/scripts/repetition-detector-snapshot.mjs +314 -0
- package/scripts/repetition-eval-live.mjs +360 -0
- package/scripts/repetition-eval.mjs +702 -0
- package/tests/activation.test.mjs +89 -0
- package/tests/active-memory-ab.test.mjs +745 -0
- package/tests/active-memory.test.mjs +129 -0
- package/tests/activity-log.test.mjs +126 -0
- package/tests/activity-wiring.test.mjs +103 -0
- package/tests/appendix-b.test.mjs +212 -0
- package/tests/appendix-b2.test.mjs +166 -0
- package/tests/audit-regression.test.mjs +272 -0
- package/tests/audit-safety.test.mjs +227 -0
- package/tests/backend-compensation.test.mjs +272 -0
- package/tests/backup.test.mjs +793 -0
- package/tests/binding-diagnostics.test.mjs +720 -0
- package/tests/browser.test.mjs +366 -0
- package/tests/client-render.test.mjs +892 -0
- package/tests/coalesce.test.mjs +113 -0
- package/tests/config.test.mjs +159 -0
- package/tests/consolidation-credential.test.mjs +320 -0
- package/tests/consolidation-fork.test.mjs +357 -0
- package/tests/consolidation-ledger.test.mjs +298 -0
- package/tests/consolidation-snapshot.test.mjs +321 -0
- package/tests/consolidation-value.test.mjs +89 -0
- package/tests/context-scope.test.mjs +40 -0
- package/tests/cue.test.mjs +44 -0
- package/tests/curator-probe.test.mjs +433 -0
- package/tests/curator.test.mjs +150 -0
- package/tests/cycle.test.mjs +411 -0
- package/tests/disclosure.test.mjs +231 -0
- package/tests/doc-drift.test.mjs +222 -0
- package/tests/embedding-catalog.test.mjs +266 -0
- package/tests/embedding-download.test.mjs +391 -0
- package/tests/embedding-runtime.test.mjs +389 -0
- package/tests/emotion-chain.test.mjs +84 -0
- package/tests/emotion-contract.test.mjs +445 -0
- package/tests/engine.test.mjs +35 -0
- package/tests/fixtures/active-memory-ab/README.md +56 -0
- package/tests/fixtures/active-memory-ab/cases/01-clean-dense-turn.json +351 -0
- package/tests/fixtures/active-memory-ab/cases/02-clean-thin-turn.json +122 -0
- package/tests/fixtures/active-memory-ab/cases/03-clean-starved-small.json +125 -0
- package/tests/fixtures/active-memory-ab/cases/04-clean-large-thin.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/05-clean-half-step.json +166 -0
- package/tests/fixtures/active-memory-ab/cases/06-clean-duplicate-reread.json +197 -0
- package/tests/fixtures/active-memory-ab/cases/07-clean-recent-recall.json +125 -0
- package/tests/fixtures/active-memory-ab/cases/08-global-relevant.json +111 -0
- package/tests/fixtures/active-memory-ab/cases/09-global-irrelevant-conflict.json +158 -0
- package/tests/fixtures/active-memory-ab/cases/10-no-human-turn.json +97 -0
- package/tests/fixtures/active-memory-ab/cases/11-clean-scale-100.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/12-clean-scale-1000.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/13-clean-scale-5000.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/14-clean-scale-20000.json +137 -0
- package/tests/fixtures/active-memory-ab/lead-ai-reviewed-reference.json +191 -0
- package/tests/fixtures/active-memory-ab/manifest.json +199 -0
- package/tests/fixtures/recall-bench/01-paraphrase.json +184 -0
- package/tests/fixtures/recall-bench/02-exact.json +153 -0
- package/tests/fixtures/recall-bench/03-negation.json +173 -0
- package/tests/fixtures/recall-bench/04-no-answer.json +162 -0
- package/tests/fixtures/recall-bench/05-conflict.json +194 -0
- package/tests/fixtures/recall-bench/06-boundary.json +273 -0
- package/tests/fixtures/recall-bench/07-secret.json +191 -0
- package/tests/fixtures/recall-bench/08-coding.json +164 -0
- package/tests/fixtures/recall-bench-gold-candidates/01-paraphrase.json +193 -0
- package/tests/fixtures/recall-bench-gold-candidates/02-exact.json +197 -0
- package/tests/fixtures/recall-bench-gold-candidates/03-negation.json +244 -0
- package/tests/fixtures/recall-bench-gold-candidates/04-no-answer.json +189 -0
- package/tests/fixtures/recall-bench-gold-candidates/05-conflict.json +245 -0
- package/tests/fixtures/recall-bench-gold-candidates/06-boundary.json +279 -0
- package/tests/fixtures/recall-bench-gold-candidates/07-secret.json +224 -0
- package/tests/fixtures/recall-bench-gold-candidates/08-coding.json +218 -0
- package/tests/fixtures/repetition-eval/01-zh-rp.json +93 -0
- package/tests/fixtures/repetition-eval/02-en.json +97 -0
- package/tests/fixtures/repetition-eval/03-code.json +91 -0
- package/tests/fixtures/repetition-eval/04-names.json +116 -0
- package/tests/fixtures/repetition-eval/05-user-quote.json +96 -0
- package/tests/fixtures/repetition-eval/06-agreement.json +86 -0
- package/tests/graph-view.test.mjs +187 -0
- package/tests/graph.test.mjs +77 -0
- package/tests/hook-order.test.mjs +72 -0
- package/tests/humanlike.test.mjs +232 -0
- package/tests/hybrid-recall.test.mjs +92 -0
- package/tests/inbound.test.mjs +393 -0
- package/tests/inject-guide.test.mjs +364 -0
- package/tests/inject.test.mjs +222 -0
- package/tests/injection-budget-settings.test.mjs +35 -0
- package/tests/injection-budget.test.mjs +64 -0
- package/tests/injection-chain.test.mjs +191 -0
- package/tests/loader-register.mjs +3 -0
- package/tests/loader.mjs +12 -0
- package/tests/local-embedding-entry.test.mjs +95 -0
- package/tests/local-embedding-manager.test.mjs +476 -0
- package/tests/local-embedding-protocol.test.mjs +95 -0
- package/tests/local-embedding-worker.test.mjs +346 -0
- package/tests/managed-memory.test.mjs +536 -0
- package/tests/managed-runtime.test.mjs +40 -0
- package/tests/media.test.mjs +132 -0
- package/tests/migration-payload.test.mjs +42 -0
- package/tests/mood.test.mjs +109 -0
- package/tests/multiprocess.test.mjs +29 -0
- package/tests/new-session-gate.test.mjs +361 -0
- package/tests/package-shipping.test.mjs +84 -0
- package/tests/preset-scope.test.mjs +212 -0
- package/tests/prompts.test.mjs +250 -0
- package/tests/react-snapshot.test.mjs +23 -0
- package/tests/recall-bench.test.mjs +306 -0
- package/tests/remote.test.mjs +140 -0
- package/tests/rename.test.mjs +30 -0
- package/tests/repetition-detector-snapshot.test.mjs +231 -0
- package/tests/repetition-eval-live.test.mjs +177 -0
- package/tests/repetition-eval.test.mjs +373 -0
- package/tests/salient-date.test.mjs +142 -0
- package/tests/settings-beat.test.mjs +221 -0
- package/tests/short-term.test.mjs +166 -0
- package/tests/sidebar-beat.test.mjs +208 -0
- package/tests/sidebar-refresh.test.mjs +123 -0
- package/tests/sidebar.test.mjs +70 -0
- package/tests/snippet.test.mjs +47 -0
- package/tests/source-kind.test.mjs +52 -0
- package/tests/state.test.mjs +150 -0
- package/tests/stats.test.mjs +112 -0
- package/tests/storage.test.mjs +125 -0
- package/tests/stubs/dsh-llm.js +5 -0
- package/tests/stubs/dsh-session.js +7 -0
- package/tests/stubs/dsh-tools.js +101 -0
- package/tests/tool-schema.test.mjs +73 -0
- package/tests/tools.test.mjs +184 -0
- package/tests/ui-defaults.test.mjs +57 -0
- package/tests/update-fields.test.mjs +138 -0
- package/tests/vector-migration.test.mjs +733 -0
- package/tests/vector-profile.test.mjs +425 -0
- package/tests/visibility-migration.test.mjs +152 -0
- package/tests/webui-auth.test.mjs +454 -0
- package/tests/webui.test.mjs +164 -0
- package/tests/windows-env.test.mjs +89 -0
|
@@ -0,0 +1,733 @@
|
|
|
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
|
+
|
|
7
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-vector-migration-'))
|
|
8
|
+
process.env.DSH_MEMU_HOME = home
|
|
9
|
+
process.env.DSH_MEMU_MEMORY_HOME = home
|
|
10
|
+
|
|
11
|
+
const catalog = await import('../lib/embedding-catalog.js')
|
|
12
|
+
const vp = await import('../lib/vector-profile.js')
|
|
13
|
+
const vm = await import('../lib/vector-migration.js')
|
|
14
|
+
|
|
15
|
+
const BGE = 'bge-small-zh-v1.5-int8'
|
|
16
|
+
const ALT = 'bge-small-zh-v1.5-int8-alt'
|
|
17
|
+
|
|
18
|
+
// 第二个可用模型:目录里目前只有一个 verified 制品,切换/回滚必须有第二个空间才测得出来。
|
|
19
|
+
// 这里只替换"id → 哪份 manifest"的解析器,指纹与校验协议仍然是 embedding-catalog 的。
|
|
20
|
+
const baseEntry = catalog.getEmbeddingModel(BGE)
|
|
21
|
+
const altEntry = JSON.parse(JSON.stringify(baseEntry))
|
|
22
|
+
altEntry.id = ALT
|
|
23
|
+
altEntry.displayName = 'BGE alternative revision (test fixture)'
|
|
24
|
+
altEntry.sources.modelscope.revision = 'b'.repeat(40)
|
|
25
|
+
altEntry.profileFingerprint = catalog.profileFingerprint(altEntry)
|
|
26
|
+
const models = { [BGE]: baseEntry, [ALT]: altEntry }
|
|
27
|
+
vp.setCatalogEntryResolver(id => models[id] ?? null)
|
|
28
|
+
|
|
29
|
+
const librariesRoot = path.join(home, 'libraries')
|
|
30
|
+
const indexRoot = path.join(home, 'index')
|
|
31
|
+
|
|
32
|
+
test.after(() => {
|
|
33
|
+
vp.resetCatalogEntryResolver()
|
|
34
|
+
try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
function resetAll() {
|
|
38
|
+
fs.rmSync(indexRoot, { recursive: true, force: true })
|
|
39
|
+
fs.rmSync(librariesRoot, { recursive: true, force: true })
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------- fixture deps
|
|
43
|
+
|
|
44
|
+
function createFixture() {
|
|
45
|
+
const dbs = new Map()
|
|
46
|
+
const indexes = { global: { version: 1, entries: {} }, lib_a: { version: 1, entries: {} }, lib_b: { version: 1, entries: {} } }
|
|
47
|
+
const calls = { commit: [], forget: [], smoke: [], profiles: [] }
|
|
48
|
+
const behaviour = { failCommit: new Set(), failForget: new Set(), smokeOk: true, onCommit: null }
|
|
49
|
+
const assertProfileArgs = (profile, generation) => {
|
|
50
|
+
assert.ok(profile && typeof profile.fingerprint === 'string', 'migration backend must receive the verified profile')
|
|
51
|
+
assert.ok(Number.isSafeInteger(generation), 'migration backend must receive the generation')
|
|
52
|
+
calls.profiles.push({ fingerprint: profile.fingerprint, generation })
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const dbOf = db => {
|
|
56
|
+
if (!dbs.has(db)) dbs.set(db, new Map())
|
|
57
|
+
return dbs.get(db)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const deps = {
|
|
61
|
+
async listLibraries() {
|
|
62
|
+
return [{ id: 'lib_a' }, { id: 'lib_b' }]
|
|
63
|
+
},
|
|
64
|
+
async loadIndex(libraryId) {
|
|
65
|
+
return indexes[libraryId ?? 'global'] ?? { version: 1, entries: {} }
|
|
66
|
+
},
|
|
67
|
+
async commit({ db, libraryId, item }) {
|
|
68
|
+
if (behaviour.failCommit.has(item.id)) {
|
|
69
|
+
const error = new Error('embedding worker is down')
|
|
70
|
+
error.code = 'EMBEDDING_WORKER_DOWN'
|
|
71
|
+
throw error
|
|
72
|
+
}
|
|
73
|
+
dbOf(db).set(item.id, { hash: item.hash, text: item.text })
|
|
74
|
+
calls.commit.push({ libraryId: libraryId ?? 'global', id: item.id, db })
|
|
75
|
+
if (behaviour.onCommit) behaviour.onCommit({ libraryId, id: item.id, indexes })
|
|
76
|
+
},
|
|
77
|
+
async forget({ db, libraryId, itemId, profile, generation }) {
|
|
78
|
+
assertProfileArgs(profile, generation)
|
|
79
|
+
if (behaviour.failForget.has(itemId)) {
|
|
80
|
+
const error = new Error('forget failed')
|
|
81
|
+
error.code = 'MEMU_FORGET_FAILED'
|
|
82
|
+
throw error
|
|
83
|
+
}
|
|
84
|
+
dbOf(db).delete(itemId)
|
|
85
|
+
calls.forget.push({ libraryId: libraryId ?? 'global', id: itemId, db })
|
|
86
|
+
},
|
|
87
|
+
async listFiles({ db, profile, generation }) {
|
|
88
|
+
assertProfileArgs(profile, generation)
|
|
89
|
+
return [...dbOf(db).keys()]
|
|
90
|
+
},
|
|
91
|
+
async smoke({ generation, databases }) {
|
|
92
|
+
calls.smoke.push({ generation, databases })
|
|
93
|
+
return behaviour.smokeOk ? { ok: true, checks: ['dimension=512'] } : { ok: false, reason: 'dimension mismatch' }
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
deps,
|
|
99
|
+
indexes,
|
|
100
|
+
calls,
|
|
101
|
+
behaviour,
|
|
102
|
+
dbs,
|
|
103
|
+
setIndex(key, entries) {
|
|
104
|
+
indexes[key] = { version: 1, entries }
|
|
105
|
+
},
|
|
106
|
+
entries(db) {
|
|
107
|
+
return [...dbOf(db).entries()]
|
|
108
|
+
},
|
|
109
|
+
dbOf,
|
|
110
|
+
migrator(overrides = {}) {
|
|
111
|
+
return vm.createVectorMigrator({ ...deps, ...overrides })
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function assertCode(fn, expected) {
|
|
117
|
+
assert.throws(fn, error => {
|
|
118
|
+
assert.equal(error.code, expected, 'expected ' + expected + ' but got ' + error.code + ' (' + error.message + ')')
|
|
119
|
+
assert.equal(error.fallback, 'lexical')
|
|
120
|
+
assert.equal(error.forbidLegacyRemote, true)
|
|
121
|
+
return true
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function assertCodeAsync(promise, expected) {
|
|
126
|
+
await assert.rejects(promise, error => {
|
|
127
|
+
assert.equal(error.code, expected, 'expected ' + expected + ' but got ' + error.code + ' (' + error.message + ')')
|
|
128
|
+
assert.equal(error.fallback, 'lexical')
|
|
129
|
+
return true
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function liveEntries(db) {
|
|
134
|
+
return [...(new Map(db)).entries()].map(([id, value]) => [id, value.hash])
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------- prepareShadow
|
|
138
|
+
|
|
139
|
+
test('prepareShadow allocates a brand-new generation path and never switches by itself', async () => {
|
|
140
|
+
resetAll()
|
|
141
|
+
const fx = createFixture()
|
|
142
|
+
const migrator = fx.migrator()
|
|
143
|
+
const first = await migrator.prepareShadow({ profile: BGE })
|
|
144
|
+
assert.equal(first.status, 'preparing')
|
|
145
|
+
assert.ok(first.generation >= 1)
|
|
146
|
+
assert.equal(path.dirname(first.databases.global), librariesRoot, 'global shadow lives under libraries/ so backups pick it up')
|
|
147
|
+
assert.match(first.databases.global, /\.g1\.sqlite3$/)
|
|
148
|
+
assert.equal(vp.activeProfile(), null, 'prepare must not activate anything')
|
|
149
|
+
assert.equal(vp.activeProfile(), null)
|
|
150
|
+
// requested 与 active 分离:prepare 只登记"用户要的模型",生效仍需一次显式原子切换
|
|
151
|
+
assert.equal(vp.requestedProfile().catalogId, BGE)
|
|
152
|
+
assert.equal(vp.requestedProfile().fingerprint, vp.profileFromCatalog(BGE).fingerprint)
|
|
153
|
+
assert.equal(vp.pendingJournal().generation, first.generation)
|
|
154
|
+
assert.equal(vp.pendingJournal().fingerprint, vp.profileFromCatalog(BGE).fingerprint)
|
|
155
|
+
|
|
156
|
+
// 同指纹默认续建(同一代际、同一路径),fresh 才换新代际/新路径
|
|
157
|
+
const resumed = await migrator.prepareShadow({ profile: BGE })
|
|
158
|
+
assert.equal(resumed.generation, first.generation)
|
|
159
|
+
assert.equal(resumed.resumed, true)
|
|
160
|
+
const fresh = await migrator.prepareShadow({ profile: BGE, fresh: true })
|
|
161
|
+
assert.notEqual(fresh.generation, first.generation)
|
|
162
|
+
assert.notEqual(fresh.databases.global, first.databases.global)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------- syncShadow
|
|
166
|
+
|
|
167
|
+
test('migration passes the verified profile and generation to list/read cleanup calls', async () => {
|
|
168
|
+
resetAll()
|
|
169
|
+
const fx = createFixture()
|
|
170
|
+
fx.setIndex('lib_a', { m1: { text: 'profile contract', status: 'active' } })
|
|
171
|
+
const migrator = fx.migrator()
|
|
172
|
+
await migrator.prepareShadow({ profile: BGE })
|
|
173
|
+
const report = await migrator.syncShadow()
|
|
174
|
+
assert.equal(report.failed.length, 0)
|
|
175
|
+
assert.ok(fx.calls.profiles.length >= 3)
|
|
176
|
+
assert.ok(fx.calls.profiles.every(call => call.generation === vp.pendingJournal().generation))
|
|
177
|
+
assert.ok(fx.calls.profiles.every(call => call.fingerprint === vp.pendingJournal().profile.fingerprint))
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
test('syncShadow writes only admitted memories and never re-embeds the same content twice', async () => {
|
|
181
|
+
resetAll()
|
|
182
|
+
const fx = createFixture()
|
|
183
|
+
fx.setIndex('lib_a', {
|
|
184
|
+
m1: { text: '用户喜欢深烘咖啡', status: 'active' },
|
|
185
|
+
m2: { text: '等待复核的草稿', status: 'pending_review' },
|
|
186
|
+
m3: { text: '过期了', status: 'active', expiresAt: '2000-01-01T00:00:00.000Z' },
|
|
187
|
+
m4: { text: '已被取代', status: 'superseded' },
|
|
188
|
+
m5: { text: '被归档', status: 'archived' },
|
|
189
|
+
})
|
|
190
|
+
fx.setIndex('global', { g1: { text: '全局:住在杭州', status: 'active' } })
|
|
191
|
+
const migrator = fx.migrator()
|
|
192
|
+
await migrator.prepareShadow({ profile: BGE })
|
|
193
|
+
const first = await migrator.syncShadow()
|
|
194
|
+
assert.equal(first.failed.length, 0)
|
|
195
|
+
assert.equal(first.committed, 2, 'one private + one global entry')
|
|
196
|
+
assert.equal(first.stable, true)
|
|
197
|
+
assert.deepEqual(fx.calls.commit.map(call => call.id).sort(), ['g1', 'm1'])
|
|
198
|
+
assert.equal(vp.pendingJournal().status, 'prepared')
|
|
199
|
+
|
|
200
|
+
const second = await migrator.syncShadow()
|
|
201
|
+
assert.equal(second.committed, 0)
|
|
202
|
+
assert.equal(second.skipped, 2)
|
|
203
|
+
assert.equal(fx.calls.commit.length, 2, 'a second sync is not a second embedding')
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
test('changed content is re-embedded, unchanged content is skipped (ledger hash key)', async () => {
|
|
207
|
+
resetAll()
|
|
208
|
+
const fx = createFixture()
|
|
209
|
+
fx.setIndex('lib_a', { m1: { text: '第一版正文', status: 'active' }, m2: { text: '不要动我', status: 'active' } })
|
|
210
|
+
const migrator = fx.migrator()
|
|
211
|
+
await migrator.prepareShadow({ profile: BGE })
|
|
212
|
+
await migrator.syncShadow()
|
|
213
|
+
fx.setIndex('lib_a', { m1: { text: '第二版正文', status: 'active' }, m2: { text: '不要动我', status: 'active' } })
|
|
214
|
+
const second = await migrator.syncShadow()
|
|
215
|
+
assert.equal(second.committed, 1)
|
|
216
|
+
assert.equal(second.skipped, 1)
|
|
217
|
+
assert.deepEqual(fx.calls.commit.slice(2).map(call => call.id), ['m1'])
|
|
218
|
+
const ledger = migrator.ledger()
|
|
219
|
+
assert.equal(ledger.libraries.lib_a.items.m1.hash, vm.contentHash('m1', '第二版正文'))
|
|
220
|
+
assert.equal(ledger.libraries.lib_a.items.m2.hash, vm.contentHash('m2', '不要动我'))
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
test('entries removed from the index are forgotten and tombstoned so a resume cannot resurrect them', async () => {
|
|
224
|
+
resetAll()
|
|
225
|
+
const fx = createFixture()
|
|
226
|
+
fx.setIndex('lib_a', { m1: { text: '会被删掉', status: 'active' }, m2: { text: '保留', status: 'active' } })
|
|
227
|
+
const migrator = fx.migrator()
|
|
228
|
+
await migrator.prepareShadow({ profile: BGE })
|
|
229
|
+
await migrator.syncShadow()
|
|
230
|
+
fx.setIndex('lib_a', { m2: { text: '保留', status: 'active' } })
|
|
231
|
+
const second = await migrator.syncShadow()
|
|
232
|
+
assert.equal(second.tombstoned, 1)
|
|
233
|
+
assert.deepEqual(fx.calls.forget.map(call => call.id), ['m1'])
|
|
234
|
+
const ledger = migrator.ledger()
|
|
235
|
+
assert.equal(ledger.libraries.lib_a.items.m1, undefined)
|
|
236
|
+
assert.equal(ledger.libraries.lib_a.tombstones.m1.reason, 'absent-from-index')
|
|
237
|
+
const db = vp.pendingJournal().databases
|
|
238
|
+
const scopeDb = path.join(home, ...db.libraries.lib_a.path.split('/'))
|
|
239
|
+
assert.deepEqual([...fx.dbOf(scopeDb).keys()], ['m2'])
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
test('a database that drifts (row without an index entry) is forgotten too', async () => {
|
|
243
|
+
resetAll()
|
|
244
|
+
const fx = createFixture()
|
|
245
|
+
fx.setIndex('lib_a', { m1: { text: '正常', status: 'active' } })
|
|
246
|
+
const migrator = fx.migrator()
|
|
247
|
+
const prepared = await migrator.prepareShadow({ profile: BGE })
|
|
248
|
+
const db = prepared.databases.libraries.lib_a
|
|
249
|
+
fs.mkdirSync(path.dirname(db), { recursive: true })
|
|
250
|
+
fx.dbOf(db).set('ghost', { hash: 'deadbeef', text: '不在索引里' })
|
|
251
|
+
const report = await migrator.syncShadow()
|
|
252
|
+
assert.equal(report.tombstoned, 1)
|
|
253
|
+
assert.deepEqual(fx.calls.forget.map(call => call.id), ['ghost'])
|
|
254
|
+
assert.equal(fx.dbOf(db).has('ghost'), false)
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
// ---------------------------------------------------------------- switchProfile
|
|
258
|
+
|
|
259
|
+
test('switchProfile atomically selects profile and database set, and is idempotent per space', async () => {
|
|
260
|
+
resetAll()
|
|
261
|
+
const fx = createFixture()
|
|
262
|
+
fx.setIndex('lib_a', { m1: { text: '记得带伞', status: 'active' } })
|
|
263
|
+
fx.setIndex('global', { g1: { text: '全局偏好:中文', status: 'active' } })
|
|
264
|
+
const migrator = fx.migrator()
|
|
265
|
+
const result = await migrator.switchProfile({ profile: BGE })
|
|
266
|
+
assert.equal(result.ok, true)
|
|
267
|
+
assert.equal(result.status, 'switched')
|
|
268
|
+
assert.equal(result.previous, null)
|
|
269
|
+
const active = vp.activeDatabases()
|
|
270
|
+
assert.equal(active.generation, result.generation)
|
|
271
|
+
assert.equal(active.fingerprint, result.fingerprint)
|
|
272
|
+
assert.deepEqual(Object.keys(active.libraries).sort(), ['lib_a', 'lib_b'])
|
|
273
|
+
assert.ok(active.libraries.lib_b.endsWith('.g' + result.generation + '.sqlite3'))
|
|
274
|
+
assert.equal(path.dirname(active.libraries.lib_a), librariesRoot)
|
|
275
|
+
assert.equal(vp.dbPathFor('lib_a'), active.libraries.lib_a)
|
|
276
|
+
assert.equal(vp.dbPathFor(null), active.global)
|
|
277
|
+
assert.equal(vp.pendingJournal(), null, 'the commit journal is settled after a successful switch')
|
|
278
|
+
// 切换成功后 requested 必须清空:它表示"待切换的另一个模型"。留着会让界面同时显示
|
|
279
|
+
// "当前生效 + 待切换",也会让状态行读起来像还有一步没做完。
|
|
280
|
+
assert.equal(vp.requestedProfile(), null, 'a completed switch leaves nothing pending to switch')
|
|
281
|
+
assert.equal(vp.activeProfile().fingerprint, result.fingerprint)
|
|
282
|
+
assert.equal(vp.assertVectorWriteAllowed().generation, active.generation)
|
|
283
|
+
assert.equal(fx.calls.smoke.length, 1)
|
|
284
|
+
|
|
285
|
+
// 同一空间再切一次:不重建、不重嵌入
|
|
286
|
+
const commitsBefore = fx.calls.commit.length
|
|
287
|
+
const again = await migrator.switchProfile({ profile: BGE })
|
|
288
|
+
assert.equal(again.status, 'already-active')
|
|
289
|
+
assert.equal(again.generation, result.generation)
|
|
290
|
+
assert.equal(fx.calls.commit.length, commitsBefore)
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
test('membership reports which ids are actually inside the active generation', async () => {
|
|
294
|
+
resetAll()
|
|
295
|
+
const fx = createFixture()
|
|
296
|
+
fx.setIndex('lib_a', { m1: { text: '记得带伞', status: 'active' }, m2: { text: '旧条目', status: 'archived' } })
|
|
297
|
+
fx.setIndex('global', { g1: { text: '全局偏好', status: 'active' } })
|
|
298
|
+
const migrator = fx.migrator()
|
|
299
|
+
await migrator.switchProfile({ profile: BGE })
|
|
300
|
+
const membership = migrator.membership()
|
|
301
|
+
assert.deepEqual((membership.lib_a || []).sort(), ['m1'], 'archived entries are not admitted')
|
|
302
|
+
assert.deepEqual((membership.global || []).sort(), ['g1'])
|
|
303
|
+
assert.ok(Array.isArray(membership.lib_b), 'every scope in the generation gets a bucket')
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
test('a legacy requested={"status":"active"} marker is not a pending switch', async () => {
|
|
307
|
+
resetAll()
|
|
308
|
+
const fx = createFixture()
|
|
309
|
+
const migrator = fx.migrator()
|
|
310
|
+
await migrator.switchProfile({ profile: BGE })
|
|
311
|
+
// 旧代码切换成功后会把 requested 写成 status=active;读的时候必须当作没有待切换请求,
|
|
312
|
+
// 否则界面永远同时显示"当前生效 + 待切换"。
|
|
313
|
+
vp.updateVectorStateSync(current => ({ ...current, requested: { status: 'active', catalogId: BGE, fingerprint: current.active.fingerprint } }))
|
|
314
|
+
assert.equal(vp.vectorStatus().requested, null)
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
test('a moving index aborts the switch instead of activating a half-caught-up generation', async () => {
|
|
318
|
+
resetAll()
|
|
319
|
+
const fx = createFixture()
|
|
320
|
+
fx.setIndex('lib_a', { m1: { text: '初始', status: 'active' } })
|
|
321
|
+
let churn = 0
|
|
322
|
+
fx.behaviour.onCommit = ({ indexes }) => {
|
|
323
|
+
churn += 1
|
|
324
|
+
indexes.lib_a.entries['churn-' + churn] = { text: '并发写入 ' + churn, status: 'active' }
|
|
325
|
+
}
|
|
326
|
+
const migrator = fx.migrator({ maxCatchUpAttempts: 2 })
|
|
327
|
+
await assertCodeAsync(migrator.switchProfile({ profile: BGE }), 'VECTOR_INDEX_MOVED')
|
|
328
|
+
assert.equal(vp.activeProfile(), null, 'the active pointer must not move')
|
|
329
|
+
const journal = vp.pendingJournal()
|
|
330
|
+
assert.equal(journal.status, 'failed')
|
|
331
|
+
assert.equal(journal.error.code, 'VECTOR_INDEX_MOVED')
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
test('a failed embedding leaves the active generation untouched and resumes from the ledger', async () => {
|
|
335
|
+
resetAll()
|
|
336
|
+
const fx = createFixture()
|
|
337
|
+
fx.setIndex('lib_a', { m1: { text: '成功', status: 'active' }, m2: { text: '失败', status: 'active' } })
|
|
338
|
+
const migrator = fx.migrator({ itemRetries: 0 })
|
|
339
|
+
await migrator.switchProfile({ profile: BGE })
|
|
340
|
+
const stillActive = vp.activeGeneration()
|
|
341
|
+
fx.behaviour.failCommit.add('m3')
|
|
342
|
+
fx.setIndex('lib_a', { m1: { text: '成功', status: 'active' }, m2: { text: '失败', status: 'active' }, m3: { text: '新的坏条目', status: 'active' } })
|
|
343
|
+
await assertCodeAsync(migrator.switchProfile({ profile: ALT }), 'VECTOR_SYNC_INCOMPLETE')
|
|
344
|
+
assert.equal(vp.activeGeneration(), stillActive, 'the old generation keeps serving')
|
|
345
|
+
assert.equal(vp.activeProfile().fingerprint, vp.profileFromCatalog(BGE).fingerprint)
|
|
346
|
+
const failedGeneration = vp.pendingJournal().generation
|
|
347
|
+
const ledger = migrator.ledger({ generation: failedGeneration })
|
|
348
|
+
assert.deepEqual(Object.keys(ledger.libraries.lib_a.items).sort(), ['m1', 'm2'])
|
|
349
|
+
|
|
350
|
+
// 修好之后续建同一代际:只补失败的那条,成功的不重新嵌入
|
|
351
|
+
fx.behaviour.failCommit.clear()
|
|
352
|
+
const commitsBefore = fx.calls.commit.length
|
|
353
|
+
const resumed = await migrator.switchProfile({ profile: ALT })
|
|
354
|
+
assert.equal(resumed.status, 'switched')
|
|
355
|
+
assert.equal(resumed.generation, failedGeneration, 'a failed build is resumed, not restarted')
|
|
356
|
+
const newCommits = fx.calls.commit.slice(commitsBefore).map(call => call.id)
|
|
357
|
+
assert.deepEqual(newCommits, ['m3'])
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
test('a failing smoke check never switches the active generation', async () => {
|
|
361
|
+
resetAll()
|
|
362
|
+
const fx = createFixture()
|
|
363
|
+
fx.setIndex('lib_a', { m1: { text: '随便', status: 'active' } })
|
|
364
|
+
fx.behaviour.smokeOk = false
|
|
365
|
+
const migrator = fx.migrator()
|
|
366
|
+
await assertCodeAsync(migrator.switchProfile({ profile: BGE }), 'VECTOR_SMOKE_FAILED')
|
|
367
|
+
assert.equal(vp.activeProfile(), null)
|
|
368
|
+
assert.equal(fx.calls.smoke.length, 1)
|
|
369
|
+
assert.equal(vp.pendingJournal().error.code, 'VECTOR_SMOKE_FAILED')
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
// ---------------------------------------------------------------- 回滚
|
|
373
|
+
|
|
374
|
+
test('rollback replays the latest index into a new generation of the previous profile', async () => {
|
|
375
|
+
resetAll()
|
|
376
|
+
const fx = createFixture()
|
|
377
|
+
fx.setIndex('lib_a', { m1: { text: '共同记忆', status: 'active' } })
|
|
378
|
+
const migrator = fx.migrator()
|
|
379
|
+
const first = await migrator.switchProfile({ profile: BGE })
|
|
380
|
+
const second = await migrator.switchProfile({ profile: ALT })
|
|
381
|
+
assert.notEqual(second.fingerprint, first.fingerprint)
|
|
382
|
+
assert.equal(second.previous.fingerprint, first.fingerprint)
|
|
383
|
+
const secondDb = vp.activeDatabases().libraries.lib_a
|
|
384
|
+
assert.ok(secondDb.endsWith('.g' + second.generation + '.sqlite3'))
|
|
385
|
+
|
|
386
|
+
// 切到新空间之后又写了记忆:回滚必须重放它,而不是把旧指针拨回去
|
|
387
|
+
fx.setIndex('lib_a', { m1: { text: '共同记忆', status: 'active' }, m2: { text: '切换后新增', status: 'active' } })
|
|
388
|
+
const back = await migrator.rollbackProfile()
|
|
389
|
+
assert.equal(back.status, 'rolled-back')
|
|
390
|
+
assert.equal(back.replayed, true)
|
|
391
|
+
assert.equal(back.fingerprint, first.fingerprint)
|
|
392
|
+
assert.ok(back.generation > second.generation, 'rollback uses a brand-new generation')
|
|
393
|
+
assert.equal(vp.activeProfile().fingerprint, first.fingerprint)
|
|
394
|
+
assert.equal(vp.activeGeneration(), back.generation)
|
|
395
|
+
const replayedDb = vp.activeDatabases().libraries.lib_a
|
|
396
|
+
assert.ok(replayedDb.endsWith('.g' + back.generation + '.sqlite3'))
|
|
397
|
+
assert.notEqual(replayedDb, secondDb, 'never reuse the previous generation file in place')
|
|
398
|
+
assert.deepEqual([...fx.dbOf(replayedDb).keys()].sort(), ['m1', 'm2'], 'writes made under the new profile are replayed back')
|
|
399
|
+
|
|
400
|
+
// 还能再往回走一步(previous 在新代里重新指向刚离开的空间)
|
|
401
|
+
const forward = await migrator.rollbackProfile()
|
|
402
|
+
assert.equal(forward.fingerprint, second.fingerprint)
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
test('rollback without a previous generation is refused', async () => {
|
|
406
|
+
resetAll()
|
|
407
|
+
const fx = createFixture()
|
|
408
|
+
const migrator = fx.migrator()
|
|
409
|
+
await assertCodeAsync(migrator.rollbackProfile(), 'VECTOR_NO_ROLLBACK_TARGET')
|
|
410
|
+
await migrator.switchProfile({ profile: BGE })
|
|
411
|
+
await assertCodeAsync(migrator.rollbackProfile(), 'VECTOR_NO_ROLLBACK_TARGET')
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
// ---------------------------------------------------------------- 启动恢复
|
|
415
|
+
|
|
416
|
+
test('recoverProfile distinguishes absent / prepared / committed / torn without guessing', async () => {
|
|
417
|
+
resetAll()
|
|
418
|
+
const fx = createFixture()
|
|
419
|
+
const migrator = fx.migrator()
|
|
420
|
+
assert.equal((await migrator.recoverProfile()).status, 'absent')
|
|
421
|
+
|
|
422
|
+
await migrator.switchProfile({ profile: BGE })
|
|
423
|
+
const activeGeneration = vp.activeGeneration()
|
|
424
|
+
assert.equal((await migrator.recoverProfile()).status, 'active')
|
|
425
|
+
|
|
426
|
+
// prepared 但没切:继续旧代,新代只报告可续建
|
|
427
|
+
fx.setIndex('lib_a', { m1: { text: '第二段', status: 'active' } })
|
|
428
|
+
await migrator.prepareShadow({ profile: ALT })
|
|
429
|
+
const synced = await migrator.syncShadow()
|
|
430
|
+
assert.equal(synced.failed.length, 0)
|
|
431
|
+
const prepared = await migrator.recoverProfile()
|
|
432
|
+
assert.equal(prepared.status, 'prepared')
|
|
433
|
+
assert.equal(prepared.recovered, false)
|
|
434
|
+
assert.equal(prepared.generation, activeGeneration, 'the previous generation keeps serving until an explicit switch')
|
|
435
|
+
assert.equal(prepared.pending.resume, true)
|
|
436
|
+
assert.equal(vp.activeProfile().fingerprint, vp.profileFromCatalog(BGE).fingerprint)
|
|
437
|
+
|
|
438
|
+
// committed 记录与指针一致 → 认新代并清 journal
|
|
439
|
+
vp.updateVectorStateSync(state => ({
|
|
440
|
+
...state,
|
|
441
|
+
pending: {
|
|
442
|
+
status: 'committed',
|
|
443
|
+
generation: state.active.generation,
|
|
444
|
+
fingerprint: state.active.fingerprint,
|
|
445
|
+
previousGeneration: state.active.previous?.generation ?? null,
|
|
446
|
+
token: 'recovered-token',
|
|
447
|
+
profile: state.active.profile,
|
|
448
|
+
databases: state.active.databases,
|
|
449
|
+
},
|
|
450
|
+
}))
|
|
451
|
+
const committed = await migrator.recoverProfile()
|
|
452
|
+
assert.equal(committed.status, 'committed')
|
|
453
|
+
assert.equal(committed.recovered, true)
|
|
454
|
+
assert.equal(committed.generation, activeGeneration)
|
|
455
|
+
assert.equal(vp.pendingJournal(), null)
|
|
456
|
+
|
|
457
|
+
// committed 记录与指针矛盾 → 撕裂,退词法
|
|
458
|
+
vp.updateVectorStateSync(state => ({
|
|
459
|
+
...state,
|
|
460
|
+
pending: {
|
|
461
|
+
status: 'committed',
|
|
462
|
+
generation: state.active.generation + 3,
|
|
463
|
+
fingerprint: state.active.fingerprint,
|
|
464
|
+
previousGeneration: null,
|
|
465
|
+
token: 'torn-token',
|
|
466
|
+
profile: state.active.profile,
|
|
467
|
+
databases: state.active.databases,
|
|
468
|
+
},
|
|
469
|
+
}))
|
|
470
|
+
await assertCodeAsync(migrator.recoverProfile(), 'VECTOR_TORN_STATE')
|
|
471
|
+
assertCode(() => vp.assertVectorWriteAllowed(), 'VECTOR_TORN_STATE')
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
test('recoverProfile refuses when a prepared build no longer matches the active generation', async () => {
|
|
475
|
+
resetAll()
|
|
476
|
+
const fx = createFixture()
|
|
477
|
+
const migrator = fx.migrator()
|
|
478
|
+
await migrator.switchProfile({ profile: BGE })
|
|
479
|
+
// 切换成功后 requested 必须清空:它表示"待切换的另一个模型",留着会让界面同时显示
|
|
480
|
+
// "当前生效 + 待切换"。
|
|
481
|
+
assert.equal(vp.vectorState().requested, null, 'a completed switch clears the pending request')
|
|
482
|
+
await migrator.prepareShadow({ profile: ALT })
|
|
483
|
+
await migrator.syncShadow()
|
|
484
|
+
// 有人把 active 指针指到了别的代际(模拟撕裂/外部写入)
|
|
485
|
+
vp.updateVectorStateSync(state => ({ ...state, active: { ...state.active, generation: state.active.generation + 1 } }))
|
|
486
|
+
await assertCodeAsync(migrator.recoverProfile(), 'VECTOR_TORN_STATE')
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
test('abortPending discards an unswitched rebuild without touching the active generation', async () => {
|
|
490
|
+
resetAll()
|
|
491
|
+
const fx = createFixture()
|
|
492
|
+
const migrator = fx.migrator()
|
|
493
|
+
await migrator.switchProfile({ profile: BGE })
|
|
494
|
+
const active = vp.activeDatabases()
|
|
495
|
+
await migrator.prepareShadow({ profile: ALT })
|
|
496
|
+
const prepared = vp.pendingJournal()
|
|
497
|
+
const abort = migrator.abortPending('用户取消')
|
|
498
|
+
assert.equal(abort.aborted, true)
|
|
499
|
+
assert.equal(abort.generation, prepared.generation)
|
|
500
|
+
assert.equal(vp.pendingJournal().status, 'aborted')
|
|
501
|
+
assert.equal(vp.activeGeneration(), active.generation)
|
|
502
|
+
assert.equal(vp.dbPathFor('lib_a'), active.libraries.lib_a)
|
|
503
|
+
assert.deepEqual(migrator.abortPending(), { ok: true, aborted: false })
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
// ---------------------------------------------------------------- 护栏联动
|
|
507
|
+
|
|
508
|
+
test('the guard binds a turn to one generation across a profile switch', async () => {
|
|
509
|
+
resetAll()
|
|
510
|
+
const fx = createFixture()
|
|
511
|
+
fx.setIndex('lib_a', { m1: { text: '这一代', status: 'active' } })
|
|
512
|
+
const migrator = fx.migrator()
|
|
513
|
+
await migrator.switchProfile({ profile: BGE })
|
|
514
|
+
const captured = vp.captureGeneration()
|
|
515
|
+
assert.equal(vp.assertGeneration(captured).generation, captured.generation)
|
|
516
|
+
await migrator.switchProfile({ profile: ALT })
|
|
517
|
+
assertCode(() => vp.assertGeneration(captured), 'VECTOR_STALE_GENERATION')
|
|
518
|
+
// 新代自己的捕获是有效的
|
|
519
|
+
const fresh = vp.captureGeneration()
|
|
520
|
+
assert.equal(vp.assertGeneration(fresh).generation, fresh.generation)
|
|
521
|
+
assert.notEqual(fresh.fingerprint, captured.fingerprint)
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
test('status() keeps requested, active and pending separately', async () => {
|
|
525
|
+
resetAll()
|
|
526
|
+
const fx = createFixture()
|
|
527
|
+
const migrator = fx.migrator()
|
|
528
|
+
vp.setRequestedProfile(ALT)
|
|
529
|
+
assert.equal(vp.vectorStatus().status, 'absent')
|
|
530
|
+
assert.equal(vp.vectorStatus().requested.catalogId, ALT)
|
|
531
|
+
await migrator.switchProfile({ profile: BGE })
|
|
532
|
+
await migrator.prepareShadow({ profile: ALT })
|
|
533
|
+
const status = migrator.status()
|
|
534
|
+
assert.equal(status.status, 'active')
|
|
535
|
+
assert.equal(status.active.profile.catalogId, BGE)
|
|
536
|
+
assert.equal(status.requested.catalogId, ALT, 'a requested model is not an active model')
|
|
537
|
+
assert.equal(status.pending.generation, vp.pendingJournal().generation)
|
|
538
|
+
assert.equal(status.pending.fingerprint, vp.profileFromCatalog(ALT).fingerprint)
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
test('the ledger file is generation-scoped and lives next to the active manifest', async () => {
|
|
542
|
+
resetAll()
|
|
543
|
+
const fx = createFixture()
|
|
544
|
+
fx.setIndex('lib_a', { m1: { text: '记账', status: 'active' } })
|
|
545
|
+
const migrator = fx.migrator()
|
|
546
|
+
await migrator.prepareShadow({ profile: BGE })
|
|
547
|
+
await migrator.syncShadow()
|
|
548
|
+
const journal = vp.pendingJournal()
|
|
549
|
+
const ledgerPath = path.join(indexRoot, 'vector-ledger.' + journal.fingerprint.slice(0, 8) + '.g' + journal.generation + '.json')
|
|
550
|
+
assert.equal(fs.existsSync(ledgerPath), true)
|
|
551
|
+
const raw = JSON.parse(fs.readFileSync(ledgerPath, 'utf8'))
|
|
552
|
+
assert.equal(raw.fingerprint, journal.fingerprint)
|
|
553
|
+
assert.equal(raw.generation, journal.generation)
|
|
554
|
+
assert.equal(raw.libraries.lib_a.items.m1.hash, vm.contentHash('m1', '记账'))
|
|
555
|
+
const summary = migrator.ledgerSummary()
|
|
556
|
+
assert.equal(summary.committed, 1)
|
|
557
|
+
assert.equal(summary.libraries.lib_a.committed, 1)
|
|
558
|
+
// 另一个代际 = 另一份账本,绝不共用进度
|
|
559
|
+
assert.notEqual(vm.ledgerFile(journal.fingerprint, journal.generation + 1), ledgerPath)
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
test('the migrator refuses to run without its index/memu dependencies', () => {
|
|
563
|
+
assert.throws(() => vm.createVectorMigrator({}), /listLibraries/)
|
|
564
|
+
assert.throws(() => vm.createVectorMigrator({ listLibraries: () => [] }), /loadIndex/)
|
|
565
|
+
})
|
|
566
|
+
|
|
567
|
+
test('admission mirrors the plugin index rules (held entries never become vectors)', () => {
|
|
568
|
+
const now = Date.parse('2026-01-01T00:00:00.000Z')
|
|
569
|
+
assert.equal(vm.admitEntry({ text: 'x', status: 'active' }, now), true)
|
|
570
|
+
assert.equal(vm.admitEntry({ text: 'x' }, now), true)
|
|
571
|
+
for (const status of ['archived', 'suppressed', 'expired', 'pending_review', 'superseded', 'deleted']) {
|
|
572
|
+
assert.equal(vm.admitEntry({ text: 'x', status }, now), false, status + ' must stay out of the vector space')
|
|
573
|
+
}
|
|
574
|
+
assert.equal(vm.admitEntry({ text: 'x', status: 'active', expiresAt: '2020-01-01T00:00:00.000Z' }, now), false)
|
|
575
|
+
assert.equal(vm.admitEntry(null, now), false)
|
|
576
|
+
const items = vm.admittedItems({
|
|
577
|
+
entries: {
|
|
578
|
+
b: { text: '第二', status: 'active' },
|
|
579
|
+
a: { text: '第一', status: 'active' },
|
|
580
|
+
held: { text: '不要', status: 'pending_review' },
|
|
581
|
+
empty: { status: 'active' },
|
|
582
|
+
},
|
|
583
|
+
}, now)
|
|
584
|
+
assert.deepEqual(items.map(item => item.id), ['a', 'b'])
|
|
585
|
+
assert.equal(items[0].hash, vm.contentHash('a', '第一'))
|
|
586
|
+
})
|
|
587
|
+
|
|
588
|
+
test('index metadata changed during smoke prevents stale activation', async () => {
|
|
589
|
+
resetAll()
|
|
590
|
+
const fx = createFixture()
|
|
591
|
+
fx.setIndex('lib_a', { m1: { text: 'same text', status: 'active' } })
|
|
592
|
+
fx.deps.smoke = async () => {
|
|
593
|
+
fx.indexes.lib_a.entries.m1.disclosure = 'only_if_asked'
|
|
594
|
+
return { ok: true }
|
|
595
|
+
}
|
|
596
|
+
await assertCodeAsync(fx.migrator().switchProfile({ profile: BGE }), 'VECTOR_INDEX_MOVED')
|
|
597
|
+
assert.equal(vp.activeProfile(), null)
|
|
598
|
+
assert.equal(vp.activeVectorState().writeGuard.frozen, null)
|
|
599
|
+
})
|
|
600
|
+
|
|
601
|
+
test('library-set movement after smoke prevents stale activation', async () => {
|
|
602
|
+
resetAll()
|
|
603
|
+
const fx = createFixture()
|
|
604
|
+
let changed = false
|
|
605
|
+
fx.deps.listLibraries = async () => changed ? ['lib_a', 'lib_b', 'new_library'] : ['lib_a', 'lib_b']
|
|
606
|
+
fx.deps.smoke = async () => { changed = true; return { ok: true } }
|
|
607
|
+
await assertCodeAsync(fx.migrator().switchProfile({ profile: BGE }), 'VECTOR_INDEX_MOVED')
|
|
608
|
+
assert.equal(vp.activeProfile(), null)
|
|
609
|
+
})
|
|
610
|
+
|
|
611
|
+
test('in-flight write prevents switching and frozen final capture excludes new writes', async () => {
|
|
612
|
+
resetAll()
|
|
613
|
+
const fx = createFixture()
|
|
614
|
+
const migrator = fx.migrator()
|
|
615
|
+
await migrator.switchProfile({ profile: BGE })
|
|
616
|
+
const before = vp.activeGeneration()
|
|
617
|
+
let release
|
|
618
|
+
const gate = new Promise(resolve => { release = resolve })
|
|
619
|
+
const write = vp.withVectorWrite(() => gate)
|
|
620
|
+
await assertCodeAsync(migrator.switchProfile({ profile: ALT }), 'VECTOR_WRITE_BUSY')
|
|
621
|
+
assert.equal(vp.activeGeneration(), before)
|
|
622
|
+
release()
|
|
623
|
+
await write
|
|
624
|
+
let rejectedWrite = false
|
|
625
|
+
const load = fx.deps.loadIndex
|
|
626
|
+
fx.deps.loadIndex = async id => {
|
|
627
|
+
if (vp.activeVectorState().writeGuard.frozen) {
|
|
628
|
+
await assert.rejects(vp.withVectorWrite(() => {}), { code: 'VECTOR_WRITE_FROZEN' })
|
|
629
|
+
rejectedWrite = true
|
|
630
|
+
}
|
|
631
|
+
return load(id)
|
|
632
|
+
}
|
|
633
|
+
await fx.migrator().switchProfile({ profile: ALT })
|
|
634
|
+
assert.equal(rejectedWrite, true)
|
|
635
|
+
})
|
|
636
|
+
|
|
637
|
+
test('list failure and missing commit readback never activate shadows', async () => {
|
|
638
|
+
resetAll()
|
|
639
|
+
const fx = createFixture()
|
|
640
|
+
fx.deps.listFiles = async () => { throw new Error('list unavailable') }
|
|
641
|
+
await assert.rejects(fx.migrator().switchProfile({ profile: BGE }), /list unavailable/)
|
|
642
|
+
assert.equal(vp.activeProfile(), null)
|
|
643
|
+
assert.equal(vp.pendingJournal().status, 'failed')
|
|
644
|
+
assert.equal(vp.pendingJournal().error.code, 'VECTOR_SYNC_FAILED')
|
|
645
|
+
assert.equal(vp.pendingJournal().report.progress.stage, 'failed')
|
|
646
|
+
resetAll()
|
|
647
|
+
const missing = createFixture()
|
|
648
|
+
missing.setIndex('lib_a', { m1: { text: 'readback required' } })
|
|
649
|
+
missing.deps.commit = async () => {}
|
|
650
|
+
await assertCodeAsync(missing.migrator({ itemRetries: 0 }).switchProfile({ profile: BGE }), 'VECTOR_SYNC_INCOMPLETE')
|
|
651
|
+
assert.equal(vp.activeProfile(), null)
|
|
652
|
+
// 失败明细必须落盘:只报"有几条失败"没法修(2026-10-30 现场)。
|
|
653
|
+
const failure = vp.pendingJournal().error
|
|
654
|
+
assert.equal(failure.code, 'VECTOR_SYNC_INCOMPLETE')
|
|
655
|
+
assert.ok(Array.isArray(failure.failedItems) && failure.failedItems.length >= 1)
|
|
656
|
+
assert.equal(failure.failedItems[0].itemId, 'm1')
|
|
657
|
+
assert.match(failure.failedItems[0].code, /VECTOR_/)
|
|
658
|
+
assert.ok(String(failure.failedItems[0].message).length > 0)
|
|
659
|
+
})
|
|
660
|
+
|
|
661
|
+
test('unchanged-description backend cannot fake reembedding after version changes', async () => {
|
|
662
|
+
resetAll()
|
|
663
|
+
const fx = createFixture()
|
|
664
|
+
fx.setIndex('lib_a', { m1: { text: 'same description', version: 1 } })
|
|
665
|
+
const migrator = fx.migrator({ itemRetries: 0 })
|
|
666
|
+
await migrator.prepareShadow({ profile: BGE })
|
|
667
|
+
await migrator.syncShadow()
|
|
668
|
+
fx.indexes.lib_a.entries.m1.version = 2
|
|
669
|
+
fx.deps.forget = async () => {} // simulate a backend that silently keeps rows
|
|
670
|
+
// Dependencies were copied by migrator: use a new manager for the same shadow.
|
|
671
|
+
const report = await fx.migrator({ itemRetries: 0 }).syncShadow()
|
|
672
|
+
assert.equal(report.failed.some(item => item.code === 'VECTOR_FORGET_FAILED'), true)
|
|
673
|
+
assert.equal(vp.activeProfile(), null)
|
|
674
|
+
})
|
|
675
|
+
|
|
676
|
+
test('injected retrieve failure is a failed migration, not an active profile', async () => {
|
|
677
|
+
resetAll()
|
|
678
|
+
const fx = createFixture()
|
|
679
|
+
fx.setIndex('lib_a', { m1: { text: 'must retrieve' } })
|
|
680
|
+
fx.deps.retrieve = async () => ({ ok: false })
|
|
681
|
+
await assertCodeAsync(fx.migrator({ itemRetries: 0 }).switchProfile({ profile: BGE }), 'VECTOR_SYNC_INCOMPLETE')
|
|
682
|
+
assert.equal(vp.activeProfile(), null)
|
|
683
|
+
})
|
|
684
|
+
|
|
685
|
+
test('progress is observable during rebuild and retained after atomic activation', async () => {
|
|
686
|
+
resetAll()
|
|
687
|
+
const fx = createFixture()
|
|
688
|
+
fx.setIndex('lib_a', { m1: { text: 'one' }, m2: { text: 'two' } })
|
|
689
|
+
const snapshots = []
|
|
690
|
+
const migrator = fx.migrator({ onProgress(snapshot) {
|
|
691
|
+
assert.deepEqual(vp.vectorStatus().pending.report.progress, snapshot)
|
|
692
|
+
snapshots.push(structuredClone(snapshot))
|
|
693
|
+
snapshot.completed = -100
|
|
694
|
+
} })
|
|
695
|
+
const result = await migrator.switchProfile({ profile: BGE })
|
|
696
|
+
assert.equal(snapshots[0].stage, 'syncing')
|
|
697
|
+
assert.equal(snapshots[0].total, 2)
|
|
698
|
+
assert.ok(snapshots.some(item => item.completed === 1 && item.current.itemId === 'm1'))
|
|
699
|
+
assert.equal(result.report.progress.completed, 2)
|
|
700
|
+
assert.equal(result.report.progress.stage, 'prepared')
|
|
701
|
+
assert.deepEqual(vp.vectorStatus().active.lastMigration, result.report.progress)
|
|
702
|
+
assert.equal(vp.vectorStatus().pending, null)
|
|
703
|
+
})
|
|
704
|
+
|
|
705
|
+
test('failed progress and throwing observers never replace old active', async () => {
|
|
706
|
+
resetAll()
|
|
707
|
+
const fx = createFixture()
|
|
708
|
+
await fx.migrator().switchProfile({ profile: BGE })
|
|
709
|
+
const active = structuredClone(vp.activeVectorState().active)
|
|
710
|
+
fx.setIndex('lib_a', { m1: { text: 'fails' } })
|
|
711
|
+
fx.behaviour.failCommit.add('m1')
|
|
712
|
+
const migrator = fx.migrator({ itemRetries: 0, onProgress() { throw new Error('observer failed') } })
|
|
713
|
+
await assertCodeAsync(migrator.switchProfile({ profile: ALT }), 'VECTOR_SYNC_INCOMPLETE')
|
|
714
|
+
assert.deepEqual(vp.activeVectorState().active, active)
|
|
715
|
+
const progress = vp.vectorStatus().pending.report.progress
|
|
716
|
+
assert.equal(progress.stage, 'failed')
|
|
717
|
+
assert.ok(progress.failed >= 1)
|
|
718
|
+
assert.equal(vp.pendingJournal().status, 'failed')
|
|
719
|
+
assert.equal(vp.pendingJournal().error.code, 'VECTOR_SYNC_INCOMPLETE')
|
|
720
|
+
})
|
|
721
|
+
|
|
722
|
+
test('explicit migration profile guard binds profile database and pending token', async () => {
|
|
723
|
+
resetAll()
|
|
724
|
+
const fx = createFixture()
|
|
725
|
+
const migrator = fx.migrator()
|
|
726
|
+
const first = await migrator.prepareShadow({ profile: BGE })
|
|
727
|
+
const captured = vp.assertMigrationProfile({ profile: first.profile, db: first.databases.global })
|
|
728
|
+
assert.equal(captured.generation, first.generation)
|
|
729
|
+
assert.equal(vp.assertMigrationProfile(captured).token, first.token)
|
|
730
|
+
assertCode(() => vp.assertMigrationProfile({ profile: first.profile, db: path.join(home, 'unrelated.sqlite3') }), 'VECTOR_STALE_GENERATION')
|
|
731
|
+
await migrator.prepareShadow({ profile: BGE, fresh: true })
|
|
732
|
+
assertCode(() => vp.assertMigrationProfile(captured), 'VECTOR_STALE_GENERATION')
|
|
733
|
+
})
|