dsh-recall-plugin 2.4.13 → 2.4.14
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 +18 -0
- package/README.en.md +38 -9
- package/README.md +38 -9
- package/cordis.patch.yml +3 -1
- package/lib/client.js +39 -1
- package/lib/config.js +13 -4
- package/lib/index.js +29 -7
- package/lib/routes-core.js +2 -8
- package/lib/routes-manage.js +12 -0
- package/lib/scripts.posix.js +34 -3
- package/lib/scripts.pwsh.js +35 -1
- package/lib/snapshots.js +42 -0
- package/lib/store.js +8 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
本文件格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循语义化版本。
|
|
4
4
|
|
|
5
|
+
## [2.4.14] - 2026-10-18
|
|
6
|
+
|
|
7
|
+
### 修复
|
|
8
|
+
|
|
9
|
+
- **启动后单核 CPU 打满、进程表出现数十个 pwsh(issue #24)——三重根因一并修复**:① 启动预热对每个工作区并发跑一条命令链(7 工作区 × 6~8 条命令 ≈ 40~56 个 pwsh 冷启动),而每个恢复会话的 `init` 端点还会再跑一遍同款链;② 快照只有单文件上限(原 100MB),挡不住「大量小文件」——报告者的下载目录工作区 321 个 ≤100MB 文件合计 1.05GB 全部进快照;③ 快照的 `add -A` 无总量上限、无扫描时间预算。
|
|
10
|
+
- **预热与 init 共用根就绪链(in-flight 去重)+ 预热并发限流 2 路**:同一工作区在本进程内只跑一次 `resolveStore/ensureGit/loadIndex/rebuildOrphans/recover`,多工作区预热不再同时起数十条 pwsh。
|
|
11
|
+
- **快照前置护栏:总量上限 + 扫描时间预算**:脚本在 `add -A` **之前**遍历工作区统计「实际会进快照的文件总量」(只计 ≤`maxFileBytes` 的文件,防单个大视频误判)与耗时,超过 `maxWorkspaceBytes`(默认 256MB)或 `scanBudgetMs`(默认 10s)即输出 `SNAP_LIMIT` 并**停用本次快照**——只停用不截断:截断快照回退时会按 index 清单误删未纳入的文件。Host 侧解析标记后短路该工作区后续快照(配置变化或 10 分钟 TTL 自动重试),并在最近消息上提示可行动的恢复方式(调大上限 / 补充排除表)。
|
|
12
|
+
- **单文件上限默认 100MB → 8MB**(`maxFileBytes`):与总量护栏配合,避免大文件首拍把对象库撑到 GB 级;含素材/模型的正常项目可按需调高。
|
|
13
|
+
|
|
14
|
+
### 新增
|
|
15
|
+
|
|
16
|
+
- 配置项 `maxWorkspaceBytes`(默认 256MB)与 `scanBudgetMs`(默认 10s),0 = 不限制;设置页「插件配置」卡片可直接修改(热生效)。负数按非法回退默认——显式关闭只认 0。
|
|
17
|
+
- `snapFeedback.kind` 新增 `workspaceLimit`:`snapshot-info` 对停用的工作区下发可行动文案(客户端 toast 直显)。
|
|
18
|
+
|
|
19
|
+
### 文档
|
|
20
|
+
|
|
21
|
+
- README 双语配置表补两项新字段与 8MB 默认值;AGENTS.md 核心机制补护栏语义;`cordis.patch.yml` 默认配置同步。
|
|
22
|
+
|
|
5
23
|
## [2.4.13] - 2026-10-17
|
|
6
24
|
|
|
7
25
|
### 兼容
|
package/README.en.md
CHANGED
|
@@ -27,7 +27,7 @@ As each message is sent, the workspace is first snapshotted into an independent
|
|
|
27
27
|
- [Snapshot Maintenance & Cleanup](#snapshot-maintenance--cleanup)
|
|
28
28
|
- [How It Works](#how-it-works)
|
|
29
29
|
- [Event Contract (for same-host plugins)](#event-contract-for-same-host-plugins)
|
|
30
|
-
- [
|
|
30
|
+
- [Contributing](#contributing)
|
|
31
31
|
- [License](#license)
|
|
32
32
|
|
|
33
33
|
## UI Preview
|
|
@@ -137,7 +137,9 @@ All options can be edited visually in the "**Settings → Plugin Config → Reca
|
|
|
137
137
|
| --- | --- | --- |
|
|
138
138
|
| `gcSnaps` | 50 | Run `git gc` after this many snapshots accumulate (env `DSH_RECALL_GC_SNAPS` force-overrides) |
|
|
139
139
|
| `gcHours` | 24 | Run gc when this many hours have passed since the last one (whichever trigger fires first; env `DSH_RECALL_GC_HOURS`) |
|
|
140
|
-
| `maxFileBytes` |
|
|
140
|
+
| `maxFileBytes` | 8388608 (8MB) | Files larger than this are neither snapshotted nor touched by recalls |
|
|
141
|
+
| `maxWorkspaceBytes` | 268435456 (256MB) | Total snapshot-eligible file size per workspace. 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 |
|
|
142
|
+
| `scanBudgetMs` | 10000 (10s) | Time budget for the pre-snapshot file scan. Exceeding it skips the snapshot with a notice (guards against pathological directories stalling startup). 0 = unlimited |
|
|
141
143
|
| `maxSnapshotsPerWorkspace` | 500 | Maximum snapshots kept per workspace; oldest pruned beyond the cap. 0 = unlimited |
|
|
142
144
|
| `retentionDays` | 0 | Keep snapshots for this many days; older ones are deleted. 0 = disabled (works independently of the cap) |
|
|
143
145
|
| `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 |
|
|
@@ -245,9 +247,26 @@ Versioning: shipped as a stable public contract from day one (not experimental).
|
|
|
245
247
|
|
|
246
248
|
Version skew: an old plugin frontend (<2.4.8) never reports, so events never fire; a new frontend with an old host (<2.4.8) gets a missing endpoint and silently ignores it — recall itself is unaffected.
|
|
247
249
|
|
|
248
|
-
##
|
|
250
|
+
## Contributing
|
|
249
251
|
|
|
250
|
-
|
|
252
|
+
### Prerequisites
|
|
253
|
+
|
|
254
|
+
- Node.js ≥ 20 (develop on Windows, Linux or macOS), git CLI;
|
|
255
|
+
- A local DSH installation (≥ 0.1.2-alpha.1): `npm run test:probe` and `npm run verify:host` need a real dsh; without one the probes skip themselves, which does not block the rest of development and testing.
|
|
256
|
+
|
|
257
|
+
### Local iteration (no publishing needed)
|
|
258
|
+
|
|
259
|
+
Clone and build once first (peer dependencies are private `@deepseek-ai/*` packages; `--legacy-peer-deps` skips them and installs only devDependencies — the build and test toolchain):
|
|
260
|
+
|
|
261
|
+
```powershell
|
|
262
|
+
git clone https://github.com/limbo947/dsh-recall-plugin.git
|
|
263
|
+
cd dsh-recall-plugin
|
|
264
|
+
npm install --legacy-peer-deps # install the build/test toolchain (esbuild, vitest, typescript, ...)
|
|
265
|
+
npm run build # produce the lib/ artifacts (what link mode loads)
|
|
266
|
+
npm test # optional: run the unit suite once to verify the baseline
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Then point the profile's dependency for this package at your clone via `link:`; DSH loads the built `lib/` artifacts from the workspace (source lives in `src/`), so run `npm run build` after editing `src/`, then restart DSH — no copying or publishing needed:
|
|
251
270
|
|
|
252
271
|
```powershell
|
|
253
272
|
# 1. Edit $env:USERPROFILE\.dsh\profiles\web\package.json:
|
|
@@ -259,17 +278,27 @@ pnpm install
|
|
|
259
278
|
# 3. Restart DSH and hard-refresh the page (Ctrl+Shift+R)
|
|
260
279
|
```
|
|
261
280
|
|
|
262
|
-
|
|
281
|
+
### Source layout & build
|
|
282
|
+
|
|
283
|
+
All source lives in `src/`: the host (Node ESM TypeScript) in `src/host/`, the browser side in `src/client/`, cross-domain shared types in `src/types/`; `lib/` is a pure build-output directory — `npm run build` generates it via esbuild (per-file host transpilation plus the `lib/client.js` bundle), and the artifacts are committed with the source. **You must run `npm run build` after changing any `src/` file**, otherwise the stale artifacts keep running (CI enforces artifact freshness).
|
|
263
284
|
|
|
264
285
|
### Tests
|
|
265
286
|
|
|
266
|
-
- `npm test`: pure-logic unit tests (vitest, 39 files /
|
|
267
|
-
- `npm run test:client`: client component tests (vitest + jsdom, 6 files /
|
|
268
|
-
- `npm run test:probe`: official-API field probes (requires a local dsh installation; **must run after any dsh upgrade**) — pins fields like `renderMessageImages`/`node`/`cwd`, `atSeq`/`increaseTitle` and the fork-cut anchors of `sessions.fork`, `listSessions` record shape, `AgentRegistry`, the shell execution seam (`execute`/`ShellExecution.result`), the settings surface (`SettingsForms`/profile entry id/volatile gate) plus `loader/volatile-update` and the `Fiber.entry` shape, and goes red on violation;
|
|
287
|
+
- `npm test`: pure-logic unit tests (vitest, 39 files / 505 cases, no DSH dependency, runs identically in CI and locally) — config parsing, snapshot parsers, rescue orchestration, error classification, the disk-format guard, the recall intent journal, recall terminal-event payload assembly, i18n dictionaries plus a missing-key scan, script-template same-name-export contract, shell execution dual-channel split and failure grading, settings bridge across both generations, client pure functions, published-package layout, snapshot index persistence, storage caps and retention, etc.;
|
|
288
|
+
- `npm run test:client`: client component tests (vitest + jsdom, 6 files / 96 cases, runs in CI) — the recall node main chain (preview→execute→fork→refill) and terminal-event reporting, snapshot-manager tree and delete flows, config/exclude card error paths, the logger switch matrix, and the zh/en rendering chains; assertions pin behaviour and structure only (className / aria / request payloads), never literal copy;
|
|
289
|
+
- `npm run test:probe`: official-API field probes (2 files / 54 cases, requires a local dsh installation; **must run after any dsh upgrade**) — pins fields like `renderMessageImages`/`node`/`cwd`, `atSeq`/`increaseTitle` and the fork-cut anchors of `sessions.fork`, `listSessions` record shape, `AgentRegistry`, the shell execution seam (`execute`/`ShellExecution.result`), the settings surface (`SettingsForms`/profile entry id/volatile gate) plus `loader/volatile-update` and the `Fiber.entry` shape, and goes red on violation;
|
|
269
290
|
- `npm run verify:host`: assembly gate (requires a local dsh installation) — boots the plugin with a real cordis context in two passes (legacy settings stub and a modern-face-only stub), asserting inject declarations, endpoint registration, Config schema, teardown cleanliness and settings-face dispatch, catching assembly regressions before release;
|
|
270
|
-
- `npm run
|
|
291
|
+
- `npm run typecheck`: full `tsc --noEmit` type check (includes the `tests/types` compile-time contract assertions; CI runs it before the unit suite);
|
|
292
|
+
- `npm run build`: full host+client build (mandatory after any `src/` change); `npm run check:dsh`: dsh version inspection (pre-release); `npm run check:upgrade`: one-shot dsh-upgrade verification gate (chains check:dsh + test:probe + verify:host; mandatory after a dsh upgrade).
|
|
271
293
|
- CI (GitHub Actions) runs `npm ci --legacy-peer-deps` + `npm run typecheck` + `npm test` + `npm run test:client` + unified artifact-freshness check (`npm run build && git diff --exit-code lib/`; probes and the assembly gate only run on machines with dsh).
|
|
272
294
|
|
|
295
|
+
### Commit conventions (summary)
|
|
296
|
+
|
|
297
|
+
- Commit messages follow Conventional Commits (`feat:` / `fix:` / `docs:` / `test:` / `ci:` / `chore:` prefixes with a concise subject); fixes bump patch, new features bump minor;
|
|
298
|
+
- Behaviour changes must be reflected in [CHANGELOG.md](CHANGELOG.md) (Keep a Changelog format) and in both README languages ([中文](README.md) / [English](README.en.md));
|
|
299
|
+
- When assumptions about dsh's official API fields change, update the [docs/compat-audit.md](docs/compat-audit.md) ledger;
|
|
300
|
+
- For the full architecture map, development workflow, official-docs compliance checklist and known pitfalls see [AGENTS.md](AGENTS.md) — the project operations manual for AI coding agents and deep maintainers.
|
|
301
|
+
|
|
273
302
|
## License
|
|
274
303
|
|
|
275
304
|
MIT
|
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
- [快照维护与清理](#快照维护与清理)
|
|
25
25
|
- [工作原理](#工作原理)
|
|
26
26
|
- [事件契约(供同宿主插件监听)](#事件契约供同宿主插件监听)
|
|
27
|
-
- [
|
|
27
|
+
- [参与贡献](#参与贡献)
|
|
28
28
|
- [License](#license)
|
|
29
29
|
|
|
30
30
|
## 界面预览
|
|
@@ -134,7 +134,9 @@ peerDependencies 按 minor 版本线开窗,与 `dsh.compatibility.dshReleases`
|
|
|
134
134
|
| --- | --- | --- |
|
|
135
135
|
| `gcSnaps` | 50 | 每积累多少条快照触发一次 `git gc`(env `DSH_RECALL_GC_SNAPS` 可强制覆盖) |
|
|
136
136
|
| `gcHours` | 24 | 距上次 gc 超过多少小时触发(与条数先到先触发;env `DSH_RECALL_GC_HOURS`) |
|
|
137
|
-
| `maxFileBytes` |
|
|
137
|
+
| `maxFileBytes` | 8388608(8MB) | 超过该大小的文件不进快照、不被回退触碰 |
|
|
138
|
+
| `maxWorkspaceBytes` | 268435456(256MB) | 单工作区可纳入快照的文件总量上限。超过时**停用该工作区快照**(不截断——截断快照回退时会误删未纳入的文件),并在最近消息上提示;调大该值或补充排除表后自动恢复;0 = 不限制 |
|
|
139
|
+
| `scanBudgetMs` | 10000(10 秒) | 快照前的文件扫描时间预算。超过时停用本次快照并提示(防病态大目录拖死启动);0 = 不限制 |
|
|
138
140
|
| `maxSnapshotsPerWorkspace` | 500 | 每个工作区保留的最大快照数,超限自动删除最旧的;0 = 不限制 |
|
|
139
141
|
| `retentionDays` | 0 | 按天数保留快照,超期自动删除;0 = 不启用(与条数上限各自独立生效) |
|
|
140
142
|
| `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 的),这类目录也没有回退价值;删掉对应项即恢复 |
|
|
@@ -242,9 +244,26 @@ ctx.on('dsh-recall/failed', (e) => {
|
|
|
242
244
|
|
|
243
245
|
版本错位:旧版插件前端(<2.4.8)不上报、事件永不触发;新版前端 + 旧版 host(<2.4.8)时上报端点不存在,前端静默忽略,撤回功能不受任何影响。
|
|
244
246
|
|
|
245
|
-
##
|
|
247
|
+
## 参与贡献
|
|
246
248
|
|
|
247
|
-
|
|
249
|
+
### 环境要求
|
|
250
|
+
|
|
251
|
+
- Node.js ≥ 20(Windows / Linux / macOS 均可开发),git CLI;
|
|
252
|
+
- 本机安装 DSH(≥ 0.1.2-alpha.1):`npm run test:probe` 与 `npm run verify:host` 依赖真实 dsh 安装;没有 dsh 时探针自动跳过,不阻塞其余开发与测试。
|
|
253
|
+
|
|
254
|
+
### 本地联调(无需发布)
|
|
255
|
+
|
|
256
|
+
先克隆并构建一次(peer 依赖是私有 `@deepseek-ai/*` 包,`--legacy-peer-deps` 跳过、只装 devDependencies——构建与测试工具链齐备):
|
|
257
|
+
|
|
258
|
+
```powershell
|
|
259
|
+
git clone https://github.com/limbo947/dsh-recall-plugin.git
|
|
260
|
+
cd dsh-recall-plugin
|
|
261
|
+
npm install --legacy-peer-deps # 安装构建/测试工具链(esbuild、vitest、typescript 等)
|
|
262
|
+
npm run build # 生成 lib/ 产物(link 模式加载的就是它)
|
|
263
|
+
npm test # 可选:跑一遍单测验证基线
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
再把 profile 对本包的依赖改成 `link:` 指向克隆目录;DSH 加载的是工作区 `lib/` 构建产物(源码在 `src/`),改 `src/` 后先 `npm run build` 再重启 DSH 生效,无需复制或发布:
|
|
248
267
|
|
|
249
268
|
```powershell
|
|
250
269
|
# 1. 编辑 $env:USERPROFILE\.dsh\profiles\web\package.json:
|
|
@@ -256,17 +275,27 @@ pnpm install
|
|
|
256
275
|
# 3. 重启 DSH + 硬刷新页面(Ctrl+Shift+R)
|
|
257
276
|
```
|
|
258
277
|
|
|
259
|
-
|
|
278
|
+
### 源码结构与构建
|
|
279
|
+
|
|
280
|
+
全部源码在 `src/`:Host(Node ESM TypeScript)在 `src/host/`、浏览器端在 `src/client/`、跨域共享类型在 `src/types/`;`lib/` 是纯构建产物目录——`npm run build` 经 esbuild 生成(逐文件转译 host 产物 + 打包 `lib/client.js`),产物随源码提交。**改任何 `src/` 后必须跑 `npm run build`**,否则运行的是旧产物(CI 有产物新鲜度统一校验)。
|
|
260
281
|
|
|
261
282
|
### 测试
|
|
262
283
|
|
|
263
|
-
- `npm test`:纯逻辑单测(vitest,39 个文件
|
|
264
|
-
- `npm run test:client`:client 组件测试(vitest + jsdom,6 个文件
|
|
265
|
-
- `npm run test:probe`:官方 API
|
|
284
|
+
- `npm test`:纯逻辑单测(vitest,39 个文件 505 例,无 DSH 依赖,CI 与本地同跑)——配置解析、快照解析器、救援编排、错误分类、磁盘格式守卫、操作意图 journal、撤回终态事件上报组装、i18n 词典与 key 漏配扫描、脚本模板同名导出契约、执行通道双分支与失败分级、settings 双代桥接、客户端纯函数、发布包内容布局、快照索引持久化、存储上限与保留天数等;
|
|
285
|
+
- `npm run test:client`:client 组件测试(vitest + jsdom,6 个文件 96 例,CI 同跑)——撤回节点主链(preview→execute→fork→回填)与终态事件上报、快照管理树与删除流、配置/排除卡片错误路径、logger 开关矩阵、zh/en 双语渲染链;断言只锁行为与结构(className / aria / 请求载荷),不锁文案字面量;
|
|
286
|
+
- `npm run test:probe`:官方 API 字段探针(2 个文件 54 例,依赖本机 dsh 安装;dsh 升级后本地必跑)——钉住 `renderMessageImages`/`node`/`cwd`、`sessions.fork` 的 `atSeq`/`increaseTitle` 与切点锚点、`listSessions` 记录结构、`AgentRegistry`、shell 执行接缝(`execute`/`ShellExecution.result`)、settings 面(`SettingsForms`/profile 条目 id/volatile 门槛)与 `loader/volatile-update`/`Fiber.entry` 形状等字段,违反即红;
|
|
266
287
|
- `npm run verify:host`:装配门禁(依赖本机 dsh 安装)——用真实 cordis 起插件跑两个 pass(旧面桩 + 只给新面的桩),断言 inject 声明、端点注册、Config schema、卸载清理与 settings 面分派,装配回归发版前即可拦截;
|
|
267
|
-
- `npm run
|
|
288
|
+
- `npm run typecheck`:`tsc --noEmit` 全量类型检查(含 `tests/types` 编译期契约断言,CI 中置于单测之前);
|
|
289
|
+
- `npm run build`:host+client 全量打包(改任何 `src/` 后必跑);`npm run check:dsh`:dsh 版本巡检(发布前);`npm run check:upgrade`:dsh 升级一键核验门禁(串联 check:dsh + test:probe + verify:host,dsh 升级后必跑)。
|
|
268
290
|
- CI(GitHub Actions)跑 `npm ci --legacy-peer-deps` + `npm run typecheck` + `npm test` + `npm run test:client` + 产物新鲜度统一校验(`npm run build && git diff --exit-code lib/`;探针与装配门禁只在有 dsh 的机器跑)。
|
|
269
291
|
|
|
292
|
+
### 提交规范(摘要)
|
|
293
|
+
|
|
294
|
+
- 提交信息遵循 Conventional Commits(`feat:` / `fix:` / `docs:` / `test:` / `ci:` / `chore:` 前缀 + 中文摘要);修复 bump patch、新功能 bump minor;
|
|
295
|
+
- 行为变更同步更新 [CHANGELOG.md](CHANGELOG.md)(Keep a Changelog 格式)与 README 双语([中文](README.md) / [English](README.en.md));
|
|
296
|
+
- 对 dsh 官方 API 的字段假设发生变化时,同步 [docs/compat-audit.md](docs/compat-audit.md) 台账;
|
|
297
|
+
- 完整的架构地图、开发流程、官方文档合规清单与已知坑见 [AGENTS.md](AGENTS.md)——面向 AI 编码代理与深度维护者的项目操作手册。
|
|
298
|
+
|
|
270
299
|
## License
|
|
271
300
|
|
|
272
301
|
MIT
|
package/cordis.patch.yml
CHANGED
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
config:
|
|
16
16
|
gcSnaps: 50 # 每积累多少条快照触发一次 git gc
|
|
17
17
|
gcHours: 24 # 距上次 gc 超过多少小时触发(与条数先到先触发)
|
|
18
|
-
maxFileBytes:
|
|
18
|
+
maxFileBytes: 8388608 # 超过该大小的文件不进快照、不被回退触碰
|
|
19
|
+
maxWorkspaceBytes: 268435456 # 单工作区可纳入快照的文件总量上限,超过即停用该工作区快照;0 = 不限制
|
|
20
|
+
scanBudgetMs: 10000 # 快照前扫描的时间预算(毫秒),超时即停用本次快照;0 = 不限制
|
|
19
21
|
refillDraft: true # 撤回后把被撤回的消息文本回填到输入框
|
|
20
22
|
# baseExcludes 默认不在此列出:默认表随插件 schema 下发,在设置页
|
|
21
23
|
# 「基础排除表」增删即可。若要在这里覆盖,必须重述完整表——patch 按行
|
package/lib/client.js
CHANGED
|
@@ -464,6 +464,12 @@
|
|
|
464
464
|
"config.maxFileBytes.label": "\u6587\u4EF6\u5927\u5C0F\u4E0A\u9650",
|
|
465
465
|
"config.maxFileBytes.hint": "\u8D85\u8FC7\u8BE5\u5927\u5C0F\u7684\u6587\u4EF6\u4E0D\u8FDB\u5FEB\u7167\u3001\u4E0D\u88AB\u56DE\u9000\u89E6\u78B0",
|
|
466
466
|
"config.maxFileBytes.suffix": "MB",
|
|
467
|
+
"config.maxWorkspaceBytes.label": "\u5DE5\u4F5C\u533A\u603B\u91CF\u4E0A\u9650",
|
|
468
|
+
"config.maxWorkspaceBytes.hint": "\u5355\u5DE5\u4F5C\u533A\u53EF\u7EB3\u5165\u5FEB\u7167\u7684\u6587\u4EF6\u603B\u91CF\uFF0C\u8D85\u8FC7\u5373\u505C\u7528\u8BE5\u5DE5\u4F5C\u533A\u5FEB\u7167\uFF08\u4E0D\u622A\u65AD\uFF0C\u907F\u514D\u56DE\u9000\u8BEF\u5220\uFF09\uFF1B0 \u8868\u793A\u4E0D\u9650\u5236",
|
|
469
|
+
"config.maxWorkspaceBytes.suffix": "MB",
|
|
470
|
+
"config.scanBudgetMs.label": "\u626B\u63CF\u65F6\u95F4\u9884\u7B97",
|
|
471
|
+
"config.scanBudgetMs.hint": "\u5FEB\u7167\u524D\u7684\u6587\u4EF6\u626B\u63CF\u8D85\u8FC7\u8BE5\u65F6\u957F\u5373\u505C\u7528\u672C\u6B21\u5FEB\u7167\uFF1B0 \u8868\u793A\u4E0D\u9650\u5236",
|
|
472
|
+
"config.scanBudgetMs.suffix": "\u79D2",
|
|
467
473
|
"config.maxSnapshotsPerWorkspace.label": "\u5FEB\u7167\u603B\u91CF\u4E0A\u9650",
|
|
468
474
|
"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",
|
|
469
475
|
"config.maxSnapshotsPerWorkspace.suffix": "\u6761",
|
|
@@ -494,6 +500,8 @@
|
|
|
494
500
|
"config.err.gcSnaps": "\u5FEB\u7167\u6761\u6570\u9608\u503C\u5FC5\u987B\u662F >= 1 \u7684\u6574\u6570",
|
|
495
501
|
"config.err.gcHours": "gc \u5C0F\u65F6\u9608\u503C\u5FC5\u987B\u662F >= 1 \u7684\u6574\u6570",
|
|
496
502
|
"config.err.maxFileBytes": "\u6587\u4EF6\u5927\u5C0F\u4E0A\u9650\u81F3\u5C11 0.01 MB",
|
|
503
|
+
"config.err.maxWorkspaceBytes": "\u5DE5\u4F5C\u533A\u603B\u91CF\u4E0A\u9650\u5FC5\u987B\u662F >= 0 \u7684\u6570\u5B57\uFF080 \u8868\u793A\u4E0D\u9650\u5236\uFF09",
|
|
504
|
+
"config.err.scanBudgetMs": "\u626B\u63CF\u65F6\u95F4\u9884\u7B97\u5FC5\u987B\u662F >= 0 \u7684\u6570\u5B57\uFF080 \u8868\u793A\u4E0D\u9650\u5236\uFF09",
|
|
497
505
|
"config.err.maxSnapshots": "\u5FEB\u7167\u603B\u91CF\u4E0A\u9650\u5FC5\u987B\u662F >= 0 \u7684\u6574\u6570\uFF080 \u8868\u793A\u4E0D\u9650\u5236\uFF09",
|
|
498
506
|
"config.err.retentionDays": "\u4FDD\u7559\u5929\u6570\u5FC5\u987B\u662F >= 0 \u7684\u6574\u6570\uFF080 \u8868\u793A\u4E0D\u542F\u7528\uFF09",
|
|
499
507
|
"config.err.locale": "\u754C\u9762\u8BED\u8A00\u5FC5\u987B\u662F auto / zh / en \u4E4B\u4E00",
|
|
@@ -676,6 +684,12 @@
|
|
|
676
684
|
"config.maxFileBytes.label": "File size limit",
|
|
677
685
|
"config.maxFileBytes.hint": "Files larger than this are not snapshotted and never touched by rollback",
|
|
678
686
|
"config.maxFileBytes.suffix": "MB",
|
|
687
|
+
"config.maxWorkspaceBytes.label": "Workspace total limit",
|
|
688
|
+
"config.maxWorkspaceBytes.hint": "Total snapshot-eligible file size per workspace; beyond it snapshots are disabled for that workspace (no truncation, which would make rollback delete skipped files); 0 means unlimited",
|
|
689
|
+
"config.maxWorkspaceBytes.suffix": "MB",
|
|
690
|
+
"config.scanBudgetMs.label": "Scan time budget",
|
|
691
|
+
"config.scanBudgetMs.hint": "Skip this snapshot when the pre-snapshot file scan exceeds this duration; 0 means unlimited",
|
|
692
|
+
"config.scanBudgetMs.suffix": "s",
|
|
679
693
|
"config.maxSnapshotsPerWorkspace.label": "Snapshot count limit",
|
|
680
694
|
"config.maxSnapshotsPerWorkspace.hint": "Maximum snapshots kept per workspace; the oldest are deleted beyond it; 0 means unlimited",
|
|
681
695
|
"config.maxSnapshotsPerWorkspace.suffix": "snapshots",
|
|
@@ -706,6 +720,8 @@
|
|
|
706
720
|
"config.err.gcSnaps": "The snapshot count threshold must be an integer >= 1",
|
|
707
721
|
"config.err.gcHours": "The gc hour threshold must be an integer >= 1",
|
|
708
722
|
"config.err.maxFileBytes": "The file size limit must be at least 0.01 MB",
|
|
723
|
+
"config.err.maxWorkspaceBytes": "The workspace total limit must be a number >= 0 (0 means unlimited)",
|
|
724
|
+
"config.err.scanBudgetMs": "The scan time budget must be a number >= 0 (0 means unlimited)",
|
|
709
725
|
"config.err.maxSnapshots": "The snapshot count limit must be an integer >= 0 (0 means unlimited)",
|
|
710
726
|
"config.err.retentionDays": "Retention days must be an integer >= 0 (0 disables it)",
|
|
711
727
|
"config.err.locale": "Language must be one of auto / zh / en",
|
|
@@ -1701,6 +1717,9 @@
|
|
|
1701
1717
|
gcSnaps: String(v.gcSnaps == null ? "" : v.gcSnaps),
|
|
1702
1718
|
gcHours: String(v.gcHours == null ? "" : v.gcHours),
|
|
1703
1719
|
maxFileBytes: bytesToMb2(v.maxFileBytes),
|
|
1720
|
+
maxWorkspaceBytes: bytesToMb2(v.maxWorkspaceBytes),
|
|
1721
|
+
// 扫描预算持久化为毫秒、展示为秒(整数);0 表示不限制,原样显示 0
|
|
1722
|
+
scanBudgetMs: String(v.scanBudgetMs == null ? "" : Math.round(Number(v.scanBudgetMs) / 1e3)),
|
|
1704
1723
|
maxSnapshotsPerWorkspace: String(v.maxSnapshotsPerWorkspace == null ? "" : v.maxSnapshotsPerWorkspace),
|
|
1705
1724
|
baseExcludes: Array.isArray(v.baseExcludes) ? v.baseExcludes.join("\n") : "",
|
|
1706
1725
|
refillDraft: v.refillDraft !== false,
|
|
@@ -1730,7 +1749,7 @@
|
|
|
1730
1749
|
function save() {
|
|
1731
1750
|
if (state.busy || !draft || !baseline) return;
|
|
1732
1751
|
const patch = {};
|
|
1733
|
-
for (const key of ["gcSnaps", "gcHours", "maxFileBytes", "maxSnapshotsPerWorkspace", "baseExcludes", "refillDraft", "snapshotEnabled", "archiveOriginal", "retentionDays", "locale"]) {
|
|
1752
|
+
for (const key of ["gcSnaps", "gcHours", "maxFileBytes", "maxWorkspaceBytes", "scanBudgetMs", "maxSnapshotsPerWorkspace", "baseExcludes", "refillDraft", "snapshotEnabled", "archiveOriginal", "retentionDays", "locale"]) {
|
|
1734
1753
|
const value = draft[key];
|
|
1735
1754
|
if (value !== void 0 && value !== baseline[key]) patch[key] = value;
|
|
1736
1755
|
}
|
|
@@ -1763,6 +1782,22 @@
|
|
|
1763
1782
|
}
|
|
1764
1783
|
clean.maxFileBytes = Math.round(mb * 1048576);
|
|
1765
1784
|
}
|
|
1785
|
+
if (patch.maxWorkspaceBytes !== void 0) {
|
|
1786
|
+
const mb = Number(patch.maxWorkspaceBytes);
|
|
1787
|
+
if (!Number.isFinite(mb) || mb < 0) {
|
|
1788
|
+
setState({ busy: false, message: t("config.err.maxWorkspaceBytes"), error: true });
|
|
1789
|
+
return;
|
|
1790
|
+
}
|
|
1791
|
+
clean.maxWorkspaceBytes = Math.round(mb * 1048576);
|
|
1792
|
+
}
|
|
1793
|
+
if (patch.scanBudgetMs !== void 0) {
|
|
1794
|
+
const s = Number(patch.scanBudgetMs);
|
|
1795
|
+
if (!Number.isFinite(s) || s < 0) {
|
|
1796
|
+
setState({ busy: false, message: t("config.err.scanBudgetMs"), error: true });
|
|
1797
|
+
return;
|
|
1798
|
+
}
|
|
1799
|
+
clean.scanBudgetMs = Math.round(s * 1e3);
|
|
1800
|
+
}
|
|
1766
1801
|
if (patch.maxSnapshotsPerWorkspace !== void 0) {
|
|
1767
1802
|
const n = parseInt(String(patch.maxSnapshotsPerWorkspace), 10);
|
|
1768
1803
|
if (!Number.isFinite(n) || n < 0) {
|
|
@@ -1937,6 +1972,9 @@
|
|
|
1937
1972
|
numRow("gcSnaps", t("config.gcSnaps.label"), t("config.gcSnaps.hint"), { suffixKey: "config.gcSnaps.suffix", min: 1, step: 1 }),
|
|
1938
1973
|
numRow("gcHours", t("config.gcHours.label"), t("config.gcHours.hint"), { suffixKey: "config.gcHours.suffix", min: 1, step: 1 }),
|
|
1939
1974
|
numRow("maxFileBytes", t("config.maxFileBytes.label"), t("config.maxFileBytes.hint"), { suffixKey: "config.maxFileBytes.suffix", min: 0.01, step: 0.5 }),
|
|
1975
|
+
// issue #24 护栏:单文件上限之外的两道闸(总量 + 扫描耗时),0 = 不限制
|
|
1976
|
+
numRow("maxWorkspaceBytes", t("config.maxWorkspaceBytes.label"), t("config.maxWorkspaceBytes.hint"), { suffixKey: "config.maxWorkspaceBytes.suffix", min: 0, step: 16 }),
|
|
1977
|
+
numRow("scanBudgetMs", t("config.scanBudgetMs.label"), t("config.scanBudgetMs.hint"), { suffixKey: "config.scanBudgetMs.suffix", min: 0, step: 1 }),
|
|
1940
1978
|
numRow("maxSnapshotsPerWorkspace", t("config.maxSnapshotsPerWorkspace.label"), t("config.maxSnapshotsPerWorkspace.hint"), { suffixKey: "config.maxSnapshotsPerWorkspace.suffix", min: 0, step: 1 }),
|
|
1941
1979
|
numRow("retentionDays", t("config.retentionDays.label"), t("config.retentionDays.hint"), { suffixKey: "config.retentionDays.suffix", min: 0, step: 1 }),
|
|
1942
1980
|
React.createElement("div", { className: "dsh-recall-cfg-group" }, t("config.group.ui")),
|
package/lib/config.js
CHANGED
|
@@ -34,7 +34,12 @@ function withVolatile(field) {
|
|
|
34
34
|
const Config = Schema.object({
|
|
35
35
|
gcSnaps: withVolatile(Schema.number().default(50).description("\u6BCF\u79EF\u7D2F\u591A\u5C11\u6761\u5FEB\u7167\u89E6\u53D1\u4E00\u6B21 git gc")),
|
|
36
36
|
gcHours: withVolatile(Schema.number().default(24).description("\u8DDD\u4E0A\u6B21 gc \u8D85\u8FC7\u591A\u5C11\u5C0F\u65F6\u89E6\u53D1\uFF08\u4E0E\u6761\u6570\u5148\u5230\u5148\u89E6\u53D1\uFF09")),
|
|
37
|
-
maxFileBytes: withVolatile(Schema.number().default(
|
|
37
|
+
maxFileBytes: withVolatile(Schema.number().default(8388608).description("\u8D85\u8FC7\u8BE5\u5B57\u8282\u6570\u7684\u6587\u4EF6\u4E0D\u8FDB\u5FEB\u7167\u3001\u4E0D\u88AB\u56DE\u9000\u89E6\u78B0")),
|
|
38
|
+
// issue #24:单文件上限挡不住「大量小文件」——下载目录 321 个 ≤100MB 文件
|
|
39
|
+
// 合计 1.05GB 全部进快照。总量超限时**整体停用该工作区快照**而非截断:
|
|
40
|
+
// 截断快照回退时,未纳入的文件会命中回退脚本的「当前有、目标无」删除侧。
|
|
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\u8D85\u8FC7\u5373\u505C\u7528\u8BE5\u5DE5\u4F5C\u533A\u5FEB\u7167\uFF1B0 \u8868\u793A\u4E0D\u9650\u5236")),
|
|
42
|
+
scanBudgetMs: withVolatile(Schema.number().default(1e4).description("\u5FEB\u7167\u524D\u626B\u63CF\uFF08\u904D\u5386\u5DE5\u4F5C\u533A\u7EDF\u8BA1\u603B\u91CF\uFF09\u7684\u65F6\u95F4\u9884\u7B97\uFF0C\u8D85\u65F6\u5373\u505C\u7528\u672C\u6B21\u5FEB\u7167\uFF1B0 \u8868\u793A\u4E0D\u9650\u5236")),
|
|
38
43
|
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")),
|
|
39
44
|
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")),
|
|
40
45
|
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")),
|
|
@@ -118,7 +123,9 @@ function isPlainObject(value) {
|
|
|
118
123
|
const DEFAULTS = {
|
|
119
124
|
gcSnaps: 50,
|
|
120
125
|
gcHours: 24,
|
|
121
|
-
maxFileBytes:
|
|
126
|
+
maxFileBytes: 8388608,
|
|
127
|
+
maxWorkspaceBytes: 268435456,
|
|
128
|
+
scanBudgetMs: 1e4,
|
|
122
129
|
maxSnapshotsPerWorkspace: 500,
|
|
123
130
|
baseExcludes: BASE_EXCLUDES,
|
|
124
131
|
refillDraft: true,
|
|
@@ -136,7 +143,9 @@ function createConfig(raw) {
|
|
|
136
143
|
}
|
|
137
144
|
const gcSnaps = pickNumber(process.env.DSH_RECALL_GC_SNAPS, pickNumber(cfg.gcSnaps, 50, 1), 1);
|
|
138
145
|
const gcHours = pickNumber(process.env.DSH_RECALL_GC_HOURS, pickNumber(cfg.gcHours, 24, 1), 1);
|
|
139
|
-
const maxFileBytes = pickNumber(cfg.maxFileBytes,
|
|
146
|
+
const maxFileBytes = pickNumber(cfg.maxFileBytes, 8388608, 1024);
|
|
147
|
+
const maxWorkspaceBytes = pickNumber(cfg.maxWorkspaceBytes, 268435456, 0);
|
|
148
|
+
const scanBudgetMs = pickNumber(cfg.scanBudgetMs, 1e4, 0);
|
|
140
149
|
const rawMax = typeof cfg.maxSnapshotsPerWorkspace === "number" ? cfg.maxSnapshotsPerWorkspace : parseInt(String(cfg.maxSnapshotsPerWorkspace == null ? "" : cfg.maxSnapshotsPerWorkspace), 10);
|
|
141
150
|
const maxSnapshotsPerWorkspace = Number.isFinite(rawMax) ? Math.max(0, rawMax) : 500;
|
|
142
151
|
const baseExcludes = Array.isArray(cfg.baseExcludes) && cfg.baseExcludes.length ? cfg.baseExcludes.filter((p) => typeof p === "string" && p.trim()) : BASE_EXCLUDES;
|
|
@@ -146,7 +155,7 @@ function createConfig(raw) {
|
|
|
146
155
|
const rawDays = typeof cfg.retentionDays === "number" ? cfg.retentionDays : parseInt(String(cfg.retentionDays == null ? "" : cfg.retentionDays), 10);
|
|
147
156
|
const retentionDays = Number.isFinite(rawDays) ? Math.max(0, rawDays) : 0;
|
|
148
157
|
const locale = cfg.locale === "zh" || cfg.locale === "en" ? cfg.locale : "auto";
|
|
149
|
-
return { gcSnaps, gcHours, maxFileBytes, maxSnapshotsPerWorkspace, baseExcludes, refillDraft, snapshotEnabled, archiveOriginal, retentionDays, locale };
|
|
158
|
+
return { gcSnaps, gcHours, maxFileBytes, maxWorkspaceBytes, scanBudgetMs, maxSnapshotsPerWorkspace, baseExcludes, refillDraft, snapshotEnabled, archiveOriginal, retentionDays, locale };
|
|
150
159
|
}
|
|
151
160
|
export {
|
|
152
161
|
Config,
|
package/lib/index.js
CHANGED
|
@@ -236,6 +236,26 @@ function apply(ctx, config) {
|
|
|
236
236
|
},
|
|
237
237
|
agentBusy: (root) => agentBusy(null, root)
|
|
238
238
|
});
|
|
239
|
+
const rootReady = /* @__PURE__ */ new Map();
|
|
240
|
+
function ensureRootReady(root, sessionId) {
|
|
241
|
+
const cached = rootReady.get(root);
|
|
242
|
+
if (cached) return cached;
|
|
243
|
+
const task = (async () => {
|
|
244
|
+
let store = await rt.resolveStore(root);
|
|
245
|
+
store = await rt.tryUpgradeToHome(root);
|
|
246
|
+
await rt.ensureGit(root, store);
|
|
247
|
+
await snaps.loadIndex(root, sessionId);
|
|
248
|
+
await snaps.rebuildOrphans(root, sessionId || "");
|
|
249
|
+
if (await snaps.guardStoreFormat(store)) await intentJournal.recover(store);
|
|
250
|
+
rt.cleanupLegacy(root);
|
|
251
|
+
return store;
|
|
252
|
+
})();
|
|
253
|
+
rootReady.set(root, task);
|
|
254
|
+
task.catch(() => {
|
|
255
|
+
rootReady.delete(root);
|
|
256
|
+
});
|
|
257
|
+
return task;
|
|
258
|
+
}
|
|
239
259
|
const deps = {
|
|
240
260
|
ctx,
|
|
241
261
|
rt,
|
|
@@ -247,6 +267,7 @@ function apply(ctx, config) {
|
|
|
247
267
|
enqueue,
|
|
248
268
|
agentBusy,
|
|
249
269
|
runLimited,
|
|
270
|
+
ensureRootReady,
|
|
250
271
|
errBody,
|
|
251
272
|
listExcludeFiles,
|
|
252
273
|
dumpStores,
|
|
@@ -343,13 +364,14 @@ function apply(ctx, config) {
|
|
|
343
364
|
} catch (error) {
|
|
344
365
|
}
|
|
345
366
|
}
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
})
|
|
351
|
-
|
|
352
|
-
|
|
367
|
+
const warmupTasks = Array.from(
|
|
368
|
+
warmupRoots,
|
|
369
|
+
([cwd, sessionId]) => () => ensureRootReady(cwd, sessionId || "").then(() => {
|
|
370
|
+
}).catch(() => {
|
|
371
|
+
})
|
|
372
|
+
// 预热链失败整体吞掉(不刷启动噪音,原语义)
|
|
373
|
+
);
|
|
374
|
+
await runLimited(warmupTasks, 2);
|
|
353
375
|
})().catch((error) => {
|
|
354
376
|
console.error("recall warmup skipped:", error);
|
|
355
377
|
});
|
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, enqueue, agentBusy, rescueRollback, intentJournal, emitEvent, E } = deps;
|
|
7
|
+
const { rt, snaps, state, cfg, supported, enqueue, agentBusy, rescueRollback, intentJournal, ensureRootReady, emitEvent, E } = deps;
|
|
8
8
|
return {
|
|
9
9
|
"init": async (args) => {
|
|
10
10
|
if (!supported) {
|
|
@@ -14,13 +14,7 @@ function createRoutesCore(deps) {
|
|
|
14
14
|
const root = await rt.resolveRoot(sessionId);
|
|
15
15
|
let notice = null;
|
|
16
16
|
if (root) {
|
|
17
|
-
|
|
18
|
-
store = await rt.tryUpgradeToHome(root);
|
|
19
|
-
await rt.ensureGit(root, store);
|
|
20
|
-
await snaps.loadIndex(root, sessionId);
|
|
21
|
-
await snaps.rebuildOrphans(root, sessionId);
|
|
22
|
-
if (store && await snaps.guardStoreFormat(store)) await intentJournal.recover(store);
|
|
23
|
-
rt.cleanupLegacy(root);
|
|
17
|
+
const store = await ensureRootReady(root, sessionId);
|
|
24
18
|
notice = {
|
|
25
19
|
gitMissing: state.gitExe === "",
|
|
26
20
|
homeFallback: store ? !store.home : false
|
package/lib/routes-manage.js
CHANGED
|
@@ -280,6 +280,8 @@ function createRoutesManage(deps) {
|
|
|
280
280
|
gcSnaps: cfg.gcSnaps,
|
|
281
281
|
gcHours: cfg.gcHours,
|
|
282
282
|
maxFileBytes: cfg.maxFileBytes,
|
|
283
|
+
maxWorkspaceBytes: cfg.maxWorkspaceBytes,
|
|
284
|
+
scanBudgetMs: cfg.scanBudgetMs,
|
|
283
285
|
maxSnapshotsPerWorkspace: cfg.maxSnapshotsPerWorkspace,
|
|
284
286
|
baseExcludes: cfg.baseExcludes.slice(),
|
|
285
287
|
refillDraft: cfg.refillDraft,
|
|
@@ -305,6 +307,16 @@ function createRoutesManage(deps) {
|
|
|
305
307
|
if (patch.gcSnaps !== void 0) clean.gcSnaps = Number(patch.gcSnaps);
|
|
306
308
|
if (patch.gcHours !== void 0) clean.gcHours = Number(patch.gcHours);
|
|
307
309
|
if (patch.maxFileBytes !== void 0) clean.maxFileBytes = Number(patch.maxFileBytes);
|
|
310
|
+
if (patch.maxWorkspaceBytes !== void 0) {
|
|
311
|
+
const n = Number(patch.maxWorkspaceBytes);
|
|
312
|
+
if (!Number.isFinite(n) || n < 0) return { ok: false, code: E.RECALL_BAD_TYPE, message: "\u5DE5\u4F5C\u533A\u603B\u91CF\u4E0A\u9650\u5FC5\u987B\u662F >= 0 \u7684\u6570\u5B57\uFF080 \u8868\u793A\u4E0D\u9650\u5236\uFF09" };
|
|
313
|
+
clean.maxWorkspaceBytes = Math.trunc(n);
|
|
314
|
+
}
|
|
315
|
+
if (patch.scanBudgetMs !== void 0) {
|
|
316
|
+
const n = Number(patch.scanBudgetMs);
|
|
317
|
+
if (!Number.isFinite(n) || n < 0) return { ok: false, code: E.RECALL_BAD_TYPE, message: "\u626B\u63CF\u65F6\u95F4\u9884\u7B97\u5FC5\u987B\u662F >= 0 \u7684\u6570\u5B57\uFF080 \u8868\u793A\u4E0D\u9650\u5236\uFF09" };
|
|
318
|
+
clean.scanBudgetMs = Math.trunc(n);
|
|
319
|
+
}
|
|
308
320
|
if (patch.maxSnapshotsPerWorkspace !== void 0) {
|
|
309
321
|
const n = Number(patch.maxSnapshotsPerWorkspace);
|
|
310
322
|
if (!Number.isFinite(n)) return { ok: false, code: E.RECALL_BAD_TYPE, message: "\u5FEB\u7167\u603B\u91CF\u4E0A\u9650\u5FC5\u987B\u662F\u6570\u5B57" };
|
package/lib/scripts.posix.js
CHANGED
|
@@ -2,7 +2,7 @@ function psq(value) {
|
|
|
2
2
|
return "'" + String(value).replace(/'/g, "'\\''") + "'";
|
|
3
3
|
}
|
|
4
4
|
const UTF8_PRELUDE = "export LC_ALL=C";
|
|
5
|
-
const MAX_FILE_BYTES =
|
|
5
|
+
const MAX_FILE_BYTES = 8388608;
|
|
6
6
|
const STALE_LOCK_MIN = 5;
|
|
7
7
|
const HEARTBEAT_TTL_S = 900;
|
|
8
8
|
const FIDELITY_ATTRS = "* -text -filter -ident -export-ignore -export-subst -working-tree-encoding";
|
|
@@ -18,7 +18,7 @@ function dropGitlinksBlock() {
|
|
|
18
18
|
"done"
|
|
19
19
|
].join("\n");
|
|
20
20
|
}
|
|
21
|
-
function
|
|
21
|
+
function dirPruneArgsBlock() {
|
|
22
22
|
return [
|
|
23
23
|
"oversize_args=()",
|
|
24
24
|
'while IFS= read -r line || [ -n "$line" ]; do',
|
|
@@ -31,12 +31,41 @@ function oversizeBlock(maxBytes) {
|
|
|
31
31
|
' if [ ${#oversize_args[@]} -eq 0 ]; then oversize_args+=(-name "$t"); else oversize_args+=(-o -name "$t"); fi',
|
|
32
32
|
'done <<< "$new_exc"',
|
|
33
33
|
"oversize_prune=()",
|
|
34
|
-
"if [ ${#oversize_args[@]} -gt 0 ]; then oversize_prune=('(' -type d '(' \"${oversize_args[@]}\" ')' ')' -prune -o); fi"
|
|
34
|
+
"if [ ${#oversize_args[@]} -gt 0 ]; then oversize_prune=('(' -type d '(' \"${oversize_args[@]}\" ')' ')' -prune -o); fi"
|
|
35
|
+
].join("\n");
|
|
36
|
+
}
|
|
37
|
+
function oversizeBlock(maxBytes) {
|
|
38
|
+
return [
|
|
39
|
+
dirPruneArgsBlock(),
|
|
35
40
|
'find "$root" "${oversize_prune[@]}" -type f -size +' + String(maxBytes || MAX_FILE_BYTES) + "c -print0 2>/dev/null | while IFS= read -r -d '' f; do",
|
|
36
41
|
` printf '%s\\0' "\${f#"$root"/}"`,
|
|
37
42
|
'done | xargs -0 "$git" --literal-pathspecs --git-dir="$g" update-index --force-remove -- 2>/dev/null || true'
|
|
38
43
|
].join("\n");
|
|
39
44
|
}
|
|
45
|
+
function limitScanBlock(maxBytes, fileMaxBytes, budgetMs) {
|
|
46
|
+
const limit = maxBytes > 0 ? String(maxBytes) : "0";
|
|
47
|
+
const fileLimit = String(fileMaxBytes || MAX_FILE_BYTES);
|
|
48
|
+
const budgetS = budgetMs > 0 ? String(Math.ceil(budgetMs / 1e3)) : "0";
|
|
49
|
+
return [
|
|
50
|
+
dirPruneArgsBlock(),
|
|
51
|
+
"limit_start=$(date +%s)",
|
|
52
|
+
`if find "$root" -maxdepth 0 -printf '' >/dev/null 2>&1; then`,
|
|
53
|
+
` limit_sizes=$(find "$root" "\${oversize_prune[@]}" -type f -printf '%s\\n' 2>/dev/null)`,
|
|
54
|
+
"else",
|
|
55
|
+
' limit_sizes=$(find "$root" "${oversize_prune[@]}" -type f -exec stat -f%z {} + 2>/dev/null)',
|
|
56
|
+
"fi",
|
|
57
|
+
// 口径 = 实际会进快照的字节:超过单文件上限的文件随后被 oversizeBlock
|
|
58
|
+
// 移出 index,不计入总量(同 pwsh 侧,防一个大文件把干净项目误判超限)
|
|
59
|
+
`limit_total=$(printf '%s\\n' "$limit_sizes" | awk -v lim=` + limit + " -v flim=" + fileLimit + ` '{ if ($1 <= flim) { s += $1; if (lim > 0 && s > lim) { print "OVER " s; exit } } } END { if (lim <= 0 || s <= lim) print s }')`,
|
|
60
|
+
"limit_elapsed=$(( $(date +%s) - limit_start ))",
|
|
61
|
+
'case "$limit_total" in',
|
|
62
|
+
' OVER\\ *) echo "SNAP_LIMIT bytes ${limit_total#OVER } ' + limit + '"; exit 0 ;;',
|
|
63
|
+
"esac",
|
|
64
|
+
"if [ " + budgetS + ' -gt 0 ] && [ "$limit_elapsed" -gt ' + budgetS + " ]; then",
|
|
65
|
+
' echo "SNAP_LIMIT time $((limit_elapsed * 1000)) $(( ' + budgetS + ' * 1000 ))"; exit 0',
|
|
66
|
+
"fi"
|
|
67
|
+
].join("\n");
|
|
68
|
+
}
|
|
40
69
|
function excludeSyncBlock(excludeFile, base) {
|
|
41
70
|
const baseList = Array.isArray(base) && base.length ? base : [".git", "node_modules/", ".dsh-recall-snapshots/", "dsh-recall-snapshots/"];
|
|
42
71
|
const baseLines = baseList.join("\n") + "\n";
|
|
@@ -152,6 +181,8 @@ function snapshotScript(root, store, gitExe, messageId, base) {
|
|
|
152
181
|
heartbeatBlock(),
|
|
153
182
|
dropGitlinksBlock(),
|
|
154
183
|
excludeSyncBlock(store.excludeFile, base),
|
|
184
|
+
// issue #24:总量/预算护栏先于 add——超限时连 add 都不跑
|
|
185
|
+
limitScanBlock(store.maxWorkspaceBytes, store.maxFileBytes, store.scanBudgetMs),
|
|
155
186
|
attrsMigrateBlock(),
|
|
156
187
|
// fail-open add(issue #7 加固,语义见 pwsh 版同款注释):--ignore-errors
|
|
157
188
|
// 下「无法索引的路径」以退出码 1 结束但索引已落盘;≥2 才是真 fatal,
|
package/lib/scripts.pwsh.js
CHANGED
|
@@ -2,7 +2,7 @@ function psq(value) {
|
|
|
2
2
|
return "'" + String(value).replace(/'/g, "''") + "'";
|
|
3
3
|
}
|
|
4
4
|
const UTF8_PRELUDE = "$OutputEncoding = [Text.UTF8Encoding]::new($false)\ntry { [Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) } catch {}";
|
|
5
|
-
const MAX_FILE_BYTES =
|
|
5
|
+
const MAX_FILE_BYTES = 8388608;
|
|
6
6
|
const STALE_LOCK_MIN = 5;
|
|
7
7
|
const HEARTBEAT_TTL_S = 900;
|
|
8
8
|
const FIDELITY_ATTRS = "* -text -filter -ident -export-ignore -export-subst -working-tree-encoding";
|
|
@@ -84,6 +84,38 @@ function oversizeBlock(maxBytes) {
|
|
|
84
84
|
"}"
|
|
85
85
|
].join("\n");
|
|
86
86
|
}
|
|
87
|
+
function limitScanBlock(maxBytes, fileMaxBytes, budgetMs) {
|
|
88
|
+
const limit = maxBytes > 0 ? String(maxBytes) : "0";
|
|
89
|
+
const fileLimit = String(fileMaxBytes || MAX_FILE_BYTES);
|
|
90
|
+
const budget = budgetMs > 0 ? String(budgetMs) : "0";
|
|
91
|
+
return [
|
|
92
|
+
"$limitSw = [Diagnostics.Stopwatch]::StartNew()",
|
|
93
|
+
dirSkipSetBlock("limitSkip"),
|
|
94
|
+
"$limitStack = [System.Collections.Generic.Stack[string]]::new()",
|
|
95
|
+
"$limitStack.Push($root)",
|
|
96
|
+
"$limitBytes = [long]0",
|
|
97
|
+
"$limitHit = ''",
|
|
98
|
+
"while ($limitStack.Count -gt 0) {",
|
|
99
|
+
" if (" + budget + " -gt 0 -and $limitSw.ElapsedMilliseconds -gt " + budget + ") { $limitHit = 'time'; break }",
|
|
100
|
+
" $dir = $limitStack.Pop()",
|
|
101
|
+
" try {",
|
|
102
|
+
" $di = [System.IO.DirectoryInfo]::new($dir)",
|
|
103
|
+
// 口径 = 实际会进快照的字节:超过单文件上限的文件随后被 oversizeBlock
|
|
104
|
+
// 移出 index,不该计入总量(否则一个 300MB 视频就能把干净项目误判超限)
|
|
105
|
+
" foreach ($f in $di.EnumerateFiles()) { if ($f.Length -le " + fileLimit + ") { $limitBytes += $f.Length } }",
|
|
106
|
+
" if (" + limit + " -gt 0 -and $limitBytes -gt " + limit + ") { $limitHit = 'bytes' }",
|
|
107
|
+
" if ($limitHit) { break }",
|
|
108
|
+
" foreach ($d in $di.EnumerateDirectories()) {",
|
|
109
|
+
" if ($limitSkip.Contains($d.Name)) { continue }",
|
|
110
|
+
" if (($d.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { continue }",
|
|
111
|
+
" $limitStack.Push($d.FullName)",
|
|
112
|
+
" }",
|
|
113
|
+
" } catch {}",
|
|
114
|
+
"}",
|
|
115
|
+
"if ($limitHit -eq 'bytes') { Write-Output ('SNAP_LIMIT bytes ' + $limitBytes + ' ' + " + limit + "); exit 0 }",
|
|
116
|
+
"if ($limitHit -eq 'time') { Write-Output ('SNAP_LIMIT time ' + $limitSw.ElapsedMilliseconds + ' ' + " + budget + "); exit 0 }"
|
|
117
|
+
].join("\n");
|
|
118
|
+
}
|
|
87
119
|
function excludeSyncBlock(excludeFile, base) {
|
|
88
120
|
const baseList = (Array.isArray(base) && base.length ? base : [".git", "node_modules/", ".dsh-recall-snapshots/", "dsh-recall-snapshots/"]).map(psq).join(",");
|
|
89
121
|
return [
|
|
@@ -199,6 +231,8 @@ function snapshotScript(root, store, gitExe, messageId, base) {
|
|
|
199
231
|
heartbeatBlock(),
|
|
200
232
|
dropGitlinksBlock(),
|
|
201
233
|
excludeSyncBlock(store.excludeFile, base),
|
|
234
|
+
// issue #24:总量/预算护栏先于 add——超限时连 add 都不跑
|
|
235
|
+
limitScanBlock(store.maxWorkspaceBytes, store.maxFileBytes, store.scanBudgetMs),
|
|
202
236
|
attrsMigrateBlock(),
|
|
203
237
|
// fail-open add(issue #7 加固):--ignore-errors 让「个别路径无法索引」
|
|
204
238
|
// (无提交的嵌入式仓库、不可读文件等)以退出码 1 结束但索引照常落盘,
|
package/lib/snapshots.js
CHANGED
|
@@ -8,6 +8,29 @@ function parseSkipped(out) {
|
|
|
8
8
|
}
|
|
9
9
|
return skipped;
|
|
10
10
|
}
|
|
11
|
+
function parseSnapshotLimit(out) {
|
|
12
|
+
for (const line of String(out || "").split(/\r?\n/)) {
|
|
13
|
+
if (line.indexOf("SNAP_LIMIT ") !== 0) continue;
|
|
14
|
+
const parts = line.slice("SNAP_LIMIT ".length).trim().split(/\s+/);
|
|
15
|
+
const kind = parts[0];
|
|
16
|
+
if (kind !== "bytes" && kind !== "time") continue;
|
|
17
|
+
const value = parseInt(parts[1] ?? "", 10);
|
|
18
|
+
const limit = parseInt(parts[2] ?? "", 10);
|
|
19
|
+
if (!Number.isFinite(value) || !Number.isFinite(limit)) continue;
|
|
20
|
+
return { kind, value, limit };
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
function bytesText(bytes) {
|
|
25
|
+
if (bytes >= 1048576) return Math.round(bytes / 1048576) + " MB";
|
|
26
|
+
return Math.max(1, Math.round(bytes / 1024)) + " KB";
|
|
27
|
+
}
|
|
28
|
+
function buildSnapshotLimitNotice(limit) {
|
|
29
|
+
if (limit.kind === "bytes") {
|
|
30
|
+
return "\u8BE5\u5DE5\u4F5C\u533A\u53EF\u7EB3\u5165\u5FEB\u7167\u7684\u6587\u4EF6\u603B\u91CF\u8D85\u8FC7\u4E0A\u9650\uFF08\u7EA6 " + bytesText(limit.value) + " / " + bytesText(limit.limit) + "\uFF09\uFF0C\u5DF2\u505C\u7528\u5FEB\u7167\uFF1B\u53EF\u8C03\u5927\u300C\u5DE5\u4F5C\u533A\u603B\u91CF\u4E0A\u9650\u300D\u6216\u8865\u5145\u6392\u9664\u8868\u540E\u81EA\u52A8\u6062\u590D";
|
|
31
|
+
}
|
|
32
|
+
return "\u5FEB\u7167\u524D\u7684\u6587\u4EF6\u626B\u63CF\u8D85\u8FC7\u65F6\u95F4\u9884\u7B97\uFF08\u7EA6 " + Math.max(1, Math.round(limit.value / 1e3)) + " \u79D2 / " + Math.max(1, Math.round(limit.limit / 1e3)) + " \u79D2\uFF09\uFF0C\u5DF2\u505C\u7528\u5FEB\u7167\uFF1B\u53EF\u8C03\u5927\u300C\u626B\u63CF\u65F6\u95F4\u9884\u7B97\u300D\u6216\u8865\u5145\u6392\u9664\u8868\u540E\u81EA\u52A8\u6062\u590D";
|
|
33
|
+
}
|
|
11
34
|
function parseChanges(text, isWin) {
|
|
12
35
|
if (isWin) {
|
|
13
36
|
const parsed = JSON.parse(text);
|
|
@@ -163,6 +186,9 @@ function createSnapshots(ctx, rt, config) {
|
|
|
163
186
|
const FUSE_BACKOFF_BASE_MS = 5 * 60 * 1e3;
|
|
164
187
|
const FUSE_BACKOFF_CAP_MS = 60 * 60 * 1e3;
|
|
165
188
|
const snapFailures = /* @__PURE__ */ new Map();
|
|
189
|
+
const SNAP_LIMIT_RETRY_MS = 10 * 60 * 1e3;
|
|
190
|
+
const snapLimits = /* @__PURE__ */ new Map();
|
|
191
|
+
const limitBlockedAt = /* @__PURE__ */ new Map();
|
|
166
192
|
const SUPPORTED_FORMAT = 1;
|
|
167
193
|
const FORMAT_OK_TTL_MS = 3e4;
|
|
168
194
|
const formatOkAt = /* @__PURE__ */ new Map();
|
|
@@ -375,6 +401,8 @@ function createSnapshots(ctx, rt, config) {
|
|
|
375
401
|
if (!root) return;
|
|
376
402
|
const fused = snapFailures.get(root);
|
|
377
403
|
if (fused && Date.now() < fused.skipUntil) return;
|
|
404
|
+
const limited = snapLimits.get(root);
|
|
405
|
+
if (limited && limited.maxWorkspaceBytes === config.maxWorkspaceBytes && limited.scanBudgetMs === config.scanBudgetMs && Date.now() - limited.at < SNAP_LIMIT_RETRY_MS) return;
|
|
378
406
|
if (buildArtifactRootSegment(root, BASE(), rt.isWin)) return;
|
|
379
407
|
let store = await rt.resolveStore(root);
|
|
380
408
|
store = await rt.tryUpgradeToHome(root);
|
|
@@ -387,6 +415,18 @@ function createSnapshots(ctx, rt, config) {
|
|
|
387
415
|
await loadIndex(root, sessionId);
|
|
388
416
|
try {
|
|
389
417
|
const out = await rt.runShell(S.snapshotScript(root, store, state.gitExe || "", messageId, BASE()), { timeoutMs: 6e5, stdoutMaxBytes: 65536 });
|
|
418
|
+
const limit = parseSnapshotLimit(out);
|
|
419
|
+
if (limit) {
|
|
420
|
+
snapLimits.set(root, { ...limit, at: Date.now(), maxWorkspaceBytes: config.maxWorkspaceBytes, scanBudgetMs: config.scanBudgetMs });
|
|
421
|
+
const notice = buildSnapshotLimitNotice(limit);
|
|
422
|
+
const last = limitBlockedAt.get(root) || 0;
|
|
423
|
+
if (Date.now() - last >= 5 * 60 * 1e3) {
|
|
424
|
+
limitBlockedAt.set(root, Date.now());
|
|
425
|
+
rt.recordError("recall snapshot disabled for " + root + ": " + notice);
|
|
426
|
+
}
|
|
427
|
+
setFeedback(messageId, { failed: true, kind: "workspaceLimit", error: notice });
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
390
430
|
snapFailures.delete(root);
|
|
391
431
|
state.snapshots.set(String(messageId), { root, time: time || Date.now(), sessionId });
|
|
392
432
|
await saveIndex(root, sessionId);
|
|
@@ -533,12 +573,14 @@ function createSnapshots(ctx, rt, config) {
|
|
|
533
573
|
return { saveIndex, loadIndex, readExclude, writeExclude, rebuildOrphans, captureSnapshot, diffFor, rollbackFor, resolveCutSeq, resolveStaleQueueItemIds, feedbackFor, loadLineage, recordLineage, guardStoreFormat };
|
|
534
574
|
}
|
|
535
575
|
export {
|
|
576
|
+
buildSnapshotLimitNotice,
|
|
536
577
|
createSnapshots,
|
|
537
578
|
isSafetySnapshotId,
|
|
538
579
|
judgeStoreFormat,
|
|
539
580
|
parseChanges,
|
|
540
581
|
parseDiffOutput,
|
|
541
582
|
parseSkipped,
|
|
583
|
+
parseSnapshotLimit,
|
|
542
584
|
parseTagsWithTime,
|
|
543
585
|
parseTreeId,
|
|
544
586
|
rescueRollback,
|
package/lib/store.js
CHANGED
|
@@ -392,6 +392,14 @@ function createRuntime(ctx, config, deps) {
|
|
|
392
392
|
excludeFile,
|
|
393
393
|
get maxFileBytes() {
|
|
394
394
|
return config.maxFileBytes;
|
|
395
|
+
},
|
|
396
|
+
// issue #24 快照护栏:与 maxFileBytes 同款 getter——设置页改完上限,
|
|
397
|
+
// 下一次快照立即按新值判定,无需重启或重建 store 缓存
|
|
398
|
+
get maxWorkspaceBytes() {
|
|
399
|
+
return config.maxWorkspaceBytes;
|
|
400
|
+
},
|
|
401
|
+
get scanBudgetMs() {
|
|
402
|
+
return config.scanBudgetMs;
|
|
395
403
|
}
|
|
396
404
|
};
|
|
397
405
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-recall-plugin",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.14",
|
|
4
4
|
"description": "在 DSH 中撤回自己发过的消息:工作区文件与对话历史一起回到那条消息发出之前,消息的文本与附件自动放回输入框,改完即可重发。文件走独立影子 git 快照(不碰项目自身的 git),对话走官方 sessions.fork,原会话归档可找回。",
|
|
5
5
|
"icon": "assets/icon.svg",
|
|
6
6
|
"type": "module",
|