dsh-conversation-recycle-bin 0.3.4 → 0.3.6
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 +45 -0
- package/README.md +17 -6
- package/client.js +186 -41
- package/config.schema.json +5 -1
- package/cordis.patch.yml +4 -0
- package/lib/core/config.js +45 -0
- package/lib/core/memory/json-index-adapter.js +26 -1
- package/lib/core/trash.js +80 -36
- package/lib/host/api.js +42 -7
- package/lib/host/gateway.js +41 -1
- package/lib/host/lifecycle.js +22 -5
- package/lib/version.js +1 -1
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,51 @@
|
|
|
3
3
|
All notable changes to `dsh-conversation-recycle-bin` are documented here.
|
|
4
4
|
The format follows Keep a Changelog; the project uses Semantic Versioning.
|
|
5
5
|
|
|
6
|
+
## [0.3.6] — 2026-10-24
|
|
7
|
+
|
|
8
|
+
对应 `AUDIT-2026-10-24-desktop-web-rc3.md` §7 的四条「建议动作」(A2 / A3 / A5 / A6)。同一节的 A1、A4、A7、A8 复核后判定为产品取舍(不申报未直接消费的 client 依赖、不导出 `Config` 以免设置页脱离非表单语义、归档读保留双源缓存、`missingSessionPaths` 保持成员判定),本版不改。
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **A3 `sessionController` 缺失时抛出的不是插件错误**:`lib/host/lifecycle.js` 新增 `peek()`,把 `ctx.get?.(name) ?? ctx[name]` 的整条退化读取留在 `try` 内。`??` 右侧的属性读会命中 cordis 的 Proxy trap(`cannot get property "sessionController" without inject`),异常类型不是 `DeleteError`,会绕过统一错误映射并让删除在归档前失败。现在 `sessionController` 明确视为可选服务:缺失时跳过 `cancel`,归档与 drain 检查照常执行。新增用例以抛异常的 getter 模拟裁剪组合,断言归档仍然发生。
|
|
13
|
+
- **A6 运行中判定永远落到 `inspect()`**:`lib/host/gateway.js` 的 `probeRunning()` 改为主探 `agents.get(id)?.status === 'running'`(与宿主 `dsh-api-session-controller` 列表行的判据一致),旧的 `Session.running/active/busy` 字段探测降为兜底。删除前置的「是否运行中」现在真正走廉价的实时探测,不必为每次判定重读会话。
|
|
14
|
+
- **A2 事件数列恒为 `—`**:`stat()` 从不返回 `eventCount`,旧代码读的正是这个不存在的字段。活跃会话改用 `Session.seq`(会话存储将其定义为日志长度,精确、纯数值读,不触发已废弃的 `snapshotEvents()`);已落盘的非活跃会话没有便宜的精确来源,继续返回 `null`、由 UI 显示 `—`,不拿投影游标冒充精确值。新增两个用例(活跃 = 精确值、非活跃 = `null`),并把测试夹具的 `stat()` 改成 0.2.1 的真实形状(去掉 `eventCount`),避免此前的假绿。
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- **A5 `engines.dsh`**:`package.json` 声明 `"dsh": ">=0.2.1-rc.2"`(本插件的实测下限)。该键在安装/声明时由版本闸门读取,运行时不强制;不设上界,因为更晚的版本尚未验证。
|
|
19
|
+
|
|
20
|
+
### Maintenance
|
|
21
|
+
|
|
22
|
+
- 测试 138 → **141**(api 41 → 44:活跃/非活跃事件数、无 `sessionController` 的裁剪组合、agents 三态运行判定)。
|
|
23
|
+
- `lib/version.js` 与 `package.json` 同步到 0.3.6(`manifest` 用例守门)。
|
|
24
|
+
|
|
25
|
+
## [0.3.5] — 2026-10-22
|
|
26
|
+
|
|
27
|
+
对应 `AUDIT-2026-10-22-desktop-web-compat.md`:把 0.3.4 之后一直只存在于工作树的修复正式发布,并落地该报告 §9 的全部建议。
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- **C-01 前缀部署下所有 API 丢失路径前缀**:`client.js` 新增 `apiUrl()`,用 `new URL('.', document.baseURI)` 归一化 base 并去掉 `API_PREFIX` 的前导斜杠,改为文档相对解析。`dsh web --public-url https://host/ui/` 下不再 404;桌面 `dsh-app://app/` 行为不变。0.3.4 的 tarball 仍带此缺陷(`new URL('/conversation-recycle-bin/api…', base)` 会替换整个 base 路径)。新增回归用例断言三种 base(根、`…/ui/`、`…/ui/index.html`、`dsh-app://app/`)解析结果。
|
|
32
|
+
- **C-02 回收站写操作超时短于宿主排队**:客户端 `TRASH_MUTATION_TIMEOUT_MS = 195s`,宿主 `TRASH_LOCK_BUDGET_MS = 180s`(此前 15s / 30s)。批量删除进行中再点恢复/彻底删除,不再出现"界面报错但宿主已执行"。
|
|
33
|
+
- **C-08 锁预算用例一直是红的**:该用例用软删除条目测 `discard`,而软删除按设计保留会话日志,`performDiscard` 必然返回 `E_TRASH_CONFLICT`(409)。改为 seed 一个从未提交的 `pending` 条目(无 `session/`、无 `memory/`),api 用例数 40 → 41 全绿。此前 0.3.4 的 `npm test` 因该用例无法通过而不能作为发布门禁。
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
|
|
37
|
+
- **C-03 `allowedHosts`**:反代部署的精确 authority 白名单(`host`、`host:port`、`[v6]:port`)。裸主机放行任意端口,带端口的条目只放行该端口;带 scheme/路径/userinfo/通配符的条目会被丢弃并写入 warnings。默认 `[]` = 仅回环,与 0.3.4 行为一致。这样不必为了让反代可用而整体打开 `allowNonLoopback`。`config.schema.json`、`cordis.patch.yml`、README §4.2 同步。
|
|
38
|
+
|
|
39
|
+
### Changed / Performance
|
|
40
|
+
|
|
41
|
+
- **P-01 `/trash` 双扫描合并**:`trash.scanAll()` 一次 `readdir` 产出 `{entries, unreadable}`,每条目只读一次 manifest,读取用 8 并发(此前 `listAll` + `scanUnreadable` 各扫一遍,合计 2 次 readdir + 2N 次读,且 `listAll` 串行)。0/50/200/500 条实测 1.2/17.2/63.3/156.3 ms → **1/4.9/14.4/38.1 ms**。
|
|
42
|
+
- **P-02 `/preview` 记忆索引只解析一次**:适配器按 `mtimeMs:size` 缓存已解析快照,`probe()` 与 `countSessions()` 共用;本适配器每次写索引后主动失效,其他进程改写由 stat key 识别。21000 节点(1.9 MB)单条预览 39.2 → **28.9 ms**(剩余部分是单次读盘 + 解析的固有成本)。
|
|
43
|
+
- **P-05 设置页按 tab 懒加载**:挂载/切换只拉当前 tab 需要的接口(回收站 `/trash`、批量 `/sessions`、审计 `/audit`),`/state` 仅在挂载与变更后取;刷新期间面板不再卸载,改为顶部细条指示,滚动位置与输入焦点不再丢失。对应把每次刷新 4 个请求(≈31 KB)降为 1–2 个。
|
|
44
|
+
- **P-04 宽限等待可见化**:彻底删除模式下对话框说明"刚使用过的对话可能多等约 1.5 秒";提交超过 1.2s 后显示"仍在执行:正在确认没有其他 DSH 实例写入该对话"。等待本身(`foreignWriterGraceMs`)是安全设计,未取消。
|
|
45
|
+
|
|
46
|
+
### Maintenance
|
|
47
|
+
|
|
48
|
+
- 测试 131 → **138**(api 41、client 22、core 15、memory 13、cascade 15、feedback 19、manifest 9、trash-audit 4)。
|
|
49
|
+
- README 状态 0.2.2 → 0.3.5,用例计数与设置页/反代说明同步。
|
|
50
|
+
|
|
6
51
|
## [0.3.4] — 2026-10-20
|
|
7
52
|
|
|
8
53
|
对应 `AUDIT-2026-10-20-last3-versions.md` 的四条非阻塞隐患 N1–N4,以及测试驱动下新发现的 P3 回归 N5。
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
给 DeepSeek Harness (DSH) Web 增加「删除对话」:会话列表的「…」菜单与会话详情页头部各有一个删除入口;确认框内可勾选「同时删除关联记忆」(默认不勾选),并实时显示将被影响的记忆条数。删除默认进入插件回收站,可恢复;每次操作写入审计日志;批量删除有上限与限流,批量软删除需输入数量,彻底删除一键执行;事务任一步失败即回滚。
|
|
4
4
|
|
|
5
|
-
> 状态:`0.
|
|
5
|
+
> 状态:`0.3.5`,Host + Web Client 双半,零构建(plain JS ESM),只依赖宿主已发布的公开扩展点。
|
|
6
6
|
> 包名:`dsh-conversation-recycle-bin`(原 `dsh-delete-conversation` 因 npm 被他人占用而改名,见 CHANGELOG 0.2.0)。
|
|
7
7
|
> 本 README 同时是设计说明与验收依据;安装/回滚步骤见 [INSTALL.md](INSTALL.md);变更见 [CHANGELOG.md](CHANGELOG.md)。
|
|
8
8
|
|
|
@@ -74,7 +74,7 @@ memory → messages → session/catalog
|
|
|
74
74
|
|---|---|---|
|
|
75
75
|
| 会话列表「…」 | `sidebar.workspaces.session.menu.item`(order 500) | 点击后关闭菜单,打开全局确认框 |
|
|
76
76
|
| 会话详情页头部 | `conversation.session.header.actions`(order 100) | 图标按钮打开同一确认框 |
|
|
77
|
-
| 设置页 | `settings.section`(order 1300) | 「回收站 / 批量删除 /
|
|
77
|
+
| 设置页 | `settings.section`(order 1300) | 「回收站 / 批量删除 / 审计留痕」三个标签页;按 tab 懒加载,刷新时保留旧数据 |
|
|
78
78
|
| 全局确认框 | `shell.overlay` | 唯一删除发起处;挂在根级,不会随菜单卸载 |
|
|
79
79
|
|
|
80
80
|
确认框内容:
|
|
@@ -111,7 +111,7 @@ memory → messages → session/catalog
|
|
|
111
111
|
|---|---|
|
|
112
112
|
| 误删 | 默认软删除;记忆复选框默认关;回收站;彻底删除一键但仍写回收站可恢复;批量软删除需输入数量 |
|
|
113
113
|
| 批量误操作 | `maxBatchSessions`(实际上限还会被本分钟剩余额度收紧,避免"合法批量必然 429");批量需输入数量;每个会话独立事务 |
|
|
114
|
-
| 暴力/脚本调用 | 同源校验(完整 authority:主机名+有效端口)+ `x-conversation-recycle-bin: 1` 私有 header + DSH `connection` 层(`requestRejection` + `connection/request` 瀑布流)+ 限流;默认仅接受 loopback
|
|
114
|
+
| 暴力/脚本调用 | 同源校验(完整 authority:主机名+有效端口)+ `x-conversation-recycle-bin: 1` 私有 header + DSH `connection` 层(`requestRejection` + `connection/request` 瀑布流)+ 限流;默认仅接受 loopback 绑定;反代部署用 `allowedHosts` 精确放行(不要为此整体打开 `allowNonLoopback`) |
|
|
115
115
|
| 两个 Host 并发写同一回收站 | mutation 串行 + `<dataDir>/mutation.lock` 跨进程文件锁,抢不到锁返回 `E_LOCK_BUSY`(而非写坏 manifest) |
|
|
116
116
|
| 越权/越界 | 不在工作区登记的会话默认拒绝;可配置允许根目录白名单;受保护 id 列表;子代理会话默认拒绝 |
|
|
117
117
|
| 活跃会话被写坏 | hard 模式对 live 会话先走 `quiesce`:取消当前轮次 → `archiveSession({stopActivity:true})` → 在 `liveDrainMs` 内等待 `running=false`,之后才移动日志;仍停不下来则整笔回滚并报 `E_POLICY_LIVE`。`stopLiveSessions:false` 可恢复为直接拒绝 |
|
|
@@ -120,6 +120,17 @@ memory → messages → session/catalog
|
|
|
120
120
|
| 不可追溯 | `<DSH_HOME>/delete-conversation/audit.jsonl` 追加成功/失败/回滚/拒绝/恢复/清理记录;设置页可查看 |
|
|
121
121
|
| 外部记忆存储损坏 | 灵枢适配器持有其 `_index.json.lock`,先写 restore bundle 再原子改写索引;任何一步失败先还原正文与索引;详情见 §5.3 |
|
|
122
122
|
|
|
123
|
+
反代部署(`dsh web --public-url https://host/ui/`)时,代理会把外部主机名放进 `Host`,插件的回环校验会以 `E_FORBIDDEN_HOST` 拒绝。用 `allowedHosts` 精确放行,而不是把 `allowNonLoopback` 整体打开:
|
|
124
|
+
|
|
125
|
+
| 配置 | 匹配 | 说明 |
|
|
126
|
+
|---|---|---|
|
|
127
|
+
| `allowedHosts: ["app.example"]` | 该主机的任意端口 | 端口在代理侧固定时更宽松 |
|
|
128
|
+
| `allowedHosts: ["app.example:8443"]` | 仅该端口 | 推荐;端口不匹配仍拒绝 |
|
|
129
|
+
| `allowedHosts: []`(默认) | 仅回环 | 与 0.3.4 行为一致 |
|
|
130
|
+
|
|
131
|
+
条目必须是裸 authority(`host`、`host:port`、`[v6]:port`);带 scheme、路径、userinfo 或通配符的条目会被丢弃并在 `/state` 的 warnings 里报告。**代理读超时仍需单独调整**:nginx `proxy_read_timeout` 默认 60s、Cloudflare 到源站默认 100s,而大批量删除的宿主预算是 `min(900s, 15s + 16s×批量 + drain)`,超时后代理会先断而宿主仍在执行。
|
|
132
|
+
|
|
133
|
+
|
|
123
134
|
### 4.3 回收站布局
|
|
124
135
|
|
|
125
136
|
存储目录沿用旧插件的 `delete-conversation`(不是新包名),这样 0.2.0 改名后已有的回收站条目和审计日志仍然可见。
|
|
@@ -190,7 +201,7 @@ dsh-conversation-recycle-bin/
|
|
|
190
201
|
│ ├─ memory.js # 适配器装配
|
|
191
202
|
│ ├─ feedback.js # 反馈校验/渲染/投递(http 或 smtp,凭证只读环境变量)
|
|
192
203
|
│ └─ api.js # 同源 HTTP API、确认 token、限流
|
|
193
|
-
└─ test/ #
|
|
204
|
+
└─ test/ # 138 个用例,覆盖用户要求的 5 类场景 + live 删除 + 记忆锁 + 原地刷新 + 部分失败/跨进程/未完成条目 + 反馈通道 + Client 半渲染冒烟
|
|
194
205
|
```
|
|
195
206
|
|
|
196
207
|
---
|
|
@@ -199,7 +210,7 @@ dsh-conversation-recycle-bin/
|
|
|
199
210
|
|
|
200
211
|
```powershell
|
|
201
212
|
cd D:\Users\34332\AI\dsh-conversation-recycle-bin
|
|
202
|
-
npm test # = manifest && client-module && core && cascade && memory && trash-audit && feedback && api(
|
|
213
|
+
npm test # = manifest && client-module && core && cascade && memory && trash-audit && feedback && api(138 个用例)
|
|
203
214
|
npm run check # 语法 + manifest
|
|
204
215
|
```
|
|
205
216
|
|
|
@@ -288,7 +299,7 @@ node -e "console.log('b64:' + Buffer.from('your-feedback@example.com','utf8').to
|
|
|
288
299
|
cd D:\Users\34332\AI\dsh-conversation-recycle-bin
|
|
289
300
|
npm run check # 语法 + manifest(含 feedback.js)
|
|
290
301
|
node test/feedback.test.mjs # 19 个反馈用例
|
|
291
|
-
npm test # 全量
|
|
302
|
+
npm test # 全量 138 个用例
|
|
292
303
|
|
|
293
304
|
# 端到端:用本机收件桩观察真实投递路径,不外发邮件
|
|
294
305
|
$env:DSH_CRB_MAIL_API_URL = 'http://127.0.0.1:9099/emails'
|
package/client.js
CHANGED
|
@@ -101,6 +101,8 @@ window.__ModuleLoader__.load({
|
|
|
101
101
|
'dialog.phraseLabel': 'Confirmation',
|
|
102
102
|
'dialog.delete': 'Delete',
|
|
103
103
|
'dialog.working': 'Deleting…',
|
|
104
|
+
'dialog.stillWorking': 'Still working — confirming no other DSH instance is writing this conversation.',
|
|
105
|
+
'dialog.hardGraceHint': 'Permanent deletion re-checks the log for a second writer first, so a conversation used moments ago can take about 1.5s longer.',
|
|
104
106
|
'dialog.loading': 'Reading the plan…',
|
|
105
107
|
'dialog.done': 'Deleted {count} conversation(s); {memory} memory entries moved to the recycle bin.',
|
|
106
108
|
'dialog.partial': 'Deleted {done}, failed {failed}.',
|
|
@@ -255,6 +257,8 @@ window.__ModuleLoader__.load({
|
|
|
255
257
|
'dialog.phraseLabel': '确认',
|
|
256
258
|
'dialog.delete': '删除',
|
|
257
259
|
'dialog.working': '正在删除…',
|
|
260
|
+
'dialog.stillWorking': '仍在执行:正在确认没有其他 DSH 实例写入该对话。',
|
|
261
|
+
'dialog.hardGraceHint': '彻底删除会先复查日志是否还有第二个写入方;刚使用过的对话可能多等约 1.5 秒。',
|
|
258
262
|
'dialog.loading': '正在读取删除计划…',
|
|
259
263
|
'dialog.done': '已删除 {count} 个对话;移入回收站的记忆条目 {memory} 条。',
|
|
260
264
|
'dialog.partial': '成功 {done} 个,失败 {failed} 个。',
|
|
@@ -472,6 +476,23 @@ window.__ModuleLoader__.load({
|
|
|
472
476
|
const API_PREFIX = '/conversation-recycle-bin/api'
|
|
473
477
|
const REQUEST_TIMEOUT_MS = 15_000
|
|
474
478
|
const MUTATION_TIMEOUT_MS = 180_000
|
|
479
|
+
/**
|
|
480
|
+
* Recycle-bin writes wait for the Host's cross-process lock, and that wait
|
|
481
|
+
* is unbounded in wall-clock terms: the request queues behind whatever
|
|
482
|
+
* mutation is already running (`serialize()` chains in-process first). A
|
|
483
|
+
* 15s budget therefore aborted restore / purge / discard while the Host was
|
|
484
|
+
* still working, so the UI reported a failure for a change that did happen.
|
|
485
|
+
* The Host caps its own lock wait at TRASH_LOCK_BUDGET_MS (180s); this sits
|
|
486
|
+
* just above it so the Host's own answer always wins the race.
|
|
487
|
+
*/
|
|
488
|
+
const TRASH_MUTATION_TIMEOUT_MS = 195_000
|
|
489
|
+
/**
|
|
490
|
+
* How long a delete may run before the dialog says what it is waiting for.
|
|
491
|
+
* A hard delete of a conversation written moments ago spends
|
|
492
|
+
* `foreignWriterGraceMs` (1.5s) re-checking the log for a second writer;
|
|
493
|
+
* without a hint the danger button looked stuck.
|
|
494
|
+
*/
|
|
495
|
+
const SLOW_SUBMIT_HINT_MS = 1_200
|
|
475
496
|
|
|
476
497
|
/**
|
|
477
498
|
* A batch is one HTTP request that can stop several live sessions (each up
|
|
@@ -493,14 +514,39 @@ window.__ModuleLoader__.load({
|
|
|
493
514
|
ApiError.prototype = Object.create(Error.prototype)
|
|
494
515
|
ApiError.prototype.constructor = ApiError
|
|
495
516
|
|
|
517
|
+
/**
|
|
518
|
+
* Resolve a plugin route against the PAGE, not against the origin.
|
|
519
|
+
*
|
|
520
|
+
* `dsh web` can be mounted under a path prefix (`--public-url
|
|
521
|
+
* https://host/ui/`, stripped by a front proxy). DSH's own client stays
|
|
522
|
+
* document-relative for exactly this reason: `dsh-client-connection` builds
|
|
523
|
+
* its requests with `` `${channel}/${endpoint}`.slice(1) `` and resolves
|
|
524
|
+
* them against `document.baseURI`. An absolute `/conversation-recycle-bin/…`
|
|
525
|
+
* would replace the whole base path, drop the prefix, and 404 every call.
|
|
526
|
+
* Keeping the same rule means one code path works for `http://127.0.0.1:<p>/`,
|
|
527
|
+
* `https://host/<prefix>/` and the desktop shell's `dsh-app://app/`.
|
|
528
|
+
*/
|
|
529
|
+
function apiUrl(path) {
|
|
530
|
+
let base = 'http://127.0.0.1/'
|
|
531
|
+
if (typeof document !== 'undefined' && typeof document.baseURI === 'string' && document.baseURI !== '') {
|
|
532
|
+
try {
|
|
533
|
+
// Normalise to the containing directory so a base without a trailing
|
|
534
|
+
// slash (`…/index.html`) does not swallow the last segment.
|
|
535
|
+
base = new URL('.', document.baseURI).href
|
|
536
|
+
} catch {
|
|
537
|
+
base = 'http://127.0.0.1/'
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
return new URL(`${API_PREFIX.replace(/^\//, '')}${path}`, base)
|
|
541
|
+
}
|
|
542
|
+
|
|
496
543
|
async function api(path, options = {}) {
|
|
497
544
|
const method = options.method ?? 'GET'
|
|
498
545
|
const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : REQUEST_TIMEOUT_MS
|
|
499
546
|
const controller = new AbortController()
|
|
500
547
|
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
501
548
|
try {
|
|
502
|
-
const
|
|
503
|
-
const url = new URL(`${API_PREFIX}${path}`, base)
|
|
549
|
+
const url = apiUrl(path)
|
|
504
550
|
const headers = { 'x-conversation-recycle-bin': '1' }
|
|
505
551
|
if (options.body !== undefined) headers['content-type'] = 'application/json'
|
|
506
552
|
const response = await fetch(url, {
|
|
@@ -938,6 +984,9 @@ window.__ModuleLoader__.load({
|
|
|
938
984
|
const [error, setError] = useState(null)
|
|
939
985
|
const [phrase, setPhrase] = useState('')
|
|
940
986
|
const [done, setDone] = useState(null)
|
|
987
|
+
// Set when a submission outlives SLOW_SUBMIT_HINT_MS, so the dialog can
|
|
988
|
+
// explain the wait instead of showing a frozen danger button.
|
|
989
|
+
const [slow, setSlow] = useState(false)
|
|
941
990
|
|
|
942
991
|
// Dismissing the result must also complete a reload the Host armed
|
|
943
992
|
// because it cannot close the deleted conversation in place; otherwise
|
|
@@ -986,6 +1035,15 @@ window.__ModuleLoader__.load({
|
|
|
986
1035
|
return () => window.removeEventListener('keydown', onKey)
|
|
987
1036
|
}, [busy])
|
|
988
1037
|
|
|
1038
|
+
useEffect(() => {
|
|
1039
|
+
if (busy !== true) {
|
|
1040
|
+
setSlow(false)
|
|
1041
|
+
return undefined
|
|
1042
|
+
}
|
|
1043
|
+
const timer = setTimeout(() => setSlow(true), SLOW_SUBMIT_HINT_MS)
|
|
1044
|
+
return () => clearTimeout(timer)
|
|
1045
|
+
}, [busy])
|
|
1046
|
+
|
|
989
1047
|
const firstSession = preview?.sessions?.[0] ?? null
|
|
990
1048
|
const title = count === 1
|
|
991
1049
|
? (firstSession?.title || fallbackTitle || sessionIds[0])
|
|
@@ -1120,6 +1178,11 @@ window.__ModuleLoader__.load({
|
|
|
1120
1178
|
? h('div', { className: 'dc-live-warning', role: 'status' }, t('dialog.liveStopHint'))
|
|
1121
1179
|
: null,
|
|
1122
1180
|
|
|
1181
|
+
// Permanent deletion waits out `foreignWriterGraceMs` when the log was
|
|
1182
|
+
// written moments ago (measured: ~1.5s once per batch). Say so before
|
|
1183
|
+
// the click, not after the user thinks the button is stuck.
|
|
1184
|
+
mode === 'hard' ? h('p', { className: 'dc-hint' }, t('dialog.hardGraceHint')) : null,
|
|
1185
|
+
|
|
1123
1186
|
h('div', { className: 'dc-memory' },
|
|
1124
1187
|
h(Checkbox, {
|
|
1125
1188
|
checked: includeMemory,
|
|
@@ -1162,6 +1225,10 @@ window.__ModuleLoader__.load({
|
|
|
1162
1225
|
}))
|
|
1163
1226
|
: null,
|
|
1164
1227
|
|
|
1228
|
+
slow === true
|
|
1229
|
+
? h('div', { className: 'dc-live-warning', role: 'status' }, t('dialog.stillWorking'))
|
|
1230
|
+
: null,
|
|
1231
|
+
|
|
1165
1232
|
h('div', { className: 'dc-actions' },
|
|
1166
1233
|
h(Button, { onClick: () => { void load(mode, includeMemory) }, disabled: busy || loading, title: t('action.refresh') },
|
|
1167
1234
|
h(GlyphRefresh, { size: 13 }), ' ', t('action.refresh')),
|
|
@@ -1518,7 +1585,10 @@ window.__ModuleLoader__.load({
|
|
|
1518
1585
|
const [trash, setTrash] = useState([])
|
|
1519
1586
|
const [incomplete, setIncomplete] = useState([])
|
|
1520
1587
|
const [audit, setAudit] = useState([])
|
|
1521
|
-
|
|
1588
|
+
// Which tab is fetching right now. `null` means idle: the panels stay
|
|
1589
|
+
// mounted while a refresh runs, so the user keeps scroll position and
|
|
1590
|
+
// never sees the whole section blink on every restore/purge.
|
|
1591
|
+
const [tabBusy, setTabBusy] = useState(null)
|
|
1522
1592
|
const [error, setError] = useState(null)
|
|
1523
1593
|
const [selected, setSelected] = useState(() => new Set())
|
|
1524
1594
|
const [purgeTarget, setPurgeTarget] = useState(null)
|
|
@@ -1532,54 +1602,100 @@ window.__ModuleLoader__.load({
|
|
|
1532
1602
|
// Two refreshes can overlap (mount, mutation broadcast, and the refresh a
|
|
1533
1603
|
// purge triggers), and the older response must not overwrite the newer.
|
|
1534
1604
|
const refreshSerial = useRef(0)
|
|
1605
|
+
// The mutation version each tab's data was fetched at. `-1` = never.
|
|
1606
|
+
const loadedRef = useRef({ trash: -1, batch: -1, audit: -1 })
|
|
1535
1607
|
|
|
1536
|
-
const
|
|
1608
|
+
const refreshState = useCallback(async () => {
|
|
1609
|
+
const response = await api('/state')
|
|
1610
|
+
setState(response.state)
|
|
1611
|
+
}, [])
|
|
1612
|
+
|
|
1613
|
+
/**
|
|
1614
|
+
* Fetch only what the visible tab renders. The section used to pull
|
|
1615
|
+
* `/state`, `/sessions`, `/trash` and `/audit` on every mount and every
|
|
1616
|
+
* mutation (~31 KB, 4 round trips) even when the user was looking at one
|
|
1617
|
+
* tab. `/sessions` is the expensive one (one projection read per row),
|
|
1618
|
+
* so opening the recycle bin must not pay for it.
|
|
1619
|
+
*/
|
|
1620
|
+
const loadTab = useCallback(async (which, { force = false } = {}) => {
|
|
1621
|
+
if (!force && loadedRef.current[which] === mutationVersion) return
|
|
1537
1622
|
const serial = refreshSerial.current + 1
|
|
1538
1623
|
refreshSerial.current = serial
|
|
1539
1624
|
const stale = () => serial !== refreshSerial.current
|
|
1540
|
-
|
|
1625
|
+
setTabBusy(which)
|
|
1541
1626
|
setError(null)
|
|
1542
1627
|
try {
|
|
1543
|
-
|
|
1544
|
-
api('/
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1628
|
+
if (which === 'trash') {
|
|
1629
|
+
const response = await api('/trash')
|
|
1630
|
+
if (stale()) return
|
|
1631
|
+
setTrash(Array.isArray(response.entries) ? response.entries : [])
|
|
1632
|
+
setIncomplete(Array.isArray(response.incomplete) ? response.incomplete : [])
|
|
1633
|
+
} else if (which === 'batch') {
|
|
1634
|
+
const response = await api('/sessions')
|
|
1635
|
+
if (stale()) return
|
|
1636
|
+
const nextSessions = Array.isArray(response.sessions) ? response.sessions : []
|
|
1637
|
+
setSessions(nextSessions)
|
|
1638
|
+
// Drop selections whose conversation no longer exists.
|
|
1639
|
+
setSelected((current) => {
|
|
1640
|
+
const alive = new Set(nextSessions.map((session) => session.id))
|
|
1641
|
+
const next = new Set([...current].filter((id) => alive.has(id)))
|
|
1642
|
+
return next.size === current.size ? current : next
|
|
1643
|
+
})
|
|
1644
|
+
} else {
|
|
1645
|
+
const response = await api('/audit?limit=50')
|
|
1646
|
+
if (stale()) return
|
|
1647
|
+
setAudit(Array.isArray(response.records) ? response.records : [])
|
|
1648
|
+
}
|
|
1649
|
+
loadedRef.current[which] = mutationVersion
|
|
1562
1650
|
} catch (caught) {
|
|
1563
1651
|
if (!stale()) setError(caught)
|
|
1564
1652
|
} finally {
|
|
1565
|
-
if (!stale())
|
|
1653
|
+
if (!stale()) setTabBusy(null)
|
|
1566
1654
|
}
|
|
1567
|
-
}, [])
|
|
1655
|
+
}, [mutationVersion])
|
|
1568
1656
|
|
|
1569
|
-
|
|
1657
|
+
// `/state` drives the capability block and the limits; refetch it on
|
|
1658
|
+
// mount and whenever a mutation happened, not on every tab switch.
|
|
1659
|
+
useEffect(() => {
|
|
1660
|
+
refreshState().catch((caught) => setError(caught))
|
|
1661
|
+
}, [mutationVersion, refreshState])
|
|
1570
1662
|
|
|
1571
|
-
//
|
|
1572
|
-
//
|
|
1663
|
+
// The active tab's own slice, on mount, on tab switch, and after a
|
|
1664
|
+
// mutation (which changes `loadTab`'s identity through the version).
|
|
1573
1665
|
useEffect(() => {
|
|
1574
|
-
|
|
1575
|
-
}, [
|
|
1666
|
+
void loadTab(tab)
|
|
1667
|
+
}, [tab, loadTab])
|
|
1668
|
+
|
|
1669
|
+
/** Header "refresh" button: re-read state and the visible tab. */
|
|
1670
|
+
const refresh = useCallback(async () => {
|
|
1671
|
+
await Promise.all([
|
|
1672
|
+
refreshState().catch((caught) => setError(caught)),
|
|
1673
|
+
loadTab(tab, { force: true }),
|
|
1674
|
+
])
|
|
1675
|
+
}, [tab, refreshState, loadTab])
|
|
1676
|
+
|
|
1677
|
+
/**
|
|
1678
|
+
* Mark a slice stale without fetching it. Recycle-bin actions call
|
|
1679
|
+
* `refreshAfterMutation({ notify: false })`, so no version bump happens:
|
|
1680
|
+
* the visible tab is re-read by `refresh()`, and any other tab must be
|
|
1681
|
+
* re-read the next time the user opens it instead of showing rows that
|
|
1682
|
+
* were deleted a moment ago.
|
|
1683
|
+
*/
|
|
1684
|
+
const markStale = useCallback((...which) => {
|
|
1685
|
+
for (const name of which) loadedRef.current[name] = -1
|
|
1686
|
+
}, [])
|
|
1576
1687
|
|
|
1577
1688
|
async function restore(trashId) {
|
|
1578
1689
|
setError(null)
|
|
1579
1690
|
const entry = trash.find((item) => item.trashId === trashId)
|
|
1580
1691
|
try {
|
|
1581
|
-
await api('/trash/restore', {
|
|
1692
|
+
await api('/trash/restore', {
|
|
1693
|
+
method: 'POST',
|
|
1694
|
+
body: { trashId },
|
|
1695
|
+
timeoutMs: TRASH_MUTATION_TIMEOUT_MS,
|
|
1696
|
+
})
|
|
1582
1697
|
flashStore.show(t('trash.restored'))
|
|
1698
|
+
markStale('trash', 'batch')
|
|
1583
1699
|
await refresh()
|
|
1584
1700
|
await refreshAfterMutation({
|
|
1585
1701
|
sessionIds: typeof entry?.sessionId === 'string' ? [entry.sessionId] : [],
|
|
@@ -1601,8 +1717,13 @@ window.__ModuleLoader__.load({
|
|
|
1601
1717
|
setError(null)
|
|
1602
1718
|
setRestoreBusyId(trashId)
|
|
1603
1719
|
try {
|
|
1604
|
-
await api('/trash/restore', {
|
|
1720
|
+
await api('/trash/restore', {
|
|
1721
|
+
method: 'POST',
|
|
1722
|
+
body: { trashId },
|
|
1723
|
+
timeoutMs: TRASH_MUTATION_TIMEOUT_MS,
|
|
1724
|
+
})
|
|
1605
1725
|
flashStore.show(t('trash.restored'))
|
|
1726
|
+
markStale('trash', 'batch')
|
|
1606
1727
|
await refresh()
|
|
1607
1728
|
await refreshAfterMutation({
|
|
1608
1729
|
sessionIds: incomplete.find((item) => item.trashId === trashId)?.sessionId
|
|
@@ -1624,10 +1745,15 @@ window.__ModuleLoader__.load({
|
|
|
1624
1745
|
setError(null)
|
|
1625
1746
|
const entry = trash.find((item) => item.trashId === trashId)
|
|
1626
1747
|
try {
|
|
1627
|
-
await api('/trash/purge', {
|
|
1748
|
+
await api('/trash/purge', {
|
|
1749
|
+
method: 'POST',
|
|
1750
|
+
body: { trashId, confirmPhrase: phrase },
|
|
1751
|
+
timeoutMs: TRASH_MUTATION_TIMEOUT_MS,
|
|
1752
|
+
})
|
|
1628
1753
|
flashStore.show(t('trash.purged'))
|
|
1629
1754
|
setPurgeTarget(null)
|
|
1630
1755
|
setPurgePhrase('')
|
|
1756
|
+
markStale('trash', 'batch')
|
|
1631
1757
|
await refresh()
|
|
1632
1758
|
// Purging a soft entry detaches the session from its workspace.
|
|
1633
1759
|
await refreshAfterMutation({
|
|
@@ -1647,10 +1773,15 @@ window.__ModuleLoader__.load({
|
|
|
1647
1773
|
setDiscardBusyId(trashId)
|
|
1648
1774
|
setError(null)
|
|
1649
1775
|
try {
|
|
1650
|
-
await api('/trash/discard', {
|
|
1776
|
+
await api('/trash/discard', {
|
|
1777
|
+
method: 'POST',
|
|
1778
|
+
body: { trashId, confirmPhrase: phrase },
|
|
1779
|
+
timeoutMs: TRASH_MUTATION_TIMEOUT_MS,
|
|
1780
|
+
})
|
|
1651
1781
|
flashStore.show(t('trash.discarded'))
|
|
1652
1782
|
setDiscardTarget(null)
|
|
1653
1783
|
setDiscardPhrase('')
|
|
1784
|
+
markStale('trash')
|
|
1654
1785
|
await refresh()
|
|
1655
1786
|
} catch (caught) {
|
|
1656
1787
|
setError(caught)
|
|
@@ -1700,7 +1831,7 @@ window.__ModuleLoader__.load({
|
|
|
1700
1831
|
h('h1', { className: 'dc-title' }, t('section.title')),
|
|
1701
1832
|
h('p', { className: 'dc-hint' }, t('section.subtitle'))),
|
|
1702
1833
|
h('div', { className: 'dc-header-actions' },
|
|
1703
|
-
h(Button, { onClick: () => { void refresh() }, disabled:
|
|
1834
|
+
h(Button, { onClick: () => { void refresh() }, disabled: tabBusy !== null, title: t('action.refresh') },
|
|
1704
1835
|
h(GlyphRefresh, { size: 13 }), ' ', t('action.refresh')))),
|
|
1705
1836
|
|
|
1706
1837
|
h(ErrorBanner, { error }),
|
|
@@ -1729,9 +1860,14 @@ window.__ModuleLoader__.load({
|
|
|
1729
1860
|
h('button', { id: 'dc-tab-batch', type: 'button', role: 'tab', 'aria-selected': tab === 'batch', 'aria-controls': 'dc-panel-batch', className: `dc-tab${tab === 'batch' ? ' dc-tab-active' : ''}`, onClick: () => setTab('batch') }, t('batch.title')),
|
|
1730
1861
|
h('button', { id: 'dc-tab-audit', type: 'button', role: 'tab', 'aria-selected': tab === 'audit', 'aria-controls': 'dc-panel-audit', className: `dc-tab${tab === 'audit' ? ' dc-tab-active' : ''}`, onClick: () => setTab('audit') }, t('audit.title'))),
|
|
1731
1862
|
|
|
1732
|
-
|
|
1863
|
+
// First paint only: once anything has been rendered, a refresh keeps the
|
|
1864
|
+
// panels mounted and shows the inline indicator below instead.
|
|
1865
|
+
state === null && tabBusy !== null ? h(Spinner, { label: t('dialog.loading') }) : null,
|
|
1866
|
+
state !== null && tabBusy !== null
|
|
1867
|
+
? h('div', { className: 'dc-loading' }, h('span', { className: 'dc-spinner' }), ' ', t('dialog.loading'))
|
|
1868
|
+
: null,
|
|
1733
1869
|
|
|
1734
|
-
tab === 'trash'
|
|
1870
|
+
tab === 'trash' ? h('div', { id: 'dc-panel-trash', role: 'tabpanel', 'aria-labelledby': 'dc-tab-trash', className: 'dc-panel' },
|
|
1735
1871
|
trash.length === 0 ? h('div', { className: 'dc-empty' }, t('trash.empty')) : null,
|
|
1736
1872
|
...trash.map((entry) => h('div', { key: entry.trashId, className: 'dc-row' },
|
|
1737
1873
|
h('div', { className: 'dc-row-main' },
|
|
@@ -1754,7 +1890,7 @@ window.__ModuleLoader__.load({
|
|
|
1754
1890
|
h(Button, { disabled: purgeBusy === true, onClick: () => { setPurgeTarget(null); setPurgePhrase('') } }, t('action.cancel')))
|
|
1755
1891
|
: h(Button, { variant: 'danger', onClick: () => { setPurgeTarget(entry.trashId); setPurgePhrase('') } }, t('action.purge')))))) : null,
|
|
1756
1892
|
|
|
1757
|
-
tab === 'trash' &&
|
|
1893
|
+
tab === 'trash' && incomplete.length > 0 ? h(IncompleteEntries, {
|
|
1758
1894
|
entries: incomplete,
|
|
1759
1895
|
discardTarget,
|
|
1760
1896
|
discardPhrase,
|
|
@@ -1767,7 +1903,7 @@ window.__ModuleLoader__.load({
|
|
|
1767
1903
|
discardBusyId,
|
|
1768
1904
|
}) : null,
|
|
1769
1905
|
|
|
1770
|
-
tab === 'batch'
|
|
1906
|
+
tab === 'batch' ? h('div', { id: 'dc-panel-batch', role: 'tabpanel', 'aria-labelledby': 'dc-tab-batch', className: 'dc-panel' },
|
|
1771
1907
|
h('div', { className: 'dc-batch-bar' },
|
|
1772
1908
|
h('span', { className: 'dc-hint' }, t('batch.selected', { count: selectedIds.length })),
|
|
1773
1909
|
// "Select all" only marks deletable rows: a batch containing one
|
|
@@ -1793,7 +1929,7 @@ window.__ModuleLoader__.load({
|
|
|
1793
1929
|
onToggle: toggle,
|
|
1794
1930
|
}))) : null,
|
|
1795
1931
|
|
|
1796
|
-
tab === 'audit'
|
|
1932
|
+
tab === 'audit' ? h('div', { id: 'dc-panel-audit', role: 'tabpanel', 'aria-labelledby': 'dc-tab-audit', className: 'dc-panel' },
|
|
1797
1933
|
audit.length === 0 ? h('div', { className: 'dc-empty' }, t('audit.empty')) : null,
|
|
1798
1934
|
...audit.map((record) => h('div', { key: record.id, className: 'dc-row' },
|
|
1799
1935
|
h('div', { className: 'dc-row-main' },
|
|
@@ -1974,6 +2110,15 @@ window.__ModuleLoader__.load({
|
|
|
1974
2110
|
exports.__store = { dialogStore, flashStore, mutationStore, uiFlagsStore }
|
|
1975
2111
|
exports.__refreshAfterMutation = refreshAfterMutation
|
|
1976
2112
|
exports.__mutationTimeoutMs = mutationTimeoutMs
|
|
2113
|
+
// Exposed so the harness can assert the client budget stays ABOVE the
|
|
2114
|
+
// Host's own TRASH_LOCK_BUDGET_MS: whichever side gives up first decides
|
|
2115
|
+
// whether the user sees "failed" for work that actually completed.
|
|
2116
|
+
exports.__requestBudgets = {
|
|
2117
|
+
request: REQUEST_TIMEOUT_MS,
|
|
2118
|
+
mutation: MUTATION_TIMEOUT_MS,
|
|
2119
|
+
trashMutation: TRASH_MUTATION_TIMEOUT_MS,
|
|
2120
|
+
}
|
|
2121
|
+
exports.__apiUrl = apiUrl
|
|
1977
2122
|
exports.__api = api
|
|
1978
2123
|
// Direct component handles keep the hook-level behavior testable without a
|
|
1979
2124
|
// DOM renderer (the registered wrapper alone only builds an element).
|
package/config.schema.json
CHANGED
|
@@ -8,7 +8,11 @@
|
|
|
8
8
|
"properties": {
|
|
9
9
|
"enabled": { "type": "boolean", "default": true, "description": "Master switch. When false the route answers 503 and the UI hides its entries." },
|
|
10
10
|
"requireSameOrigin": { "type": "boolean", "default": true, "description": "Reject requests whose Origin does not match the request's full authority (hostname AND effective port; a missing port defaults by the Origin's own scheme). The desktop shell source dsh-app://app is trusted, matching DSH's own fence." },
|
|
11
|
-
"allowNonLoopback": { "type": "boolean", "default": false, "description": "Allow requests when
|
|
11
|
+
"allowNonLoopback": { "type": "boolean", "default": false, "description": "Allow requests from any authority, not just loopback. Prefer allowedHosts when a reverse proxy presents a known hostname." },
|
|
12
|
+
"allowedHosts": {
|
|
13
|
+
"type": "array", "items": { "type": "string" }, "maxItems": 32, "default": [],
|
|
14
|
+
"description": "Authorities a prefix-stripping reverse proxy may present in Host (host, host:port, or [v6]:port). A bare host admits any port; an entry with a port admits only that port. Entries with a scheme, path, userinfo or wildcard are dropped. Empty = loopback only."
|
|
15
|
+
},
|
|
12
16
|
"allowOrphanSessions": { "type": "boolean", "default": false, "description": "Allow deleting sessions that no registered workspace owns." },
|
|
13
17
|
"allowSubagentSessions": { "type": "boolean", "default": false, "description": "Allow deleting sessions whose header origin is 'subagent'." },
|
|
14
18
|
"stopLiveSessions": { "type": "boolean", "default": true, "description": "Hard deletion first stops a live session (cancel turn + archive stopActivity + bounded drain). Set false to refuse live sessions instead." },
|
package/cordis.patch.yml
CHANGED
|
@@ -22,6 +22,10 @@
|
|
|
22
22
|
# unless the operator opts in explicitly below.
|
|
23
23
|
requireSameOrigin: true
|
|
24
24
|
allowNonLoopback: false
|
|
25
|
+
# Reverse-proxy deployments: list the exact authorities the proxy puts
|
|
26
|
+
# in `Host` (host, host:port). A bare host admits any port. Listing them
|
|
27
|
+
# is narrower than turning allowNonLoopback on for every hostname.
|
|
28
|
+
allowedHosts: []
|
|
25
29
|
# Session ownership policy.
|
|
26
30
|
allowOrphanSessions: false # sessions with no registered workspace
|
|
27
31
|
allowSubagentSessions: false # sessions created by subagents
|
package/lib/core/config.js
CHANGED
|
@@ -13,6 +13,11 @@ export const DEFAULTS = deepFreeze({
|
|
|
13
13
|
enabled: true,
|
|
14
14
|
requireSameOrigin: true,
|
|
15
15
|
allowNonLoopback: false,
|
|
16
|
+
// Exact authorities a prefix-stripping reverse proxy may present in `Host`
|
|
17
|
+
// (`app.example`, `app.example:8443`, `[::1]:8443`). Empty = loopback only.
|
|
18
|
+
// This exists so an operator behind a proxy does not have to switch
|
|
19
|
+
// `allowNonLoopback` on for every hostname the machine can be reached by.
|
|
20
|
+
allowedHosts: [],
|
|
16
21
|
allowOrphanSessions: false,
|
|
17
22
|
allowSubagentSessions: false,
|
|
18
23
|
// Hard deletion of a live session first stops it (cancel turn + archive
|
|
@@ -138,6 +143,45 @@ function readStringArray(raw, fallback, field, warnings) {
|
|
|
138
143
|
return raw.map((item) => item.trim()).filter((item) => item !== '')
|
|
139
144
|
}
|
|
140
145
|
|
|
146
|
+
/** `host`, `host:port` or `[v6]:port` — the only shapes a `Host` header may take. */
|
|
147
|
+
const HOST_AUTHORITY_PATTERN = /^(?:\[[0-9a-f:.]+\]|[a-z0-9._-]+)(?::\d{1,5})?$/i
|
|
148
|
+
const MAX_ALLOWED_HOSTS = 32
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Normalize the reverse-proxy authority allowlist.
|
|
152
|
+
*
|
|
153
|
+
* Entries are compared case-insensitively as complete authorities. A bare host
|
|
154
|
+
* (`app.example`) matches any port, an entry with a port (`app.example:8443`)
|
|
155
|
+
* matches only that port. Anything that is not a bare authority — a scheme, a
|
|
156
|
+
* path, userinfo, a wildcard — is dropped with a warning: this list is a
|
|
157
|
+
* security boundary, so a typo must not silently widen it.
|
|
158
|
+
*/
|
|
159
|
+
function readHostList(raw, field, warnings) {
|
|
160
|
+
if (raw === undefined) return []
|
|
161
|
+
if (!Array.isArray(raw)) {
|
|
162
|
+
warnings.push(`${field}: expected an array of authorities, using the default`)
|
|
163
|
+
return []
|
|
164
|
+
}
|
|
165
|
+
const out = []
|
|
166
|
+
for (const item of raw) {
|
|
167
|
+
if (typeof item !== 'string') {
|
|
168
|
+
warnings.push(`${field}: dropped a non-string entry`)
|
|
169
|
+
continue
|
|
170
|
+
}
|
|
171
|
+
const value = item.trim().toLowerCase().replace(/\.$/, '')
|
|
172
|
+
if (value === '') continue
|
|
173
|
+
if (!HOST_AUTHORITY_PATTERN.test(value)) {
|
|
174
|
+
warnings.push(`${field}: dropped "${item}", expected host or host:port without a scheme or path`)
|
|
175
|
+
continue
|
|
176
|
+
}
|
|
177
|
+
if (!out.includes(value)) out.push(value)
|
|
178
|
+
}
|
|
179
|
+
if (out.length > MAX_ALLOWED_HOSTS) {
|
|
180
|
+
warnings.push(`${field}: only the first ${MAX_ALLOWED_HOSTS} entries are used`)
|
|
181
|
+
}
|
|
182
|
+
return out.slice(0, MAX_ALLOWED_HOSTS)
|
|
183
|
+
}
|
|
184
|
+
|
|
141
185
|
/**
|
|
142
186
|
* Normalize one raw row config into the exact object the plugin reads.
|
|
143
187
|
*
|
|
@@ -197,6 +241,7 @@ export function normalizeConfig(raw) {
|
|
|
197
241
|
enabled: readBool(source.enabled, DEFAULTS.enabled, 'enabled', warnings),
|
|
198
242
|
requireSameOrigin: readBool(source.requireSameOrigin, DEFAULTS.requireSameOrigin, 'requireSameOrigin', warnings),
|
|
199
243
|
allowNonLoopback: readBool(source.allowNonLoopback, DEFAULTS.allowNonLoopback, 'allowNonLoopback', warnings),
|
|
244
|
+
allowedHosts: readHostList(source.allowedHosts, 'allowedHosts', warnings),
|
|
200
245
|
allowOrphanSessions: readBool(source.allowOrphanSessions, DEFAULTS.allowOrphanSessions, 'allowOrphanSessions', warnings),
|
|
201
246
|
allowSubagentSessions: readBool(source.allowSubagentSessions, DEFAULTS.allowSubagentSessions, 'allowSubagentSessions', warnings),
|
|
202
247
|
stopLiveSessions: readBool(source.stopLiveSessions, DEFAULTS.stopLiveSessions, 'stopLiveSessions', warnings),
|
|
@@ -137,7 +137,27 @@ export function createJsonIndexMemoryAdapter(options) {
|
|
|
137
137
|
})
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Last parsed snapshot, keyed by the file's identity (mtime + size).
|
|
142
|
+
*
|
|
143
|
+
* A single `/preview` probes the adapter and then counts nodes, and both used
|
|
144
|
+
* to `readFile` + `JSON.parse` the whole index: 2 full reads of a 1.9 MB
|
|
145
|
+
* index cost ~39 ms. The stat key catches a concurrent md_cg write without a
|
|
146
|
+
* TTL, and every write this adapter performs clears the entry.
|
|
147
|
+
*/
|
|
148
|
+
let snapshotCache = null
|
|
149
|
+
|
|
140
150
|
async function readSnapshot() {
|
|
151
|
+
let key = null
|
|
152
|
+
if (typeof fsApi.stat === 'function') {
|
|
153
|
+
try {
|
|
154
|
+
const info = await fsApi.stat(indexFile)
|
|
155
|
+
key = `${info.mtimeMs}:${info.size}`
|
|
156
|
+
} catch {
|
|
157
|
+
// Fall through: readFile below reports the real error (ENOENT etc.).
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (key !== null && snapshotCache !== null && snapshotCache.key === key) return snapshotCache.snapshot
|
|
141
161
|
const raw = await fsApi.readFile(indexFile, 'utf8')
|
|
142
162
|
let index
|
|
143
163
|
try {
|
|
@@ -158,7 +178,9 @@ export function createJsonIndexMemoryAdapter(options) {
|
|
|
158
178
|
if (nodes === null || typeof nodes !== 'object') {
|
|
159
179
|
throw new DeleteError('E_MEMORY_FAILED', `Memory index has no node map at ${nodesPointer}`, { indexFile, nodesPointer })
|
|
160
180
|
}
|
|
161
|
-
|
|
181
|
+
const snapshot = { index, nodes, sig: createHash('sha256').update(raw).digest('hex') }
|
|
182
|
+
if (key !== null) snapshotCache = { key, snapshot }
|
|
183
|
+
return snapshot
|
|
162
184
|
}
|
|
163
185
|
|
|
164
186
|
async function readIndex() {
|
|
@@ -170,6 +192,9 @@ export function createJsonIndexMemoryAdapter(options) {
|
|
|
170
192
|
const temporary = `${indexFile}.delete-conversation.tmp`
|
|
171
193
|
await fsApi.writeFile(temporary, `${JSON.stringify(index)}\n`, 'utf8')
|
|
172
194
|
await fsApi.rename(temporary, indexFile)
|
|
195
|
+
// The next reader must not see the pre-write snapshot, even if the rename
|
|
196
|
+
// happened within the filesystem's mtime granularity.
|
|
197
|
+
snapshotCache = null
|
|
173
198
|
}
|
|
174
199
|
|
|
175
200
|
async function newestLogMtimeMs() {
|
package/lib/core/trash.js
CHANGED
|
@@ -19,6 +19,29 @@ import { join } from 'node:path'
|
|
|
19
19
|
|
|
20
20
|
const MANIFEST = 'manifest.json'
|
|
21
21
|
const TRASH_ID_PATTERN = /^[0-9a-z]{2,40}-[0-9a-z]{2,20}$/
|
|
22
|
+
/**
|
|
23
|
+
* Manifest reads in flight during one directory scan. The work is dominated by
|
|
24
|
+
* syscalls on many small files, so a small fixed pool removes the serial
|
|
25
|
+
* round-trip chain without flooding the FS with hundreds of concurrent opens.
|
|
26
|
+
*/
|
|
27
|
+
const SCAN_CONCURRENCY = 8
|
|
28
|
+
|
|
29
|
+
/** Ordered `map` with at most `limit` promises in flight. */
|
|
30
|
+
async function mapLimit(items, limit, fn) {
|
|
31
|
+
const out = new Array(items.length)
|
|
32
|
+
let next = 0
|
|
33
|
+
const workers = Array.from({ length: Math.min(Math.max(1, limit), items.length) }, async () => {
|
|
34
|
+
while (true) {
|
|
35
|
+
const index = next
|
|
36
|
+
next += 1
|
|
37
|
+
if (index >= items.length) return
|
|
38
|
+
out[index] = await fn(items[index], index)
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
await Promise.all(workers)
|
|
42
|
+
return out
|
|
43
|
+
}
|
|
44
|
+
|
|
22
45
|
|
|
23
46
|
/** Statuses a manifest may carry. `pending` and `failed` are never listed. */
|
|
24
47
|
export const TRASH_STATUS = Object.freeze(['pending', 'trashed', 'failed', 'restoring', 'restored', 'purging'])
|
|
@@ -102,17 +125,64 @@ export function createTrashStore({ rootDir, now = Date.now, newId, fsApi = { mkd
|
|
|
102
125
|
return parsed
|
|
103
126
|
},
|
|
104
127
|
|
|
105
|
-
/**
|
|
106
|
-
|
|
128
|
+
/**
|
|
129
|
+
* One directory pass over the recycle bin.
|
|
130
|
+
*
|
|
131
|
+
* `listAll()` and `scanUnreadable()` used to be called together by `/trash`
|
|
132
|
+
* and each did its own `readdir` plus one `readFile` per entry, so every
|
|
133
|
+
* listing cost 2N manifest reads (measured at ~0.31 ms per entry, 156 ms at
|
|
134
|
+
* 500 entries). They now share this single pass: one `readdir`, one read per
|
|
135
|
+
* entry, bounded concurrency instead of a serial `for await`.
|
|
136
|
+
*
|
|
137
|
+
* A missing or unreadable root yields empty lists rather than throwing: the
|
|
138
|
+
* settings page must still render on a fresh install with no trash yet.
|
|
139
|
+
* @returns {Promise<{ entries: object[], unreadable: Array<{ trashId: string, reason: 'missing-manifest'|'corrupt-manifest'|'unreadable-manifest' }> }>}
|
|
140
|
+
*/
|
|
141
|
+
async scanAll() {
|
|
107
142
|
await ensureRoot()
|
|
108
|
-
|
|
109
|
-
|
|
143
|
+
let dirents = []
|
|
144
|
+
try {
|
|
145
|
+
dirents = await fsApi.readdir(rootDir, { withFileTypes: true })
|
|
146
|
+
} catch {
|
|
147
|
+
return { entries: [], unreadable: [] }
|
|
148
|
+
}
|
|
149
|
+
const names = []
|
|
110
150
|
for (const dirent of dirents) {
|
|
111
|
-
if (
|
|
112
|
-
|
|
113
|
-
|
|
151
|
+
if (dirent.isDirectory() && isTrashId(dirent.name)) names.push(dirent.name)
|
|
152
|
+
}
|
|
153
|
+
const scanned = await mapLimit(names, SCAN_CONCURRENCY, async (trashId) => {
|
|
154
|
+
let raw
|
|
155
|
+
try {
|
|
156
|
+
raw = await fsApi.readFile(join(rootDir, trashId, MANIFEST), 'utf8')
|
|
157
|
+
} catch (error) {
|
|
158
|
+
return { trashId, reason: error?.code === 'ENOENT' ? 'missing-manifest' : 'unreadable-manifest' }
|
|
159
|
+
}
|
|
160
|
+
let parsed
|
|
161
|
+
try {
|
|
162
|
+
parsed = JSON.parse(raw)
|
|
163
|
+
} catch {
|
|
164
|
+
return { trashId, reason: 'corrupt-manifest' }
|
|
165
|
+
}
|
|
166
|
+
if (parsed === null || typeof parsed !== 'object' || parsed.format !== 1 || !TRASH_STATUS.includes(parsed.status)) {
|
|
167
|
+
return { trashId, reason: 'corrupt-manifest' }
|
|
168
|
+
}
|
|
169
|
+
return { entry: parsed }
|
|
170
|
+
})
|
|
171
|
+
const entries = []
|
|
172
|
+
const unreadable = []
|
|
173
|
+
for (const item of scanned) {
|
|
174
|
+
if (item.entry !== undefined) entries.push(item.entry)
|
|
175
|
+
else unreadable.push({ trashId: item.trashId, reason: item.reason })
|
|
114
176
|
}
|
|
177
|
+
// Newest first. `Array.prototype.sort` is stable, so equal timestamps keep
|
|
178
|
+
// the directory order the scan produced.
|
|
115
179
|
entries.sort((left, right) => (right.deletedAt ?? 0) - (left.deletedAt ?? 0))
|
|
180
|
+
return { entries, unreadable }
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
/** Every readable manifest, newest first, regardless of status. */
|
|
184
|
+
async listAll() {
|
|
185
|
+
const { entries } = await this.scanAll()
|
|
116
186
|
return entries
|
|
117
187
|
},
|
|
118
188
|
|
|
@@ -128,37 +198,11 @@ export function createTrashStore({ rootDir, now = Date.now, newId, fsApi = { mkd
|
|
|
128
198
|
* are invisible to restore/purge but must still be listable and
|
|
129
199
|
* discardable, otherwise a single bad manifest strands the directory on
|
|
130
200
|
* disk forever.
|
|
131
|
-
* @returns {Array<{ trashId: string, reason: 'missing-manifest'|'corrupt-manifest'|'unreadable-manifest' }
|
|
201
|
+
* @returns {Promise<Array<{ trashId: string, reason: 'missing-manifest'|'corrupt-manifest'|'unreadable-manifest' }>>}
|
|
132
202
|
*/
|
|
133
203
|
async scanUnreadable() {
|
|
134
|
-
await
|
|
135
|
-
|
|
136
|
-
let dirents = []
|
|
137
|
-
try {
|
|
138
|
-
dirents = await fsApi.readdir(rootDir, { withFileTypes: true })
|
|
139
|
-
} catch {
|
|
140
|
-
return out
|
|
141
|
-
}
|
|
142
|
-
for (const dirent of dirents) {
|
|
143
|
-
if (!dirent.isDirectory() || !isTrashId(dirent.name)) continue
|
|
144
|
-
let raw
|
|
145
|
-
let reason = 'missing-manifest'
|
|
146
|
-
try {
|
|
147
|
-
raw = await fsApi.readFile(join(rootDir, dirent.name, MANIFEST), 'utf8')
|
|
148
|
-
} catch (error) {
|
|
149
|
-
if (error?.code !== 'ENOENT') reason = 'unreadable-manifest'
|
|
150
|
-
out.push({ trashId: dirent.name, reason })
|
|
151
|
-
continue
|
|
152
|
-
}
|
|
153
|
-
try {
|
|
154
|
-
const parsed = JSON.parse(raw)
|
|
155
|
-
const readable = parsed !== null && typeof parsed === 'object' && parsed.format === 1 && TRASH_STATUS.includes(parsed.status)
|
|
156
|
-
if (!readable) out.push({ trashId: dirent.name, reason: 'corrupt-manifest' })
|
|
157
|
-
} catch {
|
|
158
|
-
out.push({ trashId: dirent.name, reason: 'corrupt-manifest' })
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
return out
|
|
204
|
+
const { unreadable } = await this.scanAll()
|
|
205
|
+
return unreadable
|
|
162
206
|
},
|
|
163
207
|
|
|
164
208
|
async remove(trashId) {
|
package/lib/host/api.js
CHANGED
|
@@ -60,6 +60,18 @@ import { SESSION_ID_PATTERN } from '../core/ids.js'
|
|
|
60
60
|
export const ROUTE_PREFIX = '/conversation-recycle-bin/api'
|
|
61
61
|
export const REQUIRED_HEADER = 'x-conversation-recycle-bin'
|
|
62
62
|
const MAX_BODY_BYTES = 256 * 1024
|
|
63
|
+
/**
|
|
64
|
+
* Lock wait budget for the single-entry trash mutations.
|
|
65
|
+
*
|
|
66
|
+
* `serialize()` chains every mutation in-process and then takes the
|
|
67
|
+
* cross-process file lock, so restore / purge / discard can sit behind a whole
|
|
68
|
+
* batch delete. They used to fall back to the generic 30s lock timeout, which
|
|
69
|
+
* is shorter than one batch's legitimate worst case: the Host answered
|
|
70
|
+
* E_LOCK_BUSY for a request that only needed to wait. The Client's own budget
|
|
71
|
+
* (TRASH_MUTATION_TIMEOUT_MS = 195s) is deliberately slightly larger, so this
|
|
72
|
+
* budget — not the browser's AbortController — decides the outcome.
|
|
73
|
+
*/
|
|
74
|
+
export const TRASH_LOCK_BUDGET_MS = 180_000
|
|
63
75
|
|
|
64
76
|
function isLoopbackHostname(hostname) {
|
|
65
77
|
const value = String(hostname ?? '').trim().toLowerCase().replace(/^\[|\]$/g, '')
|
|
@@ -99,6 +111,28 @@ function portOf(hostHeader) {
|
|
|
99
111
|
return colon > 0 && /^\d+$/.test(value.slice(colon + 1)) ? value.slice(colon + 1) : ''
|
|
100
112
|
}
|
|
101
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Whether a request authority matches the operator's `allowedHosts` list.
|
|
116
|
+
*
|
|
117
|
+
* A bare entry (`app.example`) admits any port on that host; an entry with a
|
|
118
|
+
* port admits only that port. Comparison is hostname-normalized (IPv6 brackets
|
|
119
|
+
* and `::ffff:` mapped addresses included) so `[::1]:8443` and `[::1]:8443`
|
|
120
|
+
* agree regardless of case.
|
|
121
|
+
*/
|
|
122
|
+
function authorityAllowed(hostHeader, allowedHosts) {
|
|
123
|
+
if (!Array.isArray(allowedHosts) || allowedHosts.length === 0) return false
|
|
124
|
+
const hostname = hostnameOf(hostHeader)
|
|
125
|
+
if (hostname === '') return false
|
|
126
|
+
const port = portOf(hostHeader)
|
|
127
|
+
for (const entry of allowedHosts) {
|
|
128
|
+
if (typeof entry !== 'string') continue
|
|
129
|
+
if (!sameHostname(hostnameOf(entry), hostname)) continue
|
|
130
|
+
const entryPort = portOf(entry)
|
|
131
|
+
if (entryPort === '' || entryPort === port) return true
|
|
132
|
+
}
|
|
133
|
+
return false
|
|
134
|
+
}
|
|
135
|
+
|
|
102
136
|
/** The port a URL implies when it omits one. */
|
|
103
137
|
function defaultPortFor(protocol) {
|
|
104
138
|
if (protocol === 'https:' || protocol === 'wss:') return '443'
|
|
@@ -116,7 +150,7 @@ function defaultPortFor(protocol) {
|
|
|
116
150
|
* both mean 443).
|
|
117
151
|
* @returns {{ ok: true } | { ok: false, error: DeleteError }}
|
|
118
152
|
*/
|
|
119
|
-
export function requestOriginVerdict(headers, { requireSameOrigin, allowNonLoopback }) {
|
|
153
|
+
export function requestOriginVerdict(headers, { requireSameOrigin, allowNonLoopback, allowedHosts = [] }) {
|
|
120
154
|
const read = (name) => {
|
|
121
155
|
if (headers === null || typeof headers !== 'object') return ''
|
|
122
156
|
const value = headers[name]
|
|
@@ -124,11 +158,12 @@ export function requestOriginVerdict(headers, { requireSameOrigin, allowNonLoopb
|
|
|
124
158
|
}
|
|
125
159
|
const requestHostname = hostnameOf(read('host'))
|
|
126
160
|
|
|
127
|
-
if (!allowNonLoopback && !isLoopbackHostname(requestHostname)) {
|
|
161
|
+
if (!allowNonLoopback && !isLoopbackHostname(requestHostname) && !authorityAllowed(read('host'), allowedHosts)) {
|
|
128
162
|
return {
|
|
129
163
|
ok: false,
|
|
130
|
-
error: new DeleteError('E_FORBIDDEN_HOST', 'This route only accepts loopback requests unless allowNonLoopback is enabled.', {
|
|
164
|
+
error: new DeleteError('E_FORBIDDEN_HOST', 'This route only accepts loopback requests unless the authority is listed in allowedHosts (or allowNonLoopback is enabled).', {
|
|
131
165
|
host: read('host') || null,
|
|
166
|
+
allowedHosts: Array.isArray(allowedHosts) ? allowedHosts : [],
|
|
132
167
|
}),
|
|
133
168
|
}
|
|
134
169
|
}
|
|
@@ -845,7 +880,7 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
845
880
|
// invisible and impossible to clean up at all. Directories whose manifest
|
|
846
881
|
// is missing/corrupt are surfaced as `unreadable` so they are not stranded
|
|
847
882
|
// on disk; the Client renders both groups separately.
|
|
848
|
-
const
|
|
883
|
+
const { entries: all, unreadable } = await trash.scanAll()
|
|
849
884
|
const result = {
|
|
850
885
|
entries: all.filter((entry) => entry.status === 'trashed').map(serializeEntry),
|
|
851
886
|
incomplete: [
|
|
@@ -986,17 +1021,17 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
986
1021
|
return
|
|
987
1022
|
}
|
|
988
1023
|
if (path === '/trash/restore') {
|
|
989
|
-
const result = await serialize(() => restoreEntry(body?.trashId))
|
|
1024
|
+
const result = await serialize(() => restoreEntry(body?.trashId), { lockTimeoutMs: TRASH_LOCK_BUDGET_MS })
|
|
990
1025
|
sendJson(res, 200, { ok: true, ...result })
|
|
991
1026
|
return
|
|
992
1027
|
}
|
|
993
1028
|
if (path === '/trash/purge') {
|
|
994
|
-
const result = await serialize(() => purgeEntry(body?.trashId, body?.confirmPhrase))
|
|
1029
|
+
const result = await serialize(() => purgeEntry(body?.trashId, body?.confirmPhrase), { lockTimeoutMs: TRASH_LOCK_BUDGET_MS })
|
|
995
1030
|
sendJson(res, 200, { ok: true, ...result })
|
|
996
1031
|
return
|
|
997
1032
|
}
|
|
998
1033
|
if (path === '/trash/discard') {
|
|
999
|
-
const result = await serialize(() => discardEntry(body?.trashId, body?.confirmPhrase))
|
|
1034
|
+
const result = await serialize(() => discardEntry(body?.trashId, body?.confirmPhrase), { lockTimeoutMs: TRASH_LOCK_BUDGET_MS })
|
|
1000
1035
|
sendJson(res, 200, { ok: true, ...result })
|
|
1001
1036
|
return
|
|
1002
1037
|
}
|
package/lib/host/gateway.js
CHANGED
|
@@ -186,6 +186,21 @@ export function createSessionGateway({ ctx, paths, now = Date.now }) {
|
|
|
186
186
|
* @returns {boolean|undefined}
|
|
187
187
|
*/
|
|
188
188
|
function probeRunning(sessionId) {
|
|
189
|
+
// Authoritative cheap probe first: the Host computes a list row's `running`
|
|
190
|
+
// flag from exactly this check (`api-session-controller`: `agents.get(id)
|
|
191
|
+
// ?.status === "running"`). The `Session` object handed out by `sessions.get`
|
|
192
|
+
// carries no `running`/`active`/`busy` field, so the legacy probe below can
|
|
193
|
+
// only ever answer `undefined` on current Hosts.
|
|
194
|
+
const agents = service('agents')
|
|
195
|
+
if (agents !== undefined && typeof agents.get === 'function') {
|
|
196
|
+
try {
|
|
197
|
+
const agent = agents.get(sessionId)
|
|
198
|
+
if (agent === undefined || agent === null) return false
|
|
199
|
+
if (typeof agent.status === 'string') return agent.status === 'running'
|
|
200
|
+
} catch {
|
|
201
|
+
// Fall through to the legacy field probe.
|
|
202
|
+
}
|
|
203
|
+
}
|
|
189
204
|
const sessions = service('sessions')
|
|
190
205
|
if (sessions === undefined || typeof sessions.get !== 'function') return undefined
|
|
191
206
|
let current
|
|
@@ -202,6 +217,31 @@ export function createSessionGateway({ ctx, paths, now = Date.now }) {
|
|
|
202
217
|
return undefined
|
|
203
218
|
}
|
|
204
219
|
|
|
220
|
+
/**
|
|
221
|
+
* Event count when it is cheaply knowable, `null` otherwise.
|
|
222
|
+
*
|
|
223
|
+
* `sessionPersistence.stat()` reports `header`/`revision`/`sizeBytes` (and no
|
|
224
|
+
* `eventCount` on 0.2.1), so the old `snapshot.eventCount` read was dead. A
|
|
225
|
+
* live session does expose the count exactly: the session store defines `seq`
|
|
226
|
+
* as "always the log length" (`dsh-session` `get seq()`), and it is a plain
|
|
227
|
+
* number read, not a materialized `snapshotEvents()` (that API is deprecated).
|
|
228
|
+
* For a stored, non-live session there is no equivalent that avoids reading
|
|
229
|
+
* and parsing the whole log, so the field stays `null` instead of presenting
|
|
230
|
+
* a projection cursor as an exact count.
|
|
231
|
+
* @returns {number|null}
|
|
232
|
+
*/
|
|
233
|
+
function eventCountFor(sessionId, snapshot) {
|
|
234
|
+
if (typeof snapshot?.eventCount === 'number') return snapshot.eventCount
|
|
235
|
+
const sessions = service('sessions')
|
|
236
|
+
if (sessions === undefined || typeof sessions.get !== 'function') return null
|
|
237
|
+
try {
|
|
238
|
+
const seq = sessions.get(sessionId)?.seq
|
|
239
|
+
return typeof seq === 'number' && Number.isFinite(seq) && seq >= 0 ? seq : null
|
|
240
|
+
} catch {
|
|
241
|
+
return null
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
205
245
|
async function workspaceFor(session) {
|
|
206
246
|
const workspaces = await workspaceEntities()
|
|
207
247
|
const byId = workspaces.find((workspace) => workspace.id === session.workspaceId)
|
|
@@ -251,7 +291,7 @@ export function createSessionGateway({ ctx, paths, now = Date.now }) {
|
|
|
251
291
|
pinned: fromList?.pinned ?? false,
|
|
252
292
|
createdAt: snapshot?.header?.createdAt ?? null,
|
|
253
293
|
updatedAt: fromList?.updatedAt ?? null,
|
|
254
|
-
events:
|
|
294
|
+
events: eventCountFor(sessionId, snapshot),
|
|
255
295
|
sizeBytes: typeof snapshot?.sizeBytes === 'number' ? snapshot.sizeBytes : null,
|
|
256
296
|
workspace: workspace === null ? null : { id: workspace.id, title: workspace.title, path: workspace.path, index: entity },
|
|
257
297
|
workspaceId: workspace?.id ?? null,
|
package/lib/host/lifecycle.js
CHANGED
|
@@ -20,13 +20,26 @@ import { DeleteError } from '../core/errors.js'
|
|
|
20
20
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
21
21
|
|
|
22
22
|
export function createLifecycleOps({ ctx, gateway, config, now = Date.now, delay = sleep }) {
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Optional-service probe.
|
|
25
|
+
*
|
|
26
|
+
* The property read has to stay inside the `try`: `ctx[name]` goes through
|
|
27
|
+
* cordis' Proxy trap, which throws `cannot get property "x" without inject`
|
|
28
|
+
* for a service this composition never provided. `ctx.get?.(name)` alone
|
|
29
|
+
* resolves to `undefined` in that case, and the `??` fallback would then
|
|
30
|
+
* throw a foreign error type out of the delete cascade (why a failed `get`
|
|
31
|
+
* must not be repaired by reading the property outside the guard).
|
|
32
|
+
*/
|
|
33
|
+
function peek(name) {
|
|
25
34
|
try {
|
|
26
|
-
|
|
35
|
+
return ctx.get?.(name) ?? ctx[name]
|
|
27
36
|
} catch {
|
|
28
|
-
|
|
37
|
+
return undefined
|
|
29
38
|
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function service(name) {
|
|
42
|
+
const value = peek(name)
|
|
30
43
|
if (value === undefined) {
|
|
31
44
|
throw new DeleteError('E_INTERNAL', `The ${name} service is unavailable.`, {})
|
|
32
45
|
}
|
|
@@ -55,7 +68,11 @@ export function createLifecycleOps({ ctx, gateway, config, now = Date.now, delay
|
|
|
55
68
|
*/
|
|
56
69
|
async quiesce(session) {
|
|
57
70
|
const registry = service('workspaceRegistry')
|
|
58
|
-
|
|
71
|
+
// Optional on purpose: a composition without the controller still deletes
|
|
72
|
+
// the log, it just cannot cancel an in-flight turn first (the archive gate
|
|
73
|
+
// and the drain check below still hold). `peek` keeps that path from
|
|
74
|
+
// throwing a cordis error instead of reaching the archive step.
|
|
75
|
+
const controller = peek('sessionController')
|
|
59
76
|
const wasArchived = session.archived === true
|
|
60
77
|
const drainMs = Math.max(0, Number(config.liveDrainMs) || 0)
|
|
61
78
|
|
package/lib/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-conversation-recycle-bin",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
4
4
|
"description": "DSH conversation deletion with an optional, previewed cascade into conversation-associated memory: soft delete + recycle bin, audit trail, batch rate limits and per-session rollback. 删除对话(可选一并删除关联记忆):软删除与回收站、审计留痕、批量限流、失败回滚。",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
"LICENSE"
|
|
34
34
|
],
|
|
35
35
|
"engines": {
|
|
36
|
-
"node": ">=20"
|
|
36
|
+
"node": ">=20",
|
|
37
|
+
"dsh": ">=0.2.1-rc.2"
|
|
37
38
|
},
|
|
38
39
|
"optionalDependencies": {
|
|
39
40
|
"nodemailer": "^10.1.2"
|