dsh-recall-plugin 2.5.3 → 2.6.1
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/CHANGELOG.md +33 -1
- package/README.en.md +1 -0
- package/README.md +2 -0
- package/lib/client.js +105 -20
- package/lib/config.js +12 -1
- package/lib/dump-parse.js +6 -1
- package/lib/errors.js +3 -0
- package/lib/index.js +21 -12
- package/lib/maintenance.js +3 -1
- package/lib/queues.js +30 -0
- package/lib/routes-core.js +10 -6
- package/lib/routes-manage.js +46 -15
- package/lib/scripts.posix.js +24 -2
- package/lib/scripts.pwsh.js +8 -1
- package/lib/snapshots.js +26 -3
- package/lib/store.js +0 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,39 @@
|
|
|
2
2
|
|
|
3
3
|
本文件格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循语义化版本。
|
|
4
4
|
|
|
5
|
-
## [
|
|
5
|
+
## [2.6.1] - 2026-10-31
|
|
6
|
+
|
|
7
|
+
### 新增
|
|
8
|
+
|
|
9
|
+
- **「只回退消息」模式(`messageOnly`,默认关闭,issue #31)**:开启后每条用户消息仍登记撤回记录(撤回按钮与对话回退照常),但不再执行文件快照——大仓(gitignore 过滤后仍上千文件 / 上 GB)不必为每条消息写一次全量对象。按消息生效:`index.json` 只给这类条目记 `files: false`,开启前拍的旧快照仍可完整回退文件;确认面板对这类消息隐藏「回退文件与对话」单选,快照管理树标「仅消息」。代价是这类记录没有 tag,索引损坏被隔离后撤回按钮无法从 tag 重建。
|
|
10
|
+
|
|
11
|
+
### 修复
|
|
12
|
+
|
|
13
|
+
- **磁盘枚举失败不再表现成「没有快照」**:此前快照管理读不到磁盘时会静默返回空列表,界面只剩一棵空树,用户以为快照都没了(issue #30 报告的现场就是这样)。现在失败原因进「最近错误」,列表 / 按工作区或会话批量删除 / 全部删除回 `DUMP_FAILED`,卡片显示可重试的错误条;「全部删除」也不再用「已删除 0 条」掩盖枚举失败。
|
|
14
|
+
- **建过仓但从没拍过快照的空仓不再永久占盘**:dump 脚本为每个 store 增发 `STATE` 段(`present` / `absent` / `quarantined` / `foreign`),「立即 gc」据此把「有 store 布局但从未生成索引」的空壳整目录回收;损坏隔离现场(`index.json.corrupt-*`)、旧版脚本输出与非本插件的目录一律保留。
|
|
15
|
+
|
|
16
|
+
### 文档
|
|
17
|
+
|
|
18
|
+
- 新增 [plan-dump-visible-and-empty-reclaim.md](docs/plans/completed/plan-dump-visible-and-empty-reclaim.md):两条遗留的问题、判据表与测试清单;AGENTS 已知坑补 I46,compat-audit 补 I46 详情。
|
|
19
|
+
- 新增 [plan-message-only-mode.md](docs/plans/completed/plan-message-only-mode.md):只回退消息的问题、判据、边界与实施记录;smoke checklist 第十一节与 records 同日批次(M1〜M6 实弹)。
|
|
20
|
+
|
|
21
|
+
## [2.6.0] - 2026-10-30
|
|
22
|
+
|
|
23
|
+
### 新增
|
|
24
|
+
|
|
25
|
+
- **任务队列按工作区隔离**:快照/preview/回退/删除/gc 原先共用一条全局串行链,一个工作区的慢快照会把**所有会话**的撤回按钮一起堵住。现在同一工作区仍然串行(都在写同一个 git index),不同工作区并行;跨工作区的批量删除与全局 gc 用「等这些工作区的链尾全部落地」保证互斥。win32 下路径大小写归一,避免同一仓库拿到两把锁。
|
|
26
|
+
- **单次快照时限可配置(`snapshotBudgetMs`,默认 3 分钟)**:整条快照脚本(扫描 + `add` + 写索引/打 tag)的兜底超时从硬编码 600 秒改为配置项,设置卡片按秒展示;`0` 表示不限制。
|
|
27
|
+
|
|
28
|
+
### 修复
|
|
29
|
+
|
|
30
|
+
- **Linux/macOS 上快照管理、批量删除/全部删除与「立即 gc」全部不可用**([issue #30](https://github.com/limbo947/dsh-recall-plugin/issues/30),POSIX dump 脚本的两个缺陷):① 建仓后还没拍过快照的**空仓**没有 `index.json`/`lineage.json`,裸 `cat` 在 `set -e` 下让整条 dump 退出 1,磁盘上的仓一个都枚举不到;② 这两个文件落盘时没有尾换行,`cat` 后紧跟的段尾标记被粘到内容行尾,解析器按整行匹配永远收不到段尾——**即使文件齐全,每个仓也被解析成「无索引」**(报告者的临时处置只修了 ①,树依然是空的)。现在两处改为「缺席放行 + 内容后补一个换行」,逐仓隔离语义不变,存量数据无需迁移。
|
|
31
|
+
- **设置页 exclude 编辑器可能回显垃圾字节**(同一类行边界缺陷):`exclude.txt` 字节数为 3 的倍数时 base64 没有 `=` padding,粘在尾部的段尾标记会被一起解码;回显后再保存即污染文件。
|
|
32
|
+
- **dump 脚本的测试从「字符串断言」升级为「真跑一遍」**:新增 [tests/unit/stores-dump-script.test.js](tests/unit/stores-dump-script.test.js),在 bash 里执行生成的脚本并校验解析结果(CI 的 ubuntu 必然执行,win32 认 Git Bash)。此前只断言脚本文本与解析器行为,这两个只在执行期暴露的缺陷因此全绿漏过。
|
|
33
|
+
|
|
34
|
+
### 文档
|
|
35
|
+
|
|
36
|
+
- 新增 [plan-stores-dump-posix.md](docs/plans/completed/plan-stores-dump-posix.md):issue #30 的双缺陷分析、实测矩阵与防回归设计;含「不采纳 warmup 落 `index.json = []`」「不采纳脚本保持 fail-fast」的依据,以及两条遗留(dump 失败对用户不可见、空仓无法回收)。
|
|
37
|
+
- 新增 [plan-queue-and-budget.md](docs/plans/completed/plan-queue-and-budget.md):含 web 新会话 cwd 的官方归属核查(`startSession` 取 `workspaceId ?? 当前 ?? 最近`、全链不传 cwd、无配置项/钩子可改写)——该项为官方行为,插件不改代码,只在 README 使用注意与 AGENTS 已知坑里写明。
|
|
6
38
|
|
|
7
39
|
## [2.5.3] - 2026-10-30
|
|
8
40
|
|
package/README.en.md
CHANGED
|
@@ -158,6 +158,7 @@ All options can be edited visually in the "**Settings → Plugin Config → Reca
|
|
|
158
158
|
| `maxFileBytes` | 8388608 (8MB) | Files larger than this are neither snapshotted nor touched by recalls; they are filtered out *before* `git add` from a path list, so no objects are written (older git falls back to add-then-remove) |
|
|
159
159
|
| `maxWorkspaceBytes` | 268435456 (256MB) | Total snapshot-eligible file size per workspace, measured exactly as `git add` sees it (`.gitignore`, `.git/info/exclude` and the global excludesFile all apply). Exceeding it **disables snapshots for that workspace** (no truncation — a truncated snapshot would make rollback delete the skipped files) and shows a notice on recent messages; raise the limit or add exclusions to recover automatically. 0 = unlimited |
|
|
160
160
|
| `scanBudgetMs` | 10000 (10s) | Time budget for measuring the snapshot-eligible total. Exceeding it skips the snapshot with a notice (guards against pathological directories stalling startup). 0 = unlimited |
|
|
161
|
+
| `snapshotBudgetMs` | 180000 (3 min) | Total time for one snapshot from scan to write; exceeding it aborts the snapshot and records it under "Recent errors". 0 = unlimited |
|
|
161
162
|
| `maxSnapshotsPerWorkspace` | 500 | Maximum snapshots kept per workspace; oldest pruned beyond the cap. 0 = unlimited |
|
|
162
163
|
| `retentionDays` | 0 | Keep snapshots for this many days; older ones are deleted. 0 = disabled (works independently of the cap) |
|
|
163
164
|
| `baseExcludes` | `.git`, `node_modules/`, `.dsh-recall-snapshots/`, `dsh-recall-snapshots/`, `target/`, `dist/`, `build/`, `out/`, `coverage/`, `.next/`, `.nuxt/`, `.output/`, `.cache/`, `.gradle/`, `*.exe`, `*.dll`, `*.pdb`, `*.so`, `*.dylib`, `*.msi`, `*.zip`, `*.7z`, `*.rar`, `*.tar`, `*.tar.gz`, `*.iso` | Base exclusion list (gitignore syntax, lower priority than exclude.txt); excluded large directories are skipped as whole subtrees during snapshot scans. Directory-form entries (e.g. `target/`) mean one more thing: **when a path segment of the workspace root itself matches, snapshots are disabled for that workspace** — patterns are relative to the root, so they can never exclude the root itself (i.e. a session opened inside a build-output directory), and such directories have no rollback value anyway; remove the entry to re-enable |
|
package/README.md
CHANGED
|
@@ -155,11 +155,13 @@ peerDependencies 按 minor 版本线开窗,与 `dsh.compatibility.dshReleases`
|
|
|
155
155
|
| `maxFileBytes` | 8388608(8MB) | 超过该大小的文件不进快照、不被回退触碰;在 `git add` 之前就按清单剔除,不写对象(旧 git 退回「先加后剔」) |
|
|
156
156
|
| `maxWorkspaceBytes` | 268435456(256MB) | 单工作区可纳入快照的文件总量上限,口径与 `git add` 一致(`.gitignore`、`.git/info/exclude`、全局 excludesFile 都算数)。超过时**停用该工作区快照**(不截断——截断快照回退时会误删未纳入的文件),并在最近消息上提示;调大该值或补充排除表后自动恢复;0 = 不限制 |
|
|
157
157
|
| `scanBudgetMs` | 10000(10 秒) | 快照前统计纳入总量的时间预算。超过时停用本次快照并提示(防病态大目录拖死启动);0 = 不限制 |
|
|
158
|
+
| `snapshotBudgetMs` | 180000(3 分钟) | 单次快照从扫描到写入的总时限,超时即中止本次快照并记入「最近错误」;0 = 不限制 |
|
|
158
159
|
| `maxSnapshotsPerWorkspace` | 500 | 每个工作区保留的最大快照数,超限自动删除最旧的;0 = 不限制 |
|
|
159
160
|
| `retentionDays` | 0 | 按天数保留快照,超期自动删除;0 = 不启用(与条数上限各自独立生效) |
|
|
160
161
|
| `baseExcludes` | `.git`、`node_modules/`、`.dsh-recall-snapshots/`、`dsh-recall-snapshots/`、`target/`、`dist/`、`build/`、`out/`、`coverage/`、`.next/`、`.nuxt/`、`.output/`、`.cache/`、`.gradle/`、`*.exe`、`*.dll`、`*.pdb`、`*.so`、`*.dylib`、`*.msi`、`*.zip`、`*.7z`、`*.rar`、`*.tar`、`*.tar.gz`、`*.iso` | 基础排除表(gitignore 语法,优先级低于 exclude.txt);被排除的大目录整棵子树不进快照扫描。其中「目录形态」项(如 `target/`)另有一层含义:**工作区根自身的路径段命中时,该工作区不启用快照**——在构建产物目录里开会话时排除表本来就管不到它自己(模式是相对该 root 的),这类目录也没有回退价值;删掉对应项即恢复 |
|
|
161
162
|
| `refillDraft` | true | 撤回后把被撤回的消息(文本与附件)回填到输入框 |
|
|
162
163
|
| `snapshotEnabled` | true | 快照总开关(关闭只冻结新建,已有快照仍可撤回) |
|
|
164
|
+
| `messageOnly` | false | 只回退消息:每条用户消息仍登记撤回记录(按钮与对话回退照常),但不写文件快照——适合 gitignore 过滤后仍上 GB 的超大工作区。按消息生效:开启前的旧快照仍可完整回退文件;这类条目在快照管理里标「仅消息」,删除后不可从 tag 恢复 |
|
|
163
165
|
| `archiveOriginal` | true | 撤回后归档原会话(关闭后原会话保留在会话列表中) |
|
|
164
166
|
| `editSubmitsImmediately` | true | 「编辑并重发」提交后自动发出改后的消息;关闭则只回填输入框,由你按回车发送 |
|
|
165
167
|
| `locale` | auto | 界面语言:`auto` 跟随系统(`navigator.language` 以 `zh` 开头用中文,否则英文)、`zh` / `en` 显式锁定。见下方「界面语言」 |
|
package/lib/client.js
CHANGED
|
@@ -218,6 +218,10 @@
|
|
|
218
218
|
// 对齐;此前复用 ex-note 左对齐正文样式,空列表区显得是「缺了一行」而非
|
|
219
219
|
// 一个状态
|
|
220
220
|
".dsh-recall-empty{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5;text-align:center;padding:24px 0}",
|
|
221
|
+
// 列表读取失败条(issue #30 遗留项):error 前景 + 换行不裁切,右侧带重试 chip。
|
|
222
|
+
// 与空态区分开——「没有快照」和「读不到快照」在用户眼里必须是两件事
|
|
223
|
+
".dsh-recall-list-error{display:flex;gap:8px;align-items:flex-start;padding:8px 10px;border-radius:8px;background:var(--dsw-alias-interactive-bg-hover-danger);color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:1.5}",
|
|
224
|
+
".dsh-recall-list-error-text{flex:1;min-width:0;word-break:break-word}",
|
|
221
225
|
".dsh-recall-ex-status-success{color:var(--dsw-alias-state-success-primary)}",
|
|
222
226
|
// V6 健康徽章(git 可用性):pill 配色直接复用官方状态行配对——成功用
|
|
223
227
|
// success-tertiary 底、失败用 interactive-bg-hover-danger 底(error 无
|
|
@@ -242,6 +246,8 @@
|
|
|
242
246
|
".dsh-recall-tree-label{flex:1;min-width:0;display:flex;gap:8px;align-items:baseline;font-size:12px;line-height:1.5;overflow:hidden}",
|
|
243
247
|
".dsh-recall-tree-name{flex:none;font-weight:600;color:var(--dsw-alias-label-secondary)}",
|
|
244
248
|
".dsh-recall-tree-title{min-width:0;color:var(--dsw-alias-label-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}",
|
|
249
|
+
// issue #31:仅消息条目的静态标记——与 ex-chip 同丸形,但非按钮、无 hover
|
|
250
|
+
".dsh-recall-tree-tag{flex:none;border-radius:999px;corner-shape:round;padding:0 8px;font-size:11px;line-height:1.6;color:var(--dsw-alias-label-tertiary);background:var(--dsw-alias-bg-module-platform);white-space:nowrap}",
|
|
245
251
|
// tabular-nums:树 meta 列(「3 会话 / 28 快照」「v2/3」)多行纵向扫描时
|
|
246
252
|
// 数字等宽,个位对齐不跳动
|
|
247
253
|
".dsh-recall-tree-meta{flex:none;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary);white-space:nowrap;font-variant-numeric:tabular-nums}",
|
|
@@ -425,6 +431,7 @@
|
|
|
425
431
|
"tree.family.title": "\u7248\u672C\u5BB6\u65CF\uFF1A{chain}",
|
|
426
432
|
"tree.switch": "\u5207\u6362",
|
|
427
433
|
"tree.switch.title": "\u5207\u6362\u5230\u8BE5\u7248\u672C\u4F1A\u8BDD",
|
|
434
|
+
"tree.messageOnly": "\u4EC5\u6D88\u606F",
|
|
428
435
|
"tree.delete.snapshot.title": "\u5220\u9664\u8BE5\u5FEB\u7167\uFF08tag \u4E0E\u7D22\u5F15\u6761\u76EE\uFF09",
|
|
429
436
|
"tree.delete.snapshot.confirm": "\u786E\u8BA4\u5220\u9664\u8BE5\u5FEB\u7167\uFF1F\u6B64\u64CD\u4F5C\u4E0D\u53EF\u6062\u590D\u3002",
|
|
430
437
|
"tree.delete.session.title": "\u5220\u9664\u8BE5\u4F1A\u8BDD\u5168\u90E8\u5FEB\u7167",
|
|
@@ -438,6 +445,8 @@
|
|
|
438
445
|
"recall.truncated.files": "\u2026\u4EC5\u663E\u793A\u524D {shown} \u6761\uFF0C\u5171 {total} \u4E2A\u6587\u4EF6\u5C06\u53D8\u66F4",
|
|
439
446
|
"recall.truncated.chat": "\u2026\u4EC5\u663E\u793A\u524D {shown} \u6761\uFF0C\u5171 {total} \u5904\u5DEE\u5F02",
|
|
440
447
|
"recall.confirm.sessionOnly": "\u9879\u76EE\u6587\u4EF6\u4FDD\u6301\u5F53\u524D\u72B6\u6001\uFF0C\u4E0D\u4F1A\u88AB\u56DE\u9000\u6216\u5220\u9664\uFF1B\u5BF9\u8BDD\u56DE\u9000\u5230\u8BE5\u6D88\u606F\u4E4B\u524D\u3002",
|
|
448
|
+
"recall.confirm.messageOnly": "\u8FD9\u6761\u6D88\u606F\u53EA\u6709\u5BF9\u8BDD\u56DE\u9000\uFF1A\u4E0D\u4F1A\u6539\u52A8\u9879\u76EE\u6587\u4EF6\uFF0C\u5BF9\u8BDD\u56DE\u9000\u5230\u8BE5\u6D88\u606F\u4E4B\u524D\u3002",
|
|
449
|
+
"recall.confirm.noEffect": "\u8BE5\u6D88\u606F\u6CA1\u6709\u6587\u4EF6\u5FEB\u7167\uFF0C\u4E14\u662F\u4F1A\u8BDD\u7B2C\u4E00\u6761\u6D88\u606F\uFF0C\u4E4B\u524D\u6CA1\u6709\u53EF\u56DE\u9000\u7684\u5BF9\u8BDD\u3002",
|
|
441
450
|
"recall.confirm.rollbackAt": "\u5C06\u9879\u76EE\u6062\u590D\u5230 {time} \u53D1\u9001\u8BE5\u6D88\u606F\u65F6\u7684\u72B6\u6001\u3002",
|
|
442
451
|
"recall.confirm.rollbackNoTime": "\u5C06\u9879\u76EE\u6062\u590D\u5230\u53D1\u9001\u8BE5\u6D88\u606F\u65F6\u7684\u72B6\u6001\u3002",
|
|
443
452
|
"recall.confirm.files": "\u5171 {total} \u4E2A\u6587\u4EF6\u5C06\u53D8\u66F4{summary}\u3002\u6B64\u64CD\u4F5C\u4F1A\u8986\u76D6\u5F53\u524D\u6587\u4EF6\u5185\u5BB9\uFF1B\u56DE\u9000\u524D\u4F1A\u81EA\u52A8\u4FDD\u5B58\u4E00\u4EFD\u5F53\u524D\u72B6\u6001\u7684\u5B89\u5168\u5FEB\u7167\uFF08\u4E0D\u542B\u5728\u4E0B\u65B9\u6E05\u5355\u5185\uFF09\u3002",
|
|
@@ -511,6 +520,7 @@
|
|
|
511
520
|
"notice.buildRoot": "\u5F53\u524D\u5DE5\u4F5C\u533A\u4F4D\u4E8E\u6784\u5EFA\u4EA7\u7269\u76EE\u5F55\uFF08\u8DEF\u5F84\u6BB5 {seg}\uFF09\uFF0C\u5DF2\u8DF3\u8FC7\u9879\u76EE\u5FEB\u7167\uFF1B\u5982\u9700\u5728\u6B64\u76EE\u5F55\u4F7F\u7528\u64A4\u56DE\uFF0C\u8BF7\u5728\u63D2\u4EF6\u8BBE\u7F6E\u91CC\u4ECE\u300C\u57FA\u7840\u6392\u9664\u8868\u300D\u79FB\u9664\u8BE5\u9879\u3002",
|
|
512
521
|
// ---- 错误码文案(host 静态 message 的本地化副本;动态细节码不在表内)----
|
|
513
522
|
"err.STALE": "\u9884\u89C8\u540E\u9879\u76EE\u6587\u4EF6\u53D1\u751F\u4E86\u53D8\u5316\uFF0C\u8BF7\u91CD\u65B0\u9884\u89C8\u786E\u8BA4",
|
|
523
|
+
"err.DUMP_FAILED": "\u8BFB\u53D6\u78C1\u76D8\u5FEB\u7167\u5931\u8D25\uFF0C\u5217\u8868\u4E0E\u6279\u91CF\u5220\u9664\u6682\u4E0D\u53EF\u7528\uFF1B\u8BF7\u67E5\u770B\u300C\u6700\u8FD1\u9519\u8BEF\u300D\u540E\u91CD\u8BD5",
|
|
514
524
|
"err.AGENT_BUSY": "Agent \u6B63\u5728\u8FD0\u884C\u4E2D\uFF0C\u8BF7\u5148\u505C\u6B62\u540E\u518D\u64A4\u56DE",
|
|
515
525
|
"err.NO_SNAPSHOT": "\u8BE5\u6D88\u606F\u6CA1\u6709\u53EF\u7528\u7684\u9879\u76EE\u5FEB\u7167",
|
|
516
526
|
"err.NO_STORE": "\u5FEB\u7167\u5B58\u50A8\u4E0D\u53EF\u7528",
|
|
@@ -553,6 +563,7 @@
|
|
|
553
563
|
"manage.search.placeholder": "\u641C\u7D22\u5DE5\u4F5C\u533A / \u4F1A\u8BDD\u6807\u9898 / \u6D88\u606F\u5185\u5BB9 / ID",
|
|
554
564
|
"manage.search.aria": "\u641C\u7D22\u5FEB\u7167",
|
|
555
565
|
"manage.empty": "\u5728\u4EFB\u610F\u5DE5\u4F5C\u533A\u53D1\u9001\u4E00\u6761\u6D88\u606F\u540E\uFF0C\u8FD9\u91CC\u4F1A\u51FA\u73B0\u5FEB\u7167\u3002",
|
|
566
|
+
"manage.loadFailed": "\u8BFB\u53D6\u78C1\u76D8\u5FEB\u7167\u5931\u8D25",
|
|
556
567
|
"manage.emptyFiltered": "\u65E0\u5339\u914D\u5FEB\u7167",
|
|
557
568
|
"manage.errors.title": "\u6700\u8FD1\u9519\u8BEF ({n})",
|
|
558
569
|
"manage.errors.collapse": "\u6536\u8D77",
|
|
@@ -571,6 +582,8 @@
|
|
|
571
582
|
"config.group.ui": "\u754C\u9762",
|
|
572
583
|
"config.snapshotEnabled.label": "\u542F\u7528\u5FEB\u7167",
|
|
573
584
|
"config.snapshotEnabled.hint": "\u5173\u95ED\u540E\u4E0D\u518D\u65B0\u5EFA\u5FEB\u7167\uFF1B\u5DF2\u6709\u5FEB\u7167\u4ECD\u53EF\u64A4\u56DE",
|
|
585
|
+
"config.messageOnly.label": "\u53EA\u56DE\u9000\u6D88\u606F",
|
|
586
|
+
"config.messageOnly.hint": "\u6BCF\u6761\u6D88\u606F\u53EA\u767B\u8BB0\u64A4\u56DE\u8BB0\u5F55\uFF0C\u4E0D\u5199\u6587\u4EF6\u5FEB\u7167\uFF08\u8D85\u5927\u5DE5\u4F5C\u533A\u7701\u65F6\u7701\u76D8\uFF09\uFF1B\u5DF2\u6709\u5FEB\u7167\u4ECD\u53EF\u56DE\u9000\u6587\u4EF6",
|
|
574
587
|
"config.refillDraft.label": "\u56DE\u586B\u8F93\u5165\u6846",
|
|
575
588
|
"config.refillDraft.hint": "\u64A4\u56DE\u540E\u628A\u6D88\u606F\u6587\u672C\u56DE\u586B\u8F93\u5165\u6846\uFF0C\u4FBF\u4E8E\u6539\u5B8C\u91CD\u53D1",
|
|
576
589
|
"config.archiveOriginal.label": "\u5F52\u6863\u539F\u4F1A\u8BDD",
|
|
@@ -592,6 +605,9 @@
|
|
|
592
605
|
"config.scanBudgetMs.label": "\u626B\u63CF\u65F6\u95F4\u9884\u7B97",
|
|
593
606
|
"config.scanBudgetMs.hint": "\u5FEB\u7167\u524D\u7EDF\u8BA1\u7EB3\u5165\u603B\u91CF\u8D85\u8FC7\u8BE5\u65F6\u957F\u5373\u505C\u7528\u672C\u6B21\u5FEB\u7167\uFF1B0 \u8868\u793A\u4E0D\u9650\u5236",
|
|
594
607
|
"config.scanBudgetMs.suffix": "\u79D2",
|
|
608
|
+
"config.snapshotBudgetMs.label": "\u5355\u6B21\u5FEB\u7167\u65F6\u9650",
|
|
609
|
+
"config.snapshotBudgetMs.hint": "\u5355\u6B21\u5FEB\u7167\u4ECE\u626B\u63CF\u5230\u5199\u5165\u7684\u603B\u65F6\u9650\uFF0C\u8D85\u65F6\u5373\u4E2D\u6B62\u672C\u6B21\u5FEB\u7167\u5E76\u8BB0\u5165\u6700\u8FD1\u9519\u8BEF\uFF1B0 \u8868\u793A\u4E0D\u9650\u5236",
|
|
610
|
+
"config.snapshotBudgetMs.suffix": "\u79D2",
|
|
595
611
|
"config.maxSnapshotsPerWorkspace.label": "\u5FEB\u7167\u603B\u91CF\u4E0A\u9650",
|
|
596
612
|
"config.maxSnapshotsPerWorkspace.hint": "\u6BCF\u5DE5\u4F5C\u533A\u4FDD\u7559\u7684\u6700\u5927\u5FEB\u7167\u6570\uFF0C\u8D85\u9650\u5220\u9664\u6700\u65E7\u7684\uFF1B0 \u8868\u793A\u4E0D\u9650\u5236",
|
|
597
613
|
"config.maxSnapshotsPerWorkspace.suffix": "\u6761",
|
|
@@ -624,6 +640,7 @@
|
|
|
624
640
|
"config.err.maxFileBytes": "\u6587\u4EF6\u5927\u5C0F\u4E0A\u9650\u81F3\u5C11 0.01 MB",
|
|
625
641
|
"config.err.maxWorkspaceBytes": "\u5DE5\u4F5C\u533A\u603B\u91CF\u4E0A\u9650\u5FC5\u987B\u662F >= 0 \u7684\u6570\u5B57\uFF080 \u8868\u793A\u4E0D\u9650\u5236\uFF09",
|
|
626
642
|
"config.err.scanBudgetMs": "\u626B\u63CF\u65F6\u95F4\u9884\u7B97\u5FC5\u987B\u662F >= 0 \u7684\u6570\u5B57\uFF080 \u8868\u793A\u4E0D\u9650\u5236\uFF09",
|
|
643
|
+
"config.err.snapshotBudgetMs": "\u5355\u6B21\u5FEB\u7167\u65F6\u9650\u5FC5\u987B\u662F >= 0 \u7684\u6570\u5B57\uFF080 \u8868\u793A\u4E0D\u9650\u5236\uFF09",
|
|
627
644
|
"config.err.maxSnapshots": "\u5FEB\u7167\u603B\u91CF\u4E0A\u9650\u5FC5\u987B\u662F >= 0 \u7684\u6574\u6570\uFF080 \u8868\u793A\u4E0D\u9650\u5236\uFF09",
|
|
628
645
|
"config.err.retentionDays": "\u4FDD\u7559\u5929\u6570\u5FC5\u987B\u662F >= 0 \u7684\u6574\u6570\uFF080 \u8868\u793A\u4E0D\u542F\u7528\uFF09",
|
|
629
646
|
"config.err.locale": "\u754C\u9762\u8BED\u8A00\u5FC5\u987B\u662F auto / zh / en \u4E4B\u4E00",
|
|
@@ -679,6 +696,7 @@
|
|
|
679
696
|
"tree.family.title": "Version family: {chain}",
|
|
680
697
|
"tree.switch": "Switch",
|
|
681
698
|
"tree.switch.title": "Switch to this version session",
|
|
699
|
+
"tree.messageOnly": "Messages only",
|
|
682
700
|
"tree.delete.snapshot.title": "Delete this snapshot (tag and index entry)",
|
|
683
701
|
"tree.delete.snapshot.confirm": "Delete this snapshot? This cannot be undone.",
|
|
684
702
|
"tree.delete.session.title": "Delete all snapshots of this session",
|
|
@@ -692,6 +710,8 @@
|
|
|
692
710
|
"recall.truncated.files": "\u2026showing the first {shown} of {total} files to change",
|
|
693
711
|
"recall.truncated.chat": "\u2026showing the first {shown} of {total} differences",
|
|
694
712
|
"recall.confirm.sessionOnly": "Project files stay as they are \u2014 nothing is reverted or deleted; only the chat rewinds to before this message.",
|
|
713
|
+
"recall.confirm.messageOnly": "This message only rewinds the chat: project files are untouched, and the chat goes back to before this message.",
|
|
714
|
+
"recall.confirm.noEffect": "This message has no file snapshot, and as the first message of the session there is no earlier chat to rewind to.",
|
|
695
715
|
"recall.confirm.rollbackAt": "Restore the project to its state at {time}, when this message was sent.",
|
|
696
716
|
"recall.confirm.rollbackNoTime": "Restore the project to its state when this message was sent.",
|
|
697
717
|
// 句首空格是有意的:本句与 recall.confirm.rollbackAt 拼接成一段(前句以句点
|
|
@@ -765,6 +785,7 @@
|
|
|
765
785
|
"notice.buildRoot": 'This workspace is a build-artifact directory (path segment {seg}), so project snapshots are skipped; to use recall here, remove that entry from "Base excludes" in the plugin settings.',
|
|
766
786
|
// ---- 错误码文案 ----
|
|
767
787
|
"err.STALE": "Project files changed after the preview; preview again to confirm",
|
|
788
|
+
"err.DUMP_FAILED": 'Could not read snapshots from disk; the list and bulk delete are unavailable. Check "Recent errors" and retry',
|
|
768
789
|
"err.AGENT_BUSY": "The agent is running; stop it before recalling",
|
|
769
790
|
"err.NO_SNAPSHOT": "No project snapshot is available for this message",
|
|
770
791
|
"err.NO_STORE": "Snapshot storage is unavailable",
|
|
@@ -807,6 +828,7 @@
|
|
|
807
828
|
"manage.search.placeholder": "Search workspace / session title / message / ID",
|
|
808
829
|
"manage.search.aria": "Search snapshots",
|
|
809
830
|
"manage.empty": "Send a message in any workspace and its snapshot will appear here.",
|
|
831
|
+
"manage.loadFailed": "Could not read snapshots from disk",
|
|
810
832
|
"manage.emptyFiltered": "No matching snapshots",
|
|
811
833
|
"manage.errors.title": "Recent errors ({n})",
|
|
812
834
|
"manage.errors.collapse": "Collapse",
|
|
@@ -825,6 +847,8 @@
|
|
|
825
847
|
"config.group.ui": "Interface",
|
|
826
848
|
"config.snapshotEnabled.label": "Enable snapshots",
|
|
827
849
|
"config.snapshotEnabled.hint": "When off, no new snapshots are created; existing ones can still be recalled",
|
|
850
|
+
"config.messageOnly.label": "Messages only",
|
|
851
|
+
"config.messageOnly.hint": "Register recall records without writing file snapshots (saves time and disk in huge workspaces); existing snapshots can still roll back files",
|
|
828
852
|
"config.refillDraft.label": "Refill input box",
|
|
829
853
|
"config.refillDraft.hint": "Put the message text back into the input box after a recall, so you can edit and resend",
|
|
830
854
|
"config.archiveOriginal.label": "Archive original session",
|
|
@@ -846,6 +870,9 @@
|
|
|
846
870
|
"config.scanBudgetMs.label": "Scan time budget",
|
|
847
871
|
"config.scanBudgetMs.hint": "Skip this snapshot when measuring the snapshot-eligible total exceeds this duration; 0 means unlimited",
|
|
848
872
|
"config.scanBudgetMs.suffix": "s",
|
|
873
|
+
"config.snapshotBudgetMs.label": "Snapshot time limit",
|
|
874
|
+
"config.snapshotBudgetMs.hint": "Total time for one snapshot from scan to write; exceeding it aborts the snapshot and records it under recent errors; 0 means unlimited",
|
|
875
|
+
"config.snapshotBudgetMs.suffix": "s",
|
|
849
876
|
"config.maxSnapshotsPerWorkspace.label": "Snapshot count limit",
|
|
850
877
|
"config.maxSnapshotsPerWorkspace.hint": "Maximum snapshots kept per workspace; the oldest are deleted beyond it; 0 means unlimited",
|
|
851
878
|
"config.maxSnapshotsPerWorkspace.suffix": "snapshots",
|
|
@@ -878,6 +905,7 @@
|
|
|
878
905
|
"config.err.maxFileBytes": "The file size limit must be at least 0.01 MB",
|
|
879
906
|
"config.err.maxWorkspaceBytes": "The workspace total limit must be a number >= 0 (0 means unlimited)",
|
|
880
907
|
"config.err.scanBudgetMs": "The scan time budget must be a number >= 0 (0 means unlimited)",
|
|
908
|
+
"config.err.snapshotBudgetMs": "The snapshot time limit must be a number >= 0 (0 means unlimited)",
|
|
881
909
|
"config.err.maxSnapshots": "The snapshot count limit must be an integer >= 0 (0 means unlimited)",
|
|
882
910
|
"config.err.retentionDays": "Retention days must be an integer >= 0 (0 disables it)",
|
|
883
911
|
"config.err.locale": "Language must be one of auto / zh / en",
|
|
@@ -1882,7 +1910,7 @@
|
|
|
1882
1910
|
writeIds(rootId, next);
|
|
1883
1911
|
}
|
|
1884
1912
|
|
|
1885
|
-
// src/client/
|
|
1913
|
+
// src/client/message-blocks.ts
|
|
1886
1914
|
function attachmentRefsFromBlocks(blocks) {
|
|
1887
1915
|
const refs = [];
|
|
1888
1916
|
for (const block of blocks) {
|
|
@@ -1895,6 +1923,18 @@
|
|
|
1895
1923
|
}
|
|
1896
1924
|
return refs;
|
|
1897
1925
|
}
|
|
1926
|
+
function projectMessageBlocks(blocks) {
|
|
1927
|
+
return {
|
|
1928
|
+
text: blocks.filter((b) => b && b.type === "text" && typeof b.text === "string").map((b) => b.text).join(""),
|
|
1929
|
+
// 官方契约:images 传 image 块数组({attachment}),官方内部取
|
|
1930
|
+
// image.attachment.attachmentId —— 不是裸 attachment 对象
|
|
1931
|
+
images: blocks.filter((b) => b && b.type === "image" && b.attachment).map((b) => ({ attachment: b.attachment })),
|
|
1932
|
+
// file 块(文档/表格等非图片附件):官方渲染成文件卡片(图标 + 文件名 +
|
|
1933
|
+
// 扩展名/大小)——自绘路径必须复刻,否则它落进 rest 的 JSON 兜底
|
|
1934
|
+
files: blocks.filter((b) => b && b.type === "file"),
|
|
1935
|
+
rest: blocks.filter((b) => !b || !(b.type === "text" && typeof b.text === "string") && !(b.type === "image" && b.attachment) && b.type !== "file")
|
|
1936
|
+
};
|
|
1937
|
+
}
|
|
1898
1938
|
function fileExtOf(name) {
|
|
1899
1939
|
const dot = name.lastIndexOf(".");
|
|
1900
1940
|
const raw = dot > 0 && dot < name.length - 1 ? name.slice(dot + 1) : "";
|
|
@@ -1915,6 +1955,8 @@
|
|
|
1915
1955
|
const name = ref && typeof ref === "object" && typeof ref.name === "string" && ref.name !== "" ? ref.name : t("file.unnamed");
|
|
1916
1956
|
return { name, ext: fileExtOf(name), size: fileCardSizeText(ref && typeof ref === "object" ? ref.bytes : void 0) };
|
|
1917
1957
|
}
|
|
1958
|
+
|
|
1959
|
+
// src/client/diff-kinds.ts
|
|
1918
1960
|
var KIND_INFO = {
|
|
1919
1961
|
modified: { labelKey: "kind.modified", cls: "modified" },
|
|
1920
1962
|
restored: { labelKey: "kind.restored", cls: "restored" },
|
|
@@ -1930,6 +1972,8 @@
|
|
|
1930
1972
|
}
|
|
1931
1973
|
return parts.join(" \xB7 ");
|
|
1932
1974
|
}
|
|
1975
|
+
|
|
1976
|
+
// src/client/recall-node.ts
|
|
1933
1977
|
function buildRecallNode(React, util, ctx, sessionsSvc, workspacesSvc, uiWorkspaceSvc) {
|
|
1934
1978
|
const { api, ensureInit, showThrottledToast, writeClipboard, clockText: clockText2, pluginConfig, messageFor, t } = util;
|
|
1935
1979
|
const { EditIcon, EditPanel } = buildEditFlow(React, t);
|
|
@@ -1999,7 +2043,9 @@
|
|
|
1999
2043
|
);
|
|
2000
2044
|
});
|
|
2001
2045
|
const canRevertChat = typeof recall.cutSeq === "number";
|
|
2002
|
-
const
|
|
2046
|
+
const noFiles = recall.files === false;
|
|
2047
|
+
const sessionOnly = noFiles || canRevertChat && scope === "session-only";
|
|
2048
|
+
const noEffect = noFiles && !canRevertChat;
|
|
2003
2049
|
if (recall.truncated) {
|
|
2004
2050
|
rows.push(React.createElement(
|
|
2005
2051
|
"div",
|
|
@@ -2019,11 +2065,11 @@
|
|
|
2019
2065
|
React.createElement("div", { className: "dsh-recall-panel-title" }, t("recall.confirm.title")),
|
|
2020
2066
|
sessionOnly ? (
|
|
2021
2067
|
// session-only:零文件改动即无不可逆操作缺口——安全快照预告随之隐藏
|
|
2022
|
-
|
|
2068
|
+
//(不打安全快照),主说明换成模式语义;仅消息条目另给专属说明
|
|
2023
2069
|
React.createElement(
|
|
2024
2070
|
"div",
|
|
2025
2071
|
{ className: "dsh-recall-panel-note" },
|
|
2026
|
-
t("recall.confirm.sessionOnly")
|
|
2072
|
+
t(noFiles ? "recall.confirm.messageOnly" : "recall.confirm.sessionOnly")
|
|
2027
2073
|
)
|
|
2028
2074
|
) : React.createElement(
|
|
2029
2075
|
"div",
|
|
@@ -2045,9 +2091,10 @@
|
|
|
2045
2091
|
)
|
|
2046
2092
|
) : null,
|
|
2047
2093
|
changes.length > 0 ? React.createElement("div", { className: "dsh-recall-list" }, ...rows) : null,
|
|
2048
|
-
canRevertChat ? (
|
|
2094
|
+
canRevertChat && !noFiles ? (
|
|
2049
2095
|
// 模式二选一(原生 radio,键盘方向键可达):临场选择不设全局配置项;
|
|
2050
|
-
// 默认 both
|
|
2096
|
+
// 默认 both 与现状一致,面板每次打开都复位到默认起点。仅消息条目
|
|
2097
|
+
// 没有可选项(没有文件树),整组隐藏
|
|
2051
2098
|
React.createElement(
|
|
2052
2099
|
"div",
|
|
2053
2100
|
{ className: "dsh-recall-scope", role: "radiogroup", "aria-label": t("recall.scope.aria") },
|
|
@@ -2065,22 +2112,28 @@
|
|
|
2065
2112
|
)
|
|
2066
2113
|
)
|
|
2067
2114
|
) : null,
|
|
2115
|
+
noEffect ? (
|
|
2116
|
+
// 仅消息 + 首条消息:文件无快照、对话无更早切点,提交只会空转
|
|
2117
|
+
React.createElement("div", { className: "dsh-recall-panel-note", key: "no-effect" }, t("recall.confirm.noEffect"))
|
|
2118
|
+
) : null,
|
|
2068
2119
|
React.createElement(
|
|
2069
2120
|
"div",
|
|
2070
2121
|
{ className: "dsh-recall-panel-actions" },
|
|
2071
2122
|
React.createElement("button", { type: "button", className: "dsh-recall-btn", onClick: closePanel }, t("common.cancel")),
|
|
2072
|
-
React.createElement("button", { type: "button", className: "dsh-recall-btn dsh-recall-btn-danger", onClick: executeRecall }, t(sessionOnly ? "recall.confirm.submit.sessionOnly" : "recall.confirm.submit.both"))
|
|
2123
|
+
React.createElement("button", { type: "button", className: "dsh-recall-btn dsh-recall-btn-danger", disabled: noEffect, onClick: executeRecall }, t(sessionOnly ? "recall.confirm.submit.sessionOnly" : "recall.confirm.submit.both"))
|
|
2073
2124
|
)
|
|
2074
2125
|
);
|
|
2075
2126
|
}
|
|
2076
2127
|
if (recall.stage === "executing") {
|
|
2128
|
+
const active = recall.scope || scope;
|
|
2077
2129
|
return React.createElement(
|
|
2078
2130
|
"div",
|
|
2079
2131
|
{ className: "dsh-recall-panel" },
|
|
2080
|
-
React.createElement("div", { className: "dsh-recall-panel-title" }, t(
|
|
2132
|
+
React.createElement("div", { className: "dsh-recall-panel-title" }, t(active === "session-only" ? "recall.executing.sessionOnly" : "recall.executing.both"))
|
|
2081
2133
|
);
|
|
2082
2134
|
}
|
|
2083
2135
|
if (recall.stage === "done") {
|
|
2136
|
+
const active = recall.scope || scope;
|
|
2084
2137
|
return React.createElement(
|
|
2085
2138
|
"div",
|
|
2086
2139
|
{ className: "dsh-recall-panel" },
|
|
@@ -2088,7 +2141,7 @@
|
|
|
2088
2141
|
React.createElement(
|
|
2089
2142
|
"div",
|
|
2090
2143
|
{ className: "dsh-recall-panel-note" },
|
|
2091
|
-
|
|
2144
|
+
active === "session-only" ? (
|
|
2092
2145
|
// session-only 文案矩阵:文件侧零改动是确定事实,失败时也只描述对话侧
|
|
2093
2146
|
recall.chatReverted ? t("recall.done.sessionOnly.ok") : t("recall.done.sessionOnly.fail", { error: recall.chatError || t("common.unknownReason") })
|
|
2094
2147
|
) : recall.chatReverted ? t("recall.done.both.ok") : t("recall.done.both.partial") + (recall.chatError ? t("recall.done.both.failChat", { error: recall.chatError }) : "")
|
|
@@ -2137,10 +2190,7 @@
|
|
|
2137
2190
|
const data = node && node.data ? node.data : {};
|
|
2138
2191
|
const messageId = node ? String(node.id || node.key || "") : "";
|
|
2139
2192
|
const blocks = Array.isArray(data.content) ? data.content : [];
|
|
2140
|
-
const
|
|
2141
|
-
const imageBlocks = blocks.filter((b) => b && b.type === "image" && b.attachment).map((b) => ({ attachment: b.attachment }));
|
|
2142
|
-
const fileBlocks = blocks.filter((b) => b && b.type === "file");
|
|
2143
|
-
const rest = blocks.filter((b) => !b || !(b.type === "text" && typeof b.text === "string") && !(b.type === "image" && b.attachment) && b.type !== "file");
|
|
2193
|
+
const { text: text2, images: imageBlocks, files: fileBlocks, rest } = projectMessageBlocks(blocks);
|
|
2144
2194
|
const attachmentRefs = attachmentRefsFromBlocks(blocks);
|
|
2145
2195
|
activeSession.id = String(sessionId || "");
|
|
2146
2196
|
const [copied, setCopied] = React.useState(false);
|
|
@@ -2467,7 +2517,9 @@
|
|
|
2467
2517
|
truncated: Boolean(res.truncated),
|
|
2468
2518
|
treeId: res.treeId || null,
|
|
2469
2519
|
time: res.time || null,
|
|
2470
|
-
cutSeq: typeof res.cutSeq === "number" ? res.cutSeq : null
|
|
2520
|
+
cutSeq: typeof res.cutSeq === "number" ? res.cutSeq : null,
|
|
2521
|
+
// issue #31:仅消息条目没有文件树(老 Host 无该字段 → 按 true)
|
|
2522
|
+
files: res.files !== false
|
|
2471
2523
|
});
|
|
2472
2524
|
}).catch((error) => {
|
|
2473
2525
|
setRecall({ stage: "error", message: String(error) });
|
|
@@ -2478,7 +2530,7 @@
|
|
|
2478
2530
|
const isEdit = editText !== null;
|
|
2479
2531
|
if (!isEdit && recall.stage !== "confirm") return;
|
|
2480
2532
|
const changes = recall.stage === "confirm" ? recall.changes || [] : [];
|
|
2481
|
-
const activeScope = overrides && overrides.scope === "session-only" ? "session-only" : overrides && overrides.scope === "both" ? "both" : scope;
|
|
2533
|
+
const activeScope = overrides && overrides.scope === "session-only" ? "session-only" : overrides && overrides.scope === "both" ? "both" : recall.stage === "confirm" && recall.files === false ? "session-only" : scope;
|
|
2482
2534
|
const previewCut = overrides && overrides.cutSeq !== void 0 ? overrides.cutSeq : recall.stage === "confirm" && typeof recall.cutSeq === "number" ? recall.cutSeq : null;
|
|
2483
2535
|
const setStage = (stage) => {
|
|
2484
2536
|
if (!isEdit) {
|
|
@@ -2497,7 +2549,7 @@
|
|
|
2497
2549
|
const baseFiles = wantAttachments ? preloadAttachmentFiles(sessionId, refsForFiles) : null;
|
|
2498
2550
|
const inlineFiles = overrides && overrides.editFiles ? overrides.editFiles : null;
|
|
2499
2551
|
const attachmentFiles = baseFiles !== null && inlineFiles !== null ? Promise.all([baseFiles, inlineFiles]).then(([base, extra]) => base.concat(extra)) : baseFiles !== null ? baseFiles : inlineFiles;
|
|
2500
|
-
setStage({ stage: "executing", changes });
|
|
2552
|
+
setStage({ stage: "executing", changes, scope: activeScope });
|
|
2501
2553
|
api("execute", { messageId, sessionId, previewTotal, previewTreeId: previewTreeId || void 0, scope: activeScope, previewAt: Date.now() }).then(async (res) => {
|
|
2502
2554
|
if (!res || !res.ok) {
|
|
2503
2555
|
if (res && res.code === "STALE") {
|
|
@@ -2518,7 +2570,8 @@
|
|
|
2518
2570
|
truncated: Boolean(res2.truncated),
|
|
2519
2571
|
treeId: res2.treeId || null,
|
|
2520
2572
|
time: res2.time || null,
|
|
2521
|
-
cutSeq: typeof res2.cutSeq === "number" ? res2.cutSeq : null
|
|
2573
|
+
cutSeq: typeof res2.cutSeq === "number" ? res2.cutSeq : null,
|
|
2574
|
+
files: res2.files !== false
|
|
2522
2575
|
});
|
|
2523
2576
|
}).catch((error) => {
|
|
2524
2577
|
setStage({ stage: "error", message: String(error) });
|
|
@@ -2584,7 +2637,7 @@
|
|
|
2584
2637
|
fillDraft(fillTarget, text2, attachmentFiles);
|
|
2585
2638
|
if (fileBlocks.length > 0) showThrottledToast(t("toast.fileAttachRefill"));
|
|
2586
2639
|
}
|
|
2587
|
-
setRecall({ stage: "done", count: typeof res.count === "number" ? res.count : changes.length, chatReverted, chatError });
|
|
2640
|
+
setRecall({ stage: "done", count: typeof res.count === "number" ? res.count : changes.length, chatReverted, chatError, scope: activeScope });
|
|
2588
2641
|
}
|
|
2589
2642
|
if (chatError === "") {
|
|
2590
2643
|
api("notify", buildRecallNotify(notifyBase, {
|
|
@@ -2762,10 +2815,14 @@
|
|
|
2762
2815
|
maxWorkspaceBytes: bytesToMb2(v.maxWorkspaceBytes),
|
|
2763
2816
|
// 扫描预算持久化为毫秒、展示为秒(整数);0 表示不限制,原样显示 0
|
|
2764
2817
|
scanBudgetMs: String(v.scanBudgetMs == null ? "" : Math.round(Number(v.scanBudgetMs) / 1e3)),
|
|
2818
|
+
// issue #29:单次快照总时限,同样按秒展示
|
|
2819
|
+
snapshotBudgetMs: String(v.snapshotBudgetMs == null ? "" : Math.round(Number(v.snapshotBudgetMs) / 1e3)),
|
|
2765
2820
|
maxSnapshotsPerWorkspace: String(v.maxSnapshotsPerWorkspace == null ? "" : v.maxSnapshotsPerWorkspace),
|
|
2766
2821
|
baseExcludes: Array.isArray(v.baseExcludes) ? v.baseExcludes.join("\n") : "",
|
|
2767
2822
|
refillDraft: v.refillDraft !== false,
|
|
2768
2823
|
snapshotEnabled: v.snapshotEnabled !== false,
|
|
2824
|
+
// issue #31:老 Host 无该字段 → 按默认 false(现状)处理
|
|
2825
|
+
messageOnly: v.messageOnly === true,
|
|
2769
2826
|
archiveOriginal: v.archiveOriginal !== false,
|
|
2770
2827
|
// issue #26:老 Host 的 values 无该字段时按默认 true(与 util 同口径)
|
|
2771
2828
|
editSubmitsImmediately: v.editSubmitsImmediately !== false,
|
|
@@ -2793,7 +2850,7 @@
|
|
|
2793
2850
|
function save() {
|
|
2794
2851
|
if (state.busy || !draft || !baseline) return;
|
|
2795
2852
|
const patch = {};
|
|
2796
|
-
for (const key of ["gcSnaps", "gcHours", "maxFileBytes", "maxWorkspaceBytes", "scanBudgetMs", "maxSnapshotsPerWorkspace", "baseExcludes", "refillDraft", "snapshotEnabled", "archiveOriginal", "editSubmitsImmediately", "retentionDays", "locale"]) {
|
|
2853
|
+
for (const key of ["gcSnaps", "gcHours", "maxFileBytes", "maxWorkspaceBytes", "scanBudgetMs", "snapshotBudgetMs", "maxSnapshotsPerWorkspace", "baseExcludes", "refillDraft", "snapshotEnabled", "messageOnly", "archiveOriginal", "editSubmitsImmediately", "retentionDays", "locale"]) {
|
|
2797
2854
|
const value = draft[key];
|
|
2798
2855
|
if (value !== void 0 && value !== baseline[key]) patch[key] = value;
|
|
2799
2856
|
}
|
|
@@ -2842,6 +2899,14 @@
|
|
|
2842
2899
|
}
|
|
2843
2900
|
clean.scanBudgetMs = Math.round(s * 1e3);
|
|
2844
2901
|
}
|
|
2902
|
+
if (patch.snapshotBudgetMs !== void 0) {
|
|
2903
|
+
const s = Number(patch.snapshotBudgetMs);
|
|
2904
|
+
if (!Number.isFinite(s) || s < 0) {
|
|
2905
|
+
setState({ busy: false, message: t("config.err.snapshotBudgetMs"), error: true });
|
|
2906
|
+
return;
|
|
2907
|
+
}
|
|
2908
|
+
clean.snapshotBudgetMs = Math.round(s * 1e3);
|
|
2909
|
+
}
|
|
2845
2910
|
if (patch.maxSnapshotsPerWorkspace !== void 0) {
|
|
2846
2911
|
const n = parseInt(String(patch.maxSnapshotsPerWorkspace), 10);
|
|
2847
2912
|
if (!Number.isFinite(n) || n < 0) {
|
|
@@ -2852,6 +2917,7 @@
|
|
|
2852
2917
|
}
|
|
2853
2918
|
if (patch.refillDraft !== void 0) clean.refillDraft = Boolean(patch.refillDraft);
|
|
2854
2919
|
if (patch.snapshotEnabled !== void 0) clean.snapshotEnabled = Boolean(patch.snapshotEnabled);
|
|
2920
|
+
if (patch.messageOnly !== void 0) clean.messageOnly = Boolean(patch.messageOnly);
|
|
2855
2921
|
if (patch.archiveOriginal !== void 0) clean.archiveOriginal = Boolean(patch.archiveOriginal);
|
|
2856
2922
|
if (patch.editSubmitsImmediately !== void 0) clean.editSubmitsImmediately = Boolean(patch.editSubmitsImmediately);
|
|
2857
2923
|
if (patch.retentionDays !== void 0) {
|
|
@@ -3009,6 +3075,8 @@
|
|
|
3009
3075
|
// 不重复加标题。
|
|
3010
3076
|
React.createElement("div", { className: "dsh-recall-cfg-group" }, t("config.group.snapshot")),
|
|
3011
3077
|
boolRow("snapshotEnabled", t("config.snapshotEnabled.label"), t("config.snapshotEnabled.hint")),
|
|
3078
|
+
// issue #31:只回退消息——紧挨总开关,语义上是「快照写什么」的下一层
|
|
3079
|
+
boolRow("messageOnly", t("config.messageOnly.label"), t("config.messageOnly.hint")),
|
|
3012
3080
|
boolRow("refillDraft", t("config.refillDraft.label"), t("config.refillDraft.hint")),
|
|
3013
3081
|
boolRow("archiveOriginal", t("config.archiveOriginal.label"), t("config.archiveOriginal.hint")),
|
|
3014
3082
|
boolRow("editSubmitsImmediately", t("config.editSubmitsImmediately.label"), t("config.editSubmitsImmediately.hint")),
|
|
@@ -3021,6 +3089,8 @@
|
|
|
3021
3089
|
// issue #24 护栏:单文件上限之外的两道闸(总量 + 扫描耗时),0 = 不限制
|
|
3022
3090
|
numRow("maxWorkspaceBytes", t("config.maxWorkspaceBytes.label"), t("config.maxWorkspaceBytes.hint"), { suffixKey: "config.maxWorkspaceBytes.suffix", min: 0, step: 16 }),
|
|
3023
3091
|
numRow("scanBudgetMs", t("config.scanBudgetMs.label"), t("config.scanBudgetMs.hint"), { suffixKey: "config.scanBudgetMs.suffix", min: 0, step: 1 }),
|
|
3092
|
+
// issue #29:扫描阶段之外还有 add/写索引——单次快照的总时限兜底
|
|
3093
|
+
numRow("snapshotBudgetMs", t("config.snapshotBudgetMs.label"), t("config.snapshotBudgetMs.hint"), { suffixKey: "config.snapshotBudgetMs.suffix", min: 0, step: 30 }),
|
|
3024
3094
|
numRow("maxSnapshotsPerWorkspace", t("config.maxSnapshotsPerWorkspace.label"), t("config.maxSnapshotsPerWorkspace.hint"), { suffixKey: "config.maxSnapshotsPerWorkspace.suffix", min: 0, step: 1 }),
|
|
3025
3095
|
numRow("retentionDays", t("config.retentionDays.label"), t("config.retentionDays.hint"), { suffixKey: "config.retentionDays.suffix", min: 0, step: 1 }),
|
|
3026
3096
|
React.createElement("div", { className: "dsh-recall-cfg-group" }, t("config.group.ui")),
|
|
@@ -3267,7 +3337,7 @@
|
|
|
3267
3337
|
return result;
|
|
3268
3338
|
}
|
|
3269
3339
|
function buildSnapshotManager(React, util, sessionsSvc, workspacesSvc, uiWorkspaceSvc) {
|
|
3270
|
-
const { api, clockText: clockText2, sizeText: sizeText2, buildTree: buildTree2, t } = util;
|
|
3340
|
+
const { api, clockText: clockText2, sizeText: sizeText2, buildTree: buildTree2, messageFor, t } = util;
|
|
3271
3341
|
function DeleteButton(props) {
|
|
3272
3342
|
return React.createElement("button", {
|
|
3273
3343
|
type: "button",
|
|
@@ -3306,6 +3376,7 @@
|
|
|
3306
3376
|
}
|
|
3307
3377
|
function ManageCard() {
|
|
3308
3378
|
const [items, setItems] = React.useState(null);
|
|
3379
|
+
const [listError, setListError] = React.useState(null);
|
|
3309
3380
|
const [usage, setUsage] = React.useState(null);
|
|
3310
3381
|
const [errors, setErrors] = React.useState(null);
|
|
3311
3382
|
const [state, setState] = React.useState({ busy: false, message: "", error: false });
|
|
@@ -3349,6 +3420,7 @@
|
|
|
3349
3420
|
const useLimit = overLimit || limit;
|
|
3350
3421
|
api("manage", { op: "list", limit: useLimit }).then((res) => {
|
|
3351
3422
|
if (res && res.ok) {
|
|
3423
|
+
setListError(null);
|
|
3352
3424
|
setItems(res.items || []);
|
|
3353
3425
|
setTotal(typeof res.total === "number" ? res.total : (res.items || []).length);
|
|
3354
3426
|
fetchTitles(res.items || []);
|
|
@@ -3364,6 +3436,10 @@
|
|
|
3364
3436
|
}).catch(() => {
|
|
3365
3437
|
});
|
|
3366
3438
|
}
|
|
3439
|
+
} else if (res) {
|
|
3440
|
+
setListError(messageFor(res, t("manage.loadFailed")));
|
|
3441
|
+
setItems([]);
|
|
3442
|
+
setTotal(0);
|
|
3367
3443
|
}
|
|
3368
3444
|
api("manage", { op: "lineage" }).then((res2) => {
|
|
3369
3445
|
if (res2 && res2.ok && Array.isArray(res2.lineage)) setLineage(res2.lineage);
|
|
@@ -3499,6 +3575,8 @@
|
|
|
3499
3575
|
"span",
|
|
3500
3576
|
{ className: "dsh-recall-tree-label" },
|
|
3501
3577
|
React.createElement("span", { className: "dsh-recall-tree-title" }, label),
|
|
3578
|
+
// issue #31:仅消息条目没有文件树,标出来——否则用户会以为这条能回退文件
|
|
3579
|
+
it.files === false ? React.createElement("span", { className: "dsh-recall-tree-tag", key: "message-only" }, t("tree.messageOnly")) : null,
|
|
3502
3580
|
// 删除紧贴本条消息摘要(而非行尾):按行扫读时动作落在「看的那一行」,
|
|
3503
3581
|
// 不必横跳到右侧同列按钮再回读行号
|
|
3504
3582
|
React.createElement(DeleteButton, {
|
|
@@ -3676,6 +3754,13 @@
|
|
|
3676
3754
|
onChange: (e) => setQuery(e.target.value)
|
|
3677
3755
|
})
|
|
3678
3756
|
),
|
|
3757
|
+
// 读取失败提示:role=alert 让读屏立即播报;「重试」复用 refresh,不新造入口
|
|
3758
|
+
listError ? React.createElement(
|
|
3759
|
+
"div",
|
|
3760
|
+
{ key: "list-error", className: "dsh-recall-list-error", role: "alert" },
|
|
3761
|
+
React.createElement("span", { className: "dsh-recall-list-error-text" }, listError),
|
|
3762
|
+
React.createElement("button", { type: "button", className: "dsh-recall-ex-chip", onClick: () => refresh() }, t("common.retry"))
|
|
3763
|
+
) : null,
|
|
3679
3764
|
// V3 加载骨架:items===null 表示首查未回——用 5 条 pulse 灰条占位替代
|
|
3680
3765
|
// 打开快照管理时的一段空白;aria-hidden 纯装饰不打扰读屏
|
|
3681
3766
|
items === null ? React.createElement(
|
package/lib/config.js
CHANGED
|
@@ -40,10 +40,17 @@ const Config = Schema.object({
|
|
|
40
40
|
// 截断快照回退时,未纳入的文件会命中回退脚本的「当前有、目标无」删除侧。
|
|
41
41
|
maxWorkspaceBytes: withVolatile(Schema.number().default(268435456).description("\u5355\u5DE5\u4F5C\u533A\u53EF\u7EB3\u5165\u5FEB\u7167\u7684\u6587\u4EF6\u603B\u91CF\u4E0A\u9650\uFF0C\u53E3\u5F84\u4E0E git add \u4E00\u81F4\uFF08\u542B .gitignore\uFF09\uFF0C\u8D85\u8FC7\u5373\u505C\u7528\u8BE5\u5DE5\u4F5C\u533A\u5FEB\u7167\uFF1B0 \u8868\u793A\u4E0D\u9650\u5236")),
|
|
42
42
|
scanBudgetMs: withVolatile(Schema.number().default(1e4).description("\u5FEB\u7167\u524D\u7EDF\u8BA1\u7EB3\u5165\u603B\u91CF\u7684\u65F6\u95F4\u9884\u7B97\uFF0C\u8D85\u65F6\u5373\u505C\u7528\u672C\u6B21\u5FEB\u7167\uFF1B0 \u8868\u793A\u4E0D\u9650\u5236")),
|
|
43
|
+
// issue #29:scanBudgetMs 只约束「统计阶段」,真正的耗时与写盘在 add。
|
|
44
|
+
// 这条是整条快照脚本的兜底时限——慢盘或病态工作区上它才是把队列拖住的一段。
|
|
45
|
+
snapshotBudgetMs: withVolatile(Schema.number().default(18e4).description("\u5355\u6B21\u5FEB\u7167\u7684\u603B\u65F6\u9650\uFF08\u6BEB\u79D2\uFF0C\u542B\u626B\u63CF\u4E0E\u5199\u5165\uFF09\uFF0C\u8D85\u65F6\u5373\u4E2D\u6B62\u672C\u6B21\u5FEB\u7167\u5E76\u8BB0\u5F55\uFF1B0 \u8868\u793A\u4E0D\u9650\u5236")),
|
|
43
46
|
maxSnapshotsPerWorkspace: withVolatile(Schema.number().default(500).description("\u6BCF\u4E2A\u5DE5\u4F5C\u533A\u4FDD\u7559\u7684\u6700\u5927\u5FEB\u7167\u6570\uFF0C\u8D85\u9650\u5220\u9664\u6700\u65E7\u7684")),
|
|
44
47
|
baseExcludes: withVolatile(Schema.array(Schema.string()).default(BASE_EXCLUDES).description("\u57FA\u7840\u6392\u9664\u8868\uFF08gitignore \u8BED\u6CD5\uFF0C\u4F18\u5148\u7EA7\u4F4E\u4E8E exclude.txt\uFF09")),
|
|
45
48
|
refillDraft: withVolatile(Schema.boolean().default(true).description("\u64A4\u56DE\u540E\u628A\u88AB\u64A4\u56DE\u7684\u6D88\u606F\uFF08\u6587\u672C\u4E0E\u9644\u4EF6\uFF09\u56DE\u586B\u5230\u8F93\u5165\u6846")),
|
|
46
49
|
snapshotEnabled: withVolatile(Schema.boolean().default(true).description("\u542F\u7528\u6D88\u606F\u5FEB\u7167\uFF08\u5173\u95ED\u540E\u4E0D\u518D\u65B0\u5EFA\uFF0C\u5DF2\u6709\u5FEB\u7167\u4ECD\u53EF\u64A4\u56DE\uFF09")),
|
|
50
|
+
// issue #31:超大工作区(gitignore 过滤后仍上 GB)每条消息写一次全量对象
|
|
51
|
+
// 成本过高。开启后仍登记撤回记录(按钮与对话回退照常),但跳过文件快照;
|
|
52
|
+
// 按条落 files:false,老条目仍可完整回退文件
|
|
53
|
+
messageOnly: withVolatile(Schema.boolean().default(false).description("\u53EA\u56DE\u9000\u6D88\u606F\uFF1A\u6BCF\u6761\u6D88\u606F\u53EA\u767B\u8BB0\u64A4\u56DE\u8BB0\u5F55\uFF0C\u4E0D\u5199\u6587\u4EF6\u5FEB\u7167\uFF08\u9002\u5408\u8D85\u5927\u5DE5\u4F5C\u533A\uFF09")),
|
|
47
54
|
archiveOriginal: withVolatile(Schema.boolean().default(true).description("\u64A4\u56DE\u540E\u5F52\u6863\u539F\u4F1A\u8BDD\uFF08\u5173\u95ED\u540E\u539F\u4F1A\u8BDD\u4FDD\u7559\u5728\u5217\u8868\u4E2D\uFF09")),
|
|
48
55
|
// issue #26:编辑提交后自动发出改后的文本。关闭则退化为「只回填输入框」,
|
|
49
56
|
// 给「只想改稿、不立刻发」的用户留一条与撤回回填一致的出口
|
|
@@ -129,10 +136,12 @@ const DEFAULTS = {
|
|
|
129
136
|
maxFileBytes: 8388608,
|
|
130
137
|
maxWorkspaceBytes: 268435456,
|
|
131
138
|
scanBudgetMs: 1e4,
|
|
139
|
+
snapshotBudgetMs: 18e4,
|
|
132
140
|
maxSnapshotsPerWorkspace: 500,
|
|
133
141
|
baseExcludes: BASE_EXCLUDES,
|
|
134
142
|
refillDraft: true,
|
|
135
143
|
snapshotEnabled: true,
|
|
144
|
+
messageOnly: false,
|
|
136
145
|
archiveOriginal: true,
|
|
137
146
|
editSubmitsImmediately: true,
|
|
138
147
|
retentionDays: 0,
|
|
@@ -150,17 +159,19 @@ function createConfig(raw) {
|
|
|
150
159
|
const maxFileBytes = pickNumber(cfg.maxFileBytes, 8388608, 1024);
|
|
151
160
|
const maxWorkspaceBytes = pickNumber(cfg.maxWorkspaceBytes, 268435456, 0);
|
|
152
161
|
const scanBudgetMs = pickNumber(cfg.scanBudgetMs, 1e4, 0);
|
|
162
|
+
const snapshotBudgetMs = pickNumber(cfg.snapshotBudgetMs, 18e4, 0);
|
|
153
163
|
const rawMax = typeof cfg.maxSnapshotsPerWorkspace === "number" ? cfg.maxSnapshotsPerWorkspace : parseInt(String(cfg.maxSnapshotsPerWorkspace == null ? "" : cfg.maxSnapshotsPerWorkspace), 10);
|
|
154
164
|
const maxSnapshotsPerWorkspace = Number.isFinite(rawMax) ? Math.max(0, rawMax) : 500;
|
|
155
165
|
const baseExcludes = Array.isArray(cfg.baseExcludes) && cfg.baseExcludes.length ? cfg.baseExcludes.filter((p) => typeof p === "string" && p.trim()) : BASE_EXCLUDES;
|
|
156
166
|
const refillDraft = typeof cfg.refillDraft === "boolean" ? cfg.refillDraft : true;
|
|
157
167
|
const snapshotEnabled = typeof cfg.snapshotEnabled === "boolean" ? cfg.snapshotEnabled : true;
|
|
168
|
+
const messageOnly = cfg.messageOnly === true;
|
|
158
169
|
const archiveOriginal = typeof cfg.archiveOriginal === "boolean" ? cfg.archiveOriginal : true;
|
|
159
170
|
const editSubmitsImmediately = cfg.editSubmitsImmediately !== false;
|
|
160
171
|
const rawDays = typeof cfg.retentionDays === "number" ? cfg.retentionDays : parseInt(String(cfg.retentionDays == null ? "" : cfg.retentionDays), 10);
|
|
161
172
|
const retentionDays = Number.isFinite(rawDays) ? Math.max(0, rawDays) : 0;
|
|
162
173
|
const locale = cfg.locale === "zh" || cfg.locale === "en" ? cfg.locale : "auto";
|
|
163
|
-
return { gcSnaps, gcHours, maxFileBytes, maxWorkspaceBytes, scanBudgetMs, maxSnapshotsPerWorkspace, baseExcludes, refillDraft, snapshotEnabled, archiveOriginal, editSubmitsImmediately, retentionDays, locale };
|
|
174
|
+
return { gcSnaps, gcHours, maxFileBytes, maxWorkspaceBytes, scanBudgetMs, snapshotBudgetMs, maxSnapshotsPerWorkspace, baseExcludes, refillDraft, snapshotEnabled, messageOnly, archiveOriginal, editSubmitsImmediately, retentionDays, locale };
|
|
164
175
|
}
|
|
165
176
|
export {
|
|
166
177
|
Config,
|
package/lib/dump-parse.js
CHANGED
|
@@ -31,7 +31,7 @@ function parseStoresDump(text) {
|
|
|
31
31
|
for (const line of String(text).split(/\r?\n/)) {
|
|
32
32
|
if (line.indexOf("==DIR ") === 0) {
|
|
33
33
|
flush();
|
|
34
|
-
cur = { dir: line.slice(6).trim(), root: null, entries: null, lineage: null };
|
|
34
|
+
cur = { dir: line.slice(6).trim(), root: null, entries: null, lineage: null, indexState: "unknown" };
|
|
35
35
|
inIndex = false;
|
|
36
36
|
inLineage = false;
|
|
37
37
|
indexLines = [];
|
|
@@ -39,6 +39,11 @@ function parseStoresDump(text) {
|
|
|
39
39
|
continue;
|
|
40
40
|
}
|
|
41
41
|
if (!cur) continue;
|
|
42
|
+
if (line.indexOf("STATE ") === 0) {
|
|
43
|
+
const v = line.slice(6).trim();
|
|
44
|
+
if (v === "present" || v === "absent" || v === "quarantined" || v === "foreign") cur.indexState = v;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
42
47
|
if (line.indexOf("ROOT ") === 0) {
|
|
43
48
|
const v = line.slice(5).trim();
|
|
44
49
|
cur.root = v || null;
|
package/lib/errors.js
CHANGED
|
@@ -16,6 +16,7 @@ const RECALL_PARTIAL_DELETE = "PARTIAL_DELETE";
|
|
|
16
16
|
const RECALL_UNKNOWN_OP = "UNKNOWN_OP";
|
|
17
17
|
const RECALL_FORMAT_BLOCKED = "FORMAT_BLOCKED";
|
|
18
18
|
const RECALL_UNKNOWN_ENDPOINT = "UNKNOWN_ENDPOINT";
|
|
19
|
+
const RECALL_DUMP_FAILED = "DUMP_FAILED";
|
|
19
20
|
const RECALL_INDEX_CORRUPT = "INDEX_CORRUPT";
|
|
20
21
|
const ALL_CODES = Object.freeze([
|
|
21
22
|
RECALL_STALE,
|
|
@@ -36,6 +37,7 @@ const ALL_CODES = Object.freeze([
|
|
|
36
37
|
RECALL_UNKNOWN_OP,
|
|
37
38
|
RECALL_FORMAT_BLOCKED,
|
|
38
39
|
RECALL_UNKNOWN_ENDPOINT,
|
|
40
|
+
RECALL_DUMP_FAILED,
|
|
39
41
|
RECALL_INDEX_CORRUPT
|
|
40
42
|
]);
|
|
41
43
|
export {
|
|
@@ -43,6 +45,7 @@ export {
|
|
|
43
45
|
RECALL_AGENT_BUSY,
|
|
44
46
|
RECALL_BAD_TYPE,
|
|
45
47
|
RECALL_BODY_TOO_LARGE,
|
|
48
|
+
RECALL_DUMP_FAILED,
|
|
46
49
|
RECALL_EMPTY_PATCH,
|
|
47
50
|
RECALL_ERROR,
|
|
48
51
|
RECALL_FORMAT_BLOCKED,
|
package/lib/index.js
CHANGED
|
@@ -9,6 +9,7 @@ import { createRoutesCore } from "./routes-core.js";
|
|
|
9
9
|
import { createRoutesManage } from "./routes-manage.js";
|
|
10
10
|
import { createIntentJournal } from "./intent-journal.js";
|
|
11
11
|
import { createEnsureRootReady } from "./root-ready.js";
|
|
12
|
+
import { createQueues } from "./queues.js";
|
|
12
13
|
import * as dshSettings from "@deepseek-ai/dsh-settings";
|
|
13
14
|
import * as E from "./errors.js";
|
|
14
15
|
const name = "dsh-recall-plugin";
|
|
@@ -55,12 +56,7 @@ function apply(ctx, config) {
|
|
|
55
56
|
if (text === E.RECALL_BODY_TOO_LARGE) return { ok: false, code: E.RECALL_BODY_TOO_LARGE, message: "\u8BF7\u6C42\u4F53\u8D85\u8FC7 1MB \u4E0A\u9650" };
|
|
56
57
|
return { ok: false, code: E.RECALL_ERROR, message: text };
|
|
57
58
|
}
|
|
58
|
-
|
|
59
|
-
const run = state.queue.then(task);
|
|
60
|
-
state.queue = run.catch(() => {
|
|
61
|
-
});
|
|
62
|
-
return run;
|
|
63
|
-
}
|
|
59
|
+
const queues = createQueues(process.platform === "win32");
|
|
64
60
|
async function runLimited(tasks, concurrency) {
|
|
65
61
|
const limit = concurrency > 0 ? concurrency : 4;
|
|
66
62
|
let index = 0;
|
|
@@ -158,13 +154,20 @@ function apply(ctx, config) {
|
|
|
158
154
|
const text = rt.scripts.stripBom(await rt.runShell(rt.scripts.storesDumpScript(container || "", extras), { timeoutMs: 12e4, stdoutMaxBytes: 8388608 }));
|
|
159
155
|
return parseStoresDump(text);
|
|
160
156
|
} catch (error) {
|
|
161
|
-
|
|
162
|
-
|
|
157
|
+
const detail = String(error && error.stack || error);
|
|
158
|
+
console.error("recall stores dump failed:", detail);
|
|
159
|
+
rt.recordError("recall stores dump failed: " + detail);
|
|
160
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
163
161
|
}
|
|
164
162
|
}
|
|
165
163
|
async function locateSnapshotOnDisk(id) {
|
|
166
164
|
if (!id) return null;
|
|
167
|
-
|
|
165
|
+
let dump;
|
|
166
|
+
try {
|
|
167
|
+
dump = await dumpStores();
|
|
168
|
+
} catch (error) {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
168
171
|
const hints = /* @__PURE__ */ new Map();
|
|
169
172
|
for (const [root, st] of state.stores.entries()) {
|
|
170
173
|
if (st && st.dir) hints.set(st.dir, root);
|
|
@@ -246,7 +249,8 @@ function apply(ctx, config) {
|
|
|
246
249
|
state,
|
|
247
250
|
cfg,
|
|
248
251
|
supported,
|
|
249
|
-
|
|
252
|
+
enqueueFor: queues.enqueueFor,
|
|
253
|
+
enqueueMany: queues.enqueueMany,
|
|
250
254
|
agentBusy,
|
|
251
255
|
runLimited,
|
|
252
256
|
ensureRootReady,
|
|
@@ -314,9 +318,14 @@ function apply(ctx, config) {
|
|
|
314
318
|
if (session && session.header && session.header.origin === "subagent") return;
|
|
315
319
|
const messageId = data.id;
|
|
316
320
|
const time = event.time;
|
|
317
|
-
|
|
321
|
+
void (async () => {
|
|
322
|
+
const root = await rt.resolveRoot(session.id);
|
|
323
|
+
await queues.enqueueFor(root, async () => {
|
|
324
|
+
if (cfg.snapshotEnabled) await snaps.captureSnapshot(session.id, messageId, time);
|
|
325
|
+
});
|
|
326
|
+
await queues.enqueueFor(root, () => maint.maybeMaintain(session.id));
|
|
318
327
|
listCache.stale = true;
|
|
319
|
-
}).catch((error) => rt.recordError("recall snapshot error: " + String(error)));
|
|
328
|
+
})().catch((error) => rt.recordError("recall snapshot error: " + String(error)));
|
|
320
329
|
});
|
|
321
330
|
(async () => {
|
|
322
331
|
let shellReady = false;
|
package/lib/maintenance.js
CHANGED
|
@@ -205,7 +205,9 @@ function createMaintenance(ctx, rt, snaps, config, deps = {}) {
|
|
|
205
205
|
let removed = 0;
|
|
206
206
|
for (const [dir, info] of disk) {
|
|
207
207
|
if (!dir || !info) continue;
|
|
208
|
-
|
|
208
|
+
const emptyIndex = Array.isArray(info.entries) && info.entries.length === 0;
|
|
209
|
+
const neverCaptured = info.entries === null && info.indexState === "absent";
|
|
210
|
+
if (!emptyIndex && !neverCaptured) continue;
|
|
209
211
|
if (memorySnapshotCount(dir, info.root) > 0) continue;
|
|
210
212
|
const store = rt.storeFromDir(dir, false);
|
|
211
213
|
try {
|
package/lib/queues.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
function createQueues(isWin) {
|
|
2
|
+
const tails = /* @__PURE__ */ new Map();
|
|
3
|
+
const keyOf = (root) => {
|
|
4
|
+
const k = String(root == null ? "" : root);
|
|
5
|
+
return k !== "" && isWin ? k.toLowerCase() : k;
|
|
6
|
+
};
|
|
7
|
+
function tailFor(key) {
|
|
8
|
+
return tails.get(key) || Promise.resolve();
|
|
9
|
+
}
|
|
10
|
+
function enqueueFor(root, task) {
|
|
11
|
+
const key = keyOf(root);
|
|
12
|
+
const run = tailFor(key).then(task);
|
|
13
|
+
tails.set(key, run.catch(() => {
|
|
14
|
+
}));
|
|
15
|
+
return run;
|
|
16
|
+
}
|
|
17
|
+
function enqueueMany(roots, task) {
|
|
18
|
+
const keys = Array.from(new Set(roots.map(keyOf)));
|
|
19
|
+
if (keys.length === 0) keys.push("");
|
|
20
|
+
const run = Promise.all(keys.map(tailFor)).then(task);
|
|
21
|
+
const tail = run.catch(() => {
|
|
22
|
+
});
|
|
23
|
+
for (const key of keys) tails.set(key, tail);
|
|
24
|
+
return run;
|
|
25
|
+
}
|
|
26
|
+
return { enqueueFor, enqueueMany };
|
|
27
|
+
}
|
|
28
|
+
export {
|
|
29
|
+
createQueues
|
|
30
|
+
};
|
package/lib/routes-core.js
CHANGED
|
@@ -4,7 +4,7 @@ import { buildArtifactRootSegment, buildRootNotice } from "./exclude-patterns.js
|
|
|
4
4
|
const RECALL_EVENT_COMPLETE_HOST = "dsh-recall/complete";
|
|
5
5
|
const RECALL_EVENT_FAILED_HOST = "dsh-recall/failed";
|
|
6
6
|
function createRoutesCore(deps) {
|
|
7
|
-
const { rt, snaps, state, cfg, supported,
|
|
7
|
+
const { rt, snaps, state, cfg, supported, enqueueFor, agentBusy, rescueRollback, intentJournal, ensureRootReady, emitEvent, E } = deps;
|
|
8
8
|
return {
|
|
9
9
|
"init": async (args) => {
|
|
10
10
|
if (!supported) {
|
|
@@ -38,6 +38,8 @@ function createRoutesCore(deps) {
|
|
|
38
38
|
time: snap ? snap.time : null,
|
|
39
39
|
id,
|
|
40
40
|
...feedback,
|
|
41
|
+
// issue #31:按钮照常出现,但界面要知道这条没有文件树(隐藏范围单选)
|
|
42
|
+
files: snap ? snap.files !== false : void 0,
|
|
41
43
|
// A4:段名与中文 notice 并存下发(client 优先段名本地取词,见 init 同处注释)
|
|
42
44
|
notice: artifactSeg ? buildRootNotice(artifactSeg) : void 0,
|
|
43
45
|
artifactSeg: artifactSeg || void 0
|
|
@@ -48,25 +50,27 @@ function createRoutesCore(deps) {
|
|
|
48
50
|
const sessionId = args && args.sessionId ? String(args.sessionId) : null;
|
|
49
51
|
const snap = state.snapshots.get(id);
|
|
50
52
|
if (agentBusy(sessionId, snap ? snap.root : null)) return { ok: false, code: E.RECALL_AGENT_BUSY, message: "Agent \u6B63\u5728\u8FD0\u884C\u4E2D\uFF0C\u8BF7\u5148\u505C\u6B62\u540E\u518D\u64A4\u56DE" };
|
|
51
|
-
const result = await
|
|
53
|
+
const result = await enqueueFor(snap ? snap.root : null, () => snaps.diffFor(id));
|
|
52
54
|
if (result === null) return { ok: false, code: E.RECALL_NO_SNAPSHOT, message: "\u8BE5\u6D88\u606F\u6CA1\u6709\u53EF\u7528\u7684\u9879\u76EE\u5FEB\u7167" };
|
|
53
55
|
const snap2 = state.snapshots.get(id);
|
|
54
56
|
const cutSeq = await snaps.resolveCutSeq(sessionId, id);
|
|
55
|
-
return { ok: true, changes: result.changes, total: result.total, truncated: result.truncated, treeId: result.treeId || null, time: snap2 ? snap2.time : null, root: snap2 ? snap2.root : null, cutSeq };
|
|
57
|
+
return { ok: true, changes: result.changes, total: result.total, truncated: result.truncated, treeId: result.treeId || null, time: snap2 ? snap2.time : null, root: snap2 ? snap2.root : null, cutSeq, files: snap2 ? snap2.files !== false : void 0 };
|
|
56
58
|
},
|
|
57
59
|
"execute": async (args) => {
|
|
58
60
|
const id = args && args.messageId ? String(args.messageId) : "";
|
|
59
61
|
const sessionId = args && args.sessionId ? String(args.sessionId) : null;
|
|
60
|
-
const
|
|
62
|
+
const entry = state.snapshots.get(id);
|
|
63
|
+
const scope = args && args.scope === "session-only" || entry !== void 0 && entry.files === false ? "session-only" : "both";
|
|
61
64
|
if (scope === "session-only") {
|
|
62
|
-
const snap =
|
|
65
|
+
const snap = entry;
|
|
63
66
|
if (!snap) return { ok: false, code: E.RECALL_NO_SNAPSHOT, message: "\u8BE5\u6D88\u606F\u6CA1\u6709\u53EF\u7528\u7684\u9879\u76EE\u5FEB\u7167" };
|
|
64
67
|
if (agentBusy(sessionId, snap.root)) return { ok: false, code: E.RECALL_AGENT_BUSY, message: "Agent \u6B63\u5728\u8FD0\u884C\u4E2D\uFF0C\u8BF7\u5148\u505C\u6B62\u540E\u518D\u64A4\u56DE" };
|
|
65
68
|
const cutSeq2 = await snaps.resolveCutSeq(sessionId, id);
|
|
66
69
|
const staleQueueItemIds2 = await snaps.resolveStaleQueueItemIds(sessionId, cutSeq2);
|
|
67
70
|
return { ok: true, count: 0, cutSeq: cutSeq2, staleQueueItemIds: staleQueueItemIds2 };
|
|
68
71
|
}
|
|
69
|
-
const
|
|
72
|
+
const initialSnap = state.snapshots.get(id);
|
|
73
|
+
const result = await enqueueFor(initialSnap ? initialSnap.root : null, async () => {
|
|
70
74
|
const snap = state.snapshots.get(id);
|
|
71
75
|
if (!snap) return { ok: false, code: E.RECALL_NO_SNAPSHOT, message: "\u8BE5\u6D88\u606F\u6CA1\u6709\u53EF\u7528\u7684\u9879\u76EE\u5FEB\u7167" };
|
|
72
76
|
const store = state.stores.get(snap.root);
|
package/lib/routes-manage.js
CHANGED
|
@@ -10,7 +10,8 @@ function createRoutesManage(deps) {
|
|
|
10
10
|
state,
|
|
11
11
|
cfg,
|
|
12
12
|
supported,
|
|
13
|
-
|
|
13
|
+
enqueueFor,
|
|
14
|
+
enqueueMany,
|
|
14
15
|
runLimited,
|
|
15
16
|
listExcludeFiles,
|
|
16
17
|
dumpStores,
|
|
@@ -28,6 +29,9 @@ function createRoutesManage(deps) {
|
|
|
28
29
|
E
|
|
29
30
|
} = deps;
|
|
30
31
|
const { sessionTitles, messageTexts, liveTitleFast, liveMessageTextFast } = sessionInfo;
|
|
32
|
+
function dumpFailed() {
|
|
33
|
+
return { ok: false, code: E.RECALL_DUMP_FAILED, message: "\u8BFB\u53D6\u78C1\u76D8\u5FEB\u7167\u5931\u8D25\uFF0C\u5217\u8868\u4E0E\u6279\u91CF\u5220\u9664\u4E0D\u53EF\u7528\uFF1B\u8BF7\u67E5\u770B\u300C\u6700\u8FD1\u9519\u8BEF\u300D\u4E2D\u7684\u539F\u56E0\u540E\u91CD\u8BD5" };
|
|
34
|
+
}
|
|
31
35
|
async function buildListItems() {
|
|
32
36
|
const allItems = [];
|
|
33
37
|
const dump = await dumpStores();
|
|
@@ -36,7 +40,7 @@ function createRoutesManage(deps) {
|
|
|
36
40
|
if (st && st.dir) hints.set(st.dir, root);
|
|
37
41
|
}
|
|
38
42
|
const byId = /* @__PURE__ */ new Map();
|
|
39
|
-
function push(id, time, root, sessionId) {
|
|
43
|
+
function push(id, time, root, sessionId, files) {
|
|
40
44
|
if (!id || typeof id !== "string") return;
|
|
41
45
|
if (isSafetySnapshotId(id)) return;
|
|
42
46
|
const old = byId.get(id);
|
|
@@ -51,6 +55,7 @@ function createRoutesManage(deps) {
|
|
|
51
55
|
};
|
|
52
56
|
const liveText = liveMessageTextFast(sessionId || "", id);
|
|
53
57
|
if (liveText) rec.messageText = liveText;
|
|
58
|
+
if (files === false) rec.files = false;
|
|
54
59
|
byId.set(id, rec);
|
|
55
60
|
allItems.push(rec);
|
|
56
61
|
return;
|
|
@@ -68,16 +73,17 @@ function createRoutesManage(deps) {
|
|
|
68
73
|
if (t) old.messageText = t;
|
|
69
74
|
}
|
|
70
75
|
if (!old.time && time) old.time = time;
|
|
76
|
+
if (files === false) old.files = false;
|
|
71
77
|
}
|
|
72
78
|
for (const [dir, info] of dump) {
|
|
73
79
|
const baseRoot = info.root || hints.get(dir) || null;
|
|
74
80
|
for (const e of info.entries || []) {
|
|
75
81
|
if (!e || typeof e.id !== "string") continue;
|
|
76
|
-
push(e.id, e.time, baseRoot || typeof e.root === "string" && e.root || null, e.sessionId);
|
|
82
|
+
push(e.id, e.time, baseRoot || typeof e.root === "string" && e.root || null, e.sessionId, e.files);
|
|
77
83
|
}
|
|
78
84
|
}
|
|
79
85
|
for (const [id, s] of state.snapshots.entries()) {
|
|
80
|
-
push(id, s.time, s.root, s.sessionId);
|
|
86
|
+
push(id, s.time, s.root, s.sessionId, s.files);
|
|
81
87
|
}
|
|
82
88
|
allItems.sort((a, b) => (b.time || 0) - (a.time || 0));
|
|
83
89
|
return allItems;
|
|
@@ -114,7 +120,7 @@ function createRoutesManage(deps) {
|
|
|
114
120
|
else byRoot.set(rec.root, [rec.id]);
|
|
115
121
|
}
|
|
116
122
|
let deleted = 0;
|
|
117
|
-
await
|
|
123
|
+
await enqueueMany(Array.from(byRoot.keys()), async () => {
|
|
118
124
|
for (const [root, rootIds] of byRoot) {
|
|
119
125
|
let store = state.stores.get(root) || null;
|
|
120
126
|
if (!store) {
|
|
@@ -152,7 +158,7 @@ function createRoutesManage(deps) {
|
|
|
152
158
|
return deleted;
|
|
153
159
|
}
|
|
154
160
|
async function deleteAllSnapshots() {
|
|
155
|
-
return
|
|
161
|
+
return enqueueMany(Array.from(state.stores.keys()), async () => {
|
|
156
162
|
const stores = /* @__PURE__ */ new Map();
|
|
157
163
|
for (const [root, store] of state.stores.entries()) {
|
|
158
164
|
if (store && store.dir) stores.set(store.dir, { store, root });
|
|
@@ -282,10 +288,14 @@ function createRoutesManage(deps) {
|
|
|
282
288
|
maxFileBytes: cfg.maxFileBytes,
|
|
283
289
|
maxWorkspaceBytes: cfg.maxWorkspaceBytes,
|
|
284
290
|
scanBudgetMs: cfg.scanBudgetMs,
|
|
291
|
+
// issue #29:单次快照总时限(设置卡片的「单次快照时限」)
|
|
292
|
+
snapshotBudgetMs: cfg.snapshotBudgetMs,
|
|
285
293
|
maxSnapshotsPerWorkspace: cfg.maxSnapshotsPerWorkspace,
|
|
286
294
|
baseExcludes: cfg.baseExcludes.slice(),
|
|
287
295
|
refillDraft: cfg.refillDraft,
|
|
288
296
|
snapshotEnabled: cfg.snapshotEnabled,
|
|
297
|
+
// issue #31:只回退消息(不写文件快照)
|
|
298
|
+
messageOnly: cfg.messageOnly,
|
|
289
299
|
archiveOriginal: cfg.archiveOriginal,
|
|
290
300
|
editSubmitsImmediately: cfg.editSubmitsImmediately,
|
|
291
301
|
retentionDays: cfg.retentionDays,
|
|
@@ -325,6 +335,7 @@ function createRoutesManage(deps) {
|
|
|
325
335
|
}
|
|
326
336
|
if (patch.refillDraft !== void 0) clean.refillDraft = Boolean(patch.refillDraft);
|
|
327
337
|
if (patch.snapshotEnabled !== void 0) clean.snapshotEnabled = Boolean(patch.snapshotEnabled);
|
|
338
|
+
if (patch.messageOnly !== void 0) clean.messageOnly = Boolean(patch.messageOnly);
|
|
328
339
|
if (patch.archiveOriginal !== void 0) clean.archiveOriginal = Boolean(patch.archiveOriginal);
|
|
329
340
|
if (patch.editSubmitsImmediately !== void 0) clean.editSubmitsImmediately = Boolean(patch.editSubmitsImmediately);
|
|
330
341
|
if (patch.retentionDays !== void 0) {
|
|
@@ -374,7 +385,12 @@ function createRoutesManage(deps) {
|
|
|
374
385
|
if (stale) refreshListCacheInBackground();
|
|
375
386
|
return { ok: true, items: listCache.items.slice(0, safeLimit), total: listCache.items.length, stale };
|
|
376
387
|
}
|
|
377
|
-
|
|
388
|
+
let allItems;
|
|
389
|
+
try {
|
|
390
|
+
allItems = await buildListItems();
|
|
391
|
+
} catch (error) {
|
|
392
|
+
return dumpFailed();
|
|
393
|
+
}
|
|
378
394
|
listCache.at = Date.now();
|
|
379
395
|
listCache.items = allItems;
|
|
380
396
|
listCache.stale = false;
|
|
@@ -492,15 +508,25 @@ function createRoutesManage(deps) {
|
|
|
492
508
|
const id = args && args.messageId ? String(args.messageId) : "";
|
|
493
509
|
if (scope === "workspace") {
|
|
494
510
|
if (!root) return { ok: false, code: E.RECALL_NO_ROOT, message: "\u7F3A\u5C11\u5DE5\u4F5C\u533A\u8DEF\u5F84" };
|
|
495
|
-
|
|
511
|
+
let deleted;
|
|
512
|
+
try {
|
|
513
|
+
deleted = await deleteSnapshotsByFilter((rec) => rec.root === root, sessionId);
|
|
514
|
+
} catch (error) {
|
|
515
|
+
return dumpFailed();
|
|
516
|
+
}
|
|
496
517
|
return { ok: true, deleted };
|
|
497
518
|
}
|
|
498
519
|
if (scope === "session") {
|
|
499
520
|
if (!targetSessionId) return { ok: false, code: E.RECALL_NO_SESSION, message: "\u7F3A\u5C11\u4F1A\u8BDD ID" };
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
521
|
+
let deleted;
|
|
522
|
+
try {
|
|
523
|
+
deleted = await deleteSnapshotsByFilter(
|
|
524
|
+
(rec) => rec.sessionId === targetSessionId && (!root || rec.root === root),
|
|
525
|
+
sessionId
|
|
526
|
+
);
|
|
527
|
+
} catch (error) {
|
|
528
|
+
return dumpFailed();
|
|
529
|
+
}
|
|
504
530
|
return { ok: true, deleted };
|
|
505
531
|
}
|
|
506
532
|
let snap = state.snapshots.get(id) || null;
|
|
@@ -526,7 +552,7 @@ function createRoutesManage(deps) {
|
|
|
526
552
|
}
|
|
527
553
|
const finalStore = store;
|
|
528
554
|
const finalRoot = snapRoot;
|
|
529
|
-
await
|
|
555
|
+
await enqueueFor(finalRoot, async () => {
|
|
530
556
|
if (state.gitExe) {
|
|
531
557
|
await rt.runShell(rt.scripts.purgeTagsScript(finalStore, state.gitExe, ["snap-" + id]), { timeoutMs: 12e4, stdoutMaxBytes: 4096 });
|
|
532
558
|
}
|
|
@@ -544,7 +570,12 @@ function createRoutesManage(deps) {
|
|
|
544
570
|
return { ok: true };
|
|
545
571
|
}
|
|
546
572
|
if (op === "deleteAll") {
|
|
547
|
-
|
|
573
|
+
let result;
|
|
574
|
+
try {
|
|
575
|
+
result = await deleteAllSnapshots();
|
|
576
|
+
} catch (error) {
|
|
577
|
+
return dumpFailed();
|
|
578
|
+
}
|
|
548
579
|
if (result.failed > 0) {
|
|
549
580
|
return {
|
|
550
581
|
ok: false,
|
|
@@ -556,7 +587,7 @@ function createRoutesManage(deps) {
|
|
|
556
587
|
return { ok: true, deleted: result.deleted, stores: result.stores };
|
|
557
588
|
}
|
|
558
589
|
if (op === "gc") {
|
|
559
|
-
const done = sessionId ? await
|
|
590
|
+
const done = sessionId ? await enqueueFor(await rt.resolveRoot(sessionId), () => maint.runGc(sessionId, true)) : await enqueueMany(Array.from(state.stores.keys()), () => maint.runGcAll());
|
|
560
591
|
usageCache.payload = null;
|
|
561
592
|
return { ok: true, gc: Boolean(done) };
|
|
562
593
|
}
|
package/lib/scripts.posix.js
CHANGED
|
@@ -495,6 +495,10 @@ function excludeDumpScript(files) {
|
|
|
495
495
|
lines.push(
|
|
496
496
|
"printf 'EXCLBEGIN %s\\n' " + q,
|
|
497
497
|
"if [ -f " + q + " ]; then base64 " + q + " 2>/dev/null | tr -d '\\n'; fi",
|
|
498
|
+
// issue #30 同族:tr -d '\n' 之后内容没有行尾,直接 echo 会把 EXCLEND 粘在
|
|
499
|
+
// base64 尾上;payload 字节数为 3 的倍数(无 '=' padding)时这截尾巴会被一起
|
|
500
|
+
// 解码成垃圾字节,回显到设置页再写回就污染 exclude.txt。
|
|
501
|
+
'printf "\\n"',
|
|
498
502
|
"echo 'EXCLEND'"
|
|
499
503
|
);
|
|
500
504
|
}
|
|
@@ -531,16 +535,34 @@ function storesDumpScript(container, extraDirs) {
|
|
|
531
535
|
'for d in "${dirs[@]}"; do',
|
|
532
536
|
' [ -d "$d" ] || continue',
|
|
533
537
|
' echo "==DIR $d"',
|
|
538
|
+
// STATE 供空仓回收判定(present/absent/quarantined/foreign):判据留在
|
|
539
|
+
// 脚本侧是因为只有这里能看文件系统——absent 表示「从没拍过快照的真仓」
|
|
540
|
+
// (git/ 或 root.txt 在),foreign 表示不是我们的目录,两者不能混。
|
|
541
|
+
' if [ -f "$d/index.json" ]; then',
|
|
542
|
+
" echo 'STATE present'",
|
|
543
|
+
' elif ls "$d"/index.json.corrupt-* >/dev/null 2>&1; then',
|
|
544
|
+
" echo 'STATE quarantined'",
|
|
545
|
+
' elif [ -d "$d/git" ] || [ -f "$d/root.txt" ]; then',
|
|
546
|
+
" echo 'STATE absent'",
|
|
547
|
+
" else",
|
|
548
|
+
" echo 'STATE foreign'",
|
|
549
|
+
" fi",
|
|
534
550
|
' if [ -f "$d/root.txt" ]; then',
|
|
535
551
|
` printf "ROOT %s\\n" "$(cat "$d/root.txt" 2>/dev/null | tr -d '\\r\\n')"`,
|
|
536
552
|
" else",
|
|
537
553
|
' echo "ROOT "',
|
|
538
554
|
" fi",
|
|
539
555
|
" echo INDEXBEGIN",
|
|
540
|
-
|
|
556
|
+
// issue #30:这两个 json 是可选文件(刚建仓没拍过快照就没有),缺席必须放行——
|
|
557
|
+
// set -e 下裸 cat 会把「无文件」升级成整条 dump 失败。另外落盘内容无尾换行
|
|
558
|
+
// (writeTextViaShell = cat > file),cat 后紧接 echo 会把段尾标记粘到内容行尾,
|
|
559
|
+
// JS 侧按整行匹配 INDEXEND 就永远收不到段尾,所以内容后显式补一个换行。
|
|
560
|
+
' cat "$d/index.json" 2>/dev/null || true',
|
|
561
|
+
' printf "\\n"',
|
|
541
562
|
" echo INDEXEND",
|
|
542
563
|
" echo LINEAGEBEGIN",
|
|
543
|
-
' cat "$d/lineage.json" 2>/dev/null',
|
|
564
|
+
' cat "$d/lineage.json" 2>/dev/null || true',
|
|
565
|
+
' printf "\\n"',
|
|
544
566
|
" echo LINEAGEEND",
|
|
545
567
|
"done",
|
|
546
568
|
"exit 0"
|
package/lib/scripts.pwsh.js
CHANGED
|
@@ -675,6 +675,14 @@ function storesDumpScript(container, extraDirs) {
|
|
|
675
675
|
" if (-not $d) { continue }",
|
|
676
676
|
" if (-not (Test-Path -LiteralPath $d -PathType Container)) { continue }",
|
|
677
677
|
' Write-Output ("==DIR " + $d)',
|
|
678
|
+
// STATE 供空仓回收判定(present/absent/quarantined/foreign):判据留在脚本侧
|
|
679
|
+
// 是因为只有这里能看文件系统——absent 是「从没拍过快照的真仓」,foreign 是
|
|
680
|
+
// 别人的目录,两者必须分开(否则会删掉不是我们的东西)。
|
|
681
|
+
" $ix = Join-Path $d 'index.json'",
|
|
682
|
+
" if (Test-Path -LiteralPath $ix -PathType Leaf) { Write-Output 'STATE present' }",
|
|
683
|
+
" elseif (@(Get-ChildItem -LiteralPath $d -Filter 'index.json.corrupt-*' -ErrorAction SilentlyContinue).Count -gt 0) { Write-Output 'STATE quarantined' }",
|
|
684
|
+
" elseif ((Test-Path -LiteralPath (Join-Path $d 'git') -PathType Container) -or (Test-Path -LiteralPath (Join-Path $d 'root.txt') -PathType Leaf)) { Write-Output 'STATE absent' }",
|
|
685
|
+
" else { Write-Output 'STATE foreign' }",
|
|
678
686
|
" $rt = Join-Path $d 'root.txt'",
|
|
679
687
|
" if (Test-Path -LiteralPath $rt -PathType Leaf) {",
|
|
680
688
|
" $rv = (Get-Content -LiteralPath $rt -Raw -Encoding UTF8 -ErrorAction SilentlyContinue)",
|
|
@@ -686,7 +694,6 @@ function storesDumpScript(container, extraDirs) {
|
|
|
686
694
|
" Write-Output 'ROOT '",
|
|
687
695
|
" }",
|
|
688
696
|
" Write-Output 'INDEXBEGIN'",
|
|
689
|
-
" $ix = Join-Path $d 'index.json'",
|
|
690
697
|
" if (Test-Path -LiteralPath $ix -PathType Leaf) {",
|
|
691
698
|
" $j = Get-Content -LiteralPath $ix -Raw -Encoding UTF8 -ErrorAction SilentlyContinue",
|
|
692
699
|
" if ($j) { Write-Output $j.TrimEnd() }",
|
package/lib/snapshots.js
CHANGED
|
@@ -269,6 +269,7 @@ function createSnapshots(ctx, rt, config) {
|
|
|
269
269
|
await stampStoreFormat(store, verdict);
|
|
270
270
|
const entries = Array.from(state.snapshots.entries()).filter(([, s]) => s.root === root).map(([id, s]) => {
|
|
271
271
|
const rec = { id, time: s.time, root: s.root, sessionId: s.sessionId };
|
|
272
|
+
if (s.files === false) rec.files = false;
|
|
272
273
|
const fb = state.snapFeedback.get(id);
|
|
273
274
|
if (fb && (fb.failed || Array.isArray(fb.skipped) && fb.skipped.length)) rec.feedback = fb;
|
|
274
275
|
return rec;
|
|
@@ -320,11 +321,13 @@ function createSnapshots(ctx, rt, config) {
|
|
|
320
321
|
invalid++;
|
|
321
322
|
continue;
|
|
322
323
|
}
|
|
323
|
-
|
|
324
|
+
const info = {
|
|
324
325
|
root,
|
|
325
326
|
time: typeof entry.time === "number" ? entry.time : Date.now(),
|
|
326
327
|
sessionId: entry.sessionId || sessionId || ""
|
|
327
|
-
}
|
|
328
|
+
};
|
|
329
|
+
if (entry.files === false) info.files = false;
|
|
330
|
+
state.snapshots.set(entry.id, info);
|
|
328
331
|
const fb = entry.feedback;
|
|
329
332
|
if (fb && typeof fb === "object") {
|
|
330
333
|
const rec = {};
|
|
@@ -422,9 +425,27 @@ function createSnapshots(ctx, rt, config) {
|
|
|
422
425
|
rt.recordError("recall rebuildOrphans failed: " + String(error));
|
|
423
426
|
}
|
|
424
427
|
}
|
|
428
|
+
async function captureMessageOnly(root, sessionId, messageId, time) {
|
|
429
|
+
let store = await rt.resolveStore(root);
|
|
430
|
+
store = await rt.tryUpgradeToHome(root);
|
|
431
|
+
if (!await guardStoreFormat(store)) return;
|
|
432
|
+
const g = await rt.ensureGit(root, store);
|
|
433
|
+
if (!g.ok) {
|
|
434
|
+
setFeedback(messageId, { failed: true, ...buildFeedbackError(g.error || "\u672A\u77E5\u539F\u56E0") });
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
await loadIndex(root, sessionId);
|
|
438
|
+
state.snapshots.set(String(messageId), { root, time: time || Date.now(), sessionId, files: false });
|
|
439
|
+
await saveIndex(root, sessionId);
|
|
440
|
+
setFeedback(messageId, { skipped: [] });
|
|
441
|
+
}
|
|
425
442
|
async function captureSnapshot(sessionId, messageId, time) {
|
|
426
443
|
const root = await rt.resolveRoot(sessionId);
|
|
427
444
|
if (!root) return;
|
|
445
|
+
if (config.messageOnly) {
|
|
446
|
+
await captureMessageOnly(root, sessionId, messageId, time);
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
428
449
|
const fused = snapFailures.get(root);
|
|
429
450
|
if (fused && Date.now() < fused.skipUntil) return;
|
|
430
451
|
const limited = snapLimits.get(root);
|
|
@@ -441,7 +462,8 @@ function createSnapshots(ctx, rt, config) {
|
|
|
441
462
|
await loadIndex(root, sessionId);
|
|
442
463
|
const captureStartedAt = Date.now();
|
|
443
464
|
try {
|
|
444
|
-
const
|
|
465
|
+
const budget = config.snapshotBudgetMs > 0 ? config.snapshotBudgetMs : 864e5;
|
|
466
|
+
const out = await rt.runShell(S.snapshotScript(root, store, state.gitExe || "", messageId, BASE()), { timeoutMs: budget, stdoutMaxBytes: 65536 });
|
|
445
467
|
const limit = parseSnapshotLimit(out);
|
|
446
468
|
if (limit) {
|
|
447
469
|
markSnapshotLimited(root, limit);
|
|
@@ -518,6 +540,7 @@ function createSnapshots(ctx, rt, config) {
|
|
|
518
540
|
async function diffFor(messageId) {
|
|
519
541
|
const snap = state.snapshots.get(String(messageId));
|
|
520
542
|
if (!snap) return null;
|
|
543
|
+
if (snap.files === false) return { changes: [], total: 0, truncated: false, treeId: null };
|
|
521
544
|
const store = state.stores.get(snap.root);
|
|
522
545
|
if (!store) return null;
|
|
523
546
|
const text = S.stripBom(await rt.runShell(S.diffScript(snap.root, store, state.gitExe || "", "snap-" + messageId, BASE(), MAX_CHANGES), { timeoutMs: 6e5, stdoutMaxBytes: 8388608 }));
|
package/lib/store.js
CHANGED
|
@@ -149,7 +149,6 @@ function createRuntime(ctx, config, deps) {
|
|
|
149
149
|
roots: /* @__PURE__ */ new Map(),
|
|
150
150
|
stores: /* @__PURE__ */ new Map(),
|
|
151
151
|
snapshots: /* @__PURE__ */ new Map(),
|
|
152
|
-
queue: Promise.resolve(),
|
|
153
152
|
indexLoaded: /* @__PURE__ */ new Set(),
|
|
154
153
|
// PF-5 索引终态三/四档标记(rebuildOrphans 守卫的数据源):
|
|
155
154
|
// - indexHealthy:磁盘索引解析成功且在场(loadIndex 正常载入分支)——
|
package/package.json
CHANGED