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,878 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import crypto from 'node:crypto'
|
|
4
|
+
import { indexDir, librariesDir, ensureDir, readJsonStrict, writeJson, stamp } from './paths.js'
|
|
5
|
+
import {
|
|
6
|
+
VECTOR_ERROR_CODES, vectorError,
|
|
7
|
+
stableStringify, sha256Hex, resolveProfile, assertProfileVerified, vectorDbRelPath,
|
|
8
|
+
resolveVectorPath, readVectorState, vectorState, activeDatabases, updateVectorStateSync,
|
|
9
|
+
reserveShadowGeneration, updatePendingJournal, clearPendingJournal, pendingJournal,
|
|
10
|
+
assertVectorWriteAllowed, captureGeneration, assertGeneration, vectorStatus, withVectorWriteFreeze, profileEnv, setRequestedProfile,
|
|
11
|
+
} from './vector-profile.js'
|
|
12
|
+
|
|
13
|
+
// §14.8 影子重建 / 追平 / 原子代际切换 / 回滚 / 启动恢复。
|
|
14
|
+
//
|
|
15
|
+
// 原则:
|
|
16
|
+
// * 绝不原地重嵌入:每次都写到一个全新的影子库路径(代际号进文件名),旧库保持只读可用。
|
|
17
|
+
// * 账本键 = profile fingerprint + libraryId + itemId + content hash。命中即跳过,
|
|
18
|
+
// 所以"再来一次 commit"不是"再来一次 embed";内容变了才算重新嵌入。
|
|
19
|
+
// * tombstone:从索引里消失的条目要真的从影子库里删掉并记账,断点续建不得把它复活。
|
|
20
|
+
// * 切换只在最后短窗口里做同步 compare + 原子写 active 指针;之前的锁外工作可以失败,
|
|
21
|
+
// 失败就不动 active(§14.8 第 4/7/8 条)。
|
|
22
|
+
// * 索引快照在追平前后必须一致;不一致说明追平已经过期,中止切换而不是切到半步状态。
|
|
23
|
+
//
|
|
24
|
+
// 依赖全部注入,便于 fixture 测试:
|
|
25
|
+
// listLibraries() → [{ id }] | string[]
|
|
26
|
+
// loadIndex(libraryId | null) → { entries: { [itemId]: entry } } (null = 全局库)
|
|
27
|
+
// commit({ db, libraryId, item, entry, profile, generation }) → Promise
|
|
28
|
+
// forget({ db, libraryId, itemId, profile, generation }) → Promise
|
|
29
|
+
// listFiles({ db, libraryId, profile, generation }) → Promise<string[]> (影子里已有的条目名/ id)
|
|
30
|
+
// smoke({ profile, generation, databases, report }) → Promise<{ ok, checks? }>
|
|
31
|
+
// 主代理接 state.js/memu.js 时按上面的形状包一层即可。
|
|
32
|
+
|
|
33
|
+
export const VECTOR_LEDGER_PREFIX = 'vector-ledger.'
|
|
34
|
+
export const VECTOR_LEDGER_SCHEMA = 1
|
|
35
|
+
|
|
36
|
+
/** 这些状态不进入向量空间:重建不得把 held 的条目重新激活(§14.8 第 3 条)。 */
|
|
37
|
+
export const HELD_STATUSES = Object.freeze(['archived', 'suppressed', 'expired', 'pending_review', 'superseded', 'deleted'])
|
|
38
|
+
|
|
39
|
+
export function admitEntry(entry, nowMs = Date.now()) {
|
|
40
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return false
|
|
41
|
+
if (HELD_STATUSES.includes(String(entry.status || ''))) return false
|
|
42
|
+
if (entry.expiresAt && !(Date.parse(entry.expiresAt) > nowMs)) return false
|
|
43
|
+
return true
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function itemText(entry) {
|
|
47
|
+
if (!entry || typeof entry !== 'object') return ''
|
|
48
|
+
for (const key of ['text', 'content', 'body']) {
|
|
49
|
+
if (typeof entry[key] === 'string' && entry[key].trim()) return entry[key]
|
|
50
|
+
}
|
|
51
|
+
return ''
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 内容/版本哈希:只有正文(与 id)改变才算需要重新嵌入。 */
|
|
55
|
+
export function contentHash(id, text, version = null) {
|
|
56
|
+
return sha256Hex(stableStringify({ id: String(id), text: String(text), version }))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 从插件 index 里取出"可写进向量空间"的条目(已按准入过滤,按 id 稳定排序)。 */
|
|
60
|
+
export function admittedItems(index, nowMs = Date.now()) {
|
|
61
|
+
const entries = index && typeof index.entries === 'object' && index.entries ? index.entries : {}
|
|
62
|
+
const items = []
|
|
63
|
+
for (const [id, entry] of Object.entries(entries)) {
|
|
64
|
+
if (!admitEntry(entry, nowMs)) continue
|
|
65
|
+
const text = itemText(entry)
|
|
66
|
+
if (!text.trim()) continue
|
|
67
|
+
items.push({ id, text, hash: contentHash(id, text, entry.contentVersion ?? entry.version ?? entry.updatedAt ?? null), entry: structuredClone(entry) })
|
|
68
|
+
}
|
|
69
|
+
items.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
|
70
|
+
return items
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function scopeSnapshotDigest(items) {
|
|
74
|
+
return sha256Hex(stableStringify(items.map(item => [item.id, item.hash])))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function ledgerFile(fingerprint, generation) {
|
|
78
|
+
return path.join(indexDir(), VECTOR_LEDGER_PREFIX + String(fingerprint).slice(0, 8) + '.g' + Number(generation) + '.json')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function emptyLedger(fingerprint, generation) {
|
|
82
|
+
return {
|
|
83
|
+
version: VECTOR_LEDGER_SCHEMA,
|
|
84
|
+
fingerprint,
|
|
85
|
+
generation,
|
|
86
|
+
createdAt: stamp(),
|
|
87
|
+
updatedAt: stamp(),
|
|
88
|
+
snapshot: null,
|
|
89
|
+
libraries: {},
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function loadLedger(fingerprint, generation) {
|
|
94
|
+
const file = ledgerFile(fingerprint, generation)
|
|
95
|
+
const result = readJsonStrict(file)
|
|
96
|
+
if (!result.ok) {
|
|
97
|
+
if (result.missing) return { ok: true, ledger: emptyLedger(fingerprint, generation) }
|
|
98
|
+
return { ok: false, ledger: emptyLedger(fingerprint, generation), error: { code: result.kind === 'corrupt' ? 'VECTOR_LEDGER_CORRUPT' : 'VECTOR_LEDGER_UNREADABLE', file } }
|
|
99
|
+
}
|
|
100
|
+
const value = result.value
|
|
101
|
+
if (!value || typeof value !== 'object' || value.fingerprint !== fingerprint || Number(value.generation) !== Number(generation)) {
|
|
102
|
+
// 账本对不上代际/指纹:当作空账本重建(宁可贵一点,也不复用别人的进度)
|
|
103
|
+
return { ok: true, ledger: emptyLedger(fingerprint, generation), restarted: true }
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
ok: true,
|
|
107
|
+
ledger: {
|
|
108
|
+
version: VECTOR_LEDGER_SCHEMA,
|
|
109
|
+
fingerprint,
|
|
110
|
+
generation: Number(generation),
|
|
111
|
+
createdAt: value.createdAt || stamp(),
|
|
112
|
+
updatedAt: value.updatedAt || stamp(),
|
|
113
|
+
snapshot: value.snapshot || null,
|
|
114
|
+
libraries: value.libraries && typeof value.libraries === 'object' ? value.libraries : {},
|
|
115
|
+
},
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function saveLedger(ledger) {
|
|
120
|
+
ensureDir(indexDir())
|
|
121
|
+
writeJson(ledgerFile(ledger.fingerprint, ledger.generation), ledger)
|
|
122
|
+
return ledger
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function ledgerBucket(ledger, key) {
|
|
126
|
+
const existing = ledger.libraries[key]
|
|
127
|
+
if (existing && typeof existing === 'object') {
|
|
128
|
+
return ledger.libraries[key] = {
|
|
129
|
+
items: existing.items && typeof existing.items === 'object' ? existing.items : {},
|
|
130
|
+
tombstones: existing.tombstones && typeof existing.tombstones === 'object' ? existing.tombstones : {},
|
|
131
|
+
snapshot: existing.snapshot || null,
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const bucket = { items: {}, tombstones: {}, snapshot: null }
|
|
135
|
+
ledger.libraries[key] = bucket
|
|
136
|
+
return bucket
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function ledgerSummary(ledger) {
|
|
140
|
+
const out = { fingerprint: ledger.fingerprint, generation: ledger.generation, libraries: {}, committed: 0, tombstones: 0 }
|
|
141
|
+
for (const [key, bucket] of Object.entries(ledger.libraries || {})) {
|
|
142
|
+
const committed = Object.keys(bucket?.items || {}).length
|
|
143
|
+
const tombstones = Object.keys(bucket?.tombstones || {}).length
|
|
144
|
+
out.libraries[key] = { committed, tombstones }
|
|
145
|
+
out.committed += committed
|
|
146
|
+
out.tombstones += tombstones
|
|
147
|
+
}
|
|
148
|
+
out.snapshot = ledger.snapshot
|
|
149
|
+
return out
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function fileStats(absPath) {
|
|
153
|
+
const buffer = fs.readFileSync(absPath)
|
|
154
|
+
return { bytes: buffer.length, sha256: crypto.createHash('sha256').update(buffer).digest('hex') }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------- 迁移器
|
|
158
|
+
|
|
159
|
+
const REQUIRED_DEPS = Object.freeze(['listLibraries', 'loadIndex', 'commit', 'forget', 'listFiles'])
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* createVectorMigrator({ listLibraries, loadIndex, commit, forget, listFiles, smoke?, now?, log?, maxCatchUpAttempts?, itemRetries? })
|
|
163
|
+
* 也接受 { deps: {...}, ... } 形式。所有副作用都经注入依赖,便于纯 fixture 测试。
|
|
164
|
+
*/
|
|
165
|
+
export function createVectorMigrator(options = {}) {
|
|
166
|
+
const configured = options && typeof options.deps === 'object' && options.deps ? { ...options.deps, ...options } : { ...options }
|
|
167
|
+
const impl = {
|
|
168
|
+
listLibraries: configured.listLibraries,
|
|
169
|
+
loadIndex: configured.loadIndex,
|
|
170
|
+
commit: configured.commit,
|
|
171
|
+
forget: configured.forget,
|
|
172
|
+
listFiles: configured.listFiles ?? configured.list,
|
|
173
|
+
retrieve: typeof configured.retrieve === 'function' ? configured.retrieve : null,
|
|
174
|
+
smoke: typeof configured.smoke === 'function' ? configured.smoke : null,
|
|
175
|
+
}
|
|
176
|
+
for (const name of REQUIRED_DEPS) {
|
|
177
|
+
if (typeof impl[name] !== 'function') throw new TypeError('createVectorMigrator requires a ' + name + '(… ) dependency')
|
|
178
|
+
}
|
|
179
|
+
const now = typeof configured.now === 'function' ? configured.now : () => new Date().toISOString()
|
|
180
|
+
const log = typeof configured.log === 'function' ? configured.log : () => {}
|
|
181
|
+
const onProgress = typeof configured.onProgress === 'function' ? configured.onProgress : null
|
|
182
|
+
const maxCatchUpAttempts = Number.isSafeInteger(configured.maxCatchUpAttempts) && configured.maxCatchUpAttempts > 0 ? configured.maxCatchUpAttempts : 3
|
|
183
|
+
const itemRetries = Number.isSafeInteger(configured.itemRetries) && configured.itemRetries >= 0 ? configured.itemRetries : 1
|
|
184
|
+
|
|
185
|
+
function publishProgress(progress, { persist = true } = {}) {
|
|
186
|
+
const snapshot = { ...progress, updatedAt: now() }
|
|
187
|
+
if (persist) {
|
|
188
|
+
updatePendingJournal(current => {
|
|
189
|
+
if (current.generation !== snapshot.generation || current.fingerprint !== snapshot.fingerprint) throw vectorError(VECTOR_ERROR_CODES.STALE_GENERATION, 'progress belongs to a replaced shadow generation')
|
|
190
|
+
return { ...current, report: { ...(current.report || {}), progress: snapshot } }
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
if (onProgress) {
|
|
194
|
+
try {
|
|
195
|
+
const result = onProgress(structuredClone(snapshot))
|
|
196
|
+
if (result && typeof result.then === 'function') result.catch(error => log('progress callback failed: ' + (error?.message || error)))
|
|
197
|
+
} catch (error) {
|
|
198
|
+
log('progress callback failed: ' + (error?.message || error))
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return snapshot
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function idOf(value) {
|
|
205
|
+
const id = typeof value === 'string' ? value : value?.id
|
|
206
|
+
if (typeof id !== 'string' || !id || id === 'global') return null
|
|
207
|
+
return id
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function resolveLibraryIds(libraries) {
|
|
211
|
+
const raw = Array.isArray(libraries) ? libraries : await impl.listLibraries()
|
|
212
|
+
const ids = []
|
|
213
|
+
for (const item of raw ?? []) {
|
|
214
|
+
const id = idOf(item)
|
|
215
|
+
if (id && !ids.includes(id)) ids.push(id)
|
|
216
|
+
}
|
|
217
|
+
return ids
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function scopesFor(databases) {
|
|
221
|
+
const scopes = [{ key: 'global', libraryId: null, rel: databases.global.path, db: resolveVectorPath(databases.global.path) }]
|
|
222
|
+
for (const [libraryId, entry] of Object.entries(databases.libraries || {})) {
|
|
223
|
+
scopes.push({ key: libraryId, libraryId, rel: entry.path, db: resolveVectorPath(entry.path) })
|
|
224
|
+
}
|
|
225
|
+
return scopes
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function captureSnapshot(scopes) {
|
|
229
|
+
const parts = []
|
|
230
|
+
for (const scope of scopes) {
|
|
231
|
+
const index = structuredClone(await impl.loadIndex(scope.libraryId))
|
|
232
|
+
const items = admittedItems(index)
|
|
233
|
+
parts.push({ key: scope.key, items, digest: sha256Hex(stableStringify({ index, admitted: scopeSnapshotDigest(items) })) })
|
|
234
|
+
}
|
|
235
|
+
return {
|
|
236
|
+
digest: sha256Hex(stableStringify(parts.map(part => [part.key, part.digest]))),
|
|
237
|
+
scopes: parts,
|
|
238
|
+
at: now(),
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function presentIds(scope, profile, generation) {
|
|
243
|
+
const ids = new Set()
|
|
244
|
+
if (!fs.existsSync(scope.db)) return ids
|
|
245
|
+
const listed = await impl.listFiles({ db: scope.db, libraryId: scope.libraryId, profile, generation })
|
|
246
|
+
if (!Array.isArray(listed) && !Array.isArray(listed?.recall_files)) throw vectorError('VECTOR_LIST_INVALID', 'shadow list returned an invalid result for ' + scope.key)
|
|
247
|
+
const names = Array.isArray(listed) ? listed : Array.isArray(listed?.recall_files) ? listed.recall_files : []
|
|
248
|
+
for (const value of names) {
|
|
249
|
+
if (typeof value === 'string') ids.add(value)
|
|
250
|
+
else if (value && typeof value === 'object') {
|
|
251
|
+
const name = value.name ?? value.id
|
|
252
|
+
if (typeof name === 'string') ids.add(name)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return ids
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function commitItem(scope, item, profile, generation) {
|
|
259
|
+
let lastError = null
|
|
260
|
+
for (let attempt = 0; attempt <= itemRetries; attempt++) {
|
|
261
|
+
try {
|
|
262
|
+
// Failure may still persist a row. Forget first: unchanged-description
|
|
263
|
+
// recommit is not proof that the backend regenerated an embedding.
|
|
264
|
+
if ((await presentIds(scope, profile, generation)).has(item.id)) {
|
|
265
|
+
await impl.forget({ db: scope.db, libraryId: scope.libraryId, itemId: item.id, profile, generation })
|
|
266
|
+
if ((await presentIds(scope, profile, generation)).has(item.id)) throw vectorError('VECTOR_FORGET_FAILED', 'shadow item still exists after forget: ' + item.id)
|
|
267
|
+
}
|
|
268
|
+
await impl.commit({ db: scope.db, libraryId: scope.libraryId, item, entry: item.entry ?? null, profile, generation })
|
|
269
|
+
if (!(await presentIds(scope, profile, generation)).has(item.id)) throw vectorError('VECTOR_READBACK_FAILED', 'committed item is missing from shadow: ' + item.id)
|
|
270
|
+
if (impl.retrieve) {
|
|
271
|
+
const result = await impl.retrieve({ db: scope.db, libraryId: scope.libraryId, itemId: item.id, item, profile, generation })
|
|
272
|
+
if (result == null || result === false || result.ok === false) throw vectorError('VECTOR_READBACK_FAILED', 'cannot retrieve committed shadow item: ' + item.id)
|
|
273
|
+
}
|
|
274
|
+
return { ok: true }
|
|
275
|
+
} catch (error) {
|
|
276
|
+
lastError = error
|
|
277
|
+
if (attempt < itemRetries) log('commit retry ' + scope.key + '/' + item.id + ' after ' + (error?.code || error?.message || error))
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return { ok: false, error: lastError }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// -------------------------------------------------------------- prepareShadow
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* 为一次重建分配"全新代际 + 全新影子路径",并把 prepared journal 先落盘。
|
|
287
|
+
* 不写 active 指针、不碰旧库。同指纹的未完成重建默认续建(同一代际、同一路径、账本跳过
|
|
288
|
+
* 已嵌入条目);fresh:true 则强制换新代际(= 真正重来一遍)。
|
|
289
|
+
*/
|
|
290
|
+
async function prepareShadow(stepOptions = {}) {
|
|
291
|
+
const profile = resolveProfile(stepOptions.profile ?? stepOptions.catalogId)
|
|
292
|
+
const libraryIds = await resolveLibraryIds(stepOptions.libraries)
|
|
293
|
+
const fresh = stepOptions.fresh === true
|
|
294
|
+
const reason = typeof stepOptions.reason === 'string' ? stepOptions.reason : 'switch'
|
|
295
|
+
const current = vectorState()
|
|
296
|
+
const pending = current?.pending
|
|
297
|
+
const resumable = !fresh && pending && pending.fingerprint === profile.fingerprint
|
|
298
|
+
&& (pending.status === 'preparing' || pending.status === 'prepared' || pending.status === 'failed')
|
|
299
|
+
if (resumable) {
|
|
300
|
+
// 续建:同一代际,只把新出现的库补进同一个 DB 集合(同一次原子切换里生效)
|
|
301
|
+
const extended = updatePendingJournal(journal => {
|
|
302
|
+
const libraries = { ...(journal.databases?.libraries || {}) }
|
|
303
|
+
for (const id of libraryIds) {
|
|
304
|
+
if (!libraries[id]) libraries[id] = { path: vectorDbRelPath({ libraryId: id, fingerprint: journal.fingerprint, generation: journal.generation }), bytes: null, sha256: null }
|
|
305
|
+
}
|
|
306
|
+
return { ...journal, databases: { ...journal.databases, libraries } }
|
|
307
|
+
})
|
|
308
|
+
try {
|
|
309
|
+
ensureShadowFiles(extended.databases)
|
|
310
|
+
} catch (error) {
|
|
311
|
+
failPending(error?.code || 'VECTOR_PREPARE_FAILED', String(error?.message || error), {}, extended)
|
|
312
|
+
throw error
|
|
313
|
+
}
|
|
314
|
+
setRequestedProfile(profile, { reason })
|
|
315
|
+
log('resuming shadow generation ' + extended.generation + ' for ' + profile.catalogId)
|
|
316
|
+
return describeShadow(extended, true)
|
|
317
|
+
}
|
|
318
|
+
const reservation = reserveShadowGeneration({ profile, libraryIds, reason })
|
|
319
|
+
// 全新代际 = 全新空影子库。路径上已经有文件说明编号冲突,绝不覆盖/复用。
|
|
320
|
+
try {
|
|
321
|
+
ensureShadowFiles(reservation.databases, { refuseExisting: true })
|
|
322
|
+
} catch (error) {
|
|
323
|
+
failPending(error?.code || 'VECTOR_PREPARE_FAILED', String(error?.message || error), {}, reservation)
|
|
324
|
+
throw error
|
|
325
|
+
}
|
|
326
|
+
setRequestedProfile(profile, { reason })
|
|
327
|
+
log('prepared shadow generation ' + reservation.generation + ' for ' + profile.catalogId)
|
|
328
|
+
return describeShadow(pendingJournal(), false)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* 影子库集合必须是"存在且为空"的:空 sqlite 文件是合法起点,缺文件会被误当成撕裂。
|
|
333
|
+
* refuseExisting=true 用于全新代际——路径上已有文件说明编号被复用,直接拒绝。
|
|
334
|
+
*/
|
|
335
|
+
function ensureShadowFiles(databases, { refuseExisting = false } = {}) {
|
|
336
|
+
ensureDir(librariesDir())
|
|
337
|
+
for (const scope of scopesFor(databases)) {
|
|
338
|
+
ensureDir(path.dirname(scope.db))
|
|
339
|
+
if (fs.existsSync(scope.db)) {
|
|
340
|
+
if (refuseExisting) {
|
|
341
|
+
throw vectorError(VECTOR_ERROR_CODES.SHADOW_PATH_TAKEN, 'the reserved shadow path already exists; refusing to reuse a database from another attempt', {
|
|
342
|
+
path: scope.rel,
|
|
343
|
+
})
|
|
344
|
+
}
|
|
345
|
+
continue
|
|
346
|
+
}
|
|
347
|
+
fs.writeFileSync(scope.db, '', { flag: 'wx' })
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function describeShadow(journal, resumed) {
|
|
352
|
+
const scopes = scopesFor(journal.databases)
|
|
353
|
+
return {
|
|
354
|
+
generation: journal.generation,
|
|
355
|
+
token: journal.token,
|
|
356
|
+
fingerprint: journal.fingerprint,
|
|
357
|
+
profile: journal.profile,
|
|
358
|
+
status: journal.status,
|
|
359
|
+
reason: journal.reason ?? null,
|
|
360
|
+
resumed: Boolean(resumed),
|
|
361
|
+
previousGeneration: journal.previousGeneration ?? null,
|
|
362
|
+
databases: {
|
|
363
|
+
global: scopes[0].db,
|
|
364
|
+
libraries: Object.fromEntries(scopes.slice(1).map(scope => [scope.key, scope.db])),
|
|
365
|
+
},
|
|
366
|
+
env: profileEnv(journal.profile),
|
|
367
|
+
relative: {
|
|
368
|
+
global: journal.databases.global.path,
|
|
369
|
+
libraries: Object.fromEntries(Object.entries(journal.databases.libraries || {}).map(([id, entry]) => [id, entry.path])),
|
|
370
|
+
},
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// -------------------------------------------------------------- syncShadow
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* 把索引里所有准入条目写进影子库;命中账本的跳过(那已经是这一代的向量),
|
|
378
|
+
* 消失的条目 forget + tombstone。返回追平报告与前后快照(快照不一致 = 追平已过期)。
|
|
379
|
+
*/
|
|
380
|
+
// 只报"有几条失败"没法修:把前几条的 id/错误码/消息留下来。syncShadow 与
|
|
381
|
+
// switchProfile 两处都要带,后者会覆盖前者的 pending.error。
|
|
382
|
+
function syncFailureDetail(report) {
|
|
383
|
+
return report.failed.slice(0, 10).map(item => ({
|
|
384
|
+
libraryId: item.libraryId ?? null,
|
|
385
|
+
itemId: item.itemId ?? null,
|
|
386
|
+
code: item.code ?? 'VECTOR_COMMIT_FAILED',
|
|
387
|
+
message: String(item.message ?? '').slice(0, 300),
|
|
388
|
+
}))
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async function syncShadow(stepOptions = {}) {
|
|
392
|
+
const expected = pendingJournal()
|
|
393
|
+
try {
|
|
394
|
+
const report = await syncShadowImpl(stepOptions)
|
|
395
|
+
if (report.failed.length) {
|
|
396
|
+
// 只报"有几条失败"没法排查:把前几条的 id/错误码/消息留下来,日志里也打一行。
|
|
397
|
+
// 现场(2026-10-29)9 条失败只显示一句"未覆盖全部记忆",必须重放才知道原因。
|
|
398
|
+
const failedItems = syncFailureDetail(report)
|
|
399
|
+
log('rebuild sync failures: ' + failedItems.slice(0, 3).map(item => `${item.itemId || item.libraryId}:${item.code}:${item.message}`).join(' | '))
|
|
400
|
+
failPending('VECTOR_SYNC_INCOMPLETE', 'the rebuild did not cover every admitted memory; the active generation is untouched', { failed: report.failed.length, failedItems }, expected)
|
|
401
|
+
}
|
|
402
|
+
return report
|
|
403
|
+
} catch (error) {
|
|
404
|
+
const matches = expected && (stepOptions.generation == null || stepOptions.generation === expected.generation)
|
|
405
|
+
&& (stepOptions.fingerprint == null || stepOptions.fingerprint === expected.fingerprint)
|
|
406
|
+
if (matches) failPending(error?.code || 'VECTOR_SYNC_FAILED', String(error?.message || error), { causeCode: error?.code ?? null }, expected)
|
|
407
|
+
throw error
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function syncShadowImpl(stepOptions = {}) {
|
|
412
|
+
const journal = pendingJournal()
|
|
413
|
+
if (!journal) throw vectorError('VECTOR_PENDING_MISSING', 'there is no prepared rebuild to sync')
|
|
414
|
+
const generation = Number.isSafeInteger(stepOptions.generation) ? stepOptions.generation : journal.generation
|
|
415
|
+
const fingerprint = typeof stepOptions.fingerprint === 'string' ? stepOptions.fingerprint : journal.fingerprint
|
|
416
|
+
if (journal.generation !== generation || journal.fingerprint !== fingerprint) {
|
|
417
|
+
throw vectorError('VECTOR_PENDING_MISSING', 'the prepared rebuild belongs to a different generation', { generation, fingerprint, preparedGeneration: journal.generation })
|
|
418
|
+
}
|
|
419
|
+
if (journal.status === 'committed') throw vectorError(VECTOR_ERROR_CODES.TORN_STATE, 'this generation is already committed; nothing to sync')
|
|
420
|
+
const profile = assertProfileVerified(journal.profile)
|
|
421
|
+
const scopes = scopesFor(journal.databases)
|
|
422
|
+
if (Array.isArray(stepOptions.libraries)) {
|
|
423
|
+
// 只追平指定的私有库;全局库永远在同一代际里(不能漏,否则新代缺全局空间)
|
|
424
|
+
const wanted = new Set(stepOptions.libraries.map(idOf).filter(Boolean))
|
|
425
|
+
if (wanted.size) {
|
|
426
|
+
const libraries = Object.fromEntries(Object.entries(journal.databases.libraries || {}).filter(([id]) => wanted.has(id)))
|
|
427
|
+
scopes.length = 0
|
|
428
|
+
scopes.push(...scopesFor({ global: journal.databases.global, libraries }))
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
const loaded = loadLedger(fingerprint, generation)
|
|
432
|
+
const ledger = loaded.ledger
|
|
433
|
+
if (loaded.error) log('ledger unreadable (' + loaded.error.code + '); rebuilding it from the index')
|
|
434
|
+
const before = await captureSnapshot(scopes)
|
|
435
|
+
const total = before.scopes.reduce((sum, scope) => sum + scope.items.length, 0)
|
|
436
|
+
const report = { generation, fingerprint, committed: 0, skipped: 0, tombstoned: 0, failed: [], libraries: {}, restarted: Boolean(loaded.restarted || loaded.error) }
|
|
437
|
+
const progress = { stage: 'syncing', generation, fingerprint, total, completed: 0, skipped: 0, committed: 0, tombstoned: 0, failed: 0, current: null }
|
|
438
|
+
publishProgress(progress)
|
|
439
|
+
for (let index = 0; index < scopes.length; index++) {
|
|
440
|
+
const scope = scopes[index]
|
|
441
|
+
const items = before.scopes[index].items
|
|
442
|
+
const bucket = ledgerBucket(ledger, scope.key)
|
|
443
|
+
ensureDir(path.dirname(scope.db))
|
|
444
|
+
const present = await presentIds(scope, profile, generation)
|
|
445
|
+
const known = new Set(items.map(item => item.id))
|
|
446
|
+
for (const item of items) {
|
|
447
|
+
const recorded = bucket.items[item.id]
|
|
448
|
+
const tombstoned = bucket.tombstones[item.id]
|
|
449
|
+
if (recorded && recorded.hash === item.hash && present.has(item.id) && !tombstoned) {
|
|
450
|
+
report.skipped += 1
|
|
451
|
+
progress.skipped = report.skipped
|
|
452
|
+
progress.completed = report.committed + report.skipped
|
|
453
|
+
progress.current = { stage: 'skipped', libraryId: scope.key, itemId: item.id }
|
|
454
|
+
publishProgress(progress)
|
|
455
|
+
continue
|
|
456
|
+
}
|
|
457
|
+
const outcome = await commitItem(scope, item, profile, generation)
|
|
458
|
+
if (!outcome.ok) {
|
|
459
|
+
const error = outcome.error
|
|
460
|
+
report.failed.push({
|
|
461
|
+
libraryId: scope.key,
|
|
462
|
+
itemId: item.id,
|
|
463
|
+
code: error?.code || 'VECTOR_COMMIT_FAILED',
|
|
464
|
+
message: String(error?.message || error || 'commit failed'),
|
|
465
|
+
})
|
|
466
|
+
progress.failed = report.failed.length
|
|
467
|
+
progress.completed = report.committed + report.skipped + progress.failed
|
|
468
|
+
progress.current = { stage: 'failed', libraryId: scope.key, itemId: item.id, code: error?.code || 'VECTOR_COMMIT_FAILED' }
|
|
469
|
+
publishProgress(progress)
|
|
470
|
+
continue
|
|
471
|
+
}
|
|
472
|
+
bucket.items[item.id] = { hash: item.hash, at: now() }
|
|
473
|
+
delete bucket.tombstones[item.id]
|
|
474
|
+
report.committed += 1
|
|
475
|
+
progress.committed = report.committed
|
|
476
|
+
progress.completed = report.committed + report.skipped + progress.failed
|
|
477
|
+
progress.current = { stage: 'committed', libraryId: scope.key, itemId: item.id }
|
|
478
|
+
publishProgress(progress)
|
|
479
|
+
ledger.updatedAt = now()
|
|
480
|
+
saveLedger(ledger)
|
|
481
|
+
}
|
|
482
|
+
for (const name of present) {
|
|
483
|
+
if (known.has(name)) continue
|
|
484
|
+
try {
|
|
485
|
+
await impl.forget({ db: scope.db, libraryId: scope.libraryId, itemId: name, profile, generation })
|
|
486
|
+
} catch (error) {
|
|
487
|
+
report.failed.push({ libraryId: scope.key, itemId: name, code: error?.code || 'VECTOR_FORGET_FAILED', message: String(error?.message || error || 'forget failed') })
|
|
488
|
+
progress.failed = report.failed.length
|
|
489
|
+
progress.completed = report.committed + report.skipped + report.tombstoned + progress.failed
|
|
490
|
+
progress.current = { stage: 'failed', libraryId: scope.key, itemId: name, code: error?.code || 'VECTOR_FORGET_FAILED' }
|
|
491
|
+
publishProgress(progress)
|
|
492
|
+
continue
|
|
493
|
+
}
|
|
494
|
+
bucket.tombstones[name] = { hash: bucket.items[name]?.hash ?? null, at: now(), reason: 'absent-from-index' }
|
|
495
|
+
delete bucket.items[name]
|
|
496
|
+
report.tombstoned += 1
|
|
497
|
+
progress.tombstoned = report.tombstoned
|
|
498
|
+
progress.completed = report.committed + report.skipped + report.tombstoned + progress.failed
|
|
499
|
+
progress.current = { stage: 'tombstoned', libraryId: scope.key, itemId: name }
|
|
500
|
+
publishProgress(progress)
|
|
501
|
+
ledger.updatedAt = now()
|
|
502
|
+
saveLedger(ledger)
|
|
503
|
+
}
|
|
504
|
+
for (const [itemId, record] of Object.entries(bucket.items)) {
|
|
505
|
+
// 索引里已经没有它、影子库里也没有:留 tombstone,避免断点续建把它复活。
|
|
506
|
+
if (known.has(itemId) || present.has(itemId)) continue
|
|
507
|
+
bucket.tombstones[itemId] = { hash: record?.hash ?? null, at: now(), reason: 'not-present' }
|
|
508
|
+
delete bucket.items[itemId]
|
|
509
|
+
report.tombstoned += 1
|
|
510
|
+
progress.tombstoned = report.tombstoned
|
|
511
|
+
progress.completed = report.committed + report.skipped + report.tombstoned + progress.failed
|
|
512
|
+
progress.current = { stage: 'tombstoned', libraryId: scope.key, itemId }
|
|
513
|
+
publishProgress(progress)
|
|
514
|
+
}
|
|
515
|
+
const verifiedPresent = await presentIds(scope, profile, generation)
|
|
516
|
+
if (verifiedPresent.size !== known.size || [...known].some(id => !verifiedPresent.has(id))) {
|
|
517
|
+
report.failed.push({ libraryId: scope.key, code: 'VECTOR_COVERAGE_FAILED', message: 'shadow membership differs from eligible index snapshot' })
|
|
518
|
+
}
|
|
519
|
+
bucket.snapshot = { digest: before.scopes[index].digest, items: items.length, at: before.at }
|
|
520
|
+
report.libraries[scope.key] = { items: items.length, present: present.size, digest: before.scopes[index].digest }
|
|
521
|
+
ledger.updatedAt = now()
|
|
522
|
+
saveLedger(ledger)
|
|
523
|
+
}
|
|
524
|
+
const after = await captureSnapshot(scopes)
|
|
525
|
+
const stable = after.digest === before.digest
|
|
526
|
+
ledger.snapshot = { digest: before.digest, at: before.at, stable, libraries: report.libraries }
|
|
527
|
+
ledger.updatedAt = now()
|
|
528
|
+
saveLedger(ledger)
|
|
529
|
+
report.snapshot = { digest: before.digest, at: before.at, libraries: report.libraries }
|
|
530
|
+
report.afterDigest = after.digest
|
|
531
|
+
report.stable = stable
|
|
532
|
+
report.ledger = ledgerSummary(ledger)
|
|
533
|
+
progress.stage = report.failed.length ? 'failed' : 'prepared'
|
|
534
|
+
progress.failed = report.failed.length
|
|
535
|
+
progress.completed = report.committed + report.skipped + report.tombstoned + progress.failed
|
|
536
|
+
progress.current = null
|
|
537
|
+
report.progress = publishProgress(progress)
|
|
538
|
+
if (report.failed.length === 0) {
|
|
539
|
+
updatePendingJournal(current => ({ ...current, status: 'prepared', preparedAt: now(), snapshot: report.snapshot, report: { committed: report.committed, skipped: report.skipped, tombstoned: report.tombstoned, libraries: report.libraries, progress: report.progress } }))
|
|
540
|
+
}
|
|
541
|
+
return report
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// -------------------------------------------------------------- 切换
|
|
545
|
+
|
|
546
|
+
function measureDatabases(databases) {
|
|
547
|
+
const stats = {}
|
|
548
|
+
for (const scope of scopesFor(databases)) {
|
|
549
|
+
let info
|
|
550
|
+
try {
|
|
551
|
+
info = fileStats(scope.db)
|
|
552
|
+
} catch (error) {
|
|
553
|
+
throw vectorError(VECTOR_ERROR_CODES.TORN_STATE, 'cannot read back shadow database ' + scope.rel + ' (' + (error?.code || error?.message || error) + ')', { path: scope.rel })
|
|
554
|
+
}
|
|
555
|
+
stats[scope.rel] = info
|
|
556
|
+
}
|
|
557
|
+
return stats
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function failPending(code, message, extra = {}, expected = null) {
|
|
561
|
+
let failedProgress = null
|
|
562
|
+
updatePendingJournal(current => {
|
|
563
|
+
if (expected && (current.token !== expected.token || current.generation !== expected.generation || current.fingerprint !== expected.fingerprint)) return null
|
|
564
|
+
if (current.status === 'committed' || current.status === 'aborted') return null
|
|
565
|
+
const prior = current.report?.progress || { generation: current.generation, fingerprint: current.fingerprint, total: 0, completed: 0, skipped: 0, committed: 0, tombstoned: 0, failed: 0, current: null }
|
|
566
|
+
failedProgress = { ...prior, stage: 'failed', failed: Math.max(1, prior.failed || 0), current: { stage: 'failed', code }, updatedAt: now() }
|
|
567
|
+
return { ...current, status: 'failed', error: { code, message, at: now(), ...extra }, report: { ...(current.report || {}), progress: failedProgress } }
|
|
568
|
+
})
|
|
569
|
+
if (failedProgress && onProgress) {
|
|
570
|
+
try { onProgress(structuredClone(failedProgress)) } catch (error) { log('progress callback failed: ' + (error?.message || error)) }
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* 最后短窗口:锁内只做同步 compare(token/代际/文件大小)与一次原子指针写。
|
|
576
|
+
* 这里没有 await,也不允许有(§14.8 第 5/7 条)。
|
|
577
|
+
*/
|
|
578
|
+
function commitActiveGeneration({ profile, journal, sync, stats }) {
|
|
579
|
+
const committedAt = now()
|
|
580
|
+
return updateVectorStateSync(current => {
|
|
581
|
+
const pending = current.pending
|
|
582
|
+
if (!pending) {
|
|
583
|
+
throw vectorError('VECTOR_PENDING_MISSING', 'the prepared rebuild disappeared before the switch', { generation: journal.generation })
|
|
584
|
+
}
|
|
585
|
+
if (pending.token !== journal.token || pending.generation !== journal.generation || pending.fingerprint !== profile.fingerprint) {
|
|
586
|
+
throw vectorError(VECTOR_ERROR_CODES.TORN_STATE, 'another rebuild replaced this prepared generation; refusing to switch', {
|
|
587
|
+
prepared: { generation: journal.generation, token: journal.token }, currentPending: { generation: pending.generation, token: pending.token },
|
|
588
|
+
})
|
|
589
|
+
}
|
|
590
|
+
if (pending.status !== 'prepared') throw vectorError('VECTOR_SYNC_INCOMPLETE', 'only a prepared shadow generation can be activated')
|
|
591
|
+
const preparedAgainst = pending.previousGeneration ?? null
|
|
592
|
+
const activeGeneration = current.active?.generation ?? null
|
|
593
|
+
if (preparedAgainst !== activeGeneration) {
|
|
594
|
+
throw vectorError(VECTOR_ERROR_CODES.TORN_STATE, 'the active generation changed while the rebuild was prepared; refusing to switch', { activeGeneration, preparedAgainst })
|
|
595
|
+
}
|
|
596
|
+
for (const scope of scopesFor(pending.databases)) {
|
|
597
|
+
let info
|
|
598
|
+
try {
|
|
599
|
+
info = fs.statSync(scope.db)
|
|
600
|
+
} catch {
|
|
601
|
+
throw vectorError(VECTOR_ERROR_CODES.TORN_STATE, 'a shadow database vanished before the switch: ' + scope.rel, { path: scope.rel })
|
|
602
|
+
}
|
|
603
|
+
const expected = stats[scope.rel]
|
|
604
|
+
if (!info.isFile() || !expected || info.size !== expected.bytes || fileStats(scope.db).sha256 !== expected.sha256) {
|
|
605
|
+
throw vectorError(VECTOR_ERROR_CODES.TORN_STATE, 'a shadow database changed after the final sync: ' + scope.rel, { path: scope.rel, expected: expected?.bytes ?? null, actual: info.size })
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
const databases = {
|
|
609
|
+
global: { path: pending.databases.global.path, bytes: stats[pending.databases.global.path].bytes, sha256: stats[pending.databases.global.path].sha256 },
|
|
610
|
+
libraries: Object.fromEntries(Object.entries(pending.databases.libraries || {}).map(([libraryId, entry]) => [
|
|
611
|
+
libraryId, { path: entry.path, bytes: stats[entry.path]?.bytes ?? null, sha256: stats[entry.path]?.sha256 ?? null },
|
|
612
|
+
])),
|
|
613
|
+
}
|
|
614
|
+
const previous = current.active ? {
|
|
615
|
+
generation: current.active.generation,
|
|
616
|
+
fingerprint: current.active.fingerprint,
|
|
617
|
+
catalogId: current.active.profile?.catalogId ?? null,
|
|
618
|
+
profile: current.active.profile,
|
|
619
|
+
at: current.active.updatedAt ?? null,
|
|
620
|
+
} : null
|
|
621
|
+
return {
|
|
622
|
+
...current,
|
|
623
|
+
generation: Math.max(current.generation || 0, pending.generation),
|
|
624
|
+
// 切换成功后 requested 必须清空:它表示"待切换的另一个模型"。留着它,界面会
|
|
625
|
+
// 同时显示"当前生效 + 待切换",让人以为还有一步没做完(2026-10-30 现场)。
|
|
626
|
+
requested: null,
|
|
627
|
+
active: {
|
|
628
|
+
status: 'active',
|
|
629
|
+
generation: pending.generation,
|
|
630
|
+
fingerprint: profile.fingerprint,
|
|
631
|
+
profile,
|
|
632
|
+
databases,
|
|
633
|
+
snapshot: sync.snapshot ?? null,
|
|
634
|
+
lastMigration: sync.progress ?? pending.report?.progress ?? null,
|
|
635
|
+
previous,
|
|
636
|
+
reason: pending.reason ?? null,
|
|
637
|
+
updatedAt: committedAt,
|
|
638
|
+
},
|
|
639
|
+
pending: { ...pending, status: 'committed', databases, committedAt },
|
|
640
|
+
}
|
|
641
|
+
})
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* 完整切换:prepare(全新代际/全新影子)→ 追平(快照必须稳定)→ smoke → 原子切指针。
|
|
646
|
+
* 任一步失败都不动 active;同一空间已生效时直接返回 already-active(不重建、不重嵌入)。
|
|
647
|
+
*/
|
|
648
|
+
async function switchProfile(stepOptions = {}) {
|
|
649
|
+
const profile = resolveProfile(stepOptions.profile ?? stepOptions.catalogId)
|
|
650
|
+
const state = vectorState()
|
|
651
|
+
const active = state?.active
|
|
652
|
+
if (stepOptions.force !== true && active && active.fingerprint === profile.fingerprint) {
|
|
653
|
+
const allowed = assertVectorWriteAllowed()
|
|
654
|
+
return {
|
|
655
|
+
ok: true, status: 'already-active', switched: false,
|
|
656
|
+
generation: allowed.generation, fingerprint: allowed.fingerprint, profile: allowed.profile,
|
|
657
|
+
databases: allowed.databases, env: profileEnv(allowed.profile), previous: active.previous ?? null, report: null,
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
const prepared = await prepareShadow({ profile, libraries: stepOptions.libraries, fresh: stepOptions.fresh === true, reason: stepOptions.reason ?? 'switch' })
|
|
661
|
+
let sync = await syncShadow({ generation: prepared.generation, fingerprint: prepared.fingerprint })
|
|
662
|
+
let attempt = 1
|
|
663
|
+
while (!sync.stable && attempt < maxCatchUpAttempts) {
|
|
664
|
+
attempt += 1
|
|
665
|
+
log('index moved during catch-up; re-syncing generation ' + prepared.generation + ' (attempt ' + attempt + ')')
|
|
666
|
+
sync = await syncShadow({ generation: prepared.generation, fingerprint: prepared.fingerprint })
|
|
667
|
+
}
|
|
668
|
+
if (!sync.stable) {
|
|
669
|
+
const message = 'the memory index changed while catching up; the active generation is untouched'
|
|
670
|
+
failPending('VECTOR_INDEX_MOVED', message, { before: sync.snapshot?.digest ?? null, after: sync.afterDigest ?? null }, prepared)
|
|
671
|
+
throw vectorError('VECTOR_INDEX_MOVED', message, { generation: prepared.generation, before: sync.snapshot?.digest ?? null, after: sync.afterDigest ?? null })
|
|
672
|
+
}
|
|
673
|
+
if (sync.failed.length) {
|
|
674
|
+
const message = 'the rebuild did not cover every admitted memory; the active generation is untouched'
|
|
675
|
+
failPending('VECTOR_SYNC_INCOMPLETE', message, { failed: sync.failed.length, failedItems: syncFailureDetail(sync) }, prepared)
|
|
676
|
+
throw vectorError('VECTOR_SYNC_INCOMPLETE', message, { generation: prepared.generation, failed: sync.failed })
|
|
677
|
+
}
|
|
678
|
+
if (impl.smoke) {
|
|
679
|
+
let outcome
|
|
680
|
+
try {
|
|
681
|
+
outcome = await impl.smoke({ profile, generation: prepared.generation, databases: prepared.databases, relative: prepared.relative, report: sync })
|
|
682
|
+
} catch (error) {
|
|
683
|
+
failPending('VECTOR_SMOKE_FAILED', String(error?.message || error), {}, prepared)
|
|
684
|
+
throw vectorError('VECTOR_SMOKE_FAILED', 'smoke failed for generation ' + prepared.generation + ': ' + (error?.message || error), { causeCode: error?.code ?? null })
|
|
685
|
+
}
|
|
686
|
+
if (!outcome || outcome.ok === false) {
|
|
687
|
+
failPending('VECTOR_SMOKE_FAILED', 'smoke rejected generation ' + prepared.generation, {}, prepared)
|
|
688
|
+
throw vectorError('VECTOR_SMOKE_FAILED', 'smoke rejected generation ' + prepared.generation, { smoke: outcome ?? null })
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
let next
|
|
692
|
+
try {
|
|
693
|
+
next = await withVectorWriteFreeze(async () => {
|
|
694
|
+
const journal = pendingJournal()
|
|
695
|
+
if (journal?.token !== prepared.token) throw vectorError(VECTOR_ERROR_CODES.TORN_STATE, 'the prepared shadow was replaced before final verification')
|
|
696
|
+
const actualIds = (await resolveLibraryIds()).sort()
|
|
697
|
+
const expectedIds = Object.keys(journal.databases.libraries).sort()
|
|
698
|
+
if (stableStringify(actualIds) !== stableStringify(expectedIds)) throw vectorError('VECTOR_INDEX_MOVED', 'the library set changed after rebuild; active is untouched')
|
|
699
|
+
const final = await captureSnapshot(scopesFor(journal.databases))
|
|
700
|
+
if (final.digest !== sync.snapshot.digest) {
|
|
701
|
+
failPending('VECTOR_INDEX_MOVED', 'the memory index changed after smoke; active is untouched', {}, prepared)
|
|
702
|
+
throw vectorError('VECTOR_INDEX_MOVED', 'the memory index changed after smoke; active is untouched')
|
|
703
|
+
}
|
|
704
|
+
assertProfileVerified(profile)
|
|
705
|
+
const stats = measureDatabases(journal.databases)
|
|
706
|
+
// No await between final verified snapshot and atomic pointer swap.
|
|
707
|
+
return commitActiveGeneration({ profile, journal: prepared, sync, stats })
|
|
708
|
+
})
|
|
709
|
+
} catch (error) {
|
|
710
|
+
failPending(error?.code || 'VECTOR_SWITCH_FAILED', String(error?.message || error), {}, prepared)
|
|
711
|
+
throw error
|
|
712
|
+
}
|
|
713
|
+
// 第二次极短原子写:清掉 committed journal。若在此崩溃,recoverProfile 仍能从 committed 记录认出新代。
|
|
714
|
+
clearPendingJournal()
|
|
715
|
+
log('activated vector generation ' + next.active.generation + ' (' + profile.catalogId + ')')
|
|
716
|
+
return {
|
|
717
|
+
ok: true,
|
|
718
|
+
status: stepOptions.reason === 'rollback' ? 'rolled-back' : 'switched',
|
|
719
|
+
switched: true,
|
|
720
|
+
generation: next.active.generation,
|
|
721
|
+
fingerprint: next.active.fingerprint,
|
|
722
|
+
profile: next.active.profile,
|
|
723
|
+
env: profileEnv(next.active.profile),
|
|
724
|
+
databases: activeDatabases(),
|
|
725
|
+
previous: next.active.previous ?? null,
|
|
726
|
+
requested: next.requested ?? null,
|
|
727
|
+
report: {
|
|
728
|
+
committed: sync.committed,
|
|
729
|
+
skipped: sync.skipped,
|
|
730
|
+
tombstoned: sync.tombstoned,
|
|
731
|
+
libraries: sync.libraries,
|
|
732
|
+
snapshot: sync.snapshot,
|
|
733
|
+
progress: sync.progress ?? null,
|
|
734
|
+
},
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// -------------------------------------------------------------- 回滚
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* 回滚不是"恢复旧指针":切换后的新写入也要回到旧代,所以走同一套重建流程,
|
|
742
|
+
* 把**最新 index** 重放进旧 profile 的一个全新代际里,再原子切回去(§14.8 第 9 条)。
|
|
743
|
+
*/
|
|
744
|
+
async function rollbackProfile(stepOptions = {}) {
|
|
745
|
+
const state = vectorState()
|
|
746
|
+
const active = state?.active
|
|
747
|
+
const target = stepOptions.profile ?? stepOptions.catalogId ?? active?.previous?.profile
|
|
748
|
+
if (!target) throw vectorError('VECTOR_NO_ROLLBACK_TARGET', 'there is no previous generation to roll back to')
|
|
749
|
+
const profile = resolveProfile(target)
|
|
750
|
+
if (active && active.fingerprint === profile.fingerprint) {
|
|
751
|
+
throw vectorError('VECTOR_NO_ROLLBACK_TARGET', 'the rollback target is already the active profile', { catalogId: profile.catalogId })
|
|
752
|
+
}
|
|
753
|
+
const result = await switchProfile({
|
|
754
|
+
profile,
|
|
755
|
+
libraries: stepOptions.libraries,
|
|
756
|
+
fresh: stepOptions.fresh !== false,
|
|
757
|
+
force: true,
|
|
758
|
+
reason: 'rollback',
|
|
759
|
+
})
|
|
760
|
+
return { ...result, status: 'rolled-back', replayed: true, rolledBackFrom: active ? { generation: active.generation, catalogId: active.profile?.catalogId ?? null } : null }
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// -------------------------------------------------------------- 启动恢复
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* 读 journal 恢复:prepared 未切 → 继续旧代(不切);committed → 认新代并清 journal;
|
|
767
|
+
* 任何说不清的状态(journal 与指针矛盾、库文件缺失/变小、profile 不再有效)→ 抛 typed error,
|
|
768
|
+
* 调用方必须退词法,不能猜最近文件,也不能回落 legacy 远端。
|
|
769
|
+
*/
|
|
770
|
+
async function recoverProfile() {
|
|
771
|
+
const state = readVectorState({ tolerateMissing: true }).state
|
|
772
|
+
if (!state) return { status: 'absent', generation: null, fingerprint: null, profile: null, pending: null }
|
|
773
|
+
const active = state.active
|
|
774
|
+
const pending = state.pending
|
|
775
|
+
if (pending && pending.status === 'committed') {
|
|
776
|
+
if (!active || active.generation !== pending.generation) {
|
|
777
|
+
throw vectorError(VECTOR_ERROR_CODES.TORN_STATE, 'the commit journal and the active pointer disagree; refusing vectors', {
|
|
778
|
+
pendingGeneration: pending.generation, activeGeneration: active?.generation ?? null,
|
|
779
|
+
})
|
|
780
|
+
}
|
|
781
|
+
clearPendingJournal()
|
|
782
|
+
const allowed = assertVectorWriteAllowed({ deep: true })
|
|
783
|
+
return { status: 'committed', recovered: true, generation: allowed.generation, fingerprint: allowed.fingerprint, profile: allowed.profile, databases: allowed.databases, pending: null }
|
|
784
|
+
}
|
|
785
|
+
if (pending && (pending.status === 'preparing' || pending.status === 'prepared')) {
|
|
786
|
+
const preparedAgainst = pending.previousGeneration ?? null
|
|
787
|
+
const activeGeneration = active?.generation ?? null
|
|
788
|
+
if (preparedAgainst !== activeGeneration) {
|
|
789
|
+
throw vectorError(VECTOR_ERROR_CODES.TORN_STATE, 'a rebuild was prepared against a generation that is no longer active; refusing vectors', {
|
|
790
|
+
preparedAgainst, activeGeneration, pendingGeneration: pending.generation,
|
|
791
|
+
})
|
|
792
|
+
}
|
|
793
|
+
if (!active) return { status: 'interrupted', generation: null, fingerprint: null, profile: null, pending: { generation: pending.generation, resume: true } }
|
|
794
|
+
const allowed = assertVectorWriteAllowed({ deep: true })
|
|
795
|
+
return {
|
|
796
|
+
status: pending.status === 'prepared' ? 'prepared' : 'interrupted',
|
|
797
|
+
recovered: false,
|
|
798
|
+
generation: allowed.generation,
|
|
799
|
+
fingerprint: allowed.fingerprint,
|
|
800
|
+
profile: allowed.profile,
|
|
801
|
+
databases: allowed.databases,
|
|
802
|
+
pending: { generation: pending.generation, fingerprint: pending.fingerprint, token: pending.token, resume: pending.status === 'prepared', restart: pending.status === 'preparing' },
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
if (pending && (pending.status === 'aborted' || pending.status === 'failed')) {
|
|
806
|
+
clearPendingJournal()
|
|
807
|
+
}
|
|
808
|
+
if (!active) return { status: 'absent', generation: null, fingerprint: null, profile: null, pending: null }
|
|
809
|
+
const allowed = assertVectorWriteAllowed({ deep: true })
|
|
810
|
+
return {
|
|
811
|
+
status: 'active',
|
|
812
|
+
recovered: false,
|
|
813
|
+
generation: allowed.generation,
|
|
814
|
+
fingerprint: allowed.fingerprint,
|
|
815
|
+
profile: allowed.profile,
|
|
816
|
+
databases: allowed.databases,
|
|
817
|
+
snapshot: allowed.snapshot,
|
|
818
|
+
pending: null,
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/** 放弃一次未切换的重建;active 指针与旧库完全不动。 */
|
|
823
|
+
function abortPending(reason = 'aborted by user') {
|
|
824
|
+
const journal = pendingJournal()
|
|
825
|
+
if (!journal) return { ok: true, aborted: false }
|
|
826
|
+
if (journal.status === 'aborted' || journal.status === 'committed') return { ok: true, aborted: false }
|
|
827
|
+
updatePendingJournal(current => ({ ...current, status: 'aborted', error: { code: 'VECTOR_BUILD_ABORTED', message: String(reason), at: now() } }))
|
|
828
|
+
return { ok: true, aborted: true, generation: journal.generation, fingerprint: journal.fingerprint }
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function readLedger(stepOptions = {}) {
|
|
832
|
+
const journal = pendingJournal()
|
|
833
|
+
const state = vectorState()
|
|
834
|
+
const fingerprint = stepOptions.fingerprint ?? journal?.fingerprint ?? state?.active?.fingerprint
|
|
835
|
+
const generation = Number.isSafeInteger(stepOptions.generation) ? stepOptions.generation : (journal?.generation ?? state?.active?.generation)
|
|
836
|
+
if (!fingerprint || !Number.isSafeInteger(generation)) return null
|
|
837
|
+
return loadLedger(fingerprint, generation).ledger
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
return {
|
|
841
|
+
prepareShadow,
|
|
842
|
+
syncShadow,
|
|
843
|
+
switchProfile,
|
|
844
|
+
rollbackProfile,
|
|
845
|
+
recoverProfile,
|
|
846
|
+
abortPending,
|
|
847
|
+
status: () => vectorStatus(),
|
|
848
|
+
pending: () => pendingJournal(),
|
|
849
|
+
ledger: readLedger,
|
|
850
|
+
/**
|
|
851
|
+
* 当前生效代际里"哪些 id 已经写进向量库"(按 scope key:libraryId 或 'global')。
|
|
852
|
+
* 迁移写库时刻意不碰索引,所以索引里的老条目没有 entry.vector 戳;界面要回答
|
|
853
|
+
* "这条在不在向量库"只能查账本。
|
|
854
|
+
*/
|
|
855
|
+
membership: () => {
|
|
856
|
+
const state = vectorState()
|
|
857
|
+
const active = state?.active
|
|
858
|
+
if (!active) return null
|
|
859
|
+
const ledger = readLedger({ fingerprint: active.fingerprint, generation: active.generation })
|
|
860
|
+
if (!ledger) return null
|
|
861
|
+
const out = {}
|
|
862
|
+
for (const scope of scopesFor(active.databases)) {
|
|
863
|
+
const bucket = ledger.libraries?.[scope.key]
|
|
864
|
+
out[scope.key] = bucket?.items && typeof bucket.items === 'object' ? Object.keys(bucket.items) : []
|
|
865
|
+
}
|
|
866
|
+
return out
|
|
867
|
+
},
|
|
868
|
+
ledgerSummary: stepOptions => {
|
|
869
|
+
const ledger = readLedger(stepOptions)
|
|
870
|
+
return ledger ? ledgerSummary(ledger) : null
|
|
871
|
+
},
|
|
872
|
+
captureGeneration,
|
|
873
|
+
assertGeneration,
|
|
874
|
+
assertVectorWriteAllowed,
|
|
875
|
+
admittedItems,
|
|
876
|
+
contentHash,
|
|
877
|
+
}
|
|
878
|
+
}
|