✨ feat: 会话彻底删除插件,支持删除会话与清理孤儿临时文件
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
# <emoji> <type>[(scope)][!]: <中文描述,一句话,不加句号>
|
||||||
|
# 一条提交信息只有这一行,不写正文,也不写脚注。
|
||||||
|
# 破坏性变更只用 ! 标记,例如:♻️ refactor!: 改掉了某某行为
|
||||||
|
# 需要交代背景时,把说明写进 README.md、CONTRIBUTING.md 或代码注释。
|
||||||
|
#
|
||||||
|
# 类型与 emoji:feat ✨ / fix 🐛 / docs 📝 / refactor ♻️ / perf ⚡️ /
|
||||||
|
# test ✅ / build 📦️ / ci 💚 / chore 🔧 / revert ⏪️
|
||||||
|
# 例:✨ feat(client): 会话行悬停区加删除按钮
|
||||||
|
# 例:🐛 fix(host): 延迟删除台账只记录根会话
|
||||||
|
# 例:📝 docs(ci): 安装说明只保留 Git 标签与本地 tgz
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# 提交信息规范
|
||||||
|
|
||||||
|
本仓库的提交信息遵循 [Conventional Commits](https://www.conventionalcommits.org/) 的**首行**结构,并在行首加一个 emoji 做视觉标记。**描述用中文**,类型关键字、作用域保持英文。
|
||||||
|
|
||||||
|
## 格式
|
||||||
|
|
||||||
|
**一条提交信息只有一行**,没有正文,也没有脚注:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<emoji> <type>[(scope)][!]: <中文描述>
|
||||||
|
```
|
||||||
|
|
||||||
|
- `<emoji>`:下面表格里该类型对应的 emoji,后面跟**一个空格**。emoji 是必需的,且必须与本行类型一致——`📝 docs:` 对,`✨ docs:` 不对。
|
||||||
|
- `<type>`:小写英文关键字,见下表。
|
||||||
|
- `(scope)`:可选,改动范围。用 `()` 包住一个简短英文标识,例如 `docs(ci)`、`fix(client)`、`feat(host)`。
|
||||||
|
- `!`:可选,紧跟在 type 或 scope 之后、冒号之前,表示破坏性变更。
|
||||||
|
- `<描述>`:中文,一句话,讲清这次改动做了什么;不换行、不用列表、不加句号。宽度不超过 72(中文字符按 2 个宽度算)。
|
||||||
|
|
||||||
|
**不要写正文,也不要写脚注。** 连 `Refs:` / `Closes:` / `BREAKING CHANGE:` 这类尾注也不写——需要交代的"为什么""影响面""迁移注意事项",写进 [README.md](README.md) 或本文件,或写进代码注释,让说明跟着代码走,而不是埋在 `git log` 里。
|
||||||
|
|
||||||
|
**破坏性变更只靠 `!` 标记**(例如 `♻️ refactor!: …`)。这一条是对 Conventional Commits 的有意偏离:它要求破坏性变更必须有 `BREAKING CHANGE:` 脚注,本仓库不写脚注,所以判断破坏性变更以 `!` 为准。
|
||||||
|
|
||||||
|
如果确实需要多行说明,说明这次改动不该只用一个提交标题交代——先补文档,再提交。
|
||||||
|
|
||||||
|
## 类型与 emoji
|
||||||
|
|
||||||
|
| 类型 | emoji | 码位 | 用途 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `feat` | ✨ | `U+2728` | 新增功能 |
|
||||||
|
| `fix` | 🐛 | `U+1F41B` | 修复缺陷 |
|
||||||
|
| `docs` | 📝 | `U+1F4DD` | 只改文档 |
|
||||||
|
| `refactor` | ♻️ | `U+267B U+FE0F` | 重构,行为不变 |
|
||||||
|
| `perf` | ⚡️ | `U+26A1 U+FE0F` | 性能优化 |
|
||||||
|
| `test` | ✅ | `U+2705` | 变更测试 |
|
||||||
|
| `build` | 📦️ | `U+1F4E6 U+FE0F` | 构建系统或依赖 |
|
||||||
|
| `ci` | 💚 | `U+1F49A` | CI 配置 |
|
||||||
|
| `chore` | 🔧 | `U+1F527` | 杂项,以上都不属于时用 |
|
||||||
|
| `revert` | ⏪️ | `U+23EA U+FE0F` | 回滚某次提交,正文写 `Refs: <被回滚的提交>` |
|
||||||
|
|
||||||
|
**复制上表里的 emoji,不要手敲。** 带 `U+FE0F` 的那五个是「基础字符 + 变体选择符」两个码位,手敲时很容易只打出前一个字符;对工具来说 `♻`(`U+267B`)和 `♻️`(`U+267B U+FE0F`)是两个不同的字符串,比对会失败。写规范或写脚本时,比较 emoji 前先做一次 NFC 归一化,或把两边都按 `\uFE0F?` 处理。
|
||||||
|
|
||||||
|
## 示例
|
||||||
|
|
||||||
|
每条都是一整条提交信息,就这一行:
|
||||||
|
|
||||||
|
```text
|
||||||
|
✨ feat(client): 会话行悬停区加删除按钮
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
🐛 fix(host): 延迟删除台账只记录根会话
|
||||||
|
```
|
||||||
|
|
||||||
|
带作用域与破坏性标记(破坏性变更**没有**脚注,只看 `!`):
|
||||||
|
|
||||||
|
```text
|
||||||
|
♻️ refactor(client)!: 行内按钮改用自己的槽位 id
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
📝 docs: 安装说明改为只走 Git 标签与本地 tgz
|
||||||
|
```
|
||||||
|
|
||||||
|
## 工具链提示
|
||||||
|
|
||||||
|
emoji 在行首是本仓库的有意选择,视觉上整齐。代价是:以类型前缀开头的解析工具(`commitlint`、`semantic-release`、`release-please` 等)会读不到类型。如果以后要接这类工具,有两条路:
|
||||||
|
|
||||||
|
1. 把 emoji 挪到冒号之后(`feat: ✨ 描述`),类型回到行首,规范其余部分不变;
|
||||||
|
2. 保留行首 emoji,给工具写一个剥掉开头 emoji 与空格的预处理。可用的正则(`\p{Extended_Pictographic}` 已覆盖带与不带变体选择符两种写法):
|
||||||
|
|
||||||
|
```text
|
||||||
|
^(?:\p{Extended_Pictographic}\uFE0F?\s+)?(?<type>[a-z]+)(?:\((?<scope>[^)]*)\))?(?<breaking>!)?:\s(?<desc>.+)$
|
||||||
|
```
|
||||||
|
|
||||||
|
## 自查
|
||||||
|
|
||||||
|
`scripts/check-commit-log.mjs` 会按本规范检查现有提交,只报告、不拦提交。除了首行格式,它还会报出**任何带正文或脚注的提交**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/check-commit-log.mjs # 最近 10 条
|
||||||
|
node scripts/check-commit-log.mjs all # 全部
|
||||||
|
```
|
||||||
|
|
||||||
|
它从本文件的类型表里读 emoji 对照表,所以改了表不用改脚本。规范之前的提交不符合是正常的。
|
||||||
|
|
||||||
|
## 启用提交模板(可选)
|
||||||
|
|
||||||
|
`.gitmessage` 是提交信息模板。启用后每次 `git commit` 会带上前缀提示:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git config commit.template .gitmessage
|
||||||
|
```
|
||||||
|
|
||||||
|
模板整份都是 `#` 开头的注释,git 会自动丢弃,所以直接 `git commit -m` 也不受影响。只对当前仓库生效;换机器要重新执行一次。
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 pyh
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# 会话彻底删除
|
||||||
|
|
||||||
|
DeepSeek Harness(DSH)插件:给会话加上**真正不可逆的删除**,并清理已删除会话遗留的临时文件。
|
||||||
|
|
||||||
|
DSH 原本只能归档会话——归档只是把会话从列表里移开,日志仍然留在磁盘上(DSH 官方的 JSONL 会话存储后端明确写着「没有任何东西会删除会话文件,日志会一直堆在 root 下直到被外部移除」,会话存储契约里也没有删除 API)。这个插件补上的正是这一块:两处删除入口,以及插件页里的一次性孤儿文件清理。
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
### 1. 删除会话
|
||||||
|
|
||||||
|
两个入口,行为一致,都会先弹出确认弹窗(复刻系统 Modal 的样式与键盘行为:Esc 关闭、Tab 焦点圈定、打开时焦点进入、关闭后焦点归位):
|
||||||
|
|
||||||
|
- 会话行右侧 **⋯** 菜单 → **删除会话**
|
||||||
|
- 会话行悬停时的 🗑 按钮(在官方的「归档」「置顶」之前,是悬停区的第一个按钮)
|
||||||
|
|
||||||
|
确认后一次性处理:
|
||||||
|
|
||||||
|
| 对象 | 处理方式 |
|
||||||
|
| --- | --- |
|
||||||
|
| 会话日志目录 | 整个删除,仅保留 `session.lock` |
|
||||||
|
| 子智能体会话 | 按血缘递归收集,连同日志与溢出文件一并删除 |
|
||||||
|
| 工具输出溢出文件(spill) | 删除该会话(含子会话)对应的 spill 目录 |
|
||||||
|
| 工作区登记项 | 解除绑定、取消置顶、取消归档 |
|
||||||
|
| 客户端列表 | 广播 `api-session/removed`,列表行立即消失 |
|
||||||
|
|
||||||
|
有意**不**处理:
|
||||||
|
|
||||||
|
- **附件对象**(`$DSH_HOME/attachments/v1/objects`):按内容 sha256 寻址,同一份内容可能被多个会话共享,而 DSH 没有引用计数,跟着一个会话删除会破坏别的会话;
|
||||||
|
- **投影缓存**(`$DSH_HOME/storages/session_projcache`):由日志派生的缓存,读取时带生命周期身份校验,永远不会串到别的会话。
|
||||||
|
|
||||||
|
### 2. 清理临时文件
|
||||||
|
|
||||||
|
**设置 → 插件 → 会话彻底删除**(点会话彻底删除那一行右侧的配置按钮),清理 spill 后端留下的**孤儿**目录。判定偏保守:
|
||||||
|
|
||||||
|
1. 目录名必须精确等于 `session-<sha256(会话 id) 前 12 位>`——这是 spill 后端唯一会生成的形状;
|
||||||
|
2. 该名字不属于任何已知会话(`sessionQuery.listSessions()` 已经把存量会话与内存中正在运行的会话合并成同一份语料,所以「已知」只需要一个来源);
|
||||||
|
3. 只在 spill 的 root 内操作(后端当前 root + 系统临时目录下的 `dsh-spill*`);
|
||||||
|
4. 符号链接一律跳过、不跟随;单项失败只记警告。
|
||||||
|
|
||||||
|
结果直接显示在该页(清理了几个目录、几个文件、释放多少空间),可反复点击。
|
||||||
|
|
||||||
|
这个页面占的是 `plugins.row.config` 槽位——和官方插件「终端」的配置页是同一个位置。注册了这个槽位的 key(`<包名>#<行 id>`)之后,插件行就会长出配置入口,所以本插件不声明 `Config` 也能有自己的页面。
|
||||||
|
|
||||||
|
## 行为与边界
|
||||||
|
|
||||||
|
- **仍在运行的会话**无法被插件从内存里移除:它会先被归档并停止,日志与溢出文件在**下次启动 DSH 时**删除(界面会明确提示);在此之前取消归档就会取消这次删除。
|
||||||
|
- 删除**不可恢复**,但**不是安全擦除**:按普通文件删除处理,底层介质上可能有残留。
|
||||||
|
- 工作区里的代码文件不受影响。
|
||||||
|
- 溢出文件被删除后,fork 出来的会话日志里若仍保留指向它的路径文本,那些路径会失效。
|
||||||
|
- 行内按钮的悬停气泡由按钮自身定位。指针停在会话行上时,行自己的悬停预览卡可能和这个气泡同时出现——插件不会去动宿主的行 DOM 来避免这一点(那是插件不该依赖的自有渲染结构)。若观感不能接受,可把气泡改为只在键盘 focus 时显示:删掉 `DeleteSessionRowButton` 里的 `onMouseEnter: show` / `onMouseLeave: hide` 两行即可。
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
DSH 的 **插件 → 添加插件** 里有两个输入,含义不同:
|
||||||
|
|
||||||
|
- **上面那个输入框**:装什么(包名 / Git 地址 / 本地 tgz 的绝对路径);
|
||||||
|
- **下面的「安装源」**:只决定**按包名安装时**去哪个注册表查。填 Git 地址或 tgz 时它不参与拉取,保持默认(官方源)即可,不必切到「自定义地址」。
|
||||||
|
|
||||||
|
### 1. Git 仓库地址(推荐)
|
||||||
|
|
||||||
|
在上面的输入框里填:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://gitea.iwake.top/dsh-plugin/session-delete.git#v2.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
`#` 后面可以跟标签或提交,用来锁定版本;不写则取默认分支。仓库是公开的,不需要凭据,也不需要改安装源。
|
||||||
|
|
||||||
|
想跟随 2.x 的最新版本,可以把后缀写成 `#semver:^2.0.0`——pnpm 会按仓库里的标签挑最高的 2.x,以后发了 v2.0.1,移除旧版本再装一次就能拿到。
|
||||||
|
|
||||||
|
### 2. 本地 tgz
|
||||||
|
|
||||||
|
把仓库打包成 tgz(例如在仓库目录执行 `npm pack`,得到 `dsh-plugin-session-delete-2.0.0.tgz`),把它在运行 DSH 那台机器上的绝对路径填进上面的输入框。
|
||||||
|
|
||||||
|
### 升级
|
||||||
|
|
||||||
|
DSH 暂不支持插件自动更新,升级就是用新标签重装一次(建议先移除旧版本)。安装或升级后:**宿主半变更需要重启 DSH**(完全退出再打开,仅关闭窗口不一定退出进程);只改浏览器半时刷新页面即可。
|
||||||
|
|
||||||
|
如果你是在本地开发这个插件(profile 里装的是 `link:` 到工作目录),**光刷新页面看不到改动**:profile 的 `node_modules` 里可能是一份与源码同 inode 的硬链接副本,需要移除再重装一次插件,让 profile 重新取一份。
|
||||||
|
|
||||||
|
## 兼容性
|
||||||
|
|
||||||
|
基于 **DeepSeek Harness 0.2.0-rc.2** 编写。DSH 仍在演进,其它版本与平台组合未逐一验证。
|
||||||
|
|
||||||
|
- **浏览器半**不 import 任何 Harness 客户端包(上游规范如此要求:这些包随时可能变化,渲染抛错会让整个插槽条目变空白),而是复刻 `dsh-client-ui-primitives` 的 markup、CSS 声明、图标 path 数据与键盘行为,只保留 `--dsw-*` 主题 token 引用。它**不读写宿主的 DOM**,也不占用官方槽位:行内按钮用自己的 id 与官方「归档」「置顶」并列,不是顶替。上游若改动这些内部结构,界面细节可能失配(例如悬停气泡不再出现),但不会导致插件加载失败。
|
||||||
|
- **宿主半**只依赖稳定的服务契约:`sessionQuery`(`traceSession` 取血缘、`listSessions` 取语料)、`sessionPersistence`(含其 JSONL 后端的 `locate()` 诊断钩子,用于定位会话目录)、`workspaceRegistry`、`connection.fetch`(注册受认证的 exact 路由),以及 spill 后端的目录规则。
|
||||||
|
- **跨半边通信**走 `connection.fetch` 注册的 `POST /api/plugin/session-delete/delete` 与 `.../orphans` 两条路由,浏览器半用文档相对路径请求(支持挂载前缀)。这是静态客户端包与宿主半通信的正规通道;`host.call` 属于动态客户端运行器,不适用于包形式的插件。
|
||||||
|
|
||||||
|
## 贡献
|
||||||
|
|
||||||
|
提交信息用「emoji + Conventional Commits + 中文描述」,规则、类型表和示例见 [CONTRIBUTING.md](CONTRIBUTING.md)。想省事可以让 git 带上模板:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git config commit.template .gitmessage
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[MIT](LICENSE)
|
||||||
@@ -0,0 +1,677 @@
|
|||||||
|
/**
|
||||||
|
* Browser half of the session-delete bundle.
|
||||||
|
*
|
||||||
|
* One row in a conversation's "..." menu and one hover button on the
|
||||||
|
* conversation's sidebar row open an irreversible-deletion dialog. Confirming
|
||||||
|
* posts to the Host half's authenticated route; the Host owns every policy
|
||||||
|
* decision and the Client only renders its answer.
|
||||||
|
*
|
||||||
|
* The temporary-file sweep is the plugin's other operation, and it lives on
|
||||||
|
* this plugin's own page under Settings → Plugins: the entry registers into
|
||||||
|
* `plugins.row.config` under the `<package name>#<row id>` key the manager
|
||||||
|
* dispatches, which also gives the row its configure control. That is the same
|
||||||
|
* seat a bundled plugin's configuration form occupies, so the plugin's only
|
||||||
|
* control sits beside everything else the manager says about it instead of
|
||||||
|
* taking a row in General settings.
|
||||||
|
*
|
||||||
|
* Two client-authoring rules shape this file:
|
||||||
|
*
|
||||||
|
* - No Harness Client module is imported. Those packages change without notice,
|
||||||
|
* a plain-JavaScript bundle has no type check, and a throwing component blanks
|
||||||
|
* the whole slot entry, so the menu row, the row button, the dialog, and the
|
||||||
|
* plugin page's section re-implement the shipped markup, stylesheet
|
||||||
|
* declarations, and focus/Escape behavior themselves. Class names are renamed
|
||||||
|
* under `dsd-` and every color, radius, elevation, and motion value stays a
|
||||||
|
* `--dsw-*` theme-token reference.
|
||||||
|
* - Nothing reads another package's DOM. An earlier version nudged the row's
|
||||||
|
* hover preview out of the way by dispatching a synthetic pointer event at a
|
||||||
|
* `[data-row-key]` ancestor; that is exactly the kind of dependency on a
|
||||||
|
* self-owned rendering surface a plugin must not take. The tooltip below is
|
||||||
|
* positioned from its own button alone.
|
||||||
|
*
|
||||||
|
* @module @dsh-plugin/session-delete/client
|
||||||
|
*/
|
||||||
|
window.__ModuleLoader__.load({
|
||||||
|
id: '@dsh-plugin/session-delete',
|
||||||
|
factory(require) {
|
||||||
|
const React = require('react')
|
||||||
|
const h = React.createElement
|
||||||
|
const NS = 'session-delete'
|
||||||
|
const ROUTE = 'api/plugin/session-delete/delete'
|
||||||
|
const ORPHAN_ROUTE = 'api/plugin/session-delete/orphans'
|
||||||
|
/** The shipped menu rows occupy 100–400; this one follows them. */
|
||||||
|
const MENU_ORDER = 500
|
||||||
|
/** Between the shipped `archive` (100) and `pin` (200) hover buttons. */
|
||||||
|
const ROW_ACTION_ORDER = 150
|
||||||
|
/**
|
||||||
|
* The key `plugins.row.config` is dispatched under: the manager composes it
|
||||||
|
* as `<package name>#<row id>`, with the row id read from this bundle's own
|
||||||
|
* patch. Both halves are literals from `package.json` and
|
||||||
|
* `cordis.patch.yml`; a typo costs the configure control silently, so they
|
||||||
|
* are the only two places renaming has to reach.
|
||||||
|
*/
|
||||||
|
const PACKAGE = '@dsh-plugin/session-delete'
|
||||||
|
const ROW_ID = 'session-delete'
|
||||||
|
const CONFIG_KEY = `${PACKAGE}#${ROW_ID}`
|
||||||
|
const TOOLTIP_DELAY_MS = 500
|
||||||
|
const TOOLTIP_GAP = 8
|
||||||
|
/** Document base captured at bundle registration, before any routing. */
|
||||||
|
const BASE = typeof document === 'undefined' ? 'http://127.0.0.1/' : document.baseURI
|
||||||
|
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||||
|
|
||||||
|
const zh = {
|
||||||
|
'menu.item': '删除会话',
|
||||||
|
'actions.delete': '删除会话',
|
||||||
|
'dialog.title': '永久删除此会话?',
|
||||||
|
'dialog.body': '“{title}”的会话日志将被彻底删除,无法恢复',
|
||||||
|
'dialog.note': '工作区中的文件不受影响;该会话的对话内容不会保留',
|
||||||
|
'dialog.cancel': '取消',
|
||||||
|
'dialog.confirm': '永久删除',
|
||||||
|
'dialog.pending': '正在删除…',
|
||||||
|
'dialog.close': '关闭',
|
||||||
|
'toast.deleted': '会话及其子智能体会话已彻底删除。',
|
||||||
|
'toast.deletedSpill': '会话及其子智能体会话已彻底删除,并清理了 {n} 个溢出临时文件',
|
||||||
|
'toast.scheduled': '该会话仍在本进程中运行,已先停止并归档;它的会话日志会在下次启动 DeepSeek Harness 时删除',
|
||||||
|
'toast.failed': '删除失败:{message}',
|
||||||
|
'error.unknown': '未知错误',
|
||||||
|
'cleanup.title': '清理临时文件',
|
||||||
|
'cleanup.description': '删除已不存在会话遗留的工具输出溢出文件,不影响现有会话',
|
||||||
|
'cleanup.action': '清理',
|
||||||
|
'cleanup.pending': '正在清理…',
|
||||||
|
'cleanup.empty': '没有发现孤立的临时文件',
|
||||||
|
'cleanup.done': '已清理 {directories} 个目录、{n} 个文件,释放 {size}',
|
||||||
|
'cleanup.failed': '清理失败:{message}',
|
||||||
|
}
|
||||||
|
const en = {
|
||||||
|
'menu.item': 'Delete conversation',
|
||||||
|
'actions.delete': 'Delete conversation',
|
||||||
|
'dialog.title': 'Delete this conversation permanently?',
|
||||||
|
'dialog.body': 'The stored log of “{title}” will be permanently deleted. This cannot be undone.',
|
||||||
|
'dialog.note': 'Files in the workspace are not affected; nothing of this conversation is kept.',
|
||||||
|
'dialog.cancel': 'Cancel',
|
||||||
|
'dialog.confirm': 'Delete permanently',
|
||||||
|
'dialog.pending': 'Deleting…',
|
||||||
|
'dialog.close': 'Close',
|
||||||
|
'toast.deleted': 'The conversation and its subagent conversations were permanently deleted.',
|
||||||
|
'toast.deletedSpill': 'The conversation and its subagent conversations were permanently deleted, and {n} spilled tool-output files were removed.',
|
||||||
|
'toast.scheduled': 'This conversation is still live in this process. It was stopped and archived; its stored log will be deleted the next time DeepSeek Harness starts.',
|
||||||
|
'toast.failed': 'Deletion failed: {message}',
|
||||||
|
'error.unknown': 'unknown error',
|
||||||
|
'cleanup.title': 'Clean up temporary files',
|
||||||
|
'cleanup.description': 'Deletes spilled tool-output files left behind by conversations that no longer exist; existing conversations are untouched.',
|
||||||
|
'cleanup.action': 'Clean up',
|
||||||
|
'cleanup.pending': 'Cleaning…',
|
||||||
|
'cleanup.empty': 'No orphaned temporary files were found.',
|
||||||
|
'cleanup.done': 'Removed {n} files from {directories} directories, freeing {size}.',
|
||||||
|
'cleanup.failed': 'Cleanup failed: {message}',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Menu-row declarations mirror `dsh-client-ui-primitives`' Menu.module.css
|
||||||
|
* `.item/.itemIcon/.itemLabel`; dialog declarations mirror its
|
||||||
|
* Modal.module.css and Button.module.css plus the workspace dialog's
|
||||||
|
* destructive accent and secondary status lines; the settings row copies
|
||||||
|
* the General-section row pattern. Class names are renamed under `dsd-`,
|
||||||
|
* and every color, radius, elevation, and transition stays a
|
||||||
|
* `--dsw-*`/`--ds-*` token reference.
|
||||||
|
*/
|
||||||
|
const CSS = `
|
||||||
|
.dsd-item{display:flex;align-items:center;gap:6px;width:100%;min-height:34px;padding:6px 8px;border:none;border-radius:var(--dsw-radius-md);background:transparent;cursor:pointer;font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary);text-align:left}
|
||||||
|
.dsd-item:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}
|
||||||
|
.dsd-item:focus-visible:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);outline:none}
|
||||||
|
.dsd-item:disabled{opacity:.4;cursor:not-allowed}
|
||||||
|
.dsd-item-icon{display:inline-flex;flex:none;width:14px;height:14px;align-items:center;justify-content:center;color:var(--dsw-alias-menu-icon)}
|
||||||
|
.dsd-item-icon svg{width:14px;height:14px}
|
||||||
|
.dsd-item-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.dsd-row-button{border-radius:var(--dsw-radius-xs);cursor:pointer;width:16px;height:16px;color:var(--dsw-alias-label-tertiary);background:0 0;border:none;flex:none;justify-content:center;align-items:center;padding:0;display:inline-flex}
|
||||||
|
.dsd-row-button:hover{color:var(--dsw-alias-label-primary)}
|
||||||
|
.dsd-row-button:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:-2px}
|
||||||
|
.dsd-stack{flex-direction:column;flex:1;gap:4px;min-width:0;padding-right:48px;display:flex}
|
||||||
|
.dsd-section{flex-direction:column;gap:12px;display:flex}
|
||||||
|
.dsd-section-head{align-items:center;gap:16px;display:flex}
|
||||||
|
.dsd-section-title{color:var(--dsw-alias-label-primary);margin:0;font-size:14px;font-weight:500;line-height:22px}
|
||||||
|
.dsd-section-desc{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;font-weight:400;line-height:18px}
|
||||||
|
.dsd-tooltip{display:inline-flex;align-items:center;gap:8px;position:fixed;z-index:100;width:max-content;max-width:50vw;padding:3px 7px;border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-tooltip-bg);color:var(--dsw-static-neutral-bluish-00);font-size:13px;line-height:20px;white-space:pre-line;overflow-wrap:break-word;pointer-events:none;transform:translateX(-100%);animation:dsd-tooltip-in 150ms var(--ds-ease-in-out)}
|
||||||
|
@keyframes dsd-tooltip-in{from{opacity:0}}
|
||||||
|
@media (prefers-reduced-motion: reduce){.dsd-tooltip{animation:none}}
|
||||||
|
.dsd-modal-root{pointer-events:auto;position:fixed;inset:0;z-index:1000;display:flex;align-items:center;justify-content:center;padding:max(24px,var(--dsh-frame-overlay-top,24px)) 24px}
|
||||||
|
.dsd-mask{position:absolute;inset:var(--dsh-frame-chrome-top,0px) 0 0;backdrop-filter:var(--dsw-mask-blur)}
|
||||||
|
.dsd-mask::after{content:'';position:absolute;inset:0;background:var(--dsw-alias-bg-mask-1);animation:dsd-enter var(--ds-transition-duration) var(--ds-ease-in-out)}
|
||||||
|
.dsd-dialog{box-sizing:border-box;position:relative;z-index:1;display:flex;flex-direction:column;gap:20px;width:min(380px,100%);padding:0 0 24px;overflow:hidden;border:0;border-radius:var(--dsw-radius-panel);background:var(--dsw-alias-bg-layer-2);box-shadow:var(--dsw-elevation-prominent);animation:dsd-enter var(--ds-transition-duration) var(--ds-ease-in-out);color:var(--dsw-alias-label-primary)}
|
||||||
|
.dsd-dialog:focus{outline:none}
|
||||||
|
@keyframes dsd-enter{from{opacity:0}to{opacity:1}}
|
||||||
|
@media (prefers-reduced-motion: reduce){.dsd-mask::after,.dsd-dialog{animation:none}}
|
||||||
|
.dsd-content{display:flex;flex-direction:column;width:100%}
|
||||||
|
.dsd-header{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:22px 14px 12px 24px}
|
||||||
|
.dsd-title{margin:0;font-size:16px;line-height:24px;font-weight:500;color:var(--dsw-alias-label-primary)}
|
||||||
|
.dsd-close{flex:none;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:none;border-radius:var(--dsw-radius-sm);background:transparent;cursor:pointer;color:var(--dsw-alias-label-secondary)}
|
||||||
|
.dsd-close:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
||||||
|
.dsd-description{margin:0;padding:0 24px;font-size:14px;line-height:22px;font-weight:400;color:var(--dsw-alias-label-primary)}
|
||||||
|
.dsd-body{display:flex;flex-direction:column;min-width:0;margin-top:20px;padding:0 24px}
|
||||||
|
.dsd-note{margin:0;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px}
|
||||||
|
.dsd-status{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
|
||||||
|
.dsd-status + .dsd-status,.dsd-note + .dsd-status{margin-top:8px}
|
||||||
|
.dsd-status-error{color:var(--dsw-alias-state-error-primary)}
|
||||||
|
.dsd-footer{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:0 24px}
|
||||||
|
.dsd-button{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;gap:4px;height:36px;padding:0 14px;border:none;border-radius:var(--dsw-radius-md);cursor:pointer;font-size:14px;line-height:22px;color:var(--dsw-alias-label-primary);background:transparent}
|
||||||
|
.dsd-button:disabled{cursor:not-allowed;opacity:.4}
|
||||||
|
.dsd-outline{border:.5px solid var(--dsw-alias-border-l3);background:transparent}
|
||||||
|
.dsd-outline:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}
|
||||||
|
.dsd-destructive:not(:disabled){color:var(--dsw-alias-state-error-primary)}
|
||||||
|
`
|
||||||
|
|
||||||
|
/** Minimal observable store; the shape the Client slot framework injects as a hook. */
|
||||||
|
function createStore(initial) {
|
||||||
|
let value = initial
|
||||||
|
const listeners = new Set()
|
||||||
|
return {
|
||||||
|
getSnapshot: () => value,
|
||||||
|
subscribe: (listener) => {
|
||||||
|
listeners.add(listener)
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
set: (next) => {
|
||||||
|
if (next === value) return
|
||||||
|
value = next
|
||||||
|
for (const listener of [...listeners]) listener()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use the locale seat the slot owner hands this entry; fall back to this
|
||||||
|
* package's own dictionaries so a missing seat degrades copy, never render.
|
||||||
|
*/
|
||||||
|
function translate(seat) {
|
||||||
|
if (typeof seat === 'function') return seat
|
||||||
|
const isChinese = typeof document === 'undefined'
|
||||||
|
|| !/^en/i.test(document.documentElement?.getAttribute?.('lang') ?? 'zh')
|
||||||
|
const dictionary = isChinese ? zh : en
|
||||||
|
return (key, params) => {
|
||||||
|
const template = dictionary[key] ?? key
|
||||||
|
if (params === undefined) return template
|
||||||
|
return template.replace(/\{(\w+)\}/g, (match, name) => (
|
||||||
|
Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : match
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Icon paths are the shipped `IconTrashOutline` and `IconCloseOutline`
|
||||||
|
* artwork verbatim: a 16-unit viewBox the glyph fills, stroked at the
|
||||||
|
* regular weight of 1.
|
||||||
|
*/
|
||||||
|
function TrashIcon({ size = 16 }) {
|
||||||
|
return h('svg', {
|
||||||
|
width: size, height: size, viewBox: '0 0 16 16', fill: 'none',
|
||||||
|
xmlns: 'http://www.w3.org/2000/svg', 'aria-hidden': true, strokeWidth: 1,
|
||||||
|
},
|
||||||
|
h('path', { d: 'M1.28149 3.88831H14.7187', stroke: 'currentColor' }),
|
||||||
|
h('path', {
|
||||||
|
d: 'M5.41602 3.88833V2.47962C5.41602 2.29282 5.52492 2.11366 5.71876 1.98157C5.9126 1.84948 6.17551 1.77527 6.44964 1.77527H9.55053C9.82466 1.77527 10.0876 1.84948 10.2814 1.98157C10.4753 2.11366 10.5842 2.29282 10.5842 2.47962V3.88833',
|
||||||
|
stroke: 'currentColor',
|
||||||
|
}),
|
||||||
|
h('path', {
|
||||||
|
d: 'M2.57349 3.88831L3.19366 13.2943C3.21937 13.5502 3.33952 13.7872 3.53065 13.9593C3.72178 14.1313 3.97016 14.2259 4.22729 14.2246H11.7728C12.0299 14.2259 12.2783 14.1313 12.4694 13.9593C12.6605 13.7872 12.7807 13.5502 12.8064 13.2943L13.4266 3.88831',
|
||||||
|
stroke: 'currentColor',
|
||||||
|
}),
|
||||||
|
h('path', { d: 'M6.44946 6.98926V11.1238', stroke: 'currentColor' }),
|
||||||
|
h('path', { d: 'M9.55054 6.98926V11.1238', stroke: 'currentColor' }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close glyph of the dialog header, matching the primitive close button. */
|
||||||
|
function CloseIcon({ size = 16 }) {
|
||||||
|
return h('svg', {
|
||||||
|
width: size, height: size, viewBox: '0 0 16 16', fill: 'none',
|
||||||
|
xmlns: 'http://www.w3.org/2000/svg', 'aria-hidden': true, strokeWidth: 1,
|
||||||
|
},
|
||||||
|
h('path', { d: 'M2.5 2.5L13.5 13.5', stroke: 'currentColor' }),
|
||||||
|
h('path', { d: 'M13.5 2.5L2.5 13.5', stroke: 'currentColor' }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The menu row: raise one deletion request for the conversation it belongs
|
||||||
|
* to. The menu's own open-state hook is declared by the slot owner, so the
|
||||||
|
* row closes it before the dialog appears.
|
||||||
|
*/
|
||||||
|
function DeleteSessionMenuItem(props) {
|
||||||
|
const sessionId = props.sessionId
|
||||||
|
const displayTitle = props.displayTitle
|
||||||
|
const t = translate(props.t)
|
||||||
|
const requestSessionDelete = props.requestSessionDelete
|
||||||
|
const closeMenu = useMenuCloser(props.useMenuOpenState)
|
||||||
|
return h(React.Fragment, null,
|
||||||
|
h('style', null, CSS),
|
||||||
|
h('button', {
|
||||||
|
type: 'button',
|
||||||
|
role: 'menuitem',
|
||||||
|
className: 'dsd-item',
|
||||||
|
'aria-label': t('menu.item'),
|
||||||
|
onClick: () => {
|
||||||
|
closeMenu()
|
||||||
|
requestSessionDelete(sessionId, displayTitle ?? '')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
h('span', { className: 'dsd-item-icon' }, h(TrashIcon, { size: 14 })),
|
||||||
|
h('span', { className: 'dsd-item-label' }, t('menu.item'))))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close the owning menu through the slot's declared hook, when it has one. */
|
||||||
|
function useMenuCloser(useMenuOpenState) {
|
||||||
|
const closer = React.useRef(() => {})
|
||||||
|
if (typeof useMenuOpenState === 'function') {
|
||||||
|
/** The hook belongs to the entry for its whole life, so the order is stable. */
|
||||||
|
const openState = useMenuOpenState()
|
||||||
|
if (Array.isArray(openState) && typeof openState[1] === 'function') {
|
||||||
|
closer.current = () => openState[1](false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return closer.current
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The row's hover button. It carries its own id in the row-action list, so
|
||||||
|
* the shipped `archive` and `pin` buttons keep their cells instead of being
|
||||||
|
* shadowed.
|
||||||
|
*
|
||||||
|
* Its tooltip copies the primitive's bubble — the same delay, the same
|
||||||
|
* bottom/end placement, the same fade — and is placed from this button's own
|
||||||
|
* rect alone. While the pointer rests on the row, the row's own hover
|
||||||
|
* preview may appear alongside it; that is accepted rather than suppressed
|
||||||
|
* by reaching into the row's DOM.
|
||||||
|
*/
|
||||||
|
function DeleteSessionRowButton(props) {
|
||||||
|
const t = translate(props.t)
|
||||||
|
const requestSessionDelete = props.requestSessionDelete
|
||||||
|
const buttonRef = React.useRef(null)
|
||||||
|
const timerRef = React.useRef(null)
|
||||||
|
const [anchor, setAnchor] = React.useState(null)
|
||||||
|
|
||||||
|
const show = () => {
|
||||||
|
clearTimeout(timerRef.current)
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
const rect = buttonRef.current?.getBoundingClientRect()
|
||||||
|
if (rect === undefined) return
|
||||||
|
setAnchor({ left: rect.right, top: rect.bottom + TOOLTIP_GAP })
|
||||||
|
}, TOOLTIP_DELAY_MS)
|
||||||
|
}
|
||||||
|
const hide = () => {
|
||||||
|
clearTimeout(timerRef.current)
|
||||||
|
setAnchor(null)
|
||||||
|
}
|
||||||
|
React.useEffect(() => () => clearTimeout(timerRef.current), [])
|
||||||
|
|
||||||
|
const label = t('actions.delete')
|
||||||
|
return h(React.Fragment, null,
|
||||||
|
h('style', null, CSS),
|
||||||
|
h('button', {
|
||||||
|
ref: buttonRef,
|
||||||
|
type: 'button',
|
||||||
|
className: 'dsd-row-button',
|
||||||
|
'aria-label': label,
|
||||||
|
onMouseEnter: show,
|
||||||
|
onMouseLeave: hide,
|
||||||
|
onFocus: show,
|
||||||
|
onBlur: hide,
|
||||||
|
onClick: () => {
|
||||||
|
hide()
|
||||||
|
requestSessionDelete(props.sessionId, props.displayTitle ?? '')
|
||||||
|
},
|
||||||
|
}, h(TrashIcon, { size: 14 })),
|
||||||
|
anchor !== null && h('span', {
|
||||||
|
className: 'dsd-tooltip',
|
||||||
|
role: 'tooltip',
|
||||||
|
style: { left: anchor.left, top: anchor.top },
|
||||||
|
}, label))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This plugin's page under Settings → Plugins: sweep spilled tool output
|
||||||
|
* whose conversation no longer exists.
|
||||||
|
*
|
||||||
|
* The owner asks every `plugins.row.config` entry for two views. `summary`
|
||||||
|
* is the one-liner on the row, and this plugin's manifest description
|
||||||
|
* already fills that seat, so `summary` renders nothing and the page keeps
|
||||||
|
* the text the manager read from `locale/*.json`. `page` is the section the
|
||||||
|
* manager renders with its own heading, breadcrumb, and title; copy,
|
||||||
|
* control, and result belong to this entry.
|
||||||
|
*/
|
||||||
|
function TempCleanupPanel(props) {
|
||||||
|
const t = translate(props.t)
|
||||||
|
const cleanOrphans = props.cleanOrphans
|
||||||
|
// Every hook sits above the view guard: React must see the same hook
|
||||||
|
// order in both views, so the guard can only skip the output.
|
||||||
|
const [state, setState] = React.useState({ phase: 'idle', failed: false, message: '' })
|
||||||
|
if (props.view !== 'page') return null
|
||||||
|
const busy = state.phase === 'busy'
|
||||||
|
const run = async () => {
|
||||||
|
setState({ phase: 'busy', failed: false, message: '' })
|
||||||
|
try {
|
||||||
|
const value = await cleanOrphans()
|
||||||
|
const files = Number(value.files) || 0
|
||||||
|
const directories = Number(value.directories) || 0
|
||||||
|
setState({
|
||||||
|
phase: 'done',
|
||||||
|
failed: false,
|
||||||
|
message: files === 0
|
||||||
|
? t('cleanup.empty')
|
||||||
|
: t('cleanup.done', { n: files, directories, size: formatBytes(Number(value.bytes) || 0) }),
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
setState({
|
||||||
|
phase: 'done',
|
||||||
|
failed: true,
|
||||||
|
message: t('cleanup.failed', { message: error instanceof Error ? error.message : t('error.unknown') }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return h(React.Fragment, null,
|
||||||
|
h('style', null, CSS),
|
||||||
|
h('div', { className: 'dsd-section' },
|
||||||
|
h('div', { className: 'dsd-section-head' },
|
||||||
|
h('div', { className: 'dsd-stack' },
|
||||||
|
h('h4', { className: 'dsd-section-title' }, t('cleanup.title')),
|
||||||
|
h('p', { className: 'dsd-section-desc' }, t('cleanup.description'))),
|
||||||
|
h('button', {
|
||||||
|
type: 'button',
|
||||||
|
className: 'dsd-button dsd-outline',
|
||||||
|
disabled: busy,
|
||||||
|
onClick: run,
|
||||||
|
}, busy ? t('cleanup.pending') : t('cleanup.action'))),
|
||||||
|
state.phase === 'done' && h('p', {
|
||||||
|
className: `dsd-status${state.failed ? ' dsd-status-error' : ''}`,
|
||||||
|
role: state.failed ? 'alert' : 'status',
|
||||||
|
}, state.message)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render a byte count the way the cleanup result reports reclaimed space. */
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB']
|
||||||
|
let value = bytes
|
||||||
|
let unit = 0
|
||||||
|
while (value >= 1024 && unit < units.length - 1) {
|
||||||
|
value /= 1024
|
||||||
|
unit += 1
|
||||||
|
}
|
||||||
|
return `${unit === 0 ? value : value.toFixed(1)} ${units[unit]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The overlay entry: the dialog, or nothing while no request is pending. */
|
||||||
|
function DeleteSessionDialog(props) {
|
||||||
|
const request = props.useDeleteRequest((pending) => pending)
|
||||||
|
if (request === null) return null
|
||||||
|
return h(DeleteSessionForm, {
|
||||||
|
request,
|
||||||
|
deleteConversation: props.deleteConversation,
|
||||||
|
onSettle: props.settleSessionDelete,
|
||||||
|
t: props.t,
|
||||||
|
}, request.sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One request's dialog; its in-flight and result state die with the request.
|
||||||
|
* Focus, Escape, and Tab behave as the shipped modal does: focus moves into
|
||||||
|
* the card on open, Tab stays inside it, Escape closes it, and focus returns
|
||||||
|
* to the element that opened it.
|
||||||
|
*/
|
||||||
|
function DeleteSessionForm({ request, deleteConversation, onSettle, t: seat }) {
|
||||||
|
const t = translate(seat)
|
||||||
|
const [phase, setPhase] = React.useState('idle')
|
||||||
|
const [result, setResult] = React.useState(null)
|
||||||
|
const dialogRef = React.useRef(null)
|
||||||
|
const busy = phase === 'deleting'
|
||||||
|
|
||||||
|
// The layer effect mounts once per dialog, exactly as the shipped modal
|
||||||
|
// does: the current close command is read through a ref so a state change
|
||||||
|
// never re-runs focus setup or steals focus mid-request.
|
||||||
|
const busyRef = React.useRef(busy)
|
||||||
|
busyRef.current = busy
|
||||||
|
const closeRef = React.useRef(null)
|
||||||
|
closeRef.current = () => {
|
||||||
|
if (busyRef.current) return
|
||||||
|
onSettle()
|
||||||
|
}
|
||||||
|
const close = () => {
|
||||||
|
closeRef.current()
|
||||||
|
}
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const dialog = dialogRef.current
|
||||||
|
if (dialog === null) return undefined
|
||||||
|
const owner = dialog.ownerDocument
|
||||||
|
const previous = owner.activeElement
|
||||||
|
const initial = dialog.querySelector('[data-dsd-autofocus]')
|
||||||
|
?? dialog.querySelector(FOCUSABLE)
|
||||||
|
?? dialog
|
||||||
|
initial.focus?.({ preventScroll: true })
|
||||||
|
|
||||||
|
const onKeyDown = (event) => {
|
||||||
|
if (event.defaultPrevented || event.ctrlKey || event.altKey || event.metaKey) return
|
||||||
|
if (event.key === 'Escape' && !event.shiftKey) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!event.repeat) closeRef.current()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key !== 'Tab') return
|
||||||
|
if (owner.activeElement?.closest('[role="menu"]') != null) return
|
||||||
|
const items = [...dialog.querySelectorAll(FOCUSABLE)].filter((item) => item.closest('[inert], [hidden]') === null)
|
||||||
|
const first = items[0] ?? dialog
|
||||||
|
const last = items.at(-1) ?? dialog
|
||||||
|
const atEdge = event.shiftKey ? owner.activeElement === first : owner.activeElement === last
|
||||||
|
if (owner.activeElement === dialog || !dialog.contains(owner.activeElement) || atEdge) {
|
||||||
|
event.preventDefault()
|
||||||
|
;(event.shiftKey ? last : first).focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
owner.addEventListener('keydown', onKeyDown)
|
||||||
|
return () => {
|
||||||
|
owner.removeEventListener('keydown', onKeyDown)
|
||||||
|
if (previous instanceof HTMLElement && previous.isConnected) previous.focus({ preventScroll: true })
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (phase !== 'done' || result === null || result.kind !== 'deleted') return undefined
|
||||||
|
const timer = setTimeout(() => closeRef.current(), 2500)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [phase, result])
|
||||||
|
|
||||||
|
const confirm = async () => {
|
||||||
|
setPhase('deleting')
|
||||||
|
setResult(null)
|
||||||
|
try {
|
||||||
|
const value = await deleteConversation(request.sessionId)
|
||||||
|
if (value.status === 'scheduled') {
|
||||||
|
setResult({ kind: 'scheduled', message: t('toast.scheduled') })
|
||||||
|
} else {
|
||||||
|
const spilled = Number(value.spilledFiles) || 0
|
||||||
|
setResult({
|
||||||
|
kind: 'deleted',
|
||||||
|
message: spilled > 0 ? t('toast.deletedSpill', { n: spilled }) : t('toast.deleted'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setPhase('done')
|
||||||
|
} catch (error) {
|
||||||
|
setResult({
|
||||||
|
kind: 'failed',
|
||||||
|
message: t('toast.failed', { message: error instanceof Error ? error.message : t('error.unknown') }),
|
||||||
|
})
|
||||||
|
setPhase('error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = request.displayTitle !== undefined && request.displayTitle !== ''
|
||||||
|
? request.displayTitle
|
||||||
|
: request.sessionId
|
||||||
|
|
||||||
|
return h(React.Fragment, null,
|
||||||
|
h('style', null, CSS),
|
||||||
|
h('div', { className: 'dsd-modal-root', role: 'presentation' },
|
||||||
|
h('div', { className: 'dsd-mask', 'aria-hidden': 'true', onClick: close }),
|
||||||
|
h('div', {
|
||||||
|
ref: dialogRef,
|
||||||
|
tabIndex: -1,
|
||||||
|
className: 'dsd-dialog',
|
||||||
|
role: 'dialog',
|
||||||
|
'aria-modal': 'true',
|
||||||
|
'aria-label': t('dialog.title'),
|
||||||
|
},
|
||||||
|
h('div', { className: 'dsd-content' },
|
||||||
|
h('div', { className: 'dsd-header' },
|
||||||
|
h('h2', { className: 'dsd-title' }, t('dialog.title')),
|
||||||
|
h('button', {
|
||||||
|
type: 'button',
|
||||||
|
className: 'dsd-close',
|
||||||
|
'aria-label': t('dialog.close'),
|
||||||
|
onClick: close,
|
||||||
|
}, h(CloseIcon, { size: 14 }))),
|
||||||
|
h('p', { className: 'dsd-description' }, t('dialog.body', { title })),
|
||||||
|
h('div', { className: 'dsd-body' },
|
||||||
|
h('p', { className: 'dsd-note' }, t('dialog.note')),
|
||||||
|
busy && h('div', { className: 'dsd-status', role: 'status' }, t('dialog.pending')),
|
||||||
|
result !== null && h('div', {
|
||||||
|
className: `dsd-status${result.kind === 'failed' ? ' dsd-status-error' : ''}`,
|
||||||
|
role: result.kind === 'failed' ? 'alert' : 'status',
|
||||||
|
}, result.message))),
|
||||||
|
h('div', { className: 'dsd-footer' },
|
||||||
|
phase === 'done'
|
||||||
|
? h('button', { type: 'button', className: 'dsd-button dsd-outline', onClick: close }, t('dialog.close'))
|
||||||
|
: [
|
||||||
|
h('button', {
|
||||||
|
key: 'cancel',
|
||||||
|
type: 'button',
|
||||||
|
className: 'dsd-button dsd-outline',
|
||||||
|
disabled: busy,
|
||||||
|
'data-dsd-autofocus': true,
|
||||||
|
onClick: close,
|
||||||
|
}, t('dialog.cancel')),
|
||||||
|
h('button', {
|
||||||
|
key: 'confirm',
|
||||||
|
type: 'button',
|
||||||
|
className: 'dsd-button dsd-outline dsd-destructive',
|
||||||
|
disabled: busy,
|
||||||
|
onClick: confirm,
|
||||||
|
}, t('dialog.confirm')),
|
||||||
|
]))))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post to one of this package's Host routes. The registration path on the
|
||||||
|
* Host is absolute while this one is document-relative, so the request is
|
||||||
|
* built against the base captured at bundle registration and works under a
|
||||||
|
* mounted prefix. Routes sit behind the connection's trust fence, so the
|
||||||
|
* page's own credentials apply.
|
||||||
|
* @param route - document-relative route path.
|
||||||
|
* @param body - JSON body to send.
|
||||||
|
* @returns the Host's result value.
|
||||||
|
*/
|
||||||
|
async function post(route, body) {
|
||||||
|
const response = await fetch(new URL(route, BASE).href, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
let payload = null
|
||||||
|
try {
|
||||||
|
payload = await response.json()
|
||||||
|
} catch {
|
||||||
|
payload = null
|
||||||
|
}
|
||||||
|
if (!response.ok || payload?.ok === false) {
|
||||||
|
const code = payload?.error?.code
|
||||||
|
const detail = payload?.error?.message ?? `HTTP ${response.status}`
|
||||||
|
throw new Error(code === undefined ? detail : `${detail} (${code})`)
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete one conversation through the Host half.
|
||||||
|
* @param sessionId - conversation to delete.
|
||||||
|
* @returns the Host's result value.
|
||||||
|
*/
|
||||||
|
function deleteConversation(sessionId) {
|
||||||
|
return post(ROUTE, { sessionId })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sweep spilled tool output whose conversation no longer exists.
|
||||||
|
* @returns the Host's cleanup report.
|
||||||
|
*/
|
||||||
|
function cleanOrphans() {
|
||||||
|
return post(ORPHAN_ROUTE, {})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
inject: ['slots', 'locale'],
|
||||||
|
apply(ctx) {
|
||||||
|
const request = createStore(null)
|
||||||
|
const requestSessionDelete = (sessionId, displayTitle) => {
|
||||||
|
request.set({ sessionId, displayTitle })
|
||||||
|
}
|
||||||
|
const locale = ctx.get('locale')
|
||||||
|
if (locale !== undefined) {
|
||||||
|
ctx.effect(() => registerDictionaries(locale, NS, { zh, en }), 'session-delete: dictionaries')
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.slots.inject('sidebar.workspaces.session.menu.item', () => ctx.slots.register({
|
||||||
|
name: 'sidebar.workspaces.session.menu.item',
|
||||||
|
id: NS,
|
||||||
|
order: MENU_ORDER,
|
||||||
|
locale: NS,
|
||||||
|
inject: () => ({ requestSessionDelete }),
|
||||||
|
}, DeleteSessionMenuItem))
|
||||||
|
|
||||||
|
// This entry carries its own id, so the shipped `archive` and `pin`
|
||||||
|
// hover buttons keep their cells and are not shadowed by a lower
|
||||||
|
// priority. Archiving therefore stays exactly where the harness put it.
|
||||||
|
ctx.slots.inject('sidebar.workspaces.session.row.action', () => ctx.slots.register({
|
||||||
|
name: 'sidebar.workspaces.session.row.action',
|
||||||
|
id: NS,
|
||||||
|
order: ROW_ACTION_ORDER,
|
||||||
|
locale: NS,
|
||||||
|
inject: () => ({ requestSessionDelete }),
|
||||||
|
}, DeleteSessionRowButton))
|
||||||
|
|
||||||
|
ctx.slots.inject('shell.overlay', () => ctx.slots.register({
|
||||||
|
name: 'shell.overlay',
|
||||||
|
id: `${NS}-confirm`,
|
||||||
|
locale: NS,
|
||||||
|
inject: () => ({
|
||||||
|
hooks: { deleteRequest: request },
|
||||||
|
deleteConversation,
|
||||||
|
settleSessionDelete: () => {
|
||||||
|
request.set(null)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}, DeleteSessionDialog))
|
||||||
|
|
||||||
|
// The cleanup control belongs on this plugin's own page: registering
|
||||||
|
// this cell key is what makes the row grow a configure control, and the
|
||||||
|
// manager renders the entry for both its summary and page views.
|
||||||
|
ctx.slots.inject('plugins.row.config', () => ctx.slots.register({
|
||||||
|
name: 'plugins.row.config',
|
||||||
|
key: CONFIG_KEY,
|
||||||
|
locale: NS,
|
||||||
|
inject: () => ({ cleanOrphans }),
|
||||||
|
}, TempCleanupPanel))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Register this package's dictionaries, tolerating either registration form. */
|
||||||
|
function registerDictionaries(locale, namespace, dictionaries) {
|
||||||
|
try {
|
||||||
|
return locale.register(namespace, dictionaries)
|
||||||
|
} catch {
|
||||||
|
const disposers = Object.entries(dictionaries).map(([id, dict]) => locale.register(namespace, id, dict))
|
||||||
|
return () => {
|
||||||
|
for (const dispose of disposers) dispose?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Bundle patch layer for @dsh-plugin/session-delete.
|
||||||
|
#
|
||||||
|
# One Host row is enough: the Host half owns the two authenticated routes the
|
||||||
|
# browser half posts to, and the browser half is picked up automatically from
|
||||||
|
# this package's `dsh.client` declaration (dsh-client-modules scans enabled
|
||||||
|
# Loader entries, serves `/plugins/@dsh-plugin/session-delete/client.js`, and
|
||||||
|
# boots it with the page), so the client never needs a row of its own.
|
||||||
|
#
|
||||||
|
# The row id, this package name, and the client module id are the same string
|
||||||
|
# in three places; renaming the package means changing all three.
|
||||||
|
- insert:
|
||||||
|
- id: session-delete
|
||||||
|
name: '@dsh-plugin/session-delete'
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"
|
||||||
|
stroke="#6B7280" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M4 7h16" />
|
||||||
|
<path d="M10 4h4a1 1 0 0 1 1 1v2H9V5a1 1 0 0 1 1-1Z" />
|
||||||
|
<path d="M6.5 7l.8 11.2A2 2 0 0 0 9.3 20h5.4a2 2 0 0 0 2-1.8L17.5 7" />
|
||||||
|
<path d="M10.5 11v5.5M13.5 11v5.5" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 405 B |
@@ -0,0 +1,639 @@
|
|||||||
|
/**
|
||||||
|
* Host half of the session-delete bundle.
|
||||||
|
*
|
||||||
|
* A conversation is durable in exactly two places: its session log (one
|
||||||
|
* directory per session under the persistence root) and the Workspace registry
|
||||||
|
* that references it. Permanent deletion therefore means: stop the session and
|
||||||
|
* gate it against new work, remove its stored artifacts together with its
|
||||||
|
* subagent descendants' logs and their spilled tool output, and drop the
|
||||||
|
* registry references to it.
|
||||||
|
*
|
||||||
|
* The harness deliberately has no deletion API of its own — the shipped JSONL
|
||||||
|
* backend's own documentation says session files accumulate under `root` until
|
||||||
|
* something removes them externally — so removing them is this bundle's whole
|
||||||
|
* job. Two artifacts are deliberately left alone:
|
||||||
|
*
|
||||||
|
* - content-addressed attachments, because one blob can be referenced by
|
||||||
|
* several sessions and nothing in the harness counts those references;
|
||||||
|
* - the projection cache, which is derived and carries a lifecycle identity
|
||||||
|
* check on read, so it can never resolve to a different conversation.
|
||||||
|
*
|
||||||
|
* Spilled tool output is the session's one other session-scoped artifact: the
|
||||||
|
* spill backend stores oversized results at `<root>/session-<hash>/…`, where
|
||||||
|
* the directory is `sha256(session id)` truncated to 12 hex characters and the
|
||||||
|
* root is either the configured one or a `dsh-spill-*` directory under the OS
|
||||||
|
* temp directory.
|
||||||
|
*
|
||||||
|
* A session that is still live in this process cannot be removed from the
|
||||||
|
* in-memory Session and Agent stores by another plugin, and deleting a log
|
||||||
|
* under a live writer would let the next append recreate a header-less
|
||||||
|
* artifact. Such a session is archived (durably gated and stopped) now and its
|
||||||
|
* artifacts are removed on the next activation, when nothing is live yet.
|
||||||
|
*
|
||||||
|
* Cross-half contract: the browser half reaches both operations through the
|
||||||
|
* authenticated exact Fetch routes registered below (`ctx.connection.fetch`),
|
||||||
|
* which is how a static client bundle talks to its Host half — the `host.call`
|
||||||
|
* channel belongs to the dynamic client runner, not to a package bundle.
|
||||||
|
*
|
||||||
|
* @module @dsh-plugin/session-delete
|
||||||
|
*/
|
||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
import { lstat, open, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||||
|
import { dirname, join } from 'node:path'
|
||||||
|
import { homedir, tmpdir } from 'node:os'
|
||||||
|
|
||||||
|
/** Authenticated exact route the Client half posts one deletion to. */
|
||||||
|
const ROUTE_PATH = '/api/plugin/session-delete/delete'
|
||||||
|
/** Authenticated exact route the General-settings row posts its sweep to. */
|
||||||
|
const ORPHAN_ROUTE_PATH = '/api/plugin/session-delete/orphans'
|
||||||
|
/** Durable record of sessions whose artifacts still have to be removed. */
|
||||||
|
const LEDGER_FILENAME = 'session-delete-pending.json'
|
||||||
|
/** The backend's per-session write lock; it survives its session on POSIX. */
|
||||||
|
const LEASE_FILENAME = 'session.lock'
|
||||||
|
/** Exactly the shape the local spill backend derives from a session id. */
|
||||||
|
const SPILL_SESSION_DIRECTORY = /^session-[0-9a-f]{12}$/
|
||||||
|
|
||||||
|
/** Services this Host half needs before it may run at all. */
|
||||||
|
export const inject = [
|
||||||
|
'connection',
|
||||||
|
'sessionPersistence',
|
||||||
|
'sessionQuery',
|
||||||
|
'workspaceRegistry',
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install the deletion routes and finish deletions deferred to this activation.
|
||||||
|
*
|
||||||
|
* @param ctx - Host context carrying the persistence, query, and Workspace services.
|
||||||
|
* @returns the activation's deferred-deletion promise. The runtime ignores it
|
||||||
|
* (the effect owns the work and catches its own failures); awaiting it is how
|
||||||
|
* a test observes that the startup sweep has settled.
|
||||||
|
*/
|
||||||
|
export function apply(ctx) {
|
||||||
|
const inFlight = new Set()
|
||||||
|
|
||||||
|
ctx.effect(() => ctx.connection.fetch.register({
|
||||||
|
path: ROUTE_PATH,
|
||||||
|
methods: ['POST'],
|
||||||
|
requestBody: 'buffered',
|
||||||
|
fetch: (request) => handleRequest(ctx, request, inFlight),
|
||||||
|
}), 'session-delete: delete route')
|
||||||
|
|
||||||
|
ctx.effect(() => ctx.connection.fetch.register({
|
||||||
|
path: ORPHAN_ROUTE_PATH,
|
||||||
|
methods: ['POST'],
|
||||||
|
requestBody: 'buffered',
|
||||||
|
fetch: () => handleOrphanSweep(ctx),
|
||||||
|
}), 'session-delete: orphan sweep route')
|
||||||
|
|
||||||
|
let swept
|
||||||
|
ctx.effect(() => {
|
||||||
|
let disposed = false
|
||||||
|
swept = sweepPending(ctx, () => disposed).catch((error) => {
|
||||||
|
warn(ctx, `could not finish deferred deletions: ${message(error)}`)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
disposed = true
|
||||||
|
}
|
||||||
|
}, 'session-delete: deferred deletions')
|
||||||
|
|
||||||
|
return swept
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Answer one authenticated deletion request.
|
||||||
|
* @param ctx - Host context.
|
||||||
|
* @param request - buffered Fetch request carrying `{ sessionId }`.
|
||||||
|
* @param inFlight - identities already being deleted in this process.
|
||||||
|
* @returns the JSON result or a stable failure the Client half reports.
|
||||||
|
*/
|
||||||
|
async function handleRequest(ctx, request, inFlight) {
|
||||||
|
let body
|
||||||
|
try {
|
||||||
|
body = await request.json()
|
||||||
|
} catch {
|
||||||
|
return failure(400, 'bad-request', 'the request body must be JSON')
|
||||||
|
}
|
||||||
|
const sessionId = body?.sessionId
|
||||||
|
if (typeof sessionId !== 'string' || sessionId.trim() === '') {
|
||||||
|
return failure(400, 'bad-request', 'sessionId must be a non-empty string')
|
||||||
|
}
|
||||||
|
if (inFlight.has(sessionId)) {
|
||||||
|
return failure(409, 'busy', `conversation "${sessionId}" is already being deleted`)
|
||||||
|
}
|
||||||
|
|
||||||
|
inFlight.add(sessionId)
|
||||||
|
try {
|
||||||
|
const value = await deleteConversation(ctx, sessionId)
|
||||||
|
return json(value)
|
||||||
|
} catch (error) {
|
||||||
|
warn(ctx, `deleting "${sessionId}" failed: ${message(error)}`)
|
||||||
|
return failure(500, 'delete-failed', message(error))
|
||||||
|
} finally {
|
||||||
|
inFlight.delete(sessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete one conversation and every subagent conversation under it.
|
||||||
|
* @param ctx - Host context.
|
||||||
|
* @param sessionId - conversation the user asked to delete.
|
||||||
|
* @returns `deleted` when the artifacts are gone, `scheduled` when a live
|
||||||
|
* session forced the removal to the next activation.
|
||||||
|
*/
|
||||||
|
async function deleteConversation(ctx, sessionId) {
|
||||||
|
const lineage = await resolveLineage(ctx, sessionId)
|
||||||
|
const ids = lineage.ids
|
||||||
|
|
||||||
|
if (lineage.liveIds.length > 0) {
|
||||||
|
for (const id of lineage.ids) await ctx.workspaceRegistry.archiveSession(id, { stopActivity: true })
|
||||||
|
await rememberPending(ctx, sessionId)
|
||||||
|
return { ok: true, status: 'scheduled', sessionIds: ids, live: lineage.liveIds }
|
||||||
|
}
|
||||||
|
|
||||||
|
const removed = []
|
||||||
|
for (const id of ids) {
|
||||||
|
const header = lineage.headers.get(id)
|
||||||
|
if (header === undefined) continue
|
||||||
|
if (await removeArtifacts(ctx, header)) removed.push(id)
|
||||||
|
}
|
||||||
|
const spilled = await removeSpillArtifacts(ctx, ids)
|
||||||
|
for (const id of ids) await forgetReferences(ctx, id)
|
||||||
|
for (const id of removed) announceRemoval(ctx, id)
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 'deleted',
|
||||||
|
sessionIds: ids,
|
||||||
|
removed,
|
||||||
|
spilledDirectories: spilled.directories,
|
||||||
|
spilledFiles: spilled.files,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve one conversation and its subagent descendants from the query
|
||||||
|
* service's lineage trace.
|
||||||
|
*
|
||||||
|
* `traceSession` is the harness's own corpus observation: one call answers the
|
||||||
|
* whole parent/child structure, so this bundle never has to list every stored
|
||||||
|
* header and re-derive `parentSession` edges itself. Each copied
|
||||||
|
* `SessionRecord` also carries the header the deletion needs and a `live` flag,
|
||||||
|
* which is what keeps the `agents` and `sessions` services out of `inject`.
|
||||||
|
*
|
||||||
|
* @param ctx - Host context.
|
||||||
|
* @param sessionId - conversation the user asked to delete.
|
||||||
|
* @returns the target and descendant ids (parents first), their headers, and
|
||||||
|
* which of them this process currently holds live.
|
||||||
|
*/
|
||||||
|
async function resolveLineage(ctx, sessionId) {
|
||||||
|
let trace
|
||||||
|
try {
|
||||||
|
trace = await ctx.sessionQuery.traceSession(sessionId)
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`conversation "${sessionId}" was not found: ${message(error)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = trace?.target
|
||||||
|
const header = target?.header
|
||||||
|
if (target === undefined || header === undefined) {
|
||||||
|
throw new Error(`conversation "${sessionId}" was not found`)
|
||||||
|
}
|
||||||
|
if (header.origin === 'subagent') {
|
||||||
|
throw new Error('a subagent conversation is removed together with the conversation that owns it')
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = new Map([[String(header.id), header]])
|
||||||
|
const ids = [String(header.id)]
|
||||||
|
const liveIds = target.live === true ? [String(header.id)] : []
|
||||||
|
collectDescendants(trace.descendants, headers, ids, liveIds)
|
||||||
|
return { headers, ids, liveIds }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk one already-traced descendant tree into flat id order.
|
||||||
|
* @param nodes - lineage nodes, nearest generation first.
|
||||||
|
* @param headers - accumulator keyed by session id.
|
||||||
|
* @param ids - accumulator in parents-first order.
|
||||||
|
* @param liveIds - accumulator of ids this process holds live.
|
||||||
|
*/
|
||||||
|
function collectDescendants(nodes, headers, ids, liveIds) {
|
||||||
|
for (const node of Array.isArray(nodes) ? nodes : []) {
|
||||||
|
const header = node?.session?.header
|
||||||
|
if (header === undefined) continue
|
||||||
|
const id = String(header.id)
|
||||||
|
if (headers.has(id)) continue
|
||||||
|
headers.set(id, header)
|
||||||
|
ids.push(id)
|
||||||
|
if (node.session.live === true) liveIds.push(id)
|
||||||
|
collectDescendants(node.descendants, headers, ids, liveIds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive one session's spill directory name exactly as the local spill backend
|
||||||
|
* does: `sha256(session id)` truncated to 12 hex characters.
|
||||||
|
* @param sessionId - owning session identity.
|
||||||
|
* @returns the directory name shared by every root.
|
||||||
|
*/
|
||||||
|
function spillSessionDirectory(sessionId) {
|
||||||
|
return `session-${createHash('sha256').update(String(sessionId)).digest('hex').slice(0, 12)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every spill root this process may have written under: the backend's active
|
||||||
|
* root plus the `dsh-spill-*` default roots the backend itself sweeps.
|
||||||
|
* @param ctx - Host context.
|
||||||
|
* @returns absolute candidate roots.
|
||||||
|
*/
|
||||||
|
async function spillRoots(ctx) {
|
||||||
|
const roots = new Set()
|
||||||
|
const configured = ctx.get('spillStore')?.root
|
||||||
|
if (typeof configured === 'string' && configured !== '') roots.add(configured)
|
||||||
|
const base = tmpdir()
|
||||||
|
let entries = []
|
||||||
|
try {
|
||||||
|
entries = await readdir(base, { withFileTypes: true })
|
||||||
|
} catch (error) {
|
||||||
|
warn(ctx, `spill roots under ${base} could not be listed: ${message(error)}`)
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isDirectory() && entry.name.startsWith('dsh-spill')) roots.add(join(base, entry.name))
|
||||||
|
}
|
||||||
|
return [...roots]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the spilled tool output of the given sessions.
|
||||||
|
*
|
||||||
|
* Only a real, session-named directory under a spill root is removed; a symlink,
|
||||||
|
* a file, or any other entry is left untouched, and a failure is reported
|
||||||
|
* without failing the deletion that already happened.
|
||||||
|
*
|
||||||
|
* @param ctx - Host context.
|
||||||
|
* @param sessionIds - identities whose spilled output goes away.
|
||||||
|
* @returns how many directories and files were removed.
|
||||||
|
*/
|
||||||
|
async function removeSpillArtifacts(ctx, sessionIds) {
|
||||||
|
const names = sessionIds.map(spillSessionDirectory)
|
||||||
|
let directories = 0
|
||||||
|
let files = 0
|
||||||
|
for (const root of await spillRoots(ctx)) {
|
||||||
|
for (const name of names) {
|
||||||
|
const directory = join(root, name)
|
||||||
|
let info
|
||||||
|
try {
|
||||||
|
info = await lstat(directory)
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (info.isSymbolicLink() || !info.isDirectory()) continue
|
||||||
|
try {
|
||||||
|
files += (await readdir(directory)).length
|
||||||
|
} catch {
|
||||||
|
/* a listing failure never blocks the removal */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await rm(directory, { recursive: true, force: true })
|
||||||
|
directories += 1
|
||||||
|
} catch (error) {
|
||||||
|
warn(ctx, `spilled output at ${directory} was kept: ${message(error)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { directories, files }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Answer the General-settings cleanup request.
|
||||||
|
* @param ctx - Host context.
|
||||||
|
* @returns the JSON report the settings row shows.
|
||||||
|
*/
|
||||||
|
async function handleOrphanSweep(ctx) {
|
||||||
|
try {
|
||||||
|
const report = await sweepOrphanSpill(ctx)
|
||||||
|
return json({ ok: true, ...report })
|
||||||
|
} catch (error) {
|
||||||
|
warn(ctx, `orphan sweep failed: ${message(error)}`)
|
||||||
|
return failure(500, 'sweep-failed', message(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove spilled tool output that no session known to this home directory can
|
||||||
|
* own.
|
||||||
|
*
|
||||||
|
* A spill directory is orphaned only when its name has exactly the
|
||||||
|
* `session-<sha256(session id)[:12]>` shape the spill backend derives AND no
|
||||||
|
* session known to this process hashes to it. `listSessions()` already merges
|
||||||
|
* live and persisted sessions into one logical corpus, so "known" needs no
|
||||||
|
* second source. Anything else, including a symlink or an entry the backend
|
||||||
|
* would never create, is left alone.
|
||||||
|
*
|
||||||
|
* @param ctx - Host context.
|
||||||
|
* @returns counts for the settings row.
|
||||||
|
*/
|
||||||
|
async function sweepOrphanSpill(ctx) {
|
||||||
|
const records = await ctx.sessionQuery.listSessions()
|
||||||
|
const known = new Set()
|
||||||
|
for (const record of records) {
|
||||||
|
const id = record?.header?.id
|
||||||
|
if (id !== undefined) known.add(spillSessionDirectory(id))
|
||||||
|
}
|
||||||
|
const report = { directories: 0, files: 0, bytes: 0, roots: 0, sessions: known.size }
|
||||||
|
for (const root of await spillRoots(ctx)) {
|
||||||
|
report.roots += 1
|
||||||
|
let entries
|
||||||
|
try {
|
||||||
|
entries = await readdir(root, { withFileTypes: true })
|
||||||
|
} catch (error) {
|
||||||
|
warn(ctx, `spill root ${root} could not be listed: ${message(error)}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory() || !SPILL_SESSION_DIRECTORY.test(entry.name)) continue
|
||||||
|
if (known.has(entry.name)) continue
|
||||||
|
const directory = join(root, entry.name)
|
||||||
|
try {
|
||||||
|
const info = await lstat(directory)
|
||||||
|
if (info.isSymbolicLink() || !info.isDirectory()) continue
|
||||||
|
const measured = await measureDirectory(directory)
|
||||||
|
await rm(directory, { recursive: true, force: true })
|
||||||
|
report.directories += 1
|
||||||
|
report.files += measured.files
|
||||||
|
report.bytes += measured.bytes
|
||||||
|
} catch (error) {
|
||||||
|
warn(ctx, `orphaned spill directory ${directory} was kept: ${message(error)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return report
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measure one spill session directory. The backend writes every artifact
|
||||||
|
* directly into it, so leaf files are the whole content.
|
||||||
|
* @param directory - spill session directory.
|
||||||
|
* @returns file count and total bytes.
|
||||||
|
*/
|
||||||
|
async function measureDirectory(directory) {
|
||||||
|
let files = 0
|
||||||
|
let bytes = 0
|
||||||
|
let entries
|
||||||
|
try {
|
||||||
|
entries = await readdir(directory, { withFileTypes: true })
|
||||||
|
} catch {
|
||||||
|
return { files, bytes }
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isFile()) continue
|
||||||
|
try {
|
||||||
|
bytes += (await stat(join(directory, entry.name))).size
|
||||||
|
files += 1
|
||||||
|
} catch {
|
||||||
|
/* a file that vanished mid-measure simply does not count */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { files, bytes }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove every stored generation of one session, keeping its write lock file.
|
||||||
|
*
|
||||||
|
* The absolute artifact path comes from the backend's `locate()` hook. That
|
||||||
|
* hook is not part of the abstract `sessionPersistence` contract — the seam
|
||||||
|
* itself declares only `create`, `open`, `flush`, `stat`, and `list` — so it is
|
||||||
|
* probed rather than assumed, and a backend without it fails this one
|
||||||
|
* conversation instead of silently deleting nothing. The returned path names
|
||||||
|
* the highest canonical generation *file*, so its parent directory is the
|
||||||
|
* session-owned directory whose contents go away.
|
||||||
|
*
|
||||||
|
* @param ctx - Host context.
|
||||||
|
* @param header - the session's stored header, which names its artifact.
|
||||||
|
* @returns whether anything was removed.
|
||||||
|
*/
|
||||||
|
async function removeArtifacts(ctx, header) {
|
||||||
|
const persistence = ctx.get('sessionPersistence')
|
||||||
|
if (typeof persistence?.locate !== 'function') {
|
||||||
|
throw new Error('this session storage backend cannot locate stored artifacts, so nothing was deleted')
|
||||||
|
}
|
||||||
|
const located = persistence.locate(header)
|
||||||
|
const artifactPath = located?.path
|
||||||
|
if (typeof artifactPath !== 'string' || artifactPath === '') {
|
||||||
|
throw new Error(`conversation "${String(header.id)}" has no stored artifact to delete`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const directory = dirname(artifactPath)
|
||||||
|
let entries
|
||||||
|
try {
|
||||||
|
entries = await readdir(directory, { withFileTypes: true })
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code === 'ENOENT') return false
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
let removed = false
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.name === LEASE_FILENAME) continue
|
||||||
|
await rm(join(directory, entry.name), { recursive: true, force: true })
|
||||||
|
removed = true
|
||||||
|
}
|
||||||
|
if (removed && process.platform !== 'win32') await syncDirectory(directory)
|
||||||
|
return removed
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flush one directory entry set on POSIX, where removal is not durable until
|
||||||
|
* the parent directory is synced.
|
||||||
|
* @param directory - directory to sync.
|
||||||
|
*/
|
||||||
|
async function syncDirectory(directory) {
|
||||||
|
const handle = await open(directory, 'r')
|
||||||
|
try {
|
||||||
|
await handle.sync()
|
||||||
|
} finally {
|
||||||
|
await handle.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop every durable reference to a session whose artifacts are gone:
|
||||||
|
* Workspace accounting, the archive set, and the pin set. Each call is
|
||||||
|
* idempotent for an id it does not hold, so a failure is reported and the
|
||||||
|
* remaining references are still cleared.
|
||||||
|
* @param ctx - Host context.
|
||||||
|
* @param sessionId - removed identity.
|
||||||
|
*/
|
||||||
|
async function forgetReferences(ctx, sessionId) {
|
||||||
|
const registry = ctx.workspaceRegistry
|
||||||
|
for (const workspace of registry.list()) {
|
||||||
|
try {
|
||||||
|
await workspace.detachSession(sessionId)
|
||||||
|
} catch (error) {
|
||||||
|
warn(ctx, `workspace "${workspace.id}" kept a reference to "${sessionId}": ${message(error)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [operation, run] of [
|
||||||
|
['unpin', () => registry.unpinSession(sessionId)],
|
||||||
|
['unarchive', () => registry.unarchiveSession(sessionId)],
|
||||||
|
]) {
|
||||||
|
try {
|
||||||
|
await run()
|
||||||
|
} catch (error) {
|
||||||
|
warn(ctx, `${operation} of "${sessionId}" failed: ${message(error)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Announce one removed conversation so connected pages drop its row. */
|
||||||
|
function announceRemoval(ctx, sessionId) {
|
||||||
|
try {
|
||||||
|
ctx.emit('api-session/removed', sessionId)
|
||||||
|
} catch (error) {
|
||||||
|
warn(ctx, `could not announce removal of "${sessionId}": ${message(error)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finish deletions deferred by a live session.
|
||||||
|
*
|
||||||
|
* Every ledger entry is a root conversation, so each iteration resolves its own
|
||||||
|
* subtree and removes it whole; a child is never recorded separately, because
|
||||||
|
* the sweep that removes a root may not re-run for an entry a sibling root
|
||||||
|
* already took with it. An entry is deleted only while it is still archived, so
|
||||||
|
* restoring a conversation in the sidebar cancels its pending deletion: the
|
||||||
|
* durable archive set is what the sidebar's own unarchive drops.
|
||||||
|
*
|
||||||
|
* @param ctx - Host context.
|
||||||
|
* @param isDisposed - whether this plugin already unloaded.
|
||||||
|
*/
|
||||||
|
async function sweepPending(ctx, isDisposed) {
|
||||||
|
const pending = await readLedger(ctx)
|
||||||
|
if (pending.length === 0) return
|
||||||
|
|
||||||
|
// The registry-global archive set; membership is the user's own "still
|
||||||
|
// archived" decision and changes only through the sidebar or this plugin.
|
||||||
|
const registry = ctx.workspaceRegistry
|
||||||
|
const archivedSessionIds = registry.archivedSessionIds
|
||||||
|
const remaining = []
|
||||||
|
for (const sessionId of pending) {
|
||||||
|
if (isDisposed()) {
|
||||||
|
remaining.push(sessionId)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let lineage
|
||||||
|
try {
|
||||||
|
lineage = await resolveLineage(ctx, sessionId)
|
||||||
|
} catch (error) {
|
||||||
|
// The conversation is not in the corpus any more: what is left to do is
|
||||||
|
// drop the registry references a previous run did not reach, and stop
|
||||||
|
// retrying it on every activation.
|
||||||
|
warn(ctx, `deferred deletion of "${sessionId}" resolved to nothing: ${message(error)}`)
|
||||||
|
await forgetReferences(ctx, sessionId)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (lineage.liveIds.length > 0) {
|
||||||
|
remaining.push(sessionId)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!archivedSessionIds.includes(sessionId)) continue
|
||||||
|
try {
|
||||||
|
for (const id of lineage.ids) {
|
||||||
|
const header = lineage.headers.get(id)
|
||||||
|
if (header !== undefined) await removeArtifacts(ctx, header)
|
||||||
|
}
|
||||||
|
await removeSpillArtifacts(ctx, lineage.ids)
|
||||||
|
for (const id of lineage.ids) await forgetReferences(ctx, id)
|
||||||
|
announceRemoval(ctx, sessionId)
|
||||||
|
} catch (error) {
|
||||||
|
warn(ctx, `could not finish deleting "${sessionId}": ${message(error)}`)
|
||||||
|
remaining.push(sessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await writeLedger(ctx, remaining)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the deferred-deletion record.
|
||||||
|
* @param ctx - Host context.
|
||||||
|
* @returns session ids still waiting for removal.
|
||||||
|
*/
|
||||||
|
async function readLedger(ctx) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(await readFile(ledgerPath(ctx), 'utf8'))
|
||||||
|
return Array.isArray(parsed?.sessions)
|
||||||
|
? parsed.sessions.filter((id) => typeof id === 'string' && id !== '')
|
||||||
|
: []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the deferred-deletion record.
|
||||||
|
* @param ctx - Host context.
|
||||||
|
* @param sessionIds - ids still waiting for removal.
|
||||||
|
*/
|
||||||
|
async function writeLedger(ctx, sessionIds) {
|
||||||
|
const unique = [...new Set(sessionIds)]
|
||||||
|
const path = ledgerPath(ctx)
|
||||||
|
if (unique.length === 0) {
|
||||||
|
await rm(path, { force: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await writeFile(path, `${JSON.stringify({ version: 1, sessions: unique }, null, 2)}\n`, 'utf8')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record the root conversation whose artifacts are removed on the next
|
||||||
|
* activation.
|
||||||
|
*
|
||||||
|
* Only a tree root is recorded: the activation sweep removes a root together
|
||||||
|
* with everything under it, so a separately recorded child would either be
|
||||||
|
* deleted twice or, once its root had taken it, need its entry dropped on a
|
||||||
|
* pass that never runs.
|
||||||
|
*
|
||||||
|
* @param ctx - Host context.
|
||||||
|
* @param sessionId - the root conversation of one deferred deletion.
|
||||||
|
*/
|
||||||
|
async function rememberPending(ctx, sessionId) {
|
||||||
|
const pending = await readLedger(ctx)
|
||||||
|
await writeLedger(ctx, [...pending, sessionId])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The deferred-deletion record lives beside the session store it describes, so
|
||||||
|
* it follows `$DSH_HOME` without this plugin reading harness configuration.
|
||||||
|
* @param ctx - Host context.
|
||||||
|
* @returns absolute ledger path.
|
||||||
|
*/
|
||||||
|
function ledgerPath(ctx) {
|
||||||
|
const root = ctx.get('sessionPersistence')?.root
|
||||||
|
if (typeof root === 'string' && root !== '') return join(dirname(root), LEDGER_FILENAME)
|
||||||
|
const home = process.env.DSH_HOME
|
||||||
|
const base = typeof home === 'string' && home !== '' ? home : join(homedir(), '.dsh')
|
||||||
|
return join(base, LEDGER_FILENAME)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Answer one JSON value the Client half reads, never cached. */
|
||||||
|
function json(value, status = 200) {
|
||||||
|
return Response.json(value, { status, headers: { 'cache-control': 'no-store' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build one stable JSON failure the Client half surfaces verbatim. */
|
||||||
|
function failure(status, code, text) {
|
||||||
|
return json({ ok: false, error: { code, message: text } }, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Log one contained diagnostic. */
|
||||||
|
function warn(ctx, text) {
|
||||||
|
try {
|
||||||
|
ctx.logger?.warn?.(`session-delete: ${text}`)
|
||||||
|
} catch {
|
||||||
|
/* a diagnostic never fails the operation it describes */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render one unknown failure as text. */
|
||||||
|
function message(error) {
|
||||||
|
return error instanceof Error ? error.message : String(error)
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"title": "Session delete",
|
||||||
|
"description": "Permanently delete a conversation: stop it, remove its session log, subagent logs, and spilled tool output, then drop every registry reference. This plugin's own page sweeps what conversations that are gone left behind."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"title": "会话彻底删除",
|
||||||
|
"description": "彻底删除一个会话:先停止它,再删除会话日志、子智能体日志与工具输出溢出文件,并清理相关注册表引用;这个插件的页面里可清理已删除会话遗留的孤立临时文件。"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"name": "@dsh-plugin/session-delete",
|
||||||
|
"version": "2.0.1",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Permanently delete a conversation: stop it, remove its session log, subagent logs, and spilled tool output, and drop every registry reference. The plugin's own page under Settings → Plugins can sweep orphaned temporary files.",
|
||||||
|
"license": "MIT",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://gitea.iwake.top/dsh-plugin/session-delete.git"
|
||||||
|
},
|
||||||
|
"homepage": "https://gitea.iwake.top/dsh-plugin/session-delete",
|
||||||
|
"exports": {
|
||||||
|
".": "./index.js",
|
||||||
|
"./client": "./client.js",
|
||||||
|
"./package.json": "./package.json",
|
||||||
|
"./locale/*.json": "./locale/*.json"
|
||||||
|
},
|
||||||
|
"icon": "./icon.svg",
|
||||||
|
"meta": {
|
||||||
|
"title": "Session delete",
|
||||||
|
"description": "Permanently delete a conversation and its stored log, and sweep orphaned temporary files."
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"index.js",
|
||||||
|
"client.js",
|
||||||
|
"cordis.patch.yml",
|
||||||
|
"icon.svg",
|
||||||
|
"locale/*.json",
|
||||||
|
"README.md",
|
||||||
|
"CONTRIBUTING.md",
|
||||||
|
".gitmessage",
|
||||||
|
"scripts/**/*.mjs",
|
||||||
|
"LICENSE"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22",
|
||||||
|
"dsh": "0.2.0-rc.2"
|
||||||
|
},
|
||||||
|
"dsh": {
|
||||||
|
"manifestVersion": 1,
|
||||||
|
"bundle": {
|
||||||
|
"patch": "./cordis.patch.yml"
|
||||||
|
},
|
||||||
|
"client": {
|
||||||
|
"platform": "web",
|
||||||
|
"immediately": true,
|
||||||
|
"inject": [
|
||||||
|
"@deepseek-ai/dsh-client-ui-workspace"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
// Checks existing commits against the CONTRIBUTING.md spec. The spec allows one
|
||||||
|
// line only, so a subject must both parse (emoji + type + Chinese description)
|
||||||
|
// and be the whole message: any body or footer is reported too.
|
||||||
|
//
|
||||||
|
// This is a reporting tool, not a hook: it never blocks a commit.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// node scripts/check-commit-log.mjs # newest 10 commits
|
||||||
|
// node scripts/check-commit-log.mjs 50 # newest 50 commits
|
||||||
|
// node scripts/check-commit-log.mjs all # every commit
|
||||||
|
// DSD_SPEC=path/to/CONTRIBUTING.md node scripts/check-commit-log.mjs
|
||||||
|
import { execFileSync } from 'node:child_process'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { dirname, resolve } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||||
|
const specPath = process.env.DSD_SPEC ?? resolve(repoRoot, 'CONTRIBUTING.md')
|
||||||
|
const doc = readFileSync(specPath, 'utf8')
|
||||||
|
const count = process.argv[2] ?? '10'
|
||||||
|
|
||||||
|
/** The regex the spec documents for a subject line. */
|
||||||
|
const SUBJECT = new RegExp(
|
||||||
|
'^(?:\\p{Extended_Pictographic}\\uFE0F?\\s+)?' +
|
||||||
|
'(?<type>[a-z]+)' +
|
||||||
|
'(?:\\((?<scope>[^)]*)\\))?' +
|
||||||
|
'(?<breaking>!)?' +
|
||||||
|
':\\s(?<desc>.+)$',
|
||||||
|
'u',
|
||||||
|
)
|
||||||
|
|
||||||
|
// The spec's table is the single source of truth for the type → emoji mapping,
|
||||||
|
// so a table edit cannot drift from what this script checks.
|
||||||
|
const emojiOfType = new Map()
|
||||||
|
for (const row of doc.split('\n').filter((line) => /^\| `[a-z]+` \| /.test(line))) {
|
||||||
|
const cells = row.split('|').map((cell) => cell.trim())
|
||||||
|
emojiOfType.set(cells[1].replaceAll('`', ''), cells[2])
|
||||||
|
}
|
||||||
|
if (emojiOfType.size === 0) throw new Error(`${specPath} has no type table to read`)
|
||||||
|
|
||||||
|
/** Strip the variation selector, so '♻' and '♻️' compare equal. */
|
||||||
|
const base = (text) => text.replace(/\uFE0F/g, '')
|
||||||
|
/** Display width, counting anything above U+2000 as double. */
|
||||||
|
const widthOf = (text) => [...text].reduce((n, c) => n + (c.codePointAt(0) > 0x2000 ? 2 : 1), 0)
|
||||||
|
|
||||||
|
// %B is the raw message body, so a record can hold several lines: a record ends
|
||||||
|
// at the next hash that starts a line, which is what this splits on.
|
||||||
|
const range = count === 'all' ? [] : [`-${Number(count)}`]
|
||||||
|
const raw = execFileSync('git', ['log', ...range, '--format=%h%x09%B%x00'], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
cwd: repoRoot,
|
||||||
|
})
|
||||||
|
|
||||||
|
const commits = raw.split('\0').map((chunk) => chunk.replace(/^\n+/, '')).filter((chunk) => chunk.trim() !== '')
|
||||||
|
|
||||||
|
let problems = 0
|
||||||
|
for (const chunk of commits) {
|
||||||
|
const tab = chunk.indexOf('\t')
|
||||||
|
const hash = chunk.slice(0, tab)
|
||||||
|
const message = chunk.slice(tab + 1).replace(/\n+$/, '')
|
||||||
|
const lines = message.split('\n')
|
||||||
|
const subject = lines[0]
|
||||||
|
const extra = lines.slice(1).filter((line) => line.trim() !== '')
|
||||||
|
const notes = []
|
||||||
|
|
||||||
|
const match = SUBJECT.exec(subject)
|
||||||
|
if (match === null) {
|
||||||
|
notes.push('首行缺少类型前缀,或整体格式不是 <emoji> <type>[(scope)][!]: <描述>')
|
||||||
|
} else {
|
||||||
|
const expected = emojiOfType.get(match.groups.type)
|
||||||
|
const lead = [...subject][0]
|
||||||
|
if (expected === undefined) notes.push(`类型不在规范表里:${match.groups.type}`)
|
||||||
|
else if (base(lead) !== base(expected)) notes.push(`类型 ${match.groups.type} 的 emoji 应为 ${expected},实际是 ${lead}`)
|
||||||
|
const width = widthOf(subject)
|
||||||
|
if (width > 72) notes.push(`首行宽度 ${width},超过 72`)
|
||||||
|
if (/[。.]$/.test(subject)) notes.push('首行结尾不应有句号')
|
||||||
|
}
|
||||||
|
if (extra.length > 0) notes.push(`带了 ${extra.length} 行正文或脚注,规范要求只有一行`)
|
||||||
|
|
||||||
|
if (notes.length > 0) problems += 1
|
||||||
|
const mark = notes.length === 0 ? 'ok ' : 'FAIL'
|
||||||
|
console.log(` ${mark} ${hash} ${notes.length === 0 ? '' : notes.join(';') + ' — '}${subject}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(problems === 0
|
||||||
|
? `\n检查了 ${commits.length} 条提交,全部符合规范`
|
||||||
|
: `\n检查了 ${commits.length} 条提交,其中 ${problems} 条不符合规范(规范文件:${specPath})`)
|
||||||
|
process.exit(problems === 0 ? 0 : 1)
|
||||||
Reference in New Issue
Block a user