dsh-memu-memory 1.1.1 → 1.1.3
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 -5
- package/README.md +22 -5
- package/README.zh.md +21 -4
- package/docs/contributing.md +5 -3
- package/docs/env-and-commands.md +13 -0
- package/docs/host-api.md +35 -3
- package/docs/memory-semantics.md +41 -1
- package/docs/troubleshooting.md +395 -350
- package/lib/activity-log.js +190 -0
- package/lib/backup.js +1103 -0
- package/lib/binding-diagnostics.js +630 -0
- package/lib/client.js +703 -50
- package/lib/curator.js +81 -37
- package/lib/cycle.js +3 -0
- 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 +61 -4
- package/lib/inject.js +195 -49
- package/lib/local-embedding-entry.js +145 -0
- package/lib/local-embedding-manager.js +249 -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 +385 -45
- package/lib/memu-config.js +10 -22
- package/lib/memu.js +53 -19
- package/lib/paths.js +24 -0
- package/lib/preset-scope.js +121 -0
- package/lib/private.js +139 -13
- package/lib/rpc.js +45 -4
- package/lib/state.js +109 -161
- package/lib/tools.js +9 -5
- package/lib/vector-migration.js +878 -0
- package/lib/vector-profile.js +941 -0
- package/package.json +3 -2
- package/scripts/acceptance-local.mjs +5 -4
- package/scripts/chain-matrix.mjs +527 -0
- package/scripts/check-all.mjs +19 -0
- 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
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import crypto from 'node:crypto'
|
|
4
|
+
import { spawn } from 'node:child_process'
|
|
5
|
+
import { ensureDir, readJsonStrict, writeJson } from './paths.js'
|
|
6
|
+
import { getEmbeddingCatalog, getEmbeddingModel } from './embedding-catalog.js'
|
|
7
|
+
|
|
8
|
+
// §14.13 受管 embedding runtime 安装器。
|
|
9
|
+
//
|
|
10
|
+
// 目标:用户装好插件、MemU 和已核实的 BGE 制品后,不需要再手写
|
|
11
|
+
// DSH_MEMU_EMBED_RUNTIME_DIR,也不需要装 Ollama 之类的第二个服务——首次需要时由本模块
|
|
12
|
+
// 把目录里 pin 住的运行时制品装进受管目录,然后原子切一个 active 指针。
|
|
13
|
+
//
|
|
14
|
+
// 硬约束(每一条都由下面的代码守卫,不是注释口号):
|
|
15
|
+
// 1. 只用 catalog 里显式写出的 https URL + 逐字节 sha256/bytes;缺任何一项就拒绝安装
|
|
16
|
+
// (LOCAL_VECTOR_RUNTIME_UNVERIFIED),绝不猜 hash、绝不落到「装了个大概能用的东西」。
|
|
17
|
+
// 2. 不跑 npm、不跑 postinstall、不读宿主 node_modules:tarball 按白名单解包,
|
|
18
|
+
// postinstall 专用依赖(adm-zip/global-agent/...)根本不落盘。
|
|
19
|
+
// 3. 不支持的平台(catalog 没有该平台原生库 hash)fail-closed 报 unsupported。
|
|
20
|
+
// 4. staging → 字节/hash 校验 → 解包 → 逐文件核对 → 原生加载 smoke → 原子发布。
|
|
21
|
+
// 失败、取消、hash 不符都在 staging 里结束,active 指针保持不动。
|
|
22
|
+
// 5. 幂等 + 并发安全:同进程 join 同一个 promise,跨进程用 staging 里的 pid 锁;
|
|
23
|
+
// 已安装且核对通过就直接复用,重启后不重下 115 MB。
|
|
24
|
+
|
|
25
|
+
export const RUNTIME_RECORD = 'installed.json'
|
|
26
|
+
export const RUNTIME_ACTIVE = 'active.json'
|
|
27
|
+
export const RUNTIME_STAGING = '.staging'
|
|
28
|
+
export const RUNTIME_TRASH = '.trash'
|
|
29
|
+
export const RUNTIME_LAYOUT = 'node_modules'
|
|
30
|
+
export const RUNTIME_STATES = Object.freeze(['unknown', 'unsupported', 'absent', 'downloading', 'verifying', 'installing', 'installed', 'failed', 'cancelled'])
|
|
31
|
+
|
|
32
|
+
const DEFAULT_MAX_ARTIFACT_BYTES = 256 * 1024 * 1024
|
|
33
|
+
const DEFAULT_MAX_FILE_BYTES = 128 * 1024 * 1024
|
|
34
|
+
const DEFAULT_SMOKE_TIMEOUT_MS = 120_000
|
|
35
|
+
const DEFAULT_FETCH_TIMEOUT_MS = 600_000
|
|
36
|
+
const READY_LIMIT = 8_192
|
|
37
|
+
|
|
38
|
+
// The child smoke process needs the OS basics and nothing else: an inherited proxy
|
|
39
|
+
// variable could reroute a request and an inherited *_API_KEY would hand a cloud
|
|
40
|
+
// credential to a process whose whole job is loading a local .node file.
|
|
41
|
+
const DENIED_ENV_NAMES = Object.freeze(['http_proxy', 'https_proxy', 'all_proxy', 'no_proxy', 'node_options', 'node_path'])
|
|
42
|
+
const DENIED_ENV_PREFIXES = Object.freeze(['memu_', 'dsh_memu_embed_', 'openai_', 'azure_openai_', 'anthropic_', 'gemini_', 'google_', 'cohere_'])
|
|
43
|
+
|
|
44
|
+
function failure(code, message, extra) {
|
|
45
|
+
const error = new Error(message)
|
|
46
|
+
error.code = code
|
|
47
|
+
if (extra) Object.assign(error, extra)
|
|
48
|
+
return error
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isPlainObject(value) {
|
|
52
|
+
return !!value && typeof value === 'object' && !Array.isArray(value)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isSha256(value) {
|
|
56
|
+
return typeof value === 'string' && /^[0-9a-f]{64}$/.test(value)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isAbort(error, signal) {
|
|
60
|
+
return Boolean(signal?.aborted) || error?.name === 'AbortError' || error?.code === 'ABORT_ERR' || error?.code === 'UND_ERR_ABORTED' || error?.code === 'LOCAL_VECTOR_RUNTIME_ABORTED'
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function sha256Of(buffer) {
|
|
64
|
+
return crypto.createHash('sha256').update(buffer).digest('hex')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function embeddingRuntimePlatform(platform = process.platform, arch = process.arch) {
|
|
68
|
+
return String(platform) + '-' + String(arch)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// catalog 的 <platform> 占位符是 'win32/x64' 这种目录形状,catalog 的 key 是 'win32-x64'。
|
|
72
|
+
export function runtimeNativeDir(platform) {
|
|
73
|
+
return String(platform).replace('-', '/')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function runtimeKeyFor(entry, platform = embeddingRuntimePlatform()) {
|
|
77
|
+
const runtime = isPlainObject(entry?.runtime) ? entry.runtime : {}
|
|
78
|
+
return [String(runtime.engine || 'onnxruntime-node'), String(runtime.version || 'unpinned'), String(runtime.abi || 'unknown'), String(platform)].join('@').replace(/[^A-Za-z0-9._@-]+/g, '-')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 把 catalog 条目里的 runtime 事实整成一个可执行的安装计划;任何一项不完整都 fail-closed。 */
|
|
82
|
+
export function runtimePlan(entry, platform = embeddingRuntimePlatform()) {
|
|
83
|
+
const errors = []
|
|
84
|
+
const reject = (code, message) => errors.push({ code, message })
|
|
85
|
+
const runtime = isPlainObject(entry?.runtime) ? entry.runtime : null
|
|
86
|
+
const key = runtimeKeyFor(entry, platform)
|
|
87
|
+
const empty = { ok: false, errors, key, platform, engine: null, version: null, abi: null, artifacts: [], packageFiles: [], native: [] }
|
|
88
|
+
if (!runtime) {
|
|
89
|
+
reject('RUNTIME_SPEC_MISSING', 'the catalog entry has no runtime spec')
|
|
90
|
+
return empty
|
|
91
|
+
}
|
|
92
|
+
if (entry?.availability !== 'available') reject('RUNTIME_ENTRY_NOT_AVAILABLE', `${String(entry?.id ?? 'unknown model')} is not an available catalog entry; no runtime may be installed for it`)
|
|
93
|
+
const engine = String(runtime.engine || '').trim()
|
|
94
|
+
const version = String(runtime.version || '').trim()
|
|
95
|
+
const abi = String(runtime.abi || '').trim()
|
|
96
|
+
if (!engine) reject('RUNTIME_ENGINE_MISSING', 'runtime.engine is required')
|
|
97
|
+
if (!version) reject('RUNTIME_VERSION_UNPINNED', 'runtime.version must be a pinned version, not null')
|
|
98
|
+
if (!abi) reject('RUNTIME_ABI_MISSING', 'runtime.abi is required')
|
|
99
|
+
const artifacts = []
|
|
100
|
+
for (const artifact of Array.isArray(runtime.artifacts) ? runtime.artifacts : []) {
|
|
101
|
+
if (!isPlainObject(artifact)) { reject('RUNTIME_ARTIFACT_INVALID', 'each runtime artifact must be an object'); continue }
|
|
102
|
+
const name = String(artifact.name || '')
|
|
103
|
+
if (!/^(@[a-z0-9._-]+\/)?[a-z0-9._-]+$/.test(name)) reject('RUNTIME_ARTIFACT_NAME_INVALID', `invalid artifact name: ${JSON.stringify(artifact.name)}`)
|
|
104
|
+
let url = null
|
|
105
|
+
try { url = new URL(String(artifact.url || '')) } catch { url = null }
|
|
106
|
+
if (!url || url.protocol !== 'https:') reject('RUNTIME_ARTIFACT_URL_INVALID', `artifact ${name} needs a pinned https URL`)
|
|
107
|
+
if (!Number.isInteger(artifact.bytes) || artifact.bytes <= 0) reject('RUNTIME_ARTIFACT_BYTES_INVALID', `artifact ${name} needs exact bytes`)
|
|
108
|
+
if (!isSha256(artifact.sha256)) reject('RUNTIME_ARTIFACT_SHA256_INVALID', `artifact ${name} needs a lowercase sha256; guessed hashes are not accepted`)
|
|
109
|
+
if (!Array.isArray(artifact.extract) || !artifact.extract.length) reject('RUNTIME_ARTIFACT_EXTRACT_MISSING', `artifact ${name} needs an explicit extraction allow-list`)
|
|
110
|
+
artifacts.push({
|
|
111
|
+
name,
|
|
112
|
+
version: String(artifact.version || version),
|
|
113
|
+
url: url ? url.href : null,
|
|
114
|
+
bytes: Number.isInteger(artifact.bytes) ? artifact.bytes : null,
|
|
115
|
+
sha256: String(artifact.sha256 || ''),
|
|
116
|
+
integrity: typeof artifact.integrity === 'string' ? artifact.integrity : null,
|
|
117
|
+
license: typeof artifact.license === 'string' ? artifact.license : null,
|
|
118
|
+
extract: Array.isArray(artifact.extract) ? artifact.extract.map(item => String(item)) : [],
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
if (!artifacts.length) reject('RUNTIME_ARTIFACTS_MISSING', 'the runtime spec lists no pinned artifacts; nothing will be downloaded')
|
|
122
|
+
const packageFiles = []
|
|
123
|
+
for (const file of Array.isArray(runtime.packageFiles) ? runtime.packageFiles : []) {
|
|
124
|
+
if (!isPlainObject(file) || typeof file.package !== 'string' || !file.package) { reject('RUNTIME_PACKAGE_FILE_INVALID', 'each packageFiles entry needs a package name'); continue }
|
|
125
|
+
if (!Number.isInteger(file.bytes) || file.bytes <= 0) reject('RUNTIME_PACKAGE_FILE_INVALID', `packageFiles ${file.package} needs exact bytes`)
|
|
126
|
+
if (!isSha256(file.sha256)) reject('RUNTIME_PACKAGE_FILE_INVALID', `packageFiles ${file.package} needs a lowercase sha256`)
|
|
127
|
+
packageFiles.push({ package: file.package, path: `${RUNTIME_LAYOUT}/${file.package}/package.json`, bytes: Number(file.bytes), sha256: String(file.sha256) })
|
|
128
|
+
}
|
|
129
|
+
if (!packageFiles.length) reject('RUNTIME_PACKAGE_FILES_MISSING', 'the runtime spec pins no package.json facts; the extracted tree cannot be verified')
|
|
130
|
+
const native = []
|
|
131
|
+
for (const file of Array.isArray(runtime.native?.[platform]) ? runtime.native[platform] : []) {
|
|
132
|
+
if (!isPlainObject(file) || typeof file.path !== 'string' || !file.path.startsWith(RUNTIME_LAYOUT + '/')) { reject('RUNTIME_NATIVE_INVALID', 'each native file needs a path inside node_modules'); continue }
|
|
133
|
+
if (file.path.includes('..') || path.isAbsolute(file.path) || file.path.includes('\\')) { reject('RUNTIME_NATIVE_INVALID', `unsafe native path: ${JSON.stringify(file.path)}`); continue }
|
|
134
|
+
if (!Number.isInteger(file.bytes) || file.bytes <= 0) reject('RUNTIME_NATIVE_INVALID', `native file ${file.path} needs exact bytes`)
|
|
135
|
+
if (!isSha256(file.sha256)) reject('RUNTIME_NATIVE_INVALID', `native file ${file.path} needs a lowercase sha256`)
|
|
136
|
+
native.push({ path: file.path, bytes: Number(file.bytes), sha256: String(file.sha256) })
|
|
137
|
+
}
|
|
138
|
+
if (!native.length) reject('RUNTIME_NATIVE_MISSING', `no verified native runtime is pinned for ${platform}; refusing to install on this platform`)
|
|
139
|
+
return { ok: errors.length === 0, errors, key, platform, engine: engine || null, version: version || null, abi: abi || null, artifacts, packageFiles, native }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** 下载到的字节必须与 pin 的 bytes+sha256 完全一致;不匹配一律拒绝执行。 */
|
|
143
|
+
export function verifyRuntimeArtifact(bytes, artifact) {
|
|
144
|
+
const buffer = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes ?? [])
|
|
145
|
+
if (!isSha256(artifact?.sha256)) throw failure('LOCAL_VECTOR_RUNTIME_UNVERIFIED', `artifact ${String(artifact?.name || '?')} has no pinned sha256; refusing to install`)
|
|
146
|
+
if (!Number.isSafeInteger(artifact?.bytes) || artifact.bytes <= 0) throw failure('LOCAL_VECTOR_RUNTIME_UNVERIFIED', `artifact ${String(artifact?.name || '?')} has no exact byte count`)
|
|
147
|
+
if (buffer.length !== artifact.bytes) {
|
|
148
|
+
throw failure('LOCAL_VECTOR_RUNTIME_SIZE_MISMATCH', `artifact ${String(artifact.name)} is ${buffer.length} bytes, expected ${artifact.bytes}`)
|
|
149
|
+
}
|
|
150
|
+
const digest = sha256Of(buffer)
|
|
151
|
+
if (digest !== artifact.sha256) {
|
|
152
|
+
throw failure('LOCAL_VECTOR_RUNTIME_HASH_MISMATCH', `artifact ${String(artifact.name)} sha256 ${digest} does not match the pinned ${artifact.sha256}`)
|
|
153
|
+
}
|
|
154
|
+
return digest
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** tar 条目白名单:只允许 catalog 里写出的前缀,且必须换成该平台的原生库目录。 */
|
|
158
|
+
export function extractAllowList(artifact, platform) {
|
|
159
|
+
const nativeDir = runtimeNativeDir(platform)
|
|
160
|
+
return (Array.isArray(artifact?.extract) ? artifact.extract : [])
|
|
161
|
+
.map(item => String(item).replace('<platform>', nativeDir))
|
|
162
|
+
.map(item => item.replace(/^\/+/, '').replace(/\/+$/, ''))
|
|
163
|
+
.filter(Boolean)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Windows 上刚写完的目录会被杀软/索引器短暂占用,rename 会抛 EPERM/EBUSY;
|
|
167
|
+
// 这是可恢复的瞬时错误,重试几次再判失败,别把「正在被扫描」当成「装坏了」。
|
|
168
|
+
async function renameWithRetry(from, to, options = {}) {
|
|
169
|
+
const attempts = Number.isFinite(options.attempts) ? options.attempts : 6
|
|
170
|
+
const delayMs = Number.isFinite(options.delayMs) ? options.delayMs : 60
|
|
171
|
+
let last = null
|
|
172
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
173
|
+
try {
|
|
174
|
+
fs.renameSync(from, to)
|
|
175
|
+
return
|
|
176
|
+
} catch (error) {
|
|
177
|
+
last = error
|
|
178
|
+
const code = String(error?.code || '')
|
|
179
|
+
if (attempt === attempts || !['EPERM', 'EBUSY', 'EACCES', 'ENOTEMPTY'].includes(code)) break
|
|
180
|
+
await new Promise(resolve => setTimeout(resolve, delayMs * attempt))
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
throw last
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function createEmbeddingRuntimeInstaller(options = {}) {
|
|
187
|
+
const root = options.root
|
|
188
|
+
if (typeof root !== 'string' || !root.trim()) throw new TypeError('createEmbeddingRuntimeInstaller requires a root directory')
|
|
189
|
+
const baseDir = path.resolve(root)
|
|
190
|
+
const platform = String(options.platform || embeddingRuntimePlatform())
|
|
191
|
+
const fetchImpl = typeof options.fetchImpl === 'function' ? options.fetchImpl : fetch
|
|
192
|
+
const spawnImpl = typeof options.spawnImpl === 'function' ? options.spawnImpl : spawn
|
|
193
|
+
const executable = String(options.executable || process.execPath)
|
|
194
|
+
const now = typeof options.now === 'function' ? options.now : () => new Date().toISOString()
|
|
195
|
+
const logger = options.logger || null
|
|
196
|
+
const maxArtifactBytes = Number.isInteger(options.maxArtifactBytes) ? options.maxArtifactBytes : DEFAULT_MAX_ARTIFACT_BYTES
|
|
197
|
+
const maxFileBytes = Number.isInteger(options.maxFileBytes) ? options.maxFileBytes : DEFAULT_MAX_FILE_BYTES
|
|
198
|
+
const smokeTimeoutMs = Number.isInteger(options.smokeTimeoutMs) ? options.smokeTimeoutMs : DEFAULT_SMOKE_TIMEOUT_MS
|
|
199
|
+
const fetchTimeoutMs = Number.isInteger(options.fetchTimeoutMs) ? options.fetchTimeoutMs : DEFAULT_FETCH_TIMEOUT_MS
|
|
200
|
+
const extract = typeof options.extract === 'function' ? options.extract : defaultExtract
|
|
201
|
+
const smoke = typeof options.smoke === 'function' ? options.smoke : defaultSmoke
|
|
202
|
+
const resolveEntry = typeof options.resolveEntry === 'function' ? options.resolveEntry : id => (id ? getEmbeddingModel(String(id)) : null)
|
|
203
|
+
const activeFile = path.join(baseDir, RUNTIME_ACTIVE)
|
|
204
|
+
const inflight = new Map()
|
|
205
|
+
let job = null
|
|
206
|
+
let outcome = null
|
|
207
|
+
let disposed = false
|
|
208
|
+
|
|
209
|
+
const warn = message => { try { logger?.warn?.(message) } catch {} }
|
|
210
|
+
|
|
211
|
+
function insideRoot(target) {
|
|
212
|
+
const resolved = path.resolve(target)
|
|
213
|
+
if (resolved !== baseDir && !resolved.startsWith(baseDir + path.sep)) {
|
|
214
|
+
throw failure('LOCAL_VECTOR_RUNTIME_PATH_ESCAPE', `refusing to touch a path outside the managed runtime root: ${resolved}`)
|
|
215
|
+
}
|
|
216
|
+
return resolved
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function readActive() {
|
|
220
|
+
const result = readJsonStrict(activeFile)
|
|
221
|
+
if (!result.ok || !isPlainObject(result.value)) return null
|
|
222
|
+
const value = result.value
|
|
223
|
+
if (typeof value.key !== 'string' || typeof value.dir !== 'string' || typeof value.platform !== 'string') return null
|
|
224
|
+
return value
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function readRecord(dir) {
|
|
228
|
+
const result = readJsonStrict(path.join(dir, RUNTIME_RECORD))
|
|
229
|
+
return result.ok && isPlainObject(result.value) ? result.value : null
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// The archive hash authenticates every extracted file, not only package/native pins.
|
|
233
|
+
// Keep that complete inventory and reject new files or links before any runtime reuse.
|
|
234
|
+
function inventoryTree(plan, dir, { deep = true, allowRecord = false } = {}) {
|
|
235
|
+
const files = []
|
|
236
|
+
const allowed = plan.artifacts.flatMap(artifact => extractAllowList(artifact, plan.platform).map(prefix => RUNTIME_LAYOUT + '/' + artifact.name + '/' + prefix))
|
|
237
|
+
if (!fs.lstatSync(dir).isDirectory() || fs.lstatSync(dir).isSymbolicLink()) throw failure('LOCAL_VECTOR_RUNTIME_PATH_ESCAPE', 'runtime root must be a real directory')
|
|
238
|
+
const visit = (parent, relative = '') => {
|
|
239
|
+
for (const name of fs.readdirSync(parent).sort()) {
|
|
240
|
+
const rel = relative ? relative + '/' + name : name
|
|
241
|
+
const target = path.join(parent, name)
|
|
242
|
+
const stat = fs.lstatSync(target)
|
|
243
|
+
if (stat.isSymbolicLink()) throw failure('LOCAL_VECTOR_RUNTIME_UNEXPECTED_FILE', 'runtime inventory contains a link: ' + rel)
|
|
244
|
+
if (allowRecord && rel === RUNTIME_RECORD && stat.isFile()) continue
|
|
245
|
+
if (stat.isDirectory()) {
|
|
246
|
+
if (!allowed.some(prefix => prefix === rel || prefix.startsWith(rel + '/') || rel.startsWith(prefix + '/'))) throw failure('LOCAL_VECTOR_RUNTIME_UNEXPECTED_FILE', 'runtime inventory contains an unlisted directory: ' + rel)
|
|
247
|
+
visit(target, rel)
|
|
248
|
+
} else if (stat.isFile()) {
|
|
249
|
+
if (!allowed.some(prefix => rel === prefix || rel.startsWith(prefix + '/'))) throw failure('LOCAL_VECTOR_RUNTIME_UNEXPECTED_FILE', 'runtime inventory contains an unlisted file: ' + rel)
|
|
250
|
+
if (stat.size > maxFileBytes) throw failure('LOCAL_VECTOR_RUNTIME_TOO_LARGE', 'runtime inventory file exceeds its limit: ' + rel)
|
|
251
|
+
files.push({ path: rel, bytes: stat.size, ...(deep ? { sha256: sha256Of(fs.readFileSync(target)) } : {}) })
|
|
252
|
+
} else {
|
|
253
|
+
throw failure('LOCAL_VECTOR_RUNTIME_UNEXPECTED_FILE', 'runtime inventory contains a non-regular entry: ' + rel)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
visit(dir)
|
|
258
|
+
return files
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// 复用判定:便宜(只比字节数)还是彻底(重算 sha256)由调用方决定。
|
|
262
|
+
// 便宜路径抓「文件被删/被换/明显截断」,彻底路径抓内容差异;两者都不猜。
|
|
263
|
+
function inspectPlan(plan, { deep = false } = {}) {
|
|
264
|
+
const active = readActive()
|
|
265
|
+
if (!active) return { ok: false, code: 'LOCAL_VECTOR_RUNTIME_ABSENT' }
|
|
266
|
+
if (active.key !== plan.key || active.platform !== plan.platform) return { ok: false, code: 'LOCAL_VECTOR_RUNTIME_STALE' }
|
|
267
|
+
let dir
|
|
268
|
+
try { dir = insideRoot(active.dir) } catch { return { ok: false, code: 'LOCAL_VECTOR_RUNTIME_PATH_ESCAPE' } }
|
|
269
|
+
const record = readRecord(dir)
|
|
270
|
+
if (!record) return { ok: false, code: 'LOCAL_VECTOR_RUNTIME_RECORD_MISSING' }
|
|
271
|
+
if (record.key !== plan.key || record.platform !== plan.platform || record.engine !== plan.engine || record.version !== plan.version || record.abi !== plan.abi) return { ok: false, code: 'LOCAL_VECTOR_RUNTIME_RECORD_STALE' }
|
|
272
|
+
const recordedArtifacts = Array.isArray(record.artifacts) ? record.artifacts : []
|
|
273
|
+
if (recordedArtifacts.length !== plan.artifacts.length || plan.artifacts.some(artifact => {
|
|
274
|
+
const saved = recordedArtifacts.find(item => item && item.name === artifact.name)
|
|
275
|
+
return !saved || saved.version !== artifact.version || saved.url !== artifact.url || saved.bytes !== artifact.bytes || saved.sha256 !== artifact.sha256
|
|
276
|
+
})) return { ok: false, code: 'LOCAL_VECTOR_RUNTIME_RECORD_STALE' }
|
|
277
|
+
const expected = [...plan.packageFiles, ...plan.native]
|
|
278
|
+
const recorded = Array.isArray(record.files) ? record.files : []
|
|
279
|
+
if (record.inventoryVersion !== 1 || recorded.length < expected.length) return { ok: false, code: 'LOCAL_VECTOR_RUNTIME_RECORD_INCOMPLETE' }
|
|
280
|
+
const byPath = new Map()
|
|
281
|
+
for (const file of recorded) {
|
|
282
|
+
if (!file || typeof file.path !== 'string' || !Number.isSafeInteger(file.bytes) || file.bytes < 0 || !isSha256(file.sha256) || byPath.has(file.path)) return { ok: false, code: 'LOCAL_VECTOR_RUNTIME_RECORD_INCOMPLETE' }
|
|
283
|
+
byPath.set(file.path, file)
|
|
284
|
+
}
|
|
285
|
+
for (const file of expected) {
|
|
286
|
+
const pinned = byPath.get(file.path)
|
|
287
|
+
if (!pinned || pinned.sha256 !== file.sha256 || pinned.bytes !== file.bytes) return { ok: false, code: 'LOCAL_VECTOR_RUNTIME_RECORD_STALE', path: file.path }
|
|
288
|
+
}
|
|
289
|
+
let actual
|
|
290
|
+
try { actual = inventoryTree(plan, dir, { deep, allowRecord: true }) } catch (error) { return { ok: false, code: error?.code || 'LOCAL_VECTOR_RUNTIME_INCOMPLETE' } }
|
|
291
|
+
if (actual.length !== recorded.length) return { ok: false, code: 'LOCAL_VECTOR_RUNTIME_UNEXPECTED_FILE' }
|
|
292
|
+
for (const file of actual) {
|
|
293
|
+
const pinned = byPath.get(file.path)
|
|
294
|
+
if (!pinned) return { ok: false, code: 'LOCAL_VECTOR_RUNTIME_UNEXPECTED_FILE', path: file.path }
|
|
295
|
+
if (file.bytes !== pinned.bytes) return { ok: false, code: 'LOCAL_VECTOR_RUNTIME_INCOMPLETE', path: file.path }
|
|
296
|
+
if (deep && file.sha256 !== pinned.sha256) return { ok: false, code: 'LOCAL_VECTOR_RUNTIME_HASH_MISMATCH', path: file.path }
|
|
297
|
+
}
|
|
298
|
+
return { ok: true, dir, record }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function planFor(modelId) {
|
|
302
|
+
const entry = resolveEntry(modelId)
|
|
303
|
+
if (!entry) {
|
|
304
|
+
const fallback = getEmbeddingCatalog().find(item => item.availability === 'available')
|
|
305
|
+
if (!fallback) throw failure('LOCAL_VECTOR_RUNTIME_UNVERIFIED', 'no available catalog entry pins a runtime; nothing will be installed')
|
|
306
|
+
return { entry: fallback, plan: runtimePlan(fallback, platform) }
|
|
307
|
+
}
|
|
308
|
+
return { entry, plan: runtimePlan(entry, platform) }
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function status({ deep = false, modelId = null } = {}) {
|
|
312
|
+
const base = { source: 'managed', platform, root: baseDir }
|
|
313
|
+
let resolved = null
|
|
314
|
+
try { resolved = planFor(modelId) } catch (error) { return { ...base, status: 'unsupported', ready: false, key: null, path: null, error: { code: String(error?.code || 'LOCAL_VECTOR_RUNTIME_UNVERIFIED'), message: String(error?.message || error) } } }
|
|
315
|
+
const entry = resolved.entry
|
|
316
|
+
const plan = resolved.plan
|
|
317
|
+
const pinned = { key: plan.key, engine: plan.engine, version: plan.version, abi: plan.abi, artifacts: plan.artifacts.map(item => ({ name: item.name, version: item.version, bytes: item.bytes, sha256: item.sha256, license: item.license })), verifiedPlatforms: Object.keys(entry?.runtime?.install?.platforms || {}) }
|
|
318
|
+
if (!plan.ok) {
|
|
319
|
+
return { ...base, ...pinned, status: 'unsupported', ready: false, path: null, error: { code: 'LOCAL_VECTOR_RUNTIME_UNVERIFIED', message: 'the catalog does not pin a complete runtime for this platform', errors: plan.errors } }
|
|
320
|
+
}
|
|
321
|
+
if (job && job.key === plan.key) {
|
|
322
|
+
return { ...base, ...pinned, status: job.state, ready: false, path: null, progress: { ...job.progress }, startedAt: job.startedAt }
|
|
323
|
+
}
|
|
324
|
+
const inspect = inspectPlan(plan, { deep })
|
|
325
|
+
if (inspect.ok) {
|
|
326
|
+
return { ...base, ...pinned, status: 'installed', ready: true, path: inspect.dir, installedAt: inspect.record.installedAt || null, verifiedAt: inspect.record.verifiedAt || null, checks: Array.isArray(inspect.record.checks) ? [...inspect.record.checks] : [], smoke: inspect.record.smoke || null }
|
|
327
|
+
}
|
|
328
|
+
if (outcome && outcome.key === plan.key && outcome.status === 'failed') {
|
|
329
|
+
return { ...base, ...pinned, status: 'failed', ready: false, path: null, error: { ...outcome.error }, inspect: inspect.code }
|
|
330
|
+
}
|
|
331
|
+
return { ...base, ...pinned, status: 'absent', ready: false, path: null, inspect: inspect.code }
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function activeDir({ deep = false, modelId = null } = {}) {
|
|
335
|
+
const current = status({ deep, modelId })
|
|
336
|
+
return current.status === 'installed' ? current.path : null
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function acquireLock(stagingRoot) {
|
|
340
|
+
ensureDir(stagingRoot)
|
|
341
|
+
const lockFile = path.join(stagingRoot, 'install.lock')
|
|
342
|
+
const token = crypto.randomUUID()
|
|
343
|
+
const contents = JSON.stringify({ pid: process.pid, at: Date.now(), token })
|
|
344
|
+
const release = () => {
|
|
345
|
+
try { if (fs.readFileSync(lockFile, 'utf8') === contents) fs.unlinkSync(lockFile) } catch {}
|
|
346
|
+
}
|
|
347
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
348
|
+
try {
|
|
349
|
+
const fd = fs.openSync(lockFile, 'wx', 0o600)
|
|
350
|
+
try { fs.writeFileSync(fd, contents) } finally { fs.closeSync(fd) }
|
|
351
|
+
return release
|
|
352
|
+
} catch (error) {
|
|
353
|
+
if (error?.code !== 'EEXIST') throw error
|
|
354
|
+
let owner = null
|
|
355
|
+
let observed = null
|
|
356
|
+
try { observed = fs.readFileSync(lockFile, 'utf8'); owner = JSON.parse(observed) } catch {}
|
|
357
|
+
// Age is not proof of death. Unknown owners and EPERM probes stay busy.
|
|
358
|
+
let dead = false
|
|
359
|
+
if (owner && Number.isSafeInteger(owner.pid) && owner.pid > 0) {
|
|
360
|
+
try { process.kill(owner.pid, 0) } catch (probe) { dead = probe?.code === 'ESRCH' }
|
|
361
|
+
}
|
|
362
|
+
if (!dead) throw failure('LOCAL_VECTOR_RUNTIME_BUSY', 'another process is installing the managed embedding runtime; retry when it finishes')
|
|
363
|
+
try { if (fs.readFileSync(lockFile, 'utf8') === observed) fs.unlinkSync(lockFile) } catch {}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
throw failure('LOCAL_VECTOR_RUNTIME_BUSY', 'cannot take the managed runtime install lock; retry later')
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function downloadArtifact(artifact, targetDir, controller) {
|
|
370
|
+
if (!Number.isInteger(artifact.bytes) || artifact.bytes <= 0) throw failure('LOCAL_VECTOR_RUNTIME_UNVERIFIED', `artifact ${artifact.name} has no exact byte count`)
|
|
371
|
+
if (artifact.bytes > maxArtifactBytes) throw failure('LOCAL_VECTOR_RUNTIME_TOO_LARGE', `artifact ${artifact.name} declares ${artifact.bytes} bytes, above the ${maxArtifactBytes} byte cap`)
|
|
372
|
+
const target = path.join(targetDir, `${artifact.name.replace(/[^A-Za-z0-9._-]+/g, '-')}.tgz`)
|
|
373
|
+
let response
|
|
374
|
+
try {
|
|
375
|
+
response = await fetchImpl(artifact.url, { signal: controller.signal, redirect: 'follow', headers: { 'accept-encoding': 'identity' } })
|
|
376
|
+
} catch (error) {
|
|
377
|
+
if (isAbort(error, controller.signal)) throw failure('LOCAL_VECTOR_RUNTIME_ABORTED', 'the managed runtime install was aborted', { cause: error })
|
|
378
|
+
throw failure('LOCAL_VECTOR_RUNTIME_NETWORK_ERROR', `cannot fetch ${artifact.name}: ${error?.message || error}`, { cause: error })
|
|
379
|
+
}
|
|
380
|
+
if (!response || !response.ok) throw failure('LOCAL_VECTOR_RUNTIME_HTTP_ERROR', `registry returned ${response?.status ?? 'no response'} for ${artifact.name}`)
|
|
381
|
+
const declared = Number(response.headers?.get?.('content-length'))
|
|
382
|
+
if (Number.isFinite(declared) && declared > 0 && declared !== artifact.bytes) {
|
|
383
|
+
throw failure('LOCAL_VECTOR_RUNTIME_SIZE_MISMATCH', `${artifact.name} content-length ${declared} does not match the pinned ${artifact.bytes} bytes`)
|
|
384
|
+
}
|
|
385
|
+
const hash = crypto.createHash('sha256')
|
|
386
|
+
let size = 0
|
|
387
|
+
const fd = fs.openSync(target, 'wx', 0o600)
|
|
388
|
+
try {
|
|
389
|
+
const body = response.body
|
|
390
|
+
if (body && typeof body[Symbol.asyncIterator] === 'function') {
|
|
391
|
+
for await (const chunk of body) {
|
|
392
|
+
if (controller.signal.aborted) throw failure('LOCAL_VECTOR_RUNTIME_ABORTED', 'the managed runtime install was aborted')
|
|
393
|
+
size += chunk.length
|
|
394
|
+
if (size > artifact.bytes) throw failure('LOCAL_VECTOR_RUNTIME_SIZE_MISMATCH', `${artifact.name} exceeded the pinned ${artifact.bytes} bytes`)
|
|
395
|
+
hash.update(chunk)
|
|
396
|
+
fs.writeSync(fd, chunk)
|
|
397
|
+
job.progress.bytes += chunk.length
|
|
398
|
+
}
|
|
399
|
+
} else {
|
|
400
|
+
const buffer = Buffer.from(await response.arrayBuffer())
|
|
401
|
+
size = buffer.length
|
|
402
|
+
if (size > artifact.bytes) throw failure('LOCAL_VECTOR_RUNTIME_SIZE_MISMATCH', `${artifact.name} exceeded the pinned ${artifact.bytes} bytes`)
|
|
403
|
+
hash.update(buffer)
|
|
404
|
+
fs.writeSync(fd, buffer)
|
|
405
|
+
job.progress.bytes += buffer.length
|
|
406
|
+
}
|
|
407
|
+
} finally {
|
|
408
|
+
fs.closeSync(fd)
|
|
409
|
+
}
|
|
410
|
+
if (size !== artifact.bytes) throw failure('LOCAL_VECTOR_RUNTIME_SIZE_MISMATCH', `${artifact.name} is ${size} bytes, expected ${artifact.bytes}`)
|
|
411
|
+
const digest = hash.digest('hex')
|
|
412
|
+
if (digest !== artifact.sha256) throw failure('LOCAL_VECTOR_RUNTIME_HASH_MISMATCH', `${artifact.name} sha256 ${digest} does not match the pinned ${artifact.sha256}`)
|
|
413
|
+
return { path: target, bytes: size, sha256: digest }
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function verifyExtracted(plan, dir) {
|
|
417
|
+
const checks = []
|
|
418
|
+
for (const file of plan.packageFiles) {
|
|
419
|
+
const target = path.join(dir, ...file.path.split('/'))
|
|
420
|
+
let bytes
|
|
421
|
+
try { bytes = fs.readFileSync(target) } catch { throw failure('LOCAL_VECTOR_RUNTIME_INCOMPLETE', `the extracted tree has no ${file.path}`) }
|
|
422
|
+
if (bytes.length !== file.bytes) throw failure('LOCAL_VECTOR_RUNTIME_SIZE_MISMATCH', `${file.path} is ${bytes.length} bytes, expected ${file.bytes}`)
|
|
423
|
+
const digest = sha256Of(bytes)
|
|
424
|
+
if (digest !== file.sha256) throw failure('LOCAL_VECTOR_RUNTIME_HASH_MISMATCH', `${file.path} sha256 ${digest} does not match the pinned ${file.sha256}`)
|
|
425
|
+
checks.push(`${file.path}@${digest.slice(0, 12)}`)
|
|
426
|
+
}
|
|
427
|
+
for (const file of plan.native) {
|
|
428
|
+
const target = path.join(dir, ...file.path.split('/'))
|
|
429
|
+
let stat
|
|
430
|
+
try { stat = fs.statSync(target) } catch { throw failure('LOCAL_VECTOR_RUNTIME_INCOMPLETE', `the extracted tree has no ${file.path}`) }
|
|
431
|
+
if (!stat.isFile() || stat.size !== file.bytes) throw failure('LOCAL_VECTOR_RUNTIME_SIZE_MISMATCH', `${file.path} is not the pinned ${file.bytes} bytes`)
|
|
432
|
+
const digest = sha256Of(fs.readFileSync(target))
|
|
433
|
+
if (digest !== file.sha256) throw failure('LOCAL_VECTOR_RUNTIME_HASH_MISMATCH', `${file.path} sha256 ${digest} does not match the pinned ${file.sha256}`)
|
|
434
|
+
checks.push(`${path.posix.basename(file.path)}@${digest.slice(0, 12)}`)
|
|
435
|
+
}
|
|
436
|
+
// 解包白名单不能被绕过成「多装了别的东西」:平台目录里只许出现 pin 住的文件。
|
|
437
|
+
const nativeRoot = path.posix.join(RUNTIME_LAYOUT, 'onnxruntime-node', 'bin', plan.abi?.startsWith('napi') ? plan.abi : 'napi-v6', runtimeNativeDir(plan.platform))
|
|
438
|
+
const expectedNative = new Set(plan.native.map(file => String(file.path).split('/').pop()))
|
|
439
|
+
let entries = []
|
|
440
|
+
try { entries = fs.readdirSync(path.join(dir, ...nativeRoot.split('/'))) } catch { entries = [] }
|
|
441
|
+
if (entries.length) {
|
|
442
|
+
const unexpected = entries.filter(name => !expectedNative.has(name))
|
|
443
|
+
if (unexpected.length) throw failure('LOCAL_VECTOR_RUNTIME_UNEXPECTED_FILE', `the extracted native directory contains unexpected files: ${unexpected.join(', ')}`)
|
|
444
|
+
}
|
|
445
|
+
return checks
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async function install(plan, options) {
|
|
449
|
+
const controller = new AbortController()
|
|
450
|
+
const onAbort = () => controller.abort()
|
|
451
|
+
options.signal?.addEventListener('abort', onAbort, { once: true })
|
|
452
|
+
const totalBytes = plan.artifacts.reduce((sum, artifact) => sum + (artifact.bytes || 0), 0)
|
|
453
|
+
job = {
|
|
454
|
+
key: plan.key,
|
|
455
|
+
state: 'downloading',
|
|
456
|
+
startedAt: now(),
|
|
457
|
+
controller,
|
|
458
|
+
progress: { artifacts: plan.artifacts.length, done: 0, bytes: 0, totalBytes, current: null },
|
|
459
|
+
}
|
|
460
|
+
const report = () => { try { options.onProgress?.({ ...job.progress }) } catch {} }
|
|
461
|
+
let staging = null
|
|
462
|
+
let release = null
|
|
463
|
+
let published = false
|
|
464
|
+
try {
|
|
465
|
+
const stagingRoot = insideRoot(path.join(baseDir, RUNTIME_STAGING))
|
|
466
|
+
release = acquireLock(stagingRoot)
|
|
467
|
+
// 抢到锁之后先重看一眼:另一个进程可能刚刚装完,这时直接复用,不重复下载 115 MB。
|
|
468
|
+
const existing = inspectPlan(plan, { deep: true })
|
|
469
|
+
if (existing.ok) {
|
|
470
|
+
job = null
|
|
471
|
+
return { status: 'installed', ready: true, reused: true, key: plan.key, platform: plan.platform, path: existing.dir, engine: plan.engine, version: plan.version, abi: plan.abi, installedAt: existing.record.installedAt || null, verifiedAt: existing.record.verifiedAt || null, checks: Array.isArray(existing.record.checks) ? existing.record.checks : [], artifacts: plan.artifacts.map(item => ({ name: item.name, version: item.version, bytes: item.bytes, sha256: item.sha256 })), smoke: existing.record.smoke || null }
|
|
472
|
+
}
|
|
473
|
+
staging = fs.mkdtempSync(path.join(stagingRoot, 'install-'))
|
|
474
|
+
const artifactDir = ensureDir(path.join(staging, '.artifacts'))
|
|
475
|
+
const archives = []
|
|
476
|
+
for (const artifact of plan.artifacts) {
|
|
477
|
+
if (controller.signal.aborted) throw failure('LOCAL_VECTOR_RUNTIME_ABORTED', 'the managed runtime install was aborted')
|
|
478
|
+
job.progress.current = artifact.name
|
|
479
|
+
report()
|
|
480
|
+
const saved = await downloadArtifact(artifact, artifactDir, controller)
|
|
481
|
+
archives.push({ artifact, file: saved.path })
|
|
482
|
+
job.progress.done += 1
|
|
483
|
+
}
|
|
484
|
+
job.state = 'verifying'
|
|
485
|
+
job.progress.current = null
|
|
486
|
+
report()
|
|
487
|
+
for (const { artifact, file } of archives) {
|
|
488
|
+
const allowList = extractAllowList(artifact, plan.platform)
|
|
489
|
+
if (!allowList.length) throw failure('LOCAL_VECTOR_RUNTIME_UNVERIFIED', `artifact ${artifact.name} has an empty extraction allow-list`)
|
|
490
|
+
const target = ensureDir(path.join(staging, RUNTIME_LAYOUT, artifact.name))
|
|
491
|
+
await extract({ file, cwd: target, allowList, maxFileBytes, artifact })
|
|
492
|
+
}
|
|
493
|
+
try { fs.rmSync(artifactDir, { recursive: true, force: true }) } catch {}
|
|
494
|
+
const checks = verifyExtracted(plan, staging)
|
|
495
|
+
const files = inventoryTree(plan, staging)
|
|
496
|
+
job.state = 'installing'
|
|
497
|
+
report()
|
|
498
|
+
const smokeResult = await smoke({ dir: staging, plan, executable, spawnImpl, timeoutMs: smokeTimeoutMs })
|
|
499
|
+
if (!smokeResult || smokeResult.ok !== true) {
|
|
500
|
+
throw failure('LOCAL_VECTOR_RUNTIME_SMOKE_FAILED', `the extracted runtime cannot load its native modules: ${String(smokeResult?.message || 'unknown reason')}`)
|
|
501
|
+
}
|
|
502
|
+
if (JSON.stringify(inventoryTree(plan, staging)) !== JSON.stringify(files)) throw failure('LOCAL_VECTOR_RUNTIME_HASH_MISMATCH', 'runtime files changed during install smoke')
|
|
503
|
+
const record = {
|
|
504
|
+
schema: 1,
|
|
505
|
+
key: plan.key,
|
|
506
|
+
platform: plan.platform,
|
|
507
|
+
engine: plan.engine,
|
|
508
|
+
version: plan.version,
|
|
509
|
+
abi: plan.abi,
|
|
510
|
+
installedAt: now(),
|
|
511
|
+
verifiedAt: now(),
|
|
512
|
+
source: 'catalog-pinned',
|
|
513
|
+
postinstall: 'never-run',
|
|
514
|
+
inventoryVersion: 1,
|
|
515
|
+
artifacts: plan.artifacts.map(artifact => ({ name: artifact.name, version: artifact.version, url: artifact.url, bytes: artifact.bytes, sha256: artifact.sha256, integrity: artifact.integrity, license: artifact.license })),
|
|
516
|
+
files,
|
|
517
|
+
checks: [...checks, ...(Array.isArray(smokeResult.checks) ? smokeResult.checks : [])],
|
|
518
|
+
smoke: { ok: true, ortVersion: smokeResult.ortVersion || null, tokenizer: smokeResult.tokenizer === true },
|
|
519
|
+
}
|
|
520
|
+
writeJson(path.join(staging, RUNTIME_RECORD), record)
|
|
521
|
+
// Every install gets its own generation. Keep the old active directory in place
|
|
522
|
+
// until the active pointer commit succeeds; this makes pointer-write failure safe.
|
|
523
|
+
const previousActive = readActive()
|
|
524
|
+
const finalDir = insideRoot(path.join(baseDir, plan.key + '-' + crypto.randomUUID()))
|
|
525
|
+
try {
|
|
526
|
+
await renameWithRetry(staging, finalDir)
|
|
527
|
+
staging = null
|
|
528
|
+
published = true
|
|
529
|
+
} catch (error) {
|
|
530
|
+
throw failure('LOCAL_VECTOR_RUNTIME_INSTALL_FAILED', `cannot move the verified runtime into place: ${error?.message || error}`, { cause: error })
|
|
531
|
+
}
|
|
532
|
+
try {
|
|
533
|
+
writeJson(activeFile, { schema: 1, key: plan.key, platform: plan.platform, dir: finalDir, engine: plan.engine, version: plan.version, abi: plan.abi, installedAt: record.installedAt, verifiedAt: record.verifiedAt })
|
|
534
|
+
} catch (error) {
|
|
535
|
+
try { fs.rmSync(finalDir, { recursive: true, force: true }) } catch {}
|
|
536
|
+
throw failure('LOCAL_VECTOR_RUNTIME_INSTALL_FAILED', `cannot publish the managed runtime active pointer: ${error?.message || error}`, { cause: error })
|
|
537
|
+
}
|
|
538
|
+
// Cleanup is deliberately after pointer publication. A stale cleanup failure is harmless.
|
|
539
|
+
if (previousActive?.dir && previousActive.dir !== finalDir) {
|
|
540
|
+
try { fs.rmSync(insideRoot(previousActive.dir), { recursive: true, force: true }) } catch {}
|
|
541
|
+
}
|
|
542
|
+
outcome = null
|
|
543
|
+
job = null
|
|
544
|
+
return { status: 'installed', ready: true, reused: false, key: plan.key, platform: plan.platform, path: finalDir, engine: plan.engine, version: plan.version, abi: plan.abi, installedAt: record.installedAt, verifiedAt: record.verifiedAt, checks: record.checks, artifacts: record.artifacts.map(item => ({ name: item.name, version: item.version, bytes: item.bytes, sha256: item.sha256 })), smoke: record.smoke }
|
|
545
|
+
} catch (error) {
|
|
546
|
+
const aborted = isAbort(error, controller.signal)
|
|
547
|
+
const code = aborted ? 'LOCAL_VECTOR_RUNTIME_ABORTED' : (typeof error?.code === 'string' && error.code.startsWith('LOCAL_VECTOR_RUNTIME_') ? error.code : 'LOCAL_VECTOR_RUNTIME_INSTALL_FAILED')
|
|
548
|
+
const failed = code === error?.code ? error : failure(code, `installing the managed embedding runtime failed: ${error?.message || error}`, { cause: error })
|
|
549
|
+
outcome = { key: plan.key, status: 'failed', error: { code, message: String(failed.message || error) } }
|
|
550
|
+
job = null
|
|
551
|
+
warn(`managed embedding runtime install failed (${code}): ${failed.message}`)
|
|
552
|
+
throw failed
|
|
553
|
+
} finally {
|
|
554
|
+
options.signal?.removeEventListener?.('abort', onAbort)
|
|
555
|
+
try { release?.() } catch {}
|
|
556
|
+
if (staging && !published) { try { fs.rmSync(staging, { recursive: true, force: true }) } catch {} }
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
async function ensure(options = {}) {
|
|
561
|
+
if (disposed) throw failure('LOCAL_VECTOR_RUNTIME_CLOSED', 'the managed runtime installer is closed')
|
|
562
|
+
const { entry, plan } = planFor(options.modelId || options.id || null)
|
|
563
|
+
if (!plan.ok) {
|
|
564
|
+
const error = failure('LOCAL_VECTOR_RUNTIME_UNVERIFIED', `no verified managed runtime is available for ${plan.platform}`, { errors: plan.errors, entry: entry?.id ?? null })
|
|
565
|
+
throw error
|
|
566
|
+
}
|
|
567
|
+
const existing = inspectPlan(plan, { deep: true })
|
|
568
|
+
if (existing.ok) {
|
|
569
|
+
return { status: 'installed', ready: true, reused: true, key: plan.key, platform: plan.platform, path: existing.dir, engine: plan.engine, version: plan.version, abi: plan.abi, installedAt: existing.record.installedAt || null, verifiedAt: existing.record.verifiedAt || null, checks: Array.isArray(existing.record.checks) ? existing.record.checks : [], artifacts: plan.artifacts.map(item => ({ name: item.name, version: item.version, bytes: item.bytes, sha256: item.sha256 })), smoke: existing.record.smoke || null }
|
|
570
|
+
}
|
|
571
|
+
const pending = inflight.get(plan.key)
|
|
572
|
+
if (pending) return pending
|
|
573
|
+
if (options.reuseOnly === true) throw failure('LOCAL_VECTOR_RUNTIME_ABSENT', 'the managed embedding runtime is not installed yet', { inspect: existing.code })
|
|
574
|
+
if (options.confirmed !== true) throw failure('LOCAL_VECTOR_RUNTIME_CONFIRMATION_REQUIRED', 'installing the managed embedding runtime downloads pinned artifacts and needs explicit confirmation: ensure({ confirmed: true })')
|
|
575
|
+
if (options.signal?.aborted) throw failure('LOCAL_VECTOR_RUNTIME_ABORTED', 'the managed runtime install was aborted before it started')
|
|
576
|
+
const run = install(plan, options).finally(() => { inflight.delete(plan.key) })
|
|
577
|
+
inflight.set(plan.key, run)
|
|
578
|
+
return run
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function abort() {
|
|
582
|
+
try { job?.controller?.abort() } catch {}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function dispose() {
|
|
586
|
+
disposed = true
|
|
587
|
+
abort()
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
return { root: baseDir, platform, getStatus: status, ensure, activeDir, abort, dispose }
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// ------------------------------------------------------------------ 默认解包 / 默认 smoke
|
|
594
|
+
|
|
595
|
+
async function defaultExtract({ file, cwd, allowList, maxFileBytes }) {
|
|
596
|
+
const { x } = await import('tar')
|
|
597
|
+
ensureDir(cwd)
|
|
598
|
+
const allowed = rel => allowList.some(prefix => rel === prefix || rel.startsWith(prefix + '/'))
|
|
599
|
+
const allowDir = rel => allowList.some(prefix => prefix === rel || prefix.startsWith(rel + '/') || rel.startsWith(prefix + '/'))
|
|
600
|
+
await x({
|
|
601
|
+
file,
|
|
602
|
+
cwd,
|
|
603
|
+
strip: 1,
|
|
604
|
+
strict: true,
|
|
605
|
+
preservePaths: false,
|
|
606
|
+
filter: (name, entry) => {
|
|
607
|
+
const rel = String(name || '').replace(/^package\//, '')
|
|
608
|
+
if (!rel || rel === String(name)) return false
|
|
609
|
+
if (rel.includes('..') || path.isAbsolute(rel) || rel.includes('\\')) return false
|
|
610
|
+
if (entry?.type === 'Directory') return allowDir(rel)
|
|
611
|
+
if (entry?.type !== 'File') return false
|
|
612
|
+
if (!allowed(rel)) return false
|
|
613
|
+
return Number(entry.size || 0) <= maxFileBytes
|
|
614
|
+
},
|
|
615
|
+
})
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const SMOKE_SOURCE = [
|
|
619
|
+
"const path = require('node:path')",
|
|
620
|
+
"const { createRequire } = require('node:module')",
|
|
621
|
+
"const write = value => process.stdout.write(JSON.stringify(value) + '\\n')",
|
|
622
|
+
"try {",
|
|
623
|
+
" const from = createRequire(path.join(__dirname, 'entry.cjs'))",
|
|
624
|
+
" const ort = from('onnxruntime-node')",
|
|
625
|
+
" if (!ort || typeof ort.InferenceSession !== 'function') throw new Error('onnxruntime-node exposes no InferenceSession')",
|
|
626
|
+
" const pkg = from('onnxruntime-node/package.json')",
|
|
627
|
+
" const tok = from('@huggingface/tokenizers')",
|
|
628
|
+
" const Tokenizer = tok && (tok.Tokenizer || (tok.default && tok.default.Tokenizer))",
|
|
629
|
+
" if (typeof Tokenizer === 'undefined') throw new Error('@huggingface/tokenizers exposes no Tokenizer')",
|
|
630
|
+
" write({ ok: true, ortVersion: String((pkg && pkg.version) || ''), tokenizer: true })",
|
|
631
|
+
"} catch (error) {",
|
|
632
|
+
" write({ ok: false, message: String((error && error.message) || error) })",
|
|
633
|
+
" process.exitCode = 1",
|
|
634
|
+
"}",
|
|
635
|
+
].join('\n')
|
|
636
|
+
|
|
637
|
+
function smokeEnv(extra) {
|
|
638
|
+
const env = {}
|
|
639
|
+
for (const [name, value] of Object.entries(process.env)) {
|
|
640
|
+
if (typeof value !== 'string') continue
|
|
641
|
+
const lower = name.toLowerCase()
|
|
642
|
+
if (DENIED_ENV_NAMES.includes(lower) || lower.includes('proxy')) continue
|
|
643
|
+
if (DENIED_ENV_PREFIXES.some(prefix => lower.startsWith(prefix))) continue
|
|
644
|
+
env[name] = value
|
|
645
|
+
}
|
|
646
|
+
return { ...env, ...extra }
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
async function defaultSmoke({ dir, executable, spawnImpl, timeoutMs }) {
|
|
650
|
+
const script = path.join(dir, '.runtime-smoke.cjs')
|
|
651
|
+
fs.writeFileSync(script, SMOKE_SOURCE + '\n', { mode: 0o600, flag: 'w' })
|
|
652
|
+
try {
|
|
653
|
+
return await new Promise(resolve => {
|
|
654
|
+
let stdout = ''
|
|
655
|
+
let settled = false
|
|
656
|
+
let timer = null
|
|
657
|
+
const finish = value => {
|
|
658
|
+
if (settled) return
|
|
659
|
+
settled = true
|
|
660
|
+
if (timer) clearTimeout(timer)
|
|
661
|
+
resolve(value)
|
|
662
|
+
}
|
|
663
|
+
let child = null
|
|
664
|
+
try {
|
|
665
|
+
child = spawnImpl(executable, [script], {
|
|
666
|
+
windowsHide: true,
|
|
667
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
668
|
+
env: smokeEnv({ ELECTRON_RUN_AS_NODE: '1' }),
|
|
669
|
+
})
|
|
670
|
+
} catch (error) {
|
|
671
|
+
finish({ ok: false, message: `cannot start the runtime smoke process: ${error?.message || error}` })
|
|
672
|
+
return
|
|
673
|
+
}
|
|
674
|
+
timer = setTimeout(() => {
|
|
675
|
+
try { child?.kill() } catch {}
|
|
676
|
+
finish({ ok: false, message: 'the runtime smoke process did not finish in time' })
|
|
677
|
+
}, timeoutMs)
|
|
678
|
+
timer.unref?.()
|
|
679
|
+
try { child?.stdin?.on?.('error', () => {}) } catch {}
|
|
680
|
+
try { child?.stderr?.on?.('data', () => {}) } catch {}
|
|
681
|
+
child?.on?.('error', error => finish({ ok: false, message: `the runtime smoke process failed: ${error?.message || error}` }))
|
|
682
|
+
child?.stdout?.on?.('data', chunk => {
|
|
683
|
+
stdout += String(chunk)
|
|
684
|
+
if (stdout.length > READY_LIMIT) {
|
|
685
|
+
try { child?.kill() } catch {}
|
|
686
|
+
finish({ ok: false, message: 'the runtime smoke output exceeded the limit' })
|
|
687
|
+
}
|
|
688
|
+
})
|
|
689
|
+
child?.on?.('close', code => {
|
|
690
|
+
const line = stdout.split(String.fromCharCode(10)).map(item => item.replace(/\r+$/, '')).filter(Boolean)[0] || ''
|
|
691
|
+
let parsed = null
|
|
692
|
+
try { parsed = JSON.parse(line) } catch { parsed = null }
|
|
693
|
+
if (parsed && parsed.ok === true) finish({ ok: true, ortVersion: parsed.ortVersion || null, tokenizer: parsed.tokenizer === true, checks: [`smoke:onnxruntime-node@${parsed.ortVersion || 'unknown'}`] })
|
|
694
|
+
else finish({ ok: false, message: String(parsed?.message || `the runtime smoke process exited with code ${code}`) })
|
|
695
|
+
})
|
|
696
|
+
})
|
|
697
|
+
} finally {
|
|
698
|
+
try { fs.rmSync(script, { force: true }) } catch {}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
let singleton = null
|
|
703
|
+
export function managedEmbeddingRuntime(options = {}) {
|
|
704
|
+
if (!singleton || options.root && path.resolve(options.root) !== singleton.root) singleton = createEmbeddingRuntimeInstaller(options)
|
|
705
|
+
return singleton
|
|
706
|
+
}
|
|
707
|
+
export function resetManagedEmbeddingRuntime() {
|
|
708
|
+
try { singleton?.dispose() } catch {}
|
|
709
|
+
singleton = null
|
|
710
|
+
}
|