@a9i5k4/dsh-auto-memory 3.1.4 → 3.1.6
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/docs/CONTINUITY-FLOW.md +6 -6
- package/docs/CONTRIBUTORS.html +966 -471
- package/docs/HANDBOOK.md +54 -53
- package/docs/M-CM-PLAN.md +1 -1
- package/docs/M-CM-STATE.md +1 -1
- package/docs/M-CM7-HANDOFF-LAYERED-RETRIEVAL.md +1 -1
- package/docs/M3B-CONTRACT.md +7 -7
- package/docs/M6-CONTRACT.md +1 -1
- package/docs/M7-AUTONOMOUS-STATE.md +1 -1
- package/docs/M7-TASKSET-DISPATCH.md +1 -1
- package/docs/PROMPT-PACK-LAYERED-RECALL.md +3 -3
- package/docs/PROMPT-SET-STRICT.md +3 -3
- package/docs/RELEASE-GO-NOGO.md +1 -1
- package/docs/STATUS-BOARD.md +1 -1
- package/docs/USER-GUIDE.en.md +21 -21
- package/docs/USER-GUIDE.zh-CN.md +21 -21
- package/docs/WHITEPAPER.md +1 -1
- package/docs/landing/index.html +1178 -1624
- package/docs/prompts/FIX-AGENT-P12-FULL-REGRESSION.md +1 -1
- package/docs/prompts/LIVE-VERIFY-ZCODE.md +2 -2
- package/docs/prompts/P2-semantic-recall.md +2 -2
- package/docs/prompts/P7-write-fix.md +1 -1
- package/docs/prompts/ZCODE-DROPIN.md +1 -1
- package/lib/acceptance.js +6 -6
- package/lib/activation-host.js +15 -14
- package/lib/activation-inbox-state.js +4 -4
- package/lib/activation-inbox.js +77 -47
- package/lib/board-mode.js +3 -3
- package/lib/client.js +206 -34
- package/lib/config-io.js +12 -12
- package/lib/context-bridge.js +22 -22
- package/lib/context-host.js +24 -23
- package/lib/datadir.js +95 -0
- package/lib/degrade.js +21 -21
- package/lib/dsh-home.js +8 -8
- package/lib/engine-identity.js +12 -12
- package/lib/engine-switch.js +3 -3
- package/lib/episodic-store.js +13 -13
- package/lib/evidence-agg.js +4 -4
- package/lib/evidence-store.js +12 -12
- package/lib/fact-store.js +47 -47
- package/lib/handoff-anchor.js +4 -4
- package/lib/hub-io.js +356 -3
- package/lib/index-sync.js +5 -5
- package/lib/index.js +381 -236
- package/lib/intent-clean-safe.js +3 -3
- package/lib/intent-clean.js +1 -1
- package/lib/l0-extract.js +15 -15
- package/lib/l0-index-sync.js +8 -8
- package/lib/l0-index.js +11 -11
- package/lib/ledger-criteria.js +8 -8
- package/lib/m4-corpus.js +1 -1
- package/lib/m7-index-sync-host.js +2 -2
- package/lib/m7-wire.js +24 -24
- package/lib/memory-anchor.js +1 -1
- package/lib/memory-envelope.js +15 -15
- package/lib/memory-hub.js +95 -50
- package/lib/memory-importance.js +7 -7
- package/lib/memory-mutation.js +5 -5
- package/lib/note-status-apply.js +2 -2
- package/lib/note-status.js +7 -7
- package/lib/policies/activation_policy_v2.json +2 -2
- package/lib/policies/recall_intent_lr_v1.json +1 -1
- package/lib/procedure-observation.js +2 -2
- package/lib/procedure-store.js +117 -28
- package/lib/python-setup.js +6 -5
- package/lib/python-sidecar-client.js +26 -26
- package/lib/recall-fusion.js +12 -12
- package/lib/recall-stats.js +36 -1
- package/lib/rerank-host.js +11 -11
- package/lib/rules-edit.js +9 -9
- package/lib/rules-layer.js +26 -26
- package/lib/semantic-decide.js +7 -7
- package/lib/semantic-js.js +13 -13
- package/lib/shadow-host.js +7 -6
- package/lib/shadow-retrieval.js +40 -40
- package/lib/skill-export-host.js +7 -7
- package/lib/skill-export.js +6 -6
- package/lib/state-commit.js +10 -10
- package/lib/storage-manage.js +6 -6
- package/lib/tier-layer-inject.js +28 -28
- package/lib/tier0-catalog.js +4 -4
- package/lib/wb-contract.js +45 -45
- package/lib/wb-sidecar.js +11 -11
- package/package.json +3 -4
- package/python/m7_activation_features_v2.py +7 -7
- package/python/m7_embedding_v1.py +12 -12
- package/python/policies/activation_policy_v2.json +2 -2
- package/python/policies/decision-record-activation-v2-delta-exp-override-20260824.json +1 -1
- package/python/policies/decision-record-reasoning-kind-admission-20260826.json +2 -2
- package/python/policies/decision-record-stale-gate-per-candidate-20260825.json +2 -2
- package/python/policies/recall_intent_lr_v1.json +1 -1
- package/python/verify_policy_artifact.py +3 -3
- package/python/worker_semantic_v1.py +18 -18
- package/python/worker_v1.py +18 -18
package/lib/hub-io.js
CHANGED
|
@@ -23,11 +23,11 @@
|
|
|
23
23
|
* · 不做 unlink/copy 回退:调用方各自保留自己的原子性策略(同 `fs-retry.js` 的边界声明)。
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
-
import { mkdirSync, writeFileSync, renameSync, readFileSync, rmSync } from 'node:fs'
|
|
26
|
+
import { mkdirSync, writeFileSync, renameSync, readFileSync, rmSync, existsSync } from 'node:fs'
|
|
27
27
|
import path from 'node:path'
|
|
28
28
|
|
|
29
29
|
/** errno → 人话。口径:暴露给前端/工具的原因必须人能看懂,不能只甩机器码。 */
|
|
30
|
-
export const
|
|
30
|
+
export const HUB_IO_ERRNO_MESSAGES_PRE_V1 = Object.freeze({
|
|
31
31
|
EACCES: '权限被拒绝(目标目录不可写)',
|
|
32
32
|
EPERM: '操作被系统拒绝(权限或安全策略拦截,Windows 下也常见于文件被占用)',
|
|
33
33
|
ENOSPC: '磁盘空间不足',
|
|
@@ -45,7 +45,7 @@ export const HUB_IO_ERRNO_MESSAGES_V1 = Object.freeze({
|
|
|
45
45
|
/** 把任意异常翻成一句人话(带 errno 便于排障;无 code 时回落 message)。 */
|
|
46
46
|
export function explainHubIoErrorPre(e) {
|
|
47
47
|
const code = e && e.code ? String(e.code) : ''
|
|
48
|
-
const mapped = code ?
|
|
48
|
+
const mapped = code ? HUB_IO_ERRNO_MESSAGES_PRE_V1[code] : ''
|
|
49
49
|
if (mapped) return mapped + '(' + code + ')'
|
|
50
50
|
if (code) return '文件系统错误 ' + code
|
|
51
51
|
return String((e && e.message) || e || '未知错误')
|
|
@@ -215,3 +215,356 @@ export function hubIoHealthSnapshotPre(health) {
|
|
|
215
215
|
return empty
|
|
216
216
|
}
|
|
217
217
|
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* ★M8-B(2026-09-23 用户拍板,方案甲):**procedure 双库 IO —— 合并读 + 按 scope 分派写**。
|
|
221
|
+
*
|
|
222
|
+
* ── 为什么单独做一层 ──
|
|
223
|
+
* `createHubIoPre` 是**三店共用**的通用适配器(episodes / facts / procedures)。给它加作用域语义
|
|
224
|
+
* 会顺带改变另两店行为。故作用域只在本层实现,**只给 procedures 用**;另两店仍走原路径,
|
|
225
|
+
* 行为逐字节不变。
|
|
226
|
+
*
|
|
227
|
+
* ── 落点(与 migrate-pack 既有分层对齐)──
|
|
228
|
+
* · 通用库(跨工作区) = `<globalDir>/<name>`(既有 55 条所在,原地不动)
|
|
229
|
+
* · 工作区库(本工作区) = `<resolveWorkspace().dir>/<name>`(新增)
|
|
230
|
+
*
|
|
231
|
+
* ── 三条硬约束 ──
|
|
232
|
+
* ① **工作区目录必须惰性解析**:hub 在**引擎构造期**就建好(index.js 的 hubDir 同为构造期常量),
|
|
233
|
+
* 而工作区 state.ws 要等 _doRefresh 才赋值 ⇒ 构造期缓存会锁死空工作区,
|
|
234
|
+
* 随后 save 就可能把**空集**写进工作区库(灾难性覆盖)。故每次调用都现算。
|
|
235
|
+
* ② **路径未知一律不写工作区库**:宁可这一轮不落盘(内存态仍完整、下轮再写),
|
|
236
|
+
* 也绝不用空集/错集覆盖用户的工作区技能库。所有跳过都**可见**(返回值 + health 记账)。
|
|
237
|
+
* ③ **读坏过的文件不覆盖**:load 时「文件存在却解析失败」记入 corrupt 集,save 对这类文件**拒写**。
|
|
238
|
+
* 既有实现在「快照损坏 → load 返回 null → 内存空 → 下次 save 整份覆盖」下会**静默清空全部条目**;
|
|
239
|
+
* 双库把这一暴露面翻倍,故在此收紧为「宁可不写」。
|
|
240
|
+
*
|
|
241
|
+
* ── 明确声明的取舍 ──
|
|
242
|
+
* procedures 的 save **不参与** createHubIoPre 的批内合并(批深度由 hub 侧的通用工厂持有,
|
|
243
|
+
* 本层看不到)⇒ 批内每次 persist 都是一次整份原子写。这是**刻意选择**:本轮刚因「机器定时回写」
|
|
244
|
+
* 付出过代价,宁可多写几次也不引入新的延迟写盘通道;数据量有界(当前 55 条 / 约 100KB)。
|
|
245
|
+
* 若日后实测写放大成问题,再单独评估合并策略 —— **不要顺手加定时器**。
|
|
246
|
+
*
|
|
247
|
+
* @param {object} opts
|
|
248
|
+
* @param {string} opts.globalDir 通用库目录(必填)
|
|
249
|
+
* @param {string} [opts.name='procedures.json'] 文件名
|
|
250
|
+
* @param {function} opts.resolveWorkspace () => {dir,key};未知时给空串(必填)
|
|
251
|
+
* @param {function} [opts.isWorkspaceScoped] (entry) => boolean;缺省 scope==='workspace'
|
|
252
|
+
* @param {object} [opts.health] 复用 createHubIoHealthPre() 台账
|
|
253
|
+
* @param {function} [opts.onError] (key,msg) 失败回调
|
|
254
|
+
* @param {object} [opts.fsApi] 测试注入(透传给 createHubIoPre)
|
|
255
|
+
* @returns {{save:Function, load:Function, clear:Function, lastWrite:Function, isCorrupt:Function}}
|
|
256
|
+
*/
|
|
257
|
+
export function createScopedHubIoPre(opts = {}) {
|
|
258
|
+
const globalDir = String(opts.globalDir || '')
|
|
259
|
+
const fileName = String(opts.name || 'procedures.json')
|
|
260
|
+
const health = opts.health || createHubIoHealthPre()
|
|
261
|
+
const onError = opts.onError
|
|
262
|
+
const resolveWorkspace = typeof opts.resolveWorkspace === 'function'
|
|
263
|
+
? opts.resolveWorkspace
|
|
264
|
+
: () => ({ dir: '', key: '' })
|
|
265
|
+
const isWs = typeof opts.isWorkspaceScoped === 'function'
|
|
266
|
+
? opts.isWorkspaceScoped
|
|
267
|
+
: (e) => !!(e && e.scope === 'workspace')
|
|
268
|
+
|
|
269
|
+
// 每目录一个既有工厂(复用其原子写与 health 记账;不新建第二套 IO 实现)
|
|
270
|
+
const factories = new Map()
|
|
271
|
+
function ioFor(dir) {
|
|
272
|
+
let f = factories.get(dir)
|
|
273
|
+
if (!f) { f = createHubIoPre({ dir, health, onError, fsApi: opts.fsApi }); factories.set(dir, f) }
|
|
274
|
+
return f
|
|
275
|
+
}
|
|
276
|
+
const corrupt = new Set()
|
|
277
|
+
let last = {
|
|
278
|
+
at: 0, wroteGlobal: false, wroteWorkspace: false,
|
|
279
|
+
skippedUnknownWs: 0, skippedForeign: 0, refusedCorrupt: 0, foreignIds: [],
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** 取当前工作区 {dir,key};任何异常都退化为「未知」⇒ 走约束②不写。 */
|
|
283
|
+
function wsNow() {
|
|
284
|
+
try {
|
|
285
|
+
const r = resolveWorkspace() || {}
|
|
286
|
+
return { dir: String(r.dir || ''), key: String(r.key || '') }
|
|
287
|
+
} catch (e) {
|
|
288
|
+
notePre(health, fileName, 'resolve-ws', e, onError)
|
|
289
|
+
return { dir: '', key: '' }
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** 读一份;「文件存在但解析失败 ⇒ null」记入 corrupt(供 save 拒写)。 */
|
|
294
|
+
function readOne(dir) {
|
|
295
|
+
const file = path.join(dir, fileName)
|
|
296
|
+
const data = ioFor(dir)(fileName).load()
|
|
297
|
+
let existed
|
|
298
|
+
try { existed = existsSync(file) } catch (_) { existed = false }
|
|
299
|
+
if (data === null && existed) corrupt.add(dir)
|
|
300
|
+
else corrupt.delete(dir)
|
|
301
|
+
return data
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function load() {
|
|
305
|
+
const g = readOne(globalDir)
|
|
306
|
+
const w = wsNow()
|
|
307
|
+
const wsData = w.dir ? readOne(w.dir) : null
|
|
308
|
+
const gp = (g && Array.isArray(g.procedures)) ? g.procedures : []
|
|
309
|
+
const wp = (wsData && Array.isArray(wsData.procedures)) ? wsData.procedures : []
|
|
310
|
+
const any = g || wsData
|
|
311
|
+
if (!any) return null // 两库皆无 ⇒ 保持既有「空启动」语义
|
|
312
|
+
if (!gp.length && !wp.length) return Object.assign({}, any, { procedures: [] })
|
|
313
|
+
// 同 id 去重:**工作区优先**(更具体的一侧胜出)
|
|
314
|
+
const byId = new Map()
|
|
315
|
+
for (const p of gp) byId.set(String(p && p.procedureId), p)
|
|
316
|
+
for (const p of wp) byId.set(String(p && p.procedureId), p)
|
|
317
|
+
return {
|
|
318
|
+
schemaVersion: (g && g.schemaVersion) || (wsData && wsData.schemaVersion) || 1,
|
|
319
|
+
namespace: (g && g.namespace) || (wsData && wsData.namespace) || undefined,
|
|
320
|
+
policyVersion: (g && g.policyVersion) || (wsData && wsData.policyVersion) || undefined,
|
|
321
|
+
savedAt: Date.now(),
|
|
322
|
+
procedures: [...byId.values()],
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function save(snapshot) {
|
|
327
|
+
const all = (snapshot && Array.isArray(snapshot.procedures)) ? snapshot.procedures : []
|
|
328
|
+
const w = wsNow()
|
|
329
|
+
const globals = []
|
|
330
|
+
const mine = []
|
|
331
|
+
const foreign = []
|
|
332
|
+
for (const p of all) {
|
|
333
|
+
if (!isWs(p)) { globals.push(p); continue }
|
|
334
|
+
const ref = String((p && p.workspaceRef) || '')
|
|
335
|
+
// 归属本工作区;ref 缺失时按「当前库」保守处理(不丢用户刚写的条目)
|
|
336
|
+
if (!ref || !w.key || ref === w.key) mine.push(p)
|
|
337
|
+
else foreign.push(p)
|
|
338
|
+
}
|
|
339
|
+
// 约束③:读坏过的文件拒写(宁可不动,也不静默清空)
|
|
340
|
+
let refused = 0
|
|
341
|
+
let wroteGlobal = false
|
|
342
|
+
if (corrupt.has(globalDir)) {
|
|
343
|
+
refused += globals.length
|
|
344
|
+
} else {
|
|
345
|
+
try {
|
|
346
|
+
ioFor(globalDir)(fileName).save(Object.assign({}, snapshot, { procedures: globals }))
|
|
347
|
+
wroteGlobal = true
|
|
348
|
+
} catch (_) { wroteGlobal = false }
|
|
349
|
+
}
|
|
350
|
+
let wroteWorkspace = false
|
|
351
|
+
if (!w.dir) {
|
|
352
|
+
// 约束②:路径未知 ⇒ 不写工作区库(不抛错,但必须可见)
|
|
353
|
+
if (mine.length) {
|
|
354
|
+
notePre(health, fileName, 'ws-unknown', new Error('工作区目录未解析:工作区库未写入,' + mine.length + ' 条留在内存,下轮再写'), onError)
|
|
355
|
+
}
|
|
356
|
+
} else if (corrupt.has(w.dir)) {
|
|
357
|
+
refused += mine.length
|
|
358
|
+
} else {
|
|
359
|
+
try {
|
|
360
|
+
ioFor(w.dir)(fileName).save(Object.assign({}, snapshot, { procedures: mine }))
|
|
361
|
+
wroteWorkspace = true
|
|
362
|
+
} catch (_) { wroteWorkspace = false }
|
|
363
|
+
}
|
|
364
|
+
last = {
|
|
365
|
+
at: Date.now(), wroteGlobal, wroteWorkspace,
|
|
366
|
+
skippedUnknownWs: w.dir ? 0 : mine.length,
|
|
367
|
+
skippedForeign: foreign.length,
|
|
368
|
+
refusedCorrupt: refused,
|
|
369
|
+
foreignIds: foreign.map((p) => String(p && p.procedureId)).slice(0, 5),
|
|
370
|
+
}
|
|
371
|
+
return Object.assign({ ok: wroteGlobal || wroteWorkspace }, last)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function clear() {
|
|
375
|
+
let ok = true
|
|
376
|
+
try { ioFor(globalDir)(fileName).clear(); corrupt.delete(globalDir) } catch (e) {
|
|
377
|
+
ok = false; notePre(health, fileName, 'clear-global', e, onError)
|
|
378
|
+
}
|
|
379
|
+
const w = wsNow()
|
|
380
|
+
if (w.dir) {
|
|
381
|
+
try { ioFor(w.dir)(fileName).clear(); corrupt.delete(w.dir) } catch (e) {
|
|
382
|
+
ok = false; notePre(health, fileName, 'clear-ws', e, onError)
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return { ok }
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* ★M8-B 第4步:**两阶段迁移**(写接收库 → 读回校验 → 写捐赠库 → 读回校验;任一失败整体回滚)。
|
|
391
|
+
*
|
|
392
|
+
* 为什么必须两阶段:跨文件移动**不是原子的**。写目标成功、删源失败 ⇒ 条目复制成两份;
|
|
393
|
+
* 反序则更糟——删源成功、写目标失败 ⇒ **条目直接消失**。故这里定死两条:
|
|
394
|
+
* ① **先写接收库、后写捐赠库**——失败时最坏是「多留一份」而非「丢一份」,
|
|
395
|
+
* 即 **fail toward duplication, never toward loss**(可见、可重试、可人工清理)。
|
|
396
|
+
* ② 每写一步就**读回校验**,不是写完就当成功(写盘函数的返回不等于内容真落对)。
|
|
397
|
+
* 任一步校验不过:把两库恢复到**迁移前快照**,并如实返回 rolledBack。
|
|
398
|
+
*
|
|
399
|
+
* @param {Array<{procedureId:string, scope:string, workspaceRef?:string}>} entries 目标归属
|
|
400
|
+
* @param {function} [onProgress] (done,total,item) —— 进度是两阶段提交的**可见化**
|
|
401
|
+
* @returns {{ok:boolean,total:number,moved:number,failed:number,rolledBack:boolean,results:Array}}
|
|
402
|
+
*/
|
|
403
|
+
function migrate(entries, onProgress) {
|
|
404
|
+
const list = (Array.isArray(entries) ? entries : [entries]).filter(Boolean)
|
|
405
|
+
const total = list.length
|
|
406
|
+
const results = []
|
|
407
|
+
let moved = 0
|
|
408
|
+
let failed = 0
|
|
409
|
+
let rolledBack = false
|
|
410
|
+
const w = wsNow()
|
|
411
|
+
|
|
412
|
+
// 回滚凭据:两库**迁移前**的解析快照(null = 该库无文件/为空;损坏库已被 readOne 记入 corrupt)
|
|
413
|
+
const globalBefore = readOne(globalDir)
|
|
414
|
+
const wsBefore = w.dir ? readOne(w.dir) : null
|
|
415
|
+
// 深拷一份作为回滚源,避免后续 map 改动污染凭据
|
|
416
|
+
const snapOf = (x) => (x && Array.isArray(x.procedures))
|
|
417
|
+
? Object.assign({}, x, { procedures: x.procedures.map((p) => Object.assign({}, p)) })
|
|
418
|
+
: null
|
|
419
|
+
|
|
420
|
+
function rollbackBoth() {
|
|
421
|
+
let ok = true
|
|
422
|
+
try { ioFor(globalDir)(fileName).save(snapOf(globalBefore) || { schemaVersion: 1, procedures: [] }) }
|
|
423
|
+
catch (e) { ok = false; notePre(health, fileName, 'migrate-rollback-global', e, onError) }
|
|
424
|
+
if (w.dir) {
|
|
425
|
+
try { ioFor(w.dir)(fileName).save(snapOf(wsBefore) || { schemaVersion: 1, procedures: [] }) }
|
|
426
|
+
catch (e) { ok = false; notePre(health, fileName, 'migrate-rollback-ws', e, onError) }
|
|
427
|
+
}
|
|
428
|
+
return ok
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// 以「迁移前快照」为工作副本,逐条改归属;两条目的条目集合由 scope 决定
|
|
432
|
+
let gList = (globalBefore && Array.isArray(globalBefore.procedures)) ? globalBefore.procedures.map((p) => Object.assign({}, p)) : []
|
|
433
|
+
let wList = (wsBefore && Array.isArray(wsBefore.procedures)) ? wsBefore.procedures.map((p) => Object.assign({}, p)) : []
|
|
434
|
+
const findIn = (arr, id) => arr.findIndex((p) => String(p && p.procedureId) === String(id))
|
|
435
|
+
|
|
436
|
+
for (let i = 0; i < list.length; i++) {
|
|
437
|
+
const it = list[i] || {}
|
|
438
|
+
const id = String(it.procedureId || '')
|
|
439
|
+
const targetScope = it.scope === 'workspace' ? 'workspace' : 'global'
|
|
440
|
+
const targetRef = String(it.workspaceRef || '')
|
|
441
|
+
const tag = { procedureId: id, targetScope, targetRef }
|
|
442
|
+
// 目标库落地检查:要进工作区库但工作区未知 ⇒ 明确失败(绝不猜一个目录写下去)
|
|
443
|
+
if (targetScope === 'workspace' && (!w.dir || !w.key)) {
|
|
444
|
+
failed++
|
|
445
|
+
results.push(Object.assign({ ok: false, reason: 'ws-unknown' }, tag))
|
|
446
|
+
if (onProgress) onProgress(i + 1, total, results[results.length - 1])
|
|
447
|
+
continue
|
|
448
|
+
}
|
|
449
|
+
if (targetScope === 'workspace' && targetRef && targetRef !== w.key) {
|
|
450
|
+
failed++
|
|
451
|
+
results.push(Object.assign({ ok: false, reason: 'foreign-workspace' }, tag))
|
|
452
|
+
if (onProgress) onProgress(i + 1, total, results[results.length - 1])
|
|
453
|
+
continue
|
|
454
|
+
}
|
|
455
|
+
// 定位源(两库都找)
|
|
456
|
+
let gi = findIn(gList, id)
|
|
457
|
+
let wi = findIn(wList, id)
|
|
458
|
+
// ★双库同 id:先按 `load()` 的**既有读优先级(工作区优先)**去重,维持「一个 id 只住在一个库」。
|
|
459
|
+
// 不做这步的实测后果:接收库那条留着、又从捐赠库推一条进来 ⇒ 同一 id 两份
|
|
460
|
+
// —— 正是两阶段迁移专门要防的「复制成两份」,静态检查与语法检查都发现不了。
|
|
461
|
+
if (gi >= 0 && wi >= 0) { gList.splice(gi, 1); gi = findIn(gList, id) }
|
|
462
|
+
const src = gi >= 0 ? 'global' : (wi >= 0 ? 'workspace' : null)
|
|
463
|
+
if (!src) {
|
|
464
|
+
failed++
|
|
465
|
+
results.push(Object.assign({ ok: false, reason: 'not-found' }, tag))
|
|
466
|
+
if (onProgress) onProgress(i + 1, total, results[results.length - 1])
|
|
467
|
+
continue
|
|
468
|
+
}
|
|
469
|
+
if (src === targetScope) {
|
|
470
|
+
// 已在目标库:只把 workspaceRef 校正到位(幂等,不动位置)
|
|
471
|
+
const arr = src === 'global' ? gList : wList
|
|
472
|
+
const idx = src === 'global' ? gi : wi
|
|
473
|
+
arr[idx].scope = targetScope
|
|
474
|
+
if (targetScope === 'workspace') arr[idx].workspaceRef = w.key
|
|
475
|
+
moved++
|
|
476
|
+
results.push(Object.assign({ ok: true, noop: true, from: src, to: targetScope }, tag))
|
|
477
|
+
if (onProgress) onProgress(i + 1, total, results[results.length - 1])
|
|
478
|
+
continue
|
|
479
|
+
}
|
|
480
|
+
// 迁移:先改内存副本
|
|
481
|
+
const entry = src === 'global' ? gList.splice(gi, 1)[0] : wList.splice(wi, 1)[0]
|
|
482
|
+
entry.scope = targetScope
|
|
483
|
+
entry.workspaceRef = targetScope === 'workspace' ? w.key : ''
|
|
484
|
+
const receiving = targetScope === 'workspace' ? wList : gList
|
|
485
|
+
const donating = targetScope === 'workspace' ? gList : wList
|
|
486
|
+
receiving.push(entry)
|
|
487
|
+
|
|
488
|
+
// 两阶段提交
|
|
489
|
+
const recvDir = targetScope === 'workspace' ? w.dir : globalDir
|
|
490
|
+
const donDir = targetScope === 'workspace' ? globalDir : w.dir
|
|
491
|
+
const recvSnap = { schemaVersion: 1, namespace: 'dsh-auto-memory-pre', procedures: receiving }
|
|
492
|
+
const donSnap = { schemaVersion: 1, namespace: 'dsh-auto-memory-pre', procedures: donating }
|
|
493
|
+
let step = 'write-receiving'
|
|
494
|
+
let stepOk = false
|
|
495
|
+
try {
|
|
496
|
+
if (corrupt.has(recvDir)) throw new Error('receiving library corrupt: ' + recvDir)
|
|
497
|
+
ioFor(recvDir)(fileName).save(recvSnap)
|
|
498
|
+
step = 'verify-receiving'
|
|
499
|
+
const back = ioFor(recvDir)(fileName).load()
|
|
500
|
+
const hit = back && Array.isArray(back.procedures) && back.procedures.some((p) => String(p.procedureId) === id && (p.scope || 'global') === targetScope)
|
|
501
|
+
if (!hit) throw new Error('receiving verify failed')
|
|
502
|
+
step = 'write-donating'
|
|
503
|
+
if (corrupt.has(donDir)) throw new Error('donating library corrupt: ' + donDir)
|
|
504
|
+
ioFor(donDir)(fileName).save(donSnap)
|
|
505
|
+
step = 'verify-donating'
|
|
506
|
+
const back2 = ioFor(donDir)(fileName).load()
|
|
507
|
+
const stillThere = back2 && Array.isArray(back2.procedures) && back2.procedures.some((p) => String(p.procedureId) === id)
|
|
508
|
+
if (stillThere) throw new Error('donating verify failed (still present)')
|
|
509
|
+
stepOk = true
|
|
510
|
+
} catch (e) {
|
|
511
|
+
notePre(health, fileName, 'migrate-' + step, e, onError)
|
|
512
|
+
// 回滚:两库都恢复到迁移前快照;同时把内存副本复位,避免下一条基于坏状态继续
|
|
513
|
+
rolledBack = true
|
|
514
|
+
rollbackBoth()
|
|
515
|
+
gList = (globalBefore && Array.isArray(globalBefore.procedures)) ? globalBefore.procedures.map((p) => Object.assign({}, p)) : []
|
|
516
|
+
wList = (wsBefore && Array.isArray(wsBefore.procedures)) ? wsBefore.procedures.map((p) => Object.assign({}, p)) : []
|
|
517
|
+
failed++
|
|
518
|
+
results.push(Object.assign({ ok: false, reason: 'phase-failed', step, error: String((e && e.message) || e) }, tag))
|
|
519
|
+
if (onProgress) onProgress(i + 1, total, results[results.length - 1])
|
|
520
|
+
continue
|
|
521
|
+
}
|
|
522
|
+
if (stepOk) {
|
|
523
|
+
moved++
|
|
524
|
+
results.push(Object.assign({ ok: true, from: src, to: targetScope, phase: 'committed' }, tag))
|
|
525
|
+
}
|
|
526
|
+
if (onProgress) onProgress(i + 1, total, results[results.length - 1])
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
last = {
|
|
530
|
+
at: Date.now(), wroteGlobal: false, wroteWorkspace: false,
|
|
531
|
+
skippedUnknownWs: 0, skippedForeign: 0, refusedCorrupt: 0, foreignIds: [],
|
|
532
|
+
migrated: moved, migrateFailed: failed, rolledBack,
|
|
533
|
+
}
|
|
534
|
+
return { ok: failed === 0, total, moved, failed, rolledBack, results }
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* ★M8-B 第6步(2026-09-23):**只读**描述两库现状,供前端「双库视图」回答
|
|
538
|
+
* 「当前是哪个库 / 各库各有多少条 / 能不能写工作区库」。
|
|
539
|
+
*
|
|
540
|
+
* 为什么不把路径给前端:前端不需要、也不该拿到真实磁盘路径(那是注入面与
|
|
541
|
+
* 迁移包才关心的事);这里只回**库标识与计数**,足够渲染归属徽标与禁用理由。
|
|
542
|
+
* 读盘失败一律 fail-soft 记 -1(前端显示「不可读」),绝不让面板整个挂掉。
|
|
543
|
+
*/
|
|
544
|
+
function describe() {
|
|
545
|
+
const w = wsNow()
|
|
546
|
+
const countIn = (dir) => {
|
|
547
|
+
if (!dir) return -1
|
|
548
|
+
try {
|
|
549
|
+
const d = readOne(dir)
|
|
550
|
+
return (d && Array.isArray(d.procedures)) ? d.procedures.length : 0
|
|
551
|
+
} catch (_) { return -1 }
|
|
552
|
+
}
|
|
553
|
+
return {
|
|
554
|
+
defaultScope: 'global',
|
|
555
|
+
workspaceKey: w.key,
|
|
556
|
+
workspaceReady: !!(w.dir && w.key),
|
|
557
|
+
globalDirCorrupt: corrupt.has(String(globalDir || '')),
|
|
558
|
+
workspaceDirCorrupt: w.dir ? corrupt.has(String(w.dir)) : false,
|
|
559
|
+
globalCount: countIn(globalDir),
|
|
560
|
+
// 工作区未知时不探盘:`countIn('')` 会回 -1,含义是「当前无法归属」而非「0 条」
|
|
561
|
+
workspaceCount: countIn(w.dir),
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
return {
|
|
566
|
+
save, load, clear, migrate, describe,
|
|
567
|
+
lastWrite: () => Object.assign({}, last),
|
|
568
|
+
isCorrupt: (dir) => corrupt.has(String(dir || '')),
|
|
569
|
+
}
|
|
570
|
+
}
|
package/lib/index-sync.js
CHANGED
|
@@ -13,7 +13,7 @@ import { workspaceRefOf } from './evidence-store.js'
|
|
|
13
13
|
import {
|
|
14
14
|
buildChunkIdPre, buildSyncIdPre, computePageDigestPre, computeFinalDigestPre,
|
|
15
15
|
validateIndexSyncBeginPre, validateIndexSyncPagePre, validateIndexSyncCommitPre, validateSemanticRecordPre,
|
|
16
|
-
|
|
16
|
+
M7_INDEX_POLICY_VERSION_PRE_V1,
|
|
17
17
|
} from './m7-wire.js'
|
|
18
18
|
|
|
19
19
|
/**
|
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
*
|
|
29
29
|
* 故留 **4KiB 余量**(≈ 实测信封 202–230B 的 18 倍)。
|
|
30
30
|
*/
|
|
31
|
-
export const
|
|
31
|
+
export const INDEX_SYNC_PAGE_BUDGET_PRE_V1 = Object.freeze({ maxRecordsPerPage: 64, maxPageBytes: 252 * 1024 })
|
|
32
32
|
const SCOPE_ORDER_PRE = ['Workspace', 'User']
|
|
33
33
|
|
|
34
34
|
function pagePayloadBytes(syncId, pageNo, pageCount, records) {
|
|
@@ -67,12 +67,12 @@ export function toSemanticRecordPre(rec, workspaceRef) {
|
|
|
67
67
|
* 返回 {ok:true, plans:[{scope, begin, pages[], commit}], recordTotal}|{ok:false, reason}。
|
|
68
68
|
*/
|
|
69
69
|
export function buildIndexSyncPlansPre(input) {
|
|
70
|
-
const B =
|
|
70
|
+
const B = INDEX_SYNC_PAGE_BUDGET_PRE_V1
|
|
71
71
|
const snap = input && input.snapshot
|
|
72
72
|
const recordsRaw = Array.isArray(input && input.records) ? input.records : (snap ? snap.records : [])
|
|
73
73
|
const sourcesAll = Array.isArray(input && input.sources) ? input.sources : (snap ? snap.sources : [])
|
|
74
74
|
const miv = String((input && input.memoryIndexVersion) || (snap ? snap.memoryIndexVersion : '') || '')
|
|
75
|
-
if (!miv.startsWith('
|
|
75
|
+
if (!miv.startsWith('idx_pre_')) return { ok: false, reason: 'memoryIndexVersion' }
|
|
76
76
|
const wsRef = workspaceRefOf(String((input && input.workspaceKey) || ''))
|
|
77
77
|
const byScope = new Map()
|
|
78
78
|
for (const r of recordsRaw) {
|
|
@@ -118,7 +118,7 @@ export function buildIndexSyncPlansPre(input) {
|
|
|
118
118
|
const pageCount = pagesRecords.length
|
|
119
119
|
const begin = {
|
|
120
120
|
schemaVersion: 1, syncId, workspaceRef: wsRef, scope, memoryIndexVersion: miv,
|
|
121
|
-
sourceTuples, recordCount, pageCount, indexPolicyVersion:
|
|
121
|
+
sourceTuples, recordCount, pageCount, indexPolicyVersion: M7_INDEX_POLICY_VERSION_PRE_V1,
|
|
122
122
|
}
|
|
123
123
|
const bv = validateIndexSyncBeginPre(begin)
|
|
124
124
|
if (!bv.ok) return { ok: false, reason: 'begin:' + bv.reason }
|