memory-eternal 0.10.3 → 0.10.5
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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/README.en.md +8 -1
- package/README.md +11 -1
- package/bin/dsh-memory.mjs +53 -5
- package/index.js +51 -8
- package/lib/api.js +62 -8
- package/lib/capture.js +128 -20
- package/lib/client.js +11 -11
- package/lib/config-sync.js +137 -1
- package/lib/host-heartbeat.js +72 -0
- package/lib/watchdog.js +165 -3
- package/package.json +2 -2
- package/scripts/release.mjs +14 -0
- package/web/app.js +12 -12
package/lib/config-sync.js
CHANGED
|
@@ -98,6 +98,62 @@ export function clearPendingConfig(env = process.env) {
|
|
|
98
98
|
try { fs.unlinkSync(pendingConfigPath(env)); return true } catch { return false }
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/**
|
|
102
|
+
* 直接把 patch 合并进**共享配置文件**(宿主无关路径:本机没有 DSH 宿主时用)。
|
|
103
|
+
*
|
|
104
|
+
* 只在「确认没有活着的 DSH 宿主」时调用(见 lib/host-heartbeat.js):宿主活着时共享文件是
|
|
105
|
+
* 它的派生镜像,绕过它直写会与 volatile 配置分叉,下一次 syncConfigFile() 就会盖回来。
|
|
106
|
+
* 合并(而不是整体替换)是有意为之:老版本写下的键、以及其它工具手工加的键都要留住。
|
|
107
|
+
*
|
|
108
|
+
* @returns {object} 合并后的完整配置
|
|
109
|
+
*/
|
|
110
|
+
export function applySharedConfigDirect(env, patch) {
|
|
111
|
+
const file = configFilePath(env)
|
|
112
|
+
let current = {}
|
|
113
|
+
try {
|
|
114
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
115
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) current = parsed
|
|
116
|
+
} catch { current = {} }
|
|
117
|
+
const next = { ...current, ...(patch && typeof patch === 'object' ? patch : {}) }
|
|
118
|
+
writeFileAtomicSync(file, JSON.stringify(next, null, 2))
|
|
119
|
+
return next
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 宿主守卫类报错的特征串 —— 同一族缺陷在不同宿主/不同服务上的措辞不同(issue #21):
|
|
123
|
+
// · Cordis HMR 宿主(官方桌面版):runExclusive 拒绝嵌套事务
|
|
124
|
+
// · dsh-tui 一类宿主:能力守卫在 settings.update 内部的 describe() 上抛
|
|
125
|
+
const HOST_GUARD_PATTERNS = [
|
|
126
|
+
/HMR transactions cannot be nested/i,
|
|
127
|
+
/is unavailable from a plugin activation/i,
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
/** 这个报错是不是「宿主不允许插件从自己的回调上下文写配置」。 */
|
|
131
|
+
export function isHostGuardError(message) {
|
|
132
|
+
const s = String(message || '')
|
|
133
|
+
return HOST_GUARD_PATTERNS.some((re) => re.test(s))
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 把「应用失败」翻译成给用户看的话(诊断信息 / 自动沉淀日志 / 独立页提示共用)。
|
|
138
|
+
*
|
|
139
|
+
* host-guard 类**不是配置内容的问题**,所以说清「去哪改」比重复报错有用得多:
|
|
140
|
+
* 宿主自己的设置页走的写路径由宿主实现,不受插件上下文限制。
|
|
141
|
+
*
|
|
142
|
+
* @returns {{kind:'host-guard'|'other', hint:string}}
|
|
143
|
+
*/
|
|
144
|
+
export function describeApplyFailure(message) {
|
|
145
|
+
const raw = String(message || '')
|
|
146
|
+
if (!raw) return { kind: 'other', hint: '' }
|
|
147
|
+
if (isHostGuardError(raw)) {
|
|
148
|
+
return {
|
|
149
|
+
kind: 'host-guard',
|
|
150
|
+
hint: '此宿主不允许插件从自己的回调上下文里写配置(原报错:' + raw + ')—— 与配置内容无关。请到「DSH 设置 → 记忆」里改同一项(那条写路径由宿主自己实现,不受此限制),或直接编辑 profile 的配置补丁层。',
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return { kind: 'other', hint: '原报错:' + raw + ';可到「DSH 设置 → 记忆」重试,或查看「反馈异常」里的诊断信息。' }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
101
157
|
/**
|
|
102
158
|
* 监听待应用文件的变化(毫秒级触发),用于把「独立页保存 → DSH 应用」从 5 秒轮询
|
|
103
159
|
* 提升到准即时。监听失败(平台/权限)时退化为不监听 —— 仍有 5 秒轮询兜底。
|
|
@@ -125,6 +181,82 @@ export function watchPendingConfig(env, onChange, { debounceMs = 60 } = {}) {
|
|
|
125
181
|
}
|
|
126
182
|
}
|
|
127
183
|
|
|
184
|
+
/**
|
|
185
|
+
* patch 里的每个键是否都已在配置快照里生效(浅比较,数组/对象按 JSON 串等价判断)。
|
|
186
|
+
*
|
|
187
|
+
* 用途(issue #21):dsh-tui 一类宿主的能力守卫会在 settings.update **内部**的
|
|
188
|
+
* describe() 上抛「root.events.emit is unavailable from a plugin activation」,
|
|
189
|
+
* 但写入本身可能已经提交。只认「有没有抛错」,就会把其实已经生效的改动重试 5 次
|
|
190
|
+
* 后标成 dropped,日志里还报成失败 —— 回读是唯一能自证的信号。
|
|
191
|
+
*
|
|
192
|
+
* @returns {boolean} 三个条件全部满足才为 true:patch 非空、config 是对象、每个键取值一致
|
|
193
|
+
*/
|
|
194
|
+
export function patchApplied(config, patch) {
|
|
195
|
+
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) return false
|
|
196
|
+
if (!config || typeof config !== 'object') return false
|
|
197
|
+
const keys = Object.keys(patch)
|
|
198
|
+
if (!keys.length) return false
|
|
199
|
+
for (const key of keys) {
|
|
200
|
+
const want = JSON.stringify(patch[key])
|
|
201
|
+
const got = config[key] === undefined ? undefined : JSON.stringify(config[key])
|
|
202
|
+
if (want !== got) return false
|
|
203
|
+
}
|
|
204
|
+
return true
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* 应用一次 patch,并在抛错后**回读确认**(issue #21 的核心修复)。
|
|
209
|
+
*
|
|
210
|
+
* 背景:dsh-tui 一类宿主的能力守卫会在 settings.update **内部**的 describe() 上抛
|
|
211
|
+
* 「root.events.emit is unavailable from a plugin activation」,但写入可能已经提交。
|
|
212
|
+
* 只看有没有抛错,就会把其实已经生效的改动当成失败重试 5 次、最后标成 dropped。
|
|
213
|
+
*
|
|
214
|
+
* @param {object} patch
|
|
215
|
+
* @param {object} opts
|
|
216
|
+
* @param {(patch:object)=>any} opts.apply 真正的写入(可能抛)
|
|
217
|
+
* @param {()=>object} opts.read 回读配置快照
|
|
218
|
+
* @param {(config:object, patch:object)=>boolean} [opts.verify] 判定是否已生效(默认浅比较)
|
|
219
|
+
* @param {(error:any)=>void} [opts.onRepaired] 命中「抛错但其实已生效」时的回调(记日志用)
|
|
220
|
+
* @returns {Promise<{applied:boolean, repaired:boolean}>} repaired=由回读判定成功
|
|
221
|
+
*/
|
|
222
|
+
export async function applyPatchVerified(patch, { apply, read, verify = patchApplied, tries = 3, delayMs = 120, onRepaired } = {}) {
|
|
223
|
+
try {
|
|
224
|
+
await apply(patch)
|
|
225
|
+
return { applied: true, repaired: false }
|
|
226
|
+
} catch (error) {
|
|
227
|
+
const rounds = Math.max(1, Number(tries) || 1)
|
|
228
|
+
for (let i = 0; i < rounds; i++) {
|
|
229
|
+
let snapshot = null
|
|
230
|
+
try { snapshot = read() } catch { snapshot = null }
|
|
231
|
+
if (verify(snapshot, patch)) {
|
|
232
|
+
if (typeof onRepaired === 'function') { try { onRepaired(error) } catch { /* 回调异常不影响结果 */ } }
|
|
233
|
+
return { applied: true, repaired: true }
|
|
234
|
+
}
|
|
235
|
+
if (i < rounds - 1 && Number(delayMs) > 0) await new Promise((r) => setTimeout(r, Number(delayMs)))
|
|
236
|
+
}
|
|
237
|
+
throw error
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* 等一下看这次写入的最终去向(issue #21 建议 3):宿主在跑时对 pending 文件有
|
|
243
|
+
* fs.watch(毫秒级),稍微等一会儿就能分辨「真的应用了」和「只是排上队了」。
|
|
244
|
+
*
|
|
245
|
+
* @returns {Promise<'applied'|'failed'|'queued'>}
|
|
246
|
+
* applied = pending 文件已被宿主消费;failed = 已被标记 dropped;
|
|
247
|
+
* queued = 到超时还在(DSH 没运行 / 还没轮到它)
|
|
248
|
+
*/
|
|
249
|
+
export async function waitPendingOutcome(env = process.env, { timeoutMs = 800, intervalMs = 100 } = {}) {
|
|
250
|
+
const deadline = Date.now() + Math.max(0, Number(timeoutMs) || 0)
|
|
251
|
+
for (;;) {
|
|
252
|
+
const pending = readPendingConfig(env)
|
|
253
|
+
if (!pending) return 'applied'
|
|
254
|
+
if (pending.dropped) return 'failed'
|
|
255
|
+
if (Date.now() >= deadline) return 'queued'
|
|
256
|
+
await new Promise((r) => setTimeout(r, Math.max(20, Number(intervalMs) || 100)))
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
128
260
|
/**
|
|
129
261
|
* 取出并应用一次待应用改动:apply(patch) 成功后删除文件;失败保留(下轮重试)。
|
|
130
262
|
*
|
|
@@ -148,7 +280,11 @@ export async function drainPendingConfig(env, apply, maxTries = 5) {
|
|
|
148
280
|
const reason = String((error && error.message) || error)
|
|
149
281
|
if (tries >= maxTries) {
|
|
150
282
|
writePendingConfig(env, pending.patch, pending.at, tries, { dropped: true, lastError: reason })
|
|
151
|
-
|
|
283
|
+
// 失败原因分两类:宿主守卫类(不是配置内容的问题,要说清去哪改)与其它。
|
|
284
|
+
const advice = describeApplyFailure(reason)
|
|
285
|
+
const dropped = new Error('待应用配置连续失败 ' + tries + ' 次已放弃(原报错:' + reason + ')'
|
|
286
|
+
+ (advice.kind === 'host-guard' ? '。' + advice.hint : '')
|
|
287
|
+
+ ';改动仍保留在 ' + pendingConfigPath(env) + ',可修正后重新保存')
|
|
152
288
|
dropped.dropped = true
|
|
153
289
|
dropped.lastError = reason
|
|
154
290
|
throw dropped
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// 宿主心跳:让「独立 Web 端」知道本机此刻有没有活着的 DSH 宿主(issue #21 的宿主无关写路径)。
|
|
2
|
+
//
|
|
3
|
+
// 为什么需要它:配置只有一个权威写入者 —— DSH 宿主(插件用 settings.update 应用,再由
|
|
4
|
+
// index.js 的 syncConfigFile 写共享配置文件)。独立 Web 进程里没有 settings 服务,只能把改动
|
|
5
|
+
// 写进「待应用」文件、等宿主来 drain。但**非 DSH 宿主**(Codex / Claude Code / Cursor,
|
|
6
|
+
// 或只跑 `dsh-memory serve` 的用户)根本没有宿主来 drain:改动会永远停在 pending 文件里,
|
|
7
|
+
// 保存等于没保存 —— 而界面上那句「下次启动生效」在那类环境下是错的(不会有"下次启动 DSH")。
|
|
8
|
+
//
|
|
9
|
+
// 判据:宿主活着时周期性刷新 `<共享配置>.host.json`(pid + 版本 + 时间戳)。
|
|
10
|
+
// 独立端保存后若「没人消费 pending」且「心跳不新鲜」,即判定本机没有宿主 → 直接原子写共享配置。
|
|
11
|
+
// 反之(宿主活着)**绝不**绕过宿主写文件:那会与宿主的 volatile 配置分叉,下一次
|
|
12
|
+
// syncConfigFile() 就会把改动盖回去。
|
|
13
|
+
import fs from 'node:fs'
|
|
14
|
+
import { configFilePath } from './capture-run.js'
|
|
15
|
+
import { writeFileAtomicSync } from './config-sync.js'
|
|
16
|
+
|
|
17
|
+
/** 心跳文件路径(与共享配置同目录:`xxx.json` → `xxx.host.json`)。 */
|
|
18
|
+
export function hostMarkerPath(env = process.env) {
|
|
19
|
+
return String(configFilePath(env)).replace(/\.json$/i, '.host.json')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 进程存活判定(EPERM = 存在但无权发信号,仍算活着)。 */
|
|
23
|
+
export function isProcessAlive(pid) {
|
|
24
|
+
const n = Number(pid)
|
|
25
|
+
if (!Number.isFinite(n) || n <= 0) return false
|
|
26
|
+
try {
|
|
27
|
+
process.kill(n, 0)
|
|
28
|
+
return true
|
|
29
|
+
} catch (error) {
|
|
30
|
+
return Boolean(error && error.code === 'EPERM')
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 写/刷新心跳(原子写:这是跨进程判据,读方可能正好在写)。 */
|
|
35
|
+
export function writeHostMarker(env = process.env, info = {}, now = Date.now()) {
|
|
36
|
+
const data = { pid: process.pid, at: now, ...info }
|
|
37
|
+
writeFileAtomicSync(hostMarkerPath(env), JSON.stringify(data, null, 2))
|
|
38
|
+
return data
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 读心跳;文件不存在 / 内容损坏都返回 null。 */
|
|
42
|
+
export function readHostMarker(env = process.env) {
|
|
43
|
+
try {
|
|
44
|
+
const data = JSON.parse(fs.readFileSync(hostMarkerPath(env), 'utf8'))
|
|
45
|
+
if (!data || typeof data !== 'object') return null
|
|
46
|
+
return {
|
|
47
|
+
pid: Number(data.pid) || 0,
|
|
48
|
+
at: Number(data.at) || 0,
|
|
49
|
+
version: String(data.version || ''),
|
|
50
|
+
}
|
|
51
|
+
} catch { return null }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 删除心跳(插件 dispose 时尽力而为;宿主被硬杀时靠新鲜度判定兜底)。 */
|
|
55
|
+
export function clearHostMarker(env = process.env) {
|
|
56
|
+
try { fs.unlinkSync(hostMarkerPath(env)); return true } catch { return false }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 本机此刻有没有活着的 DSH 宿主:心跳新鲜(默认 45s,宿主每 15s 刷一次)**且**那个 pid 还在。
|
|
61
|
+
* 两个条件都要,避免「宿主崩了但心跳文件还在」被误判成有宿主。
|
|
62
|
+
*
|
|
63
|
+
* @param {Record<string,string>} [env]
|
|
64
|
+
* @param {{maxAgeMs?:number, now?:number, isAlive?:(pid:number)=>boolean}} [opts]
|
|
65
|
+
* @returns {boolean}
|
|
66
|
+
*/
|
|
67
|
+
export function hostAlive(env = process.env, { maxAgeMs = 45000, now = Date.now(), isAlive = isProcessAlive } = {}) {
|
|
68
|
+
const marker = readHostMarker(env)
|
|
69
|
+
if (!marker || !marker.pid || !marker.at) return false
|
|
70
|
+
if (now - marker.at > Math.max(0, Number(maxAgeMs) || 0)) return false
|
|
71
|
+
return isAlive(marker.pid)
|
|
72
|
+
}
|
package/lib/watchdog.js
CHANGED
|
@@ -168,9 +168,13 @@ export function watchdogStatus({ env = process.env, port } = {}) {
|
|
|
168
168
|
* @param {(pid:number)=>boolean} [opts.kill] 注入用(单测)
|
|
169
169
|
* @param {(pid:number)=>boolean} [opts.isAlive] 注入用(单测)
|
|
170
170
|
* @param {()=>Promise<Array<{pid:number,port:number,command:string}>>} [opts.listProcesses] 注入用(单测)
|
|
171
|
-
* @
|
|
171
|
+
* @param {boolean} [opts.checkPort] 停完是否再问一次「端口上还有谁在服务」(issue #23)
|
|
172
|
+
* @param {boolean} [opts.forcePortOccupant] 是否连「按端口找到、且确认是本插件的 web」也一起收掉
|
|
173
|
+
* @param {(port:number)=>Promise<{pid:number,command:string}|null>} [opts.findListener] 注入用(单测)
|
|
174
|
+
* @returns {Promise<{stopped:number[], webStopped:number[], failed:number[], skipped:number[],
|
|
175
|
+
* portOwners:Array<{port:number,pid:number,command:string}>, portStopped:number[], warnings:string[]}>}
|
|
172
176
|
*/
|
|
173
|
-
export async function stopWatchdogs({ env = process.env, port, timeoutMs = 5000, kill, isAlive, sleep, listProcesses } = {}) {
|
|
177
|
+
export async function stopWatchdogs({ env = process.env, port, timeoutMs = 5000, kill, isAlive, sleep, listProcesses, checkPort = false, forcePortOccupant = false, findListener } = {}) {
|
|
174
178
|
const want = port === undefined || port === null || port === '' ? null : Number(port)
|
|
175
179
|
const alive = isAlive || isPidAlive
|
|
176
180
|
const killFn = kill || ((pid) => { try { process.kill(pid, 'SIGTERM'); return true } catch { return false } })
|
|
@@ -204,9 +208,14 @@ export async function stopWatchdogs({ env = process.env, port, timeoutMs = 5000,
|
|
|
204
208
|
// watchdog 的 stop() 处理器不会执行 —— 不在这里收,就会留下一个占着端口的孤儿 web,
|
|
205
209
|
// 而「stop 了却还占端口」正是 issue #19 抱怨的那类不可控状态。
|
|
206
210
|
const webStopped = []
|
|
211
|
+
const webSkipped = [] // issue #23 建议 4:要收但没敢收的,必须留一行可查的话,不能静默
|
|
207
212
|
for (const target of wantedWeb) {
|
|
208
213
|
if (!alive(target.pid)) continue
|
|
209
|
-
if (confirmedWeb && !confirmedWeb.has(target.pid)) {
|
|
214
|
+
if (confirmedWeb && !confirmedWeb.has(target.pid)) {
|
|
215
|
+
skipped.push(target.pid)
|
|
216
|
+
webSkipped.push(target.pid)
|
|
217
|
+
continue
|
|
218
|
+
}
|
|
210
219
|
if (!killFn(target.pid)) { failed.push(target.pid); continue }
|
|
211
220
|
webStopped.push(target.pid)
|
|
212
221
|
}
|
|
@@ -215,11 +224,54 @@ export async function stopWatchdogs({ env = process.env, port, timeoutMs = 5000,
|
|
|
215
224
|
if (alive(pid)) { if (pid !== 0) failed.push(pid); continue }
|
|
216
225
|
releaseWatchdogLock({ env, pid })
|
|
217
226
|
}
|
|
227
|
+
|
|
228
|
+
// 端口兜底(issue #23):锁里登记的 webPid 可能压根没写、或者写了却枚举不到,
|
|
229
|
+
// 于是「已停止旧实例」打印了、端口上跑的还是旧代码(升级静默不生效)。
|
|
230
|
+
// 收紧到「直接问操作系统谁占着端口」,不再只信锁;可选地把它一起收掉。
|
|
231
|
+
const warnings = []
|
|
232
|
+
const portOwners = []
|
|
233
|
+
const portStopped = []
|
|
234
|
+
if (webSkipped.length) {
|
|
235
|
+
warnings.push(`锁里登记的 web 子进程 pid ${webSkipped.join(', ')} 未被收掉:在进程列表里没确认到它是 web.js(防 pid 复用误杀,宁可不杀)——若端口仍被占着,请手工结束它`)
|
|
236
|
+
}
|
|
237
|
+
if (checkPort) {
|
|
238
|
+
const ports = want === null
|
|
239
|
+
? [...new Set(slots.map((w) => Number(w.port) || 0))].filter((p) => p > 0)
|
|
240
|
+
: [want]
|
|
241
|
+
const probeListener = async (p) => {
|
|
242
|
+
try { return findListener ? await findListener(p) : await findPortListener(p) } catch { return null }
|
|
243
|
+
}
|
|
244
|
+
for (const p of ports) {
|
|
245
|
+
const found = await probeListener(p)
|
|
246
|
+
if (found && found.pid) portOwners.push({ port: p, pid: Number(found.pid), command: String(found.command || '') })
|
|
247
|
+
}
|
|
248
|
+
if (portOwners.length && forcePortOccupant) {
|
|
249
|
+
for (const occ of portOwners) {
|
|
250
|
+
if (!looksLikeOurWeb(occ.command)) {
|
|
251
|
+
warnings.push(`端口 ${occ.port} 被 pid ${occ.pid} 占用,但它看起来不是本插件拉起的 web(${occ.command || '命令行未知'})→ 不自动终止,请人工确认`)
|
|
252
|
+
continue
|
|
253
|
+
}
|
|
254
|
+
if (!killFn(occ.pid)) { warnings.push(`端口 ${occ.port} 上的旧 web(pid ${occ.pid})未能终止,请手工 kill`); continue }
|
|
255
|
+
const webDeadline = Date.now() + Math.max(0, Number(timeoutMs) || 0)
|
|
256
|
+
while (Date.now() < webDeadline && alive(occ.pid)) await wait(100)
|
|
257
|
+
portStopped.push(occ.pid)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
// 收尾复检:这时还占着端口的,就是需要人介入的
|
|
261
|
+
for (const p of ports) {
|
|
262
|
+
const found = await probeListener(p)
|
|
263
|
+
if (!found || !found.pid) continue
|
|
264
|
+
warnings.push(`端口 ${p} 仍被 pid ${found.pid} 占用(${found.command || '命令行未知'});status 显示的版本可能不是端口上真正服务的版本`)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
218
267
|
return {
|
|
219
268
|
stopped: [...new Set(stopped.filter((p) => !failed.includes(p)))],
|
|
220
269
|
webStopped: [...new Set(webStopped.filter((p) => !failed.includes(p)))],
|
|
221
270
|
failed: [...new Set(failed)],
|
|
222
271
|
skipped: [...new Set(skipped)],
|
|
272
|
+
portOwners,
|
|
273
|
+
portStopped: [...new Set(portStopped)],
|
|
274
|
+
warnings,
|
|
223
275
|
}
|
|
224
276
|
}
|
|
225
277
|
|
|
@@ -327,6 +379,116 @@ export async function probe(port, timeoutMs = 1000) {
|
|
|
327
379
|
})
|
|
328
380
|
}
|
|
329
381
|
|
|
382
|
+
/**
|
|
383
|
+
* 探测端口上**实际服务**的那个进程加载的包版本(issue #23)。
|
|
384
|
+
*
|
|
385
|
+
* 为什么必须问端口而不是读锁文件:锁里的 pkgVersion 是 watchdog 自己写的,
|
|
386
|
+
* 与端口上真正跑着的代码无关 —— 升级后旧 web 仍占着端口时,status 会骗人地报新版本。
|
|
387
|
+
* /overview 的 version 字段由被探测进程自己(lib/web.js 模块加载时读 package.json)给出。
|
|
388
|
+
*
|
|
389
|
+
* @returns {Promise<string>} 取不到返回空串
|
|
390
|
+
*/
|
|
391
|
+
export async function probeServedVersion(port, timeoutMs = 1200) {
|
|
392
|
+
try {
|
|
393
|
+
const info = await probe(port, timeoutMs)
|
|
394
|
+
return info && typeof info.version === 'string' ? info.version : ''
|
|
395
|
+
} catch { return '' }
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* 轮询等到端口上的服务版本等于期望值(restart 后的自检,issue #23 建议 2)。
|
|
400
|
+
*
|
|
401
|
+
* @returns {Promise<{ok:boolean, version:string, waitedMs:number}>}
|
|
402
|
+
*/
|
|
403
|
+
export async function waitForServedVersion(port, expect, { timeoutMs = 15000, intervalMs = 500, probeFn } = {}) {
|
|
404
|
+
const probeVersion = probeFn || probeServedVersion
|
|
405
|
+
const start = Date.now()
|
|
406
|
+
let version = ''
|
|
407
|
+
for (;;) {
|
|
408
|
+
version = String((await probeVersion(port)) || '')
|
|
409
|
+
if (version && version === String(expect || '')) return { ok: true, version, waitedMs: Date.now() - start }
|
|
410
|
+
if (Date.now() - start >= Math.max(0, Number(timeoutMs) || 0)) return { ok: false, version, waitedMs: Date.now() - start }
|
|
411
|
+
await new Promise((r) => setTimeout(r, Math.max(50, Number(intervalMs) || 500)))
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* 解析 `lsof -Fpcn` 的输出(每个进程一段:p<pid> / c<command> / n<name>)。
|
|
417
|
+
* 纯函数,便于单测(POSIX 侧探测端口占用者用它)。
|
|
418
|
+
*/
|
|
419
|
+
export function parseLsofFields(text) {
|
|
420
|
+
const out = []
|
|
421
|
+
let cur = null
|
|
422
|
+
for (const raw of String(text || '').split(/\r?\n/)) {
|
|
423
|
+
const line = raw.trim()
|
|
424
|
+
if (!line) continue
|
|
425
|
+
const tag = line[0]
|
|
426
|
+
const val = line.slice(1)
|
|
427
|
+
if (tag === 'p') {
|
|
428
|
+
if (cur) out.push(cur)
|
|
429
|
+
cur = { pid: Number(val) || 0, command: '', name: '' }
|
|
430
|
+
} else if (!cur) {
|
|
431
|
+
continue
|
|
432
|
+
} else if (tag === 'c') cur.command = val
|
|
433
|
+
else if (tag === 'n') cur.name = val
|
|
434
|
+
}
|
|
435
|
+
if (cur) out.push(cur)
|
|
436
|
+
return out.filter((p) => p.pid > 0)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* 查「谁正占着这个端口」(不依赖锁文件,issue #23 建议 3)。
|
|
441
|
+
*
|
|
442
|
+
* 锁里没登记 webPid 的旧实例、或 pid 复用导致的登记失效,只有直接问操作系统
|
|
443
|
+
* 才能发现 —— 这正是「restart 打印成功、端口上还是旧代码」的漏网处。
|
|
444
|
+
*
|
|
445
|
+
* @param {number} port
|
|
446
|
+
* @param {{exec?: (cmd:string,args:string[])=>Promise<string>}} [opts]
|
|
447
|
+
* @returns {Promise<{pid:number, command:string}|null>}
|
|
448
|
+
*/
|
|
449
|
+
export async function findPortListener(port, { exec } = {}) {
|
|
450
|
+
const p = Number(port)
|
|
451
|
+
if (!Number.isFinite(p) || p <= 0) return null
|
|
452
|
+
const runExec = exec || defaultExec()
|
|
453
|
+
try {
|
|
454
|
+
if (process.platform === 'win32') {
|
|
455
|
+
const out = await runExec('powershell', ['-NoProfile', '-Command', `Get-NetTCPConnection -LocalPort ${p} -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess`])
|
|
456
|
+
const pid = Number(String(out || '').split(/\r?\n/).map((s) => s.trim()).find((s) => /^\d+$/.test(s))) || 0
|
|
457
|
+
if (!pid) return null
|
|
458
|
+
const cmd = await runExec('powershell', ['-NoProfile', '-Command', `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}" | Select-Object -ExpandProperty CommandLine)`])
|
|
459
|
+
return { pid, command: String(cmd || '').trim() }
|
|
460
|
+
}
|
|
461
|
+
const fields = await runExec('lsof', ['-nP', `-iTCP:${p}`, '-sTCP:LISTEN', '-Fpcn'])
|
|
462
|
+
const list = parseLsofFields(fields)
|
|
463
|
+
if (!list.length) return null
|
|
464
|
+
return { pid: list[0].pid, command: (list[0].command + (list[0].name ? ' ' + list[0].name : '')).trim() }
|
|
465
|
+
} catch { return null }
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* 这个占用者看起来是不是本插件拉起的 web(只按命令行判定,用于「收不收」的门禁)。
|
|
470
|
+
*
|
|
471
|
+
* 判据必须收紧到「本插件自己的 web.js」:早先只要命令行里出现 `web.js` 就算命中,
|
|
472
|
+
* 而 `node web.js` 是别的项目里极常见的写法 —— `dsh-memory restart --port N` 时,
|
|
473
|
+
* 恰好占着 N 端口的外来进程会被**误杀**(issue #23 的修复不能引入新的误杀)。
|
|
474
|
+
* 现在只在两种情况下放行:
|
|
475
|
+
* ① 命令行里带 `memory-eternal`(包名 / 安装路径 / pnpm store 路径);
|
|
476
|
+
* ② 命令行里出现**本进程加载的** lib/web.js 绝对路径 —— 同一次安装拉起的 web 必然命中
|
|
477
|
+
* (watchdog spawn 用的就是同一个 WEB_JS,见 spawnWeb)。
|
|
478
|
+
* 认不出来的一律只告警、不动手:宁可不杀,也不误杀。
|
|
479
|
+
*
|
|
480
|
+
* @param {string} command 占用者的命令行
|
|
481
|
+
* @param {{webJs?: string}} [opts] webJs 默认本文件同级的 web.js(单测注入用)
|
|
482
|
+
*/
|
|
483
|
+
export function looksLikeOurWeb(command, { webJs = WEB_JS } = {}) {
|
|
484
|
+
const s = String(command || '')
|
|
485
|
+
if (!s) return false
|
|
486
|
+
if (/memory-eternal/i.test(s)) return true
|
|
487
|
+
const norm = (p) => String(p || '').replace(/\\/g, '/').toLowerCase()
|
|
488
|
+
const target = norm(webJs)
|
|
489
|
+
return Boolean(target) && norm(s).includes(target)
|
|
490
|
+
}
|
|
491
|
+
|
|
330
492
|
/** 探测端口是否有任何 TCP 监听(即便不是我们的服务也占用)。 */
|
|
331
493
|
export async function probeTcp(port, timeoutMs = 600) {
|
|
332
494
|
return new Promise((resolve) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memory-eternal",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.5",
|
|
4
4
|
"description": "记忆核心(Memory Eternal):自研的 DeepSeek Harness 记忆插件,不移植任何既有记忆框架——对话结束后自动沉淀知识卡到本地 Markdown Vault(自研去重、自研 CJK 检索、可 git 管理),设置页提供图形化知识库(统计 / 搜索 / 知识图谱 + 侧边栏一键弹窗),Agent 通过 memory_recall 工具按需召回历史上下文。零人工干预。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"prepublishOnly": "node build.mjs",
|
|
65
65
|
"release": "node scripts/release.mjs",
|
|
66
66
|
"release:dry": "node scripts/release.mjs --dry-run",
|
|
67
|
-
"test": "node build.mjs && node tests/vault.test.mjs && node tests/capture.test.mjs && node tests/capture-route.test.mjs && node tests/stall.test.mjs && node tests/vault-path.test.mjs && node tests/vault-resolve.test.mjs && node tests/minhash.test.mjs && node tests/settings-save.test.mjs && node tests/config-coverage.test.mjs && node tests/config-sync.test.mjs && node tests/config-merge.test.mjs && node tests/sse.test.mjs && node tests/api.test.mjs && node tests/import-roundtrip.test.mjs && node tests/graph-perf.test.mjs && node tests/settings-compat.test.mjs && node tests/node-bin.test.mjs && node tests/watchdog-lock.test.mjs && node tests/markdown.test.mjs && node tests/feedback.test.mjs && node tests/graph-lod.test.mjs && node tests/ssr.test.mjs && node tests/web-assets.test.mjs && node tests/manifests.test.mjs && node tests/two-store.test.mjs && node tests/noise-gate.test.mjs && node tests/e2e-recall.test.mjs && node tests/issue-fixes.test.mjs"
|
|
67
|
+
"test": "node build.mjs && node tests/vault.test.mjs && node tests/capture.test.mjs && node tests/capture-route.test.mjs && node tests/stall.test.mjs && node tests/vault-path.test.mjs && node tests/vault-resolve.test.mjs && node tests/minhash.test.mjs && node tests/settings-save.test.mjs && node tests/config-coverage.test.mjs && node tests/config-sync.test.mjs && node tests/config-merge.test.mjs && node tests/sse.test.mjs && node tests/api.test.mjs && node tests/import-roundtrip.test.mjs && node tests/graph-perf.test.mjs && node tests/settings-compat.test.mjs && node tests/node-bin.test.mjs && node tests/watchdog-lock.test.mjs && node tests/markdown.test.mjs && node tests/feedback.test.mjs && node tests/graph-lod.test.mjs && node tests/ssr.test.mjs && node tests/web-assets.test.mjs && node tests/manifests.test.mjs && node tests/two-store.test.mjs && node tests/noise-gate.test.mjs && node tests/e2e-recall.test.mjs && node tests/issue-fixes.test.mjs && node tests/issue-fixes-2.test.mjs && node tests/issue-fixes-3.test.mjs"
|
|
68
68
|
},
|
|
69
69
|
"dependencies": {
|
|
70
70
|
"@deepseek-ai/schemastery": "^3.18.1"
|
package/scripts/release.mjs
CHANGED
|
@@ -88,6 +88,20 @@ if (!SKIP_TESTS) {
|
|
|
88
88
|
say('(--skip-tests:跳过测试)')
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
// 构建产物一致性闸门(v0.10.3 踩过的坑):
|
|
92
|
+
// npm test 会跑 build.mjs 重新生成 lib/client.js 与 web/app.js,而这两个 bundle 内嵌了
|
|
93
|
+
// package.json 的版本号(__ME_CLIENT_VERSION__)。如果改完版本号忘了先 npm run build 就提交,
|
|
94
|
+
// 这一步会把工作区弄脏 —— 结果是 tag/仓库里的 bundle 嵌的是旧版本号,而 npm 上的却是新的。
|
|
95
|
+
// 一旦 tag 与 npm 都发出去就很难收回,所以在打 tag、发布**之前**直接拦住。
|
|
96
|
+
const dirtyAfter = run('git', ['status', '--porcelain']).out
|
|
97
|
+
if (dirtyAfter && !flag('allow-dirty')) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
'测试/构建后工作区变脏 —— 多半是改了版本号却没先 npm run build:\n' + dirtyAfter +
|
|
100
|
+
'\n处理:npm run build 后把产物一起提交,再重新跑发布。\n(确属有意请加 --allow-dirty)',
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
say('构建产物与提交一致 ✓')
|
|
104
|
+
|
|
91
105
|
// ---- 2. 发布说明 -----------------------------------------------------------
|
|
92
106
|
step(2, '准备发布说明')
|
|
93
107
|
const notesFile = opt('notes-file')
|