memory-eternal 0.10.24 → 0.10.26
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 +5 -3
- package/README.md +5 -3
- package/bin/dsh-memory.mjs +6 -1
- package/lib/api.js +77 -4
- package/lib/client.js +17 -17
- package/lib/db.js +32 -0
- package/lib/watchdog.js +30 -1
- package/package.json +1 -1
- package/web/app.js +13 -13
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
|
|
3
3
|
"name": "memory-eternal",
|
|
4
|
-
"version": "0.10.
|
|
4
|
+
"version": "0.10.26",
|
|
5
5
|
"description": "记忆核心(Memory Eternal):对话结束后自动沉淀知识卡到本地记忆库(~/.dsh/memory-vault),支持检索 / 召回 / 审核中心。",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "EternalNight996",
|
package/README.en.md
CHANGED
|
@@ -361,14 +361,16 @@ curl http://127.0.0.1:7999/memory-eternal/api/web-info # is the standalone we
|
|
|
361
361
|
|
|
362
362
|
| Version | Date | Highlights |
|
|
363
363
|
|---|---|---|
|
|
364
|
-
| **v0.10.
|
|
365
|
-
| **v0.10.
|
|
364
|
+
| **v0.10.26** | 2026-10-13 | **Lock contention now retries asynchronously at the application level instead of using `busy_timeout` (the measured remedy for issues #31/#33)**. Local controlled experiment: while an external process held a write transaction we immediately got `database is locked`, and it **stayed locked even after that process was killed** — the old code had no waiting at all, so a single collision failed permanently (on restricted or externally-occupied drives like A: this shows up as "the whole memory database is broken"). The review rejected `PRAGMA busy_timeout` (`DatabaseSync` from `node:sqlite` is a **synchronous API**, so busy-waiting blocks the host event loop and freezes all of DSH), so we added `withDbRetry()` instead: on a lock, **yield the event loop, sleep briefly and retry** (linear backoff, up to 3 attempts), only on **write routes**; once headers have been sent retrying is explicitly forbidden (`e.noRetry`, preventing double writes), and non-lock errors (data/syntax) are tried once and rethrown unchanged. Tests cover: retry until success / one attempt for non-busy errors / `noRetry` honoured / exhaustion / a source guard against sneaking `busy_timeout` back in. `npm test` now runs **335 checks** |
|
|
365
|
+
| **v0.10.25** | 2026-10-13 | **"Cannot be determined" no longer masquerades as "unmanaged", plus a unified UX for an unwritable DB (closing layer ③ of issue #33)**. ① **Managed state becomes three-valued**: `/version-check` now also reports `resident.managedKnown`, `lock:{path,writable,error}` and `pid` — in restricted environments (sandbox write bans, ACLs, non-system drives) where the watchdog lock file cannot be written, the panel no longer says "unmanaged" (which made users think the instance was broken and restart it repeatedly) but "**management state unknown**: the lock file cannot be written, so it cannot be determined; keep-alive usually still works", together with the real cause and path; `writeWatchdogLock` failures are no longer fully silent either (one traceable line per process). ② **Unified UX when the DB is unwritable**: write routes (delete / merge / card / refine-apply / optimize-execute / …) now **fail fast at the entrance** (503 + `DB_UNAVAILABLE` + recovery guidance), and any write path not enumerated is caught by the outermost wrapper translating raw SQLite text into the same reply; the panel shows a **cross-tab banner** at the top: "the memory database is currently not writable — card reads/writes will fail, but **settings can still be saved**". ③ `dsh-memory doctor` now reports watchdog lock writability (visible at a glance in restricted environments). `npm test` now runs **334 checks** |
|
|
366
366
|
|
|
367
367
|
<details>
|
|
368
|
-
<summary>Older release notes (v0.10.
|
|
368
|
+
<summary>Older release notes (v0.10.24 and earlier · 51 releases, click to expand)</summary>
|
|
369
369
|
|
|
370
370
|
| Version | Date | Highlights |
|
|
371
371
|
|---|---|---|
|
|
372
|
+
| **v0.10.24** | 2026-10-13 | **"Same goal, different approach" now clusters too (third and final slice of issue #36)**. The two previous versions covered "merge duplicates" and "judge conflicts", but the last piece — one purpose, several workflows/codebases — is **undetectable by body similarity** ("compress with 7z", "distribute as tar.gz", "slim the npm package" read very differently yet share a goal). Approach: **the model first writes one line per card stating what problem it solves, then we cluster by goal** — goals are cached in `refine-goals.json` (each card extracted once; a second scan makes zero calls) and clustered with union-find; hits are grouped as "one goal, N cards" with two actions: **keep all** (records pairwise rulings so it stops asking) or **merge this group** (via the existing /merge; the new card enters the audit queue). *Read-only throughout — the only write is the goal cache.* Entry points: the panel (organize → 🎯 same goal, different approach) and APIs `/refine-goals`, `/refine-goals-keep`; CLI `dsh-memory refine --goals --keep --path A --path B` (the clustering itself needs a model, run it in the panel). `npm test` now runs **332 checks** |
|
|
373
|
+
| **v0.10.23** | 2026-10-13 | **Existing "injected junk cards" can be cleaned up in one click (second slice of issue #36)**. The previous version stopped such cards from being **created** (skill catalogs / `<system-reminder>` arriving as user messages were captured as knowledge after their tags were stripped), but the **backlog remained**: a scan of a real vault immediately found **32** cards unrelated to any conversation (catalog lines like `- \`code-review\`: Review the changes…`, fragments like `用户system-reminder-you-are-teammate-c…`). Adds `findInjectedCards()` (a **proposal only**, it changes nothing; criteria: injection tags present / catalog lines ≥30% of the body / under 200 characters left after stripping / a fragmentary title) and `purgeInjected()` (**goes to the recycle bin, recoverable**, reporting failures per card). Entry points: the panel (organize → 🧹 injected junk cards: scan, recycle one, recycle all) and the CLI `dsh-memory refine --junk [--purge --path P] [--json]`; API `/refine-junk`, `/refine-junk-purge`. `npm test` now runs **331 checks** |
|
|
372
374
|
| **v0.10.22** | 2026-10-13 | **"Conflict scan": similar cards may be merged, conflicts must be judged by a human (first slice of issue #36)**. Until now `/optimize` merged **blindly** on similarity (≥0.55) — yet two semantically close cards with mutually exclusive conclusions score just as high (0.93 measured), so a blind merge quietly "reconciles" the conflict, and `mergeCards` **permanently deletes** the sources. New `lib/refine.js` plus a "organize → 🧭 Conflict scan" panel section: ① **wide recall** (≥0.30, upper bound lifted to 1.0, deliberately including the near-duplicate band — the most dangerous one for blind merging); ② the model only classifies each pair as "duplicate / complement / conflict / unrelated" and states the conflict in one line plus who to keep; ③ **the human decides**: keep A / keep B / keep both / merge; the **scan never writes**, and `keepA|keepB` only moves the yielding card to the **recycle bin** (recoverable); ④ rulings are recorded in `refine-decisions.json` so the same pair is not raised again; ⑤ **safety interlock**: pairs ruled conflicting are **excluded from one-click optimize** entirely. Also fixes one source of redundancy: skill-catalog injections (`- \`skill-name\`: …`) are no longer captured as junk cards (real examples already existed in the vault). API: `/refine`, `/refine-scan`, `/refine-apply`; CLI: `dsh-memory refine` (lists candidates without calling a model; `--apply` records a ruling). `npm test` now runs **329 checks** |
|
|
373
375
|
| **v0.10.21** | 2026-10-13 | **"DB unavailable" goes from silent to visible (issue #33, scoped by review)**. ① **Health check at open**: the old code ran `PRAGMA journal_mode=WAL` and assumed success (that pragma does not throw on failure — it just returns the actual mode), so a read-only file or unwritable directory let the plugin run on silently; now the real journal mode is **read back** and a **write probe** runs (create then drop a table, in the main DB rather than temp), the verdict lands in `dbStatus()`, and an unwritable DB immediately logs the reason plus where to look. ② **`/overview` degrades instead of 500**: when the DB or directory is unavailable it no longer returns 500 (probes, the panel and scripts read 500 as "this port is broken / not our service", which historically started the restart storm and port drift); it now returns 200 with `degraded:true`, `dbError` and `db:{journalMode,writable,error}` while keeping the `vaultDir` / `version` identity fields — so "this is us, but the DB is broken" can finally be expressed separately. ③ **New `dsh-memory doctor`**: DB health self-check (real journal mode / write probe / backup list / recovery guidance) with `--json` for scripting; when the DB is unwritable or suspected corrupt it prints **guidance that never touches data** (stop writers → copy the scene aside → list backups → swap → re-verify). **Deliberately not done** (review conclusion, now guarded by tests so nobody adds it back casually): `busy_timeout` (`DatabaseSync` is a synchronous API, so waiting on a lock blocks the host event loop) and automatic WAL→DELETE downgrade (it **permanently** changes the DB mode, and a rollback journal has worse concurrency, which could worsen lock contention). `npm test` now runs **322 checks** |
|
|
374
376
|
| **v0.10.20** | 2026-10-12 | **Fixing the three-layer "DB unavailable → the watchdog kills its own web → the port drifts to 8006" scene (issues #31 / #33, measured by AkiMei0721)**. ① **A non-200 `/overview` is not a dead port**: `/overview` touches the database, so a read-only / WAL-corrupted DB makes it 500, and the watchdog declared the port offline → `spawnWeb()` (which first SIGTERMs its own previous child) → **it killed and respawned its own healthy web, drifting the port from 7999 all the way to 8006 (six instances)**; a new pure `decideTickAction()` treats **a reachable TCP port as alive** (`api-degraded-but-listening`, resetting the restart counter), and the readiness check for a new instance likewise accepts "listening but `/overview` non-200". ② **The version probe now asks `/version-check`** (no database access; falls back to `/overview`) — the root symptom was "the port is clearly alive yet no version comes back", so verify reported "(no response)" and `status` even misjudged it as "a pre-v0.10.4 build". ③ **No more crash in restricted sandboxes**: the default `stdio:'pipe'` makes `spawn()` **throw EPERM synchronously** (the old code only guarded the `'error'` event, which never ran), so `dsh-memory watchdog --reap` crashed and process enumeration stayed permanently empty; `spawn` is now wrapped in `try/catch` and stdout goes through a **temp file**, touching no named pipes. ④ The stall check also gained a "minimum uptime" (30 minutes): a freshly restarted process necessarily has zero stops, so it must not report "turn-stop events are broken" (a false alarm was measured 1.5 minutes after a restart). Adds `tests/issue-fixes-8.test.mjs` (4 checks) plus a too-early case in `tests/stall.test.mjs`. `npm test` now runs **318 checks** |
|
package/README.md
CHANGED
|
@@ -393,14 +393,16 @@ curl http://127.0.0.1:7999/memory-eternal/api/web-info # 独立 web 是否活
|
|
|
393
393
|
|
|
394
394
|
| 版本 | 日期 | 关键改动 |
|
|
395
395
|
|---|---|---|
|
|
396
|
-
| **v0.10.
|
|
397
|
-
| **v0.10.
|
|
396
|
+
| **v0.10.26** | 2026-10-13 | **撞锁改用「应用层异步重试」,而不是 `busy_timeout`(issue #31/#33 实测对症)**。本机对照实验:外部进程持写事务时我方立刻拿到 `database is locked`,而且**对方被强杀之后仍然锁着** —— 老实现没有忙等,一撞锁就永久失败(在 A: 盘那类受限 / 被外部占用的现场,表现出来就是「整个记忆库坏了」)。评审已否决 `PRAGMA busy_timeout`(`node:sqlite` 的 `DatabaseSync` 是**同步 API**,忙等会阻塞宿主事件循环、把整个 DSH 卡住),所以改成新增 `withDbRetry()`:撞锁时**让出事件循环睡一小会儿再试**(线性退避,最多 3 次),只用**写路由**;「响应已发出」时显式禁止重试(`e.noRetry`,避免重复写),非锁类错误(数据 / 语法)只试一次并原样抛。测试覆盖:重试到成功 / 非忙错误只试一次 / `noRetry` 生效 / 试满仍失败 / 源码守卫(不许偷偷加 `busy_timeout`)。`npm test` 共 **335 项** |
|
|
397
|
+
| **v0.10.25** | 2026-10-13 | **「判不出来」不再冒充「未托管」+ 库不可写时的统一 UX(issue #33 第③层收口)**。① **托管状态变三态**:`/version-check` 增报 `resident.managedKnown`、`lock:{path,writable,error}` 与 `pid` —— 受限环境(沙箱禁写 / ACL / 非系统盘)写不进看门狗锁文件时,面板不再显示「未托管」(那会让用户以为实例坏了、反复重启),改为「**托管状态未知**:锁文件写不进去 → 无法判断;保活通常仍然有效」,并同时显示真实原因与路径;`writeWatchdogLock` 的失败也不再全静默(每个进程留一条可查的痕)。② **库不可写时统一 UX**:写路由(delete / merge / card / refine-apply / optimize-execute / …)在入口**短路径失败**(503 + `DB_UNAVAILABLE` + 恢复指引),没枚举到的写路径由最外层兜底把 SQLite 原文翻译成同一套答复;面板**跨标签顶部**明示「记忆库当前不可写 —— 卡片读写会失败,但**配置仍可保存**」。③ `dsh-memory doctor` 增报看门狗锁可写性(受限环境一眼可见)。`npm test` 共 **334 项** |
|
|
398
398
|
|
|
399
399
|
<details>
|
|
400
|
-
<summary>更早的更新日志(v0.10.
|
|
400
|
+
<summary>更早的更新日志(v0.10.24 及以前 · 共 51 版,点开查看)</summary>
|
|
401
401
|
|
|
402
402
|
| 版本 | 日期 | 关键改动 |
|
|
403
403
|
|---|---|---|
|
|
404
|
+
| **v0.10.24** | 2026-10-13 | **「同目标、不同做法」也能归到一起了(issue #36 第三块,闭环)**。前两版解决了「冗余合并」与「矛盾判别」,但最后一块 —— 同一目的、不同工作流/代码 —— 按正文相似度**根本抓不到**(「用 7z 压缩」「用 tar.gz 分发」「npm 包瘦身」字面差很远、目标却一样)。做法:**先让模型给每张卡抽一句「这张卡解决什么问题」,再按目标聚类** —— 目标抽取结果缓存在 `refine-goals.json`(同一张卡只抽一次,第二次扫描 0 次调用),聚类用并查集;命中后按「同一目标 N 张卡」成组给出,动作二选一:**都保留**(记两两决策,下次不再提示)或**合并这一组**(走现有 /merge,新卡进审核队列)。*全程只读,唯一写入是目标缓存*。入口:面板「整理 → 🎯 同目标不同做法」+ API `/refine-goals`、`/refine-goals-keep`;CLI `dsh-memory refine --goals --keep --path A --path B`(聚类本身需要模型,在面板跑)。`npm test` 共 **332 项** |
|
|
405
|
+
| **v0.10.23** | 2026-10-13 | **存量「注入垃圾卡」可一键清理(issue #36 第二刀)**。上一版修掉了**新生成**的注入垃圾卡(技能目录 / `<system-reminder>` 被当 user 消息 → 剥标签后仍被当知识沉淀),但**存量还在**:在真实库里一跑就找出 **32 张**「和对话毫无关系」的卡(`- \`code-review\`: Review the changes…` 这种目录行、`用户system-reminder-you-are-teammate-c…` 这种碎片)。新增 `findInjectedCards()`(只**提议**、不动数据;判据:含注入标签 / 技能目录条目占比 ≥30% / 剥离注入后只剩 <200 字 / 标题是碎片)与 `purgeInjected()`(**走回收站、可还原**,逐个报告失败)。入口:面板「整理 → 🧹 注入垃圾卡」(扫描存量 / 单张回收 / 全部回收)+ CLI `dsh-memory refine --junk [--purge --path P] [--json]`;API `/refine-junk`、`/refine-junk-purge`。`npm test` 共 **331 项** |
|
|
404
406
|
| **v0.10.22** | 2026-10-13 | **「矛盾扫描」:相似可以合并,矛盾必须人判断(issue #36 第一刀)**。现状是 `/optimize` 只按相似度(≥0.55)**盲合并** —— 而两张语义相近、结论互斥的卡相似度同样很高(实测 0.93),盲合并会把冲突悄悄「和解」掉,且 `mergeCards` 会**永久删除**源卡。新增 `lib/refine.js` + 客户端「整理 → 🧭 矛盾扫描」:① **宽召回**(≥0.30,上限放到 1.0,特意把近重复那一段纳入 —— 那正是盲合并最危险的区间);② 模型只判「重复 / 补充 / 矛盾 / 无关」并给一句冲突点 + 建议保留谁;③ **人拍板**:保留 A / 保留 B / 都保留 / 合并;**扫描绝不写库**,`keepA|keepB` 只把让位那张送**回收站**(可还原);④ 决策记进 `refine-decisions.json`,同一对不再重复打扰;⑤ **安全联锁**:被判为矛盾的对**绝不参与一键优化**的盲合并。另修一处冗余来源:技能目录(`- \`skill-name\`: …`)这类注入文本不再被抓成垃圾卡(实测库里已有这种卡)。API:`/refine`、`/refine-scan`、`/refine-apply`;CLI:`dsh-memory refine`(列候选不调模型,`--apply` 落裁决)。`npm test` 共 **329 项** |
|
|
405
407
|
| **v0.10.21** | 2026-10-13 | **「库不可用」从「静默运行」变成「看得清」(issue #33,按评审裁剪范围)**。① **打开即体检**:老实现执行完 `PRAGMA journal_mode=WAL` 就当成功(这条 PRAGMA 失败时**不抛错**,只把真实模式当一行返回),文件只读 / 目录不可写时插件会一路静默跑下去;现在打开后**读回真实 journal 模式 + 真写一次探针**(建表→删表,写主库而非 temp 库),结论记进 `dbStatus()`,不可写时立即把原因与排查方向打到日志。② **`/overview` 降级返回**:库/目录不可用时不再吐 500(500 会被探针、面板、脚本读成「这个端口坏了 / 不是我们的服务」,历史上正是重启风暴与端口漂移的入口),改成 200 + `degraded:true` + `dbError` + `db:{journalMode,writable,error}`,并保住 `vaultDir` / `version` 身份字段 —— 「这是我们、但库有问题」从此可以被分开表达。③ **新增 `dsh-memory doctor`**:库健康自检(真实 journal 模式 / 写探针 / 备份清单 / 恢复引导),带 `--json` 可脚本化;不可写或疑似损坏时给出**只引导、不动数据**的步骤(先停写者 → 复制现场留证 → 查备份 → 替换 → 复核)。**刻意不做**(评审结论,已写成测试守卫防止顺手加回):`busy_timeout`(`DatabaseSync` 是同步 API,撞锁会阻塞宿主事件循环)与 WAL→DELETE 自动降级(会**永久**改变库模式,且 rollback journal 并发更差、可能反过来加剧锁竞争)。`npm test` 共 **322 项** |
|
|
406
408
|
| **v0.10.20** | 2026-10-12 | **「库不可用 → 看门狗自杀自拉 → 端口漂到 8006」三层现场一起修(issue #31 / #33,AkiMei0721 实测)**。① **`/overview` 非 200 ≠ 端口死了**:`/overview` 会碰数据库,库只读 / WAL 损坏时它返回 500,而看门狗据此判「离线」→ 走 `spawnWeb()`(开头先 SIGTERM 掉自己的上一个 child)→ **自杀自拉、端口从 7999 一路漂到 8006(6 个实例)**;新增 `decideTickAction()` 纯决策:只要 **TCP 还能连**就视为存活(`api-degraded-but-listening`,并重置重启计数),新实例的就绪判定同样接受「已监听但 `/overview` 非 200」。② **版本探测改走 `/version-check`**(不碰库;拿不到才回落 `/overview`)—— 病根是「端口上明明活着、版本却拿不到」,于是 verify 报「(无响应)」、`status` 甚至误判成「v0.10.4 之前的旧版」。③ **受限沙箱里不再崩**:默认 `stdio:'pipe'` 会在 `spawn()` 时**同步抛 EPERM**(老实现只在 `'error'` 事件里兜底,根本没机会执行)→ `dsh-memory watchdog --reap` 直接崩、进程枚举永远空名单;现在 `try/catch` 包住 spawn,并**用临时文件接 stdout**、完全不碰命名管道。④ 顺带给 stall 判据加「最短运行时长(30 分钟)」:刚落进程必然 0 收尾,不该据此报「收尾事件失效」(实测重启 1.5 分钟后就误报过)。新增 `tests/issue-fixes-8.test.mjs`(4 项)+ `tests/stall.test.mjs` 的 too-early 用例。`npm test` 共 **318 项** |
|
package/bin/dsh-memory.mjs
CHANGED
|
@@ -247,12 +247,16 @@ async function main() {
|
|
|
247
247
|
} catch (e) {
|
|
248
248
|
health = { journalMode: '', writable: false, error: String((e && e.message) || e), corrupt: /malformed|not a database|corrupt/i.test(String((e && e.message) || e)), checkedAt: Date.now() }
|
|
249
249
|
}
|
|
250
|
+
// 看门狗锁文件可写性(#33 第③层):受限环境写不进去时,「是否被托管」将无法判断 —— 说清楚,
|
|
251
|
+
// 免得用户把「判不出来」读成「未托管 / 实例坏了」。
|
|
252
|
+
const { probeLockWritable } = await import('../lib/watchdog.js')
|
|
253
|
+
const lockInfo = probeLockWritable()
|
|
250
254
|
const backupDir = path.join(root, 'backups')
|
|
251
255
|
const backups = (await fsPromises.readdir(backupDir).catch(() => []))
|
|
252
256
|
.filter((x) => x.startsWith('memory-eternal-') && x.endsWith('.db')).sort().reverse()
|
|
253
257
|
const guidance = rebuildGuidance(root, health)
|
|
254
258
|
if (has('--json')) {
|
|
255
|
-
console.log(JSON.stringify({ ok: Boolean(health.writable), vault: root, health, backups, guidance }, null, 2))
|
|
259
|
+
console.log(JSON.stringify({ ok: Boolean(health.writable), vault: root, health, lock: lockInfo, backups, guidance }, null, 2))
|
|
256
260
|
if (!health.writable) process.exitCode = 1
|
|
257
261
|
return
|
|
258
262
|
}
|
|
@@ -261,6 +265,7 @@ async function main() {
|
|
|
261
265
|
console.log('journal_mode:' + (health.journalMode || '(未知)'))
|
|
262
266
|
console.log('写探针:' + (health.writable ? 'OK(这个库现在可写)' : '失败 → ' + health.error))
|
|
263
267
|
if (health.corrupt) console.log('损坏判定:疑似文件损坏(见下方引导)')
|
|
268
|
+
console.log('看门狗锁:' + (lockInfo.writable ? '可写(' + lockInfo.path + ')' : '**写不进去** → ' + lockInfo.error + '(' + lockInfo.path + ')—— 保活一般仍有效,但「是否被托管」将无法判断'))
|
|
264
269
|
console.log('备份:' + (backups.length ? backups.length + ' 个(最近:' + backups[0] + ')' : '(还没有备份 —— 可在面板或 backupDb 生成)'))
|
|
265
270
|
if (guidance.length) { console.log(''); for (const line of guidance) console.log('· ' + line) }
|
|
266
271
|
if (!health.writable) process.exitCode = 1
|
package/lib/api.js
CHANGED
|
@@ -18,9 +18,9 @@ import { listCards, countCards, readCard, search, searchAll, graph, graphAll, ov
|
|
|
18
18
|
import { compressExcerpt, DEFAULT_CAPTURE_MAX_TOKENS } from './capture.js'
|
|
19
19
|
import { buildDiagnostics } from './feedback.js'
|
|
20
20
|
import { getSetupStatus, runSetup, mcpAgentAction } from './setup.js'
|
|
21
|
-
import { backupDb, dbStatus, rebuildGuidance } from './db.js'
|
|
21
|
+
import { backupDb, dbStatus, rebuildGuidance, withDbRetry } from './db.js'
|
|
22
22
|
import { createHub } from './sse.js'
|
|
23
|
-
import { probeServedVersion, restartResident, currentPkgVersion, readWatchdogLock, isPidAlive } from './watchdog.js'
|
|
23
|
+
import { probeServedVersion, restartResident, currentPkgVersion, readWatchdogLock, isPidAlive, probeLockWritable } from './watchdog.js'
|
|
24
24
|
|
|
25
25
|
// 独立 web 的 SSE 中心:配置/数据变更即时推给已打开的页面(无需手动刷新)
|
|
26
26
|
const hub = createHub()
|
|
@@ -127,6 +127,12 @@ export function emptyNumericFields(patch, dict = {}) {
|
|
|
127
127
|
.map(([k]) => k)
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
/**
|
|
131
|
+
* **真的会写库**的路由(/card、/cards、/search 这些是读,绝不能拦)。
|
|
132
|
+
* 用途:库不可写时短路径失败;撞锁(SQLITE_BUSY)时做应用层异步重试。
|
|
133
|
+
*/
|
|
134
|
+
const DB_WRITE_ROUTES = /^\/(delete|merge|write|import|refine-apply|refine-goals-keep|refine-junk-purge|optimize-execute|feedback|audit\/(approve|reject)|recycle\/(restore|purge|purge-expired)|backup)$/
|
|
135
|
+
|
|
130
136
|
export function createApi(deps = {}) {
|
|
131
137
|
const vaultDir = deps.vaultDir ?? (() => '')
|
|
132
138
|
const vaultRoots = deps.vaultRoots ?? (() => [{ name: '', root: vaultDir() }])
|
|
@@ -147,7 +153,7 @@ export function createApi(deps = {}) {
|
|
|
147
153
|
// 两者不同,探错端口会给出错误结论)。
|
|
148
154
|
const selfPort = () => (typeof deps.selfPort === 'function' ? Number(deps.selfPort()) || 0 : Number(deps.selfPort) || 0)
|
|
149
155
|
|
|
150
|
-
|
|
156
|
+
const dispatch = async function handleApi(req, res) {
|
|
151
157
|
// 状态变更请求先过同源闸门(见 crossOriginError):无鉴权的本机 API 不能让任意网页写配置
|
|
152
158
|
const crossOrigin = crossOriginError(req)
|
|
153
159
|
if (crossOrigin) return json(res, 403, { ok: false, error: crossOrigin, code: 'CROSS_ORIGIN_BLOCKED' })
|
|
@@ -155,6 +161,21 @@ export function createApi(deps = {}) {
|
|
|
155
161
|
const url = new URL(req.url, 'http://localhost')
|
|
156
162
|
const route = url.pathname.slice(API_PREFIX.length).replace(/\/+$/, '') || '/overview'
|
|
157
163
|
const query = url.searchParams
|
|
164
|
+
// 库不可写时**短路径失败**(issue #33):这些路由一定会写库,早失败早说清,避免做一半、
|
|
165
|
+
// 也避免每个接口各自把 `attempt to write a readonly database` 原文丢给用户。
|
|
166
|
+
// **只读路由与配置路由不受影响**(配置不经过数据库 —— 用户至少还能改设置)。
|
|
167
|
+
if (DB_WRITE_ROUTES.test(route)) {
|
|
168
|
+
const st = dbStatus(vaultRoot)
|
|
169
|
+
if (st && st.writable === false) {
|
|
170
|
+
return json(res, 503, {
|
|
171
|
+
ok: false,
|
|
172
|
+
code: 'DB_UNAVAILABLE',
|
|
173
|
+
error: '记忆库当前不可写' + (st.error ? ':' + st.error : '') + '(配置仍可保存)',
|
|
174
|
+
db: st,
|
|
175
|
+
guidance: rebuildGuidance(vaultRoot, st),
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
}
|
|
158
179
|
|
|
159
180
|
switch (route) {
|
|
160
181
|
case '/overview': {
|
|
@@ -302,13 +323,25 @@ export function createApi(deps = {}) {
|
|
|
302
323
|
residentManaged = readWatchdogLock(process.env).watchdogs
|
|
303
324
|
.some((w) => Number(w.port) === Number(residentPort) && isPidAlive(w.pid))
|
|
304
325
|
} catch { residentManaged = false }
|
|
326
|
+
// 「托管状态」其实有三态(#33 第③层):确实被托管 / 确实没被托管 / **判不出来**(锁文件写不进去)。
|
|
327
|
+
// 受限环境(沙箱禁写、ACL、非系统盘)里锁写不成 → 老实现一律显示「未托管 / 未运行」,
|
|
328
|
+
// 用户会以为实例坏了、反复重启。这里把「判不出来」单独标出来,并解释原因。
|
|
329
|
+
const lockInfo = probeLockWritable(process.env)
|
|
330
|
+
const managedKnown = residentManaged || lockInfo.writable
|
|
305
331
|
json(res, 200, {
|
|
306
332
|
ok: true,
|
|
307
333
|
loaded,
|
|
308
334
|
onDisk,
|
|
309
335
|
latest,
|
|
310
336
|
checkError,
|
|
311
|
-
resident: {
|
|
337
|
+
resident: {
|
|
338
|
+
port: residentPort,
|
|
339
|
+
served: residentServed,
|
|
340
|
+
managed: residentManaged,
|
|
341
|
+
managedKnown,
|
|
342
|
+
pid: process.pid,
|
|
343
|
+
lock: lockInfo,
|
|
344
|
+
},
|
|
312
345
|
stale: !!(loaded && onDisk && loaded !== onDisk),
|
|
313
346
|
// 只在 **npm 上确实更新** 时提示升级:本地跑着未发布版本(磁盘 0.10.7 / npm 0.10.6)时
|
|
314
347
|
// 旧的 `latest !== onDisk` 会反过来劝用户装回旧版。
|
|
@@ -867,6 +900,46 @@ export function createApi(deps = {}) {
|
|
|
867
900
|
json(res, 404, { ok: false, error: '未知接口' })
|
|
868
901
|
}
|
|
869
902
|
}
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* 对外入口:把**任何**未预判到的数据库故障翻译成可读的 503(issue #33)。
|
|
906
|
+
* 入口处的 DB_WRITE_ROUTES 已覆盖主要写路由,这里兜住剩下的(不同接口各自吐
|
|
907
|
+
* `attempt to write a readonly database` 原文对用户毫无意义),并带上恢复指引。
|
|
908
|
+
*/
|
|
909
|
+
return async function handleApi(req, res) {
|
|
910
|
+
const routePath = (() => { try { return new URL(req.url, 'http://localhost').pathname.slice(API_PREFIX.length).replace(/\/+$/, '') || '/overview' } catch { return '' } })()
|
|
911
|
+
const isWriteRoute = DB_WRITE_ROUTES.test(routePath)
|
|
912
|
+
try {
|
|
913
|
+
// 写路由:撞锁(SQLITE_BUSY / database is locked)做**应用层异步重试** ——
|
|
914
|
+
// 不阻塞事件循环(`busy_timeout` 是同步忙等,评审已否),且只在**还没发出响应**时重试,
|
|
915
|
+
// 避免重复写;SQLite 在 BUSY 时事务已回滚,重试是安全的。
|
|
916
|
+
if (isWriteRoute) {
|
|
917
|
+
return await withDbRetry(async () => {
|
|
918
|
+
try { return await dispatch(req, res) } catch (e) {
|
|
919
|
+
// 响应已经发出(writeHead 过了)→ 不能再重试,否则可能写两遍
|
|
920
|
+
if (res && res.headersSent) { e.noRetry = true; throw e }
|
|
921
|
+
throw e
|
|
922
|
+
}
|
|
923
|
+
}, { tries: 3, delayMs: 120 })
|
|
924
|
+
}
|
|
925
|
+
return await dispatch(req, res)
|
|
926
|
+
} catch (e) {
|
|
927
|
+
const msg = String((e && e.message) || e)
|
|
928
|
+
if (/readonly|read-only|只读|database is locked|malformed|not a database/i.test(msg)) {
|
|
929
|
+
const st = dbStatus(vaultDir()) || { writable: false, error: msg }
|
|
930
|
+
return json(res, 503, {
|
|
931
|
+
ok: false,
|
|
932
|
+
code: 'DB_UNAVAILABLE',
|
|
933
|
+
error: '记忆库当前不可用:' + msg + '(配置仍可保存)',
|
|
934
|
+
db: st,
|
|
935
|
+
guidance: rebuildGuidance(vaultDir(), st),
|
|
936
|
+
})
|
|
937
|
+
}
|
|
938
|
+
// 非数据库故障:**原样抛**,保持「业务错误由调用方处理」的既有契约
|
|
939
|
+
// (宿主有外层 try/catch,测试也用它来判断"请求是否真的走到了业务逻辑")
|
|
940
|
+
throw e
|
|
941
|
+
}
|
|
942
|
+
}
|
|
870
943
|
}
|
|
871
944
|
|
|
872
945
|
export function json(res, status, body) {
|