refactor!: align with the DSH plugin conventions and drop the npm publish workflow
Follow the cordis-plugin-development references instead of the ad-hoc choices the first version made. Client half: - register the row button under this package's own id instead of shadowing the shipped `archive` action at a lower priority, so the official hover buttons keep their cells and archiving stays where the harness put it - drop the synthetic `pointerout` dispatched at a `[data-row-key]` ancestor: a plugin does not read or drive another package's DOM, so the tooltip is now positioned from its own button alone - keep the self-rendered primitives, the `--dsw-*` token-only styling, and the modal focus/Escape behavior, and document why Host half: - resolve a conversation's descendants with `sessionQuery.traceSession()` instead of listing every stored header and re-deriving `parentSession` edges - decide liveness from the traced `SessionRecord.live` flag, which removes the `agents` and `sessions` dependencies from `inject` - build the orphan-sweep corpus from `sessionQuery.listSessions()`, which already merges live and persisted sessions - document that `sessionPersistence.locate()` is a JSONL-backend diagnostic hook, not part of the seam, and keep probing it explicitly - record only tree roots in the deferred-deletion ledger: the activation sweep removes a root with everything under it, so a separately recorded child was deleted twice or needed a pass that never runs - return the activation's sweep promise from `apply()` so a test can await it Manifest and docs: - version 2.0.0, `private`, `dsh.manifestVersion`, and `engines` - delete the Gitea npm publish workflow and every npm-publishing task: the package is distributed only through the Git repository and tags - rewrite the README around the current install paths and the plugin's limits
This commit is contained in:
@@ -1,142 +0,0 @@
|
|||||||
# 把打了 v* 标签的版本发布到 Gitea 的 npm 包仓库。
|
|
||||||
#
|
|
||||||
# 触发方式:
|
|
||||||
# 1. 推送版本标签(推荐):git tag -a v1.0.1 -m "..." && git push origin v1.0.1
|
|
||||||
# 2. 仓库 → Actions → 选择本工作流 → Run workflow(手动,发布当前 ref 的版本)
|
|
||||||
#
|
|
||||||
# 本工作流刻意不使用任何 `uses:` 外部 action:runner 在中国大陆访问 github.com 往往超时
|
|
||||||
# (表现为所有步骤 cancelled,日志里是 dial tcp ...:443: i/o timeout),而 checkout 只是
|
|
||||||
# git fetch、Node 本来就在 runner 镜像里。这样工作流只依赖本实例的 git 与镜像自带的 node。
|
|
||||||
#
|
|
||||||
# 已发布的版本不会让工作流失败:注册表返回 409(version already exists)时这一步记为
|
|
||||||
# “已存在,本次跳过”,所以手动重跑同一个版本是安全的。
|
|
||||||
#
|
|
||||||
# 需要的凭据(实测结论,Gitea 1.27.2,本仓库 v1.0.2 那次发布):
|
|
||||||
# 内置任务令牌**不能**发布包。它在注册表眼里是伪用户 gitea-actions(id -2),不是组织成员,
|
|
||||||
# 而 Gitea 的包写权限要求「组织成员且具 admin/write 权限」——于是即使工作流声明了
|
|
||||||
# permissions: packages: write、组织也把 Actions 令牌权限设成宽松,npm publish 仍返回
|
|
||||||
# E401 Incorrect or missing password。(组织设置里那张“最大令牌权限”表也没有 packages 这一项。)
|
|
||||||
# 所以发布用 secret NPM_TOKEN = 个人访问令牌,权限勾 write:package;内置令牌只在没配它时兜底。
|
|
||||||
#
|
|
||||||
# 依赖前提:
|
|
||||||
# - 实例启用了 Actions 并注册了 act_runner;runs-on 的标签要与 runner 一致。
|
|
||||||
# - runner 镜像里要有 node 与 npm(Gitea 官方 runner-images 自带)。
|
|
||||||
# - 包名、`cordis.patch.yml` 的 name、客户端模块 id 三处必须一致,改名时别漏。
|
|
||||||
name: publish
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
# 读取代码用于检出,向本组织写包。
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
npm:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
env:
|
|
||||||
REGISTRY: https://gitea.iwake.top/api/packages/dsh-plugin/npm/
|
|
||||||
steps:
|
|
||||||
- name: Check out the pushed ref
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
url="${GITHUB_SERVER_URL:-https://gitea.iwake.top}/${GITHUB_REPOSITORY:-dsh-plugin/session-delete}.git"
|
|
||||||
echo "从 ${url} 检出 ${GITHUB_REF}"
|
|
||||||
git init -q .
|
|
||||||
git remote add origin "${url}"
|
|
||||||
git fetch -q --depth 1 origin "${GITHUB_REF}"
|
|
||||||
git checkout -q FETCH_HEAD
|
|
||||||
git log --oneline -1
|
|
||||||
|
|
||||||
- name: Show the toolchain
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
node -v
|
|
||||||
npm -v
|
|
||||||
|
|
||||||
- name: Read and check the version
|
|
||||||
id: version
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
version="$(node -p "require('./package.json').version")"
|
|
||||||
name="$(node -p "require('./package.json').name")"
|
|
||||||
case "${GITHUB_REF}" in
|
|
||||||
refs/tags/*)
|
|
||||||
tag="${GITHUB_REF_NAME#v}"
|
|
||||||
if [ "$tag" != "$version" ]; then
|
|
||||||
echo "标签 ${GITHUB_REF_NAME} 与 package.json 的版本 ${version} 不一致" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
echo "name=${name}" >> "${GITHUB_OUTPUT}"
|
|
||||||
echo "version=${version}" >> "${GITHUB_OUTPUT}"
|
|
||||||
echo "目标:${name}@${version}"
|
|
||||||
|
|
||||||
- name: Pack (preview the published contents)
|
|
||||||
run: npm pack --dry-run
|
|
||||||
|
|
||||||
- name: Publish
|
|
||||||
env:
|
|
||||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
||||||
JOB_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
# 作用域与默认注册表都指向本组织的 npm 仓库(默认注册表也要设,否则 npm 会拿
|
|
||||||
# registry.npmjs.org 的 packument 做“是否已发布”判断,甚至可能发错地方)。
|
|
||||||
# 注意:npm 不允许用 `npm config get` 读回 _authToken(会报 protected),所以不打印它。
|
|
||||||
npm config set registry "${REGISTRY}"
|
|
||||||
npm config set @dsh-plugin:registry "${REGISTRY}"
|
|
||||||
echo "registry = $(npm config get registry)"
|
|
||||||
|
|
||||||
RESULT=""
|
|
||||||
LAST_LABEL=""
|
|
||||||
# 结果分类:published 成功 / exists 版本已存在(不视为失败)/ auth 令牌被拒 / error 其他
|
|
||||||
run_publish() {
|
|
||||||
label="$1"
|
|
||||||
token="$2"
|
|
||||||
LAST_LABEL="${label}"
|
|
||||||
echo "--- 用 ${label} 发布 ---"
|
|
||||||
npm config set "//gitea.iwake.top/api/packages/dsh-plugin/npm/:_authToken" "${token}"
|
|
||||||
set +e
|
|
||||||
output="$(npm publish --registry "${REGISTRY}" --access public 2>&1)"
|
|
||||||
status=$?
|
|
||||||
set -e
|
|
||||||
printf '%s\n' "${output}"
|
|
||||||
if [ "${status}" -eq 0 ]; then RESULT="published"; return 0; fi
|
|
||||||
if printf '%s' "${output}" | grep -Eqi 'E409|already exists|previously published|cannot publish over'; then RESULT="exists"; return 0; fi
|
|
||||||
if printf '%s' "${output}" | grep -Eqi 'E401|E403|EOTP|unauthorized|forbidden'; then RESULT="auth"; return 0; fi
|
|
||||||
RESULT="error"
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
if [ -n "${NPM_TOKEN:-}" ]; then
|
|
||||||
run_publish "个人访问令牌 NPM_TOKEN" "${NPM_TOKEN}"
|
|
||||||
elif [ -n "${JOB_TOKEN:-}" ]; then
|
|
||||||
# 兜底路径:本实例实测内置令牌写不了包命名空间(见文件头),这里仍会打印它的身份,
|
|
||||||
# 方便在其它实例上判断到底是"令牌不存在"还是"权限不够"。
|
|
||||||
server="${GITHUB_SERVER_URL:-https://gitea.iwake.top}"
|
|
||||||
who="$(curl -fsS -H "Authorization: token ${JOB_TOKEN}" "${server}/api/v1/user" 2>/dev/null | head -c 300 || true)"
|
|
||||||
echo "没有配置 NPM_TOKEN,退回内置任务令牌;它的身份是:${who:-(查询失败)}"
|
|
||||||
run_publish "内置任务令牌 GITEA_TOKEN" "${JOB_TOKEN}"
|
|
||||||
else
|
|
||||||
echo "既没有 NPM_TOKEN 也没有内置任务令牌,无法发布" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
case "${RESULT}" in
|
|
||||||
published)
|
|
||||||
echo "已发布 ${{ steps.version.outputs.name }}@${{ steps.version.outputs.version }}(本次用的是 ${LAST_LABEL:-令牌})"
|
|
||||||
;;
|
|
||||||
exists)
|
|
||||||
echo "${{ steps.version.outputs.name }}@${{ steps.version.outputs.version }} 已存在,本次跳过(不算失败)"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "发布失败" >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
DeepSeek Harness(DSH)插件:给会话加上**真正不可逆的删除**,并清理已删除会话遗留的临时文件。
|
DeepSeek Harness(DSH)插件:给会话加上**真正不可逆的删除**,并清理已删除会话遗留的临时文件。
|
||||||
|
|
||||||
DSH 原本只能归档会话——归档只是把会话从列表里移开,日志仍然留在磁盘上。这个插件提供两处删除入口,以及设置页里的一次性孤儿文件清理。
|
DSH 原本只能归档会话——归档只是把会话从列表里移开,日志仍然留在磁盘上(DSH 官方的 JSONL 会话存储后端明确写着「没有任何东西会删除会话文件,日志会一直堆在 root 下直到被外部移除」,会话存储契约里也没有删除 API)。这个插件补上的正是这一块:两处删除入口,以及设置页里的一次性孤儿文件清理。
|
||||||
|
|
||||||
## 功能
|
## 功能
|
||||||
|
|
||||||
@@ -11,14 +11,14 @@ DSH 原本只能归档会话——归档只是把会话从列表里移开,日
|
|||||||
两个入口,行为一致,都会先弹出确认弹窗(复刻系统 Modal 的样式与键盘行为:Esc 关闭、Tab 焦点圈定、打开时焦点进入、关闭后焦点归位):
|
两个入口,行为一致,都会先弹出确认弹窗(复刻系统 Modal 的样式与键盘行为:Esc 关闭、Tab 焦点圈定、打开时焦点进入、关闭后焦点归位):
|
||||||
|
|
||||||
- 会话行右侧 **⋯** 菜单 → **删除会话**
|
- 会话行右侧 **⋯** 菜单 → **删除会话**
|
||||||
- 会话行悬停时的 🗑 快捷按钮(占用原「归档会话」的位置,归档改从菜单进入)
|
- 会话行悬停时的 🗑 按钮(在官方的「归档」「置顶」之前,是悬停区的第一个按钮)
|
||||||
|
|
||||||
确认后一次性处理:
|
确认后一次性处理:
|
||||||
|
|
||||||
| 对象 | 处理方式 |
|
| 对象 | 处理方式 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 会话日志目录 | 整个删除,仅保留 `session.lock` |
|
| 会话日志目录 | 整个删除,仅保留 `session.lock` |
|
||||||
| 子智能体会话 | 按 `parentSession` 递归收集,连同日志与溢出文件一并删除 |
|
| 子智能体会话 | 按血缘递归收集,连同日志与溢出文件一并删除 |
|
||||||
| 工具输出溢出文件(spill) | 删除该会话(含子会话)对应的 spill 目录 |
|
| 工具输出溢出文件(spill) | 删除该会话(含子会话)对应的 spill 目录 |
|
||||||
| 工作区登记项 | 解除绑定、取消置顶、取消归档 |
|
| 工作区登记项 | 解除绑定、取消置顶、取消归档 |
|
||||||
| 客户端列表 | 广播 `api-session/removed`,列表行立即消失 |
|
| 客户端列表 | 广播 `api-session/removed`,列表行立即消失 |
|
||||||
@@ -33,7 +33,7 @@ DSH 原本只能归档会话——归档只是把会话从列表里移开,日
|
|||||||
设置 → 通用设置 → **清理临时文件**,清理 spill 后端留下的**孤儿**目录。判定偏保守:
|
设置 → 通用设置 → **清理临时文件**,清理 spill 后端留下的**孤儿**目录。判定偏保守:
|
||||||
|
|
||||||
1. 目录名必须精确等于 `session-<sha256(会话 id) 前 12 位>`——这是 spill 后端唯一会生成的形状;
|
1. 目录名必须精确等于 `session-<sha256(会话 id) 前 12 位>`——这是 spill 后端唯一会生成的形状;
|
||||||
2. 该名字不属于任何已知会话(存量会话与内存中正在运行的会话都算);
|
2. 该名字不属于任何已知会话(`sessionQuery.listSessions()` 已经把存量会话与内存中正在运行的会话合并成同一份语料,所以「已知」只需要一个来源);
|
||||||
3. 只在 spill 的 root 内操作(后端当前 root + 系统临时目录下的 `dsh-spill*`);
|
3. 只在 spill 的 root 内操作(后端当前 root + 系统临时目录下的 `dsh-spill*`);
|
||||||
4. 符号链接一律跳过、不跟随;单项失败只记警告。
|
4. 符号链接一律跳过、不跟随;单项失败只记警告。
|
||||||
|
|
||||||
@@ -45,6 +45,7 @@ DSH 原本只能归档会话——归档只是把会话从列表里移开,日
|
|||||||
- 删除**不可恢复**,但**不是安全擦除**:按普通文件删除处理,底层介质上可能有残留。
|
- 删除**不可恢复**,但**不是安全擦除**:按普通文件删除处理,底层介质上可能有残留。
|
||||||
- 工作区里的代码文件不受影响。
|
- 工作区里的代码文件不受影响。
|
||||||
- 溢出文件被删除后,fork 出来的会话日志里若仍保留指向它的路径文本,那些路径会失效。
|
- 溢出文件被删除后,fork 出来的会话日志里若仍保留指向它的路径文本,那些路径会失效。
|
||||||
|
- 行内按钮的悬停气泡由按钮自身定位。指针停在会话行上时,行自己的悬停预览卡可能和这个气泡同时出现——插件不会去动宿主的行 DOM 来避免这一点(那是插件不该依赖的自有渲染结构)。若观感不能接受,可把气泡改为只在键盘 focus 时显示:删掉 `DeleteSessionRowButton` 里的 `onMouseEnter: show` / `onMouseLeave: hide` 两行即可。
|
||||||
|
|
||||||
## 安装
|
## 安装
|
||||||
|
|
||||||
@@ -58,35 +59,28 @@ DSH 的 **插件 → 添加插件** 里有两个输入,含义不同:
|
|||||||
在上面的输入框里填:
|
在上面的输入框里填:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
https://gitea.iwake.top/dsh-plugin/session-delete.git#v1.0.2
|
https://gitea.iwake.top/dsh-plugin/session-delete.git#v2.0.0
|
||||||
```
|
```
|
||||||
|
|
||||||
`#` 后面可以跟标签或提交,用来锁定版本;不写则取默认分支。仓库是公开的,不需要凭据,也不需要改安装源。
|
`#` 后面可以跟标签或提交,用来锁定版本;不写则取默认分支。仓库是公开的,不需要凭据,也不需要改安装源。
|
||||||
|
|
||||||
想跟随 1.x 的最新版本,可以把后缀写成 `#semver:^1.0.2`——pnpm 会按仓库里的标签挑最高的 1.x(当前即 v1.0.2 那个提交),以后发了 v1.0.3,移除旧版本再装一次就能拿到。
|
想跟随 2.x 的最新版本,可以把后缀写成 `#semver:^2.0.0`——pnpm 会按仓库里的标签挑最高的 2.x,以后发了 v2.0.1,移除旧版本再装一次就能拿到。
|
||||||
|
|
||||||
### 2. 本地 tgz
|
### 2. 本地 tgz
|
||||||
|
|
||||||
把 [session-delete-1.0.2.tgz](https://gitea.iwake.top/api/packages/dsh-plugin/npm/@dsh-plugin%2Fsession-delete/-/1.0.2/session-delete-1.0.2.tgz) 下载到运行 DSH 的机器上,把它的绝对路径填进上面的输入框。
|
把仓库打包成 tgz(例如在仓库目录执行 `npm pack`,得到 `dsh-plugin-session-delete-2.0.0.tgz`),把它在运行 DSH 那台机器上的绝对路径填进上面的输入框。
|
||||||
|
|
||||||
### 关于「npm 源」这条走不通的路
|
### 升级
|
||||||
|
|
||||||
本插件也发布在自建 Gitea 的 npm 仓库里(安装源填 `https://gitea.iwake.top/api/packages/dsh-plugin/npm/`、包名填 `@dsh-plugin/session-delete`),但**目前不能用它安装**:Gitea 返回包清单时只保留 `name`、`version`、`description`、`author`、`homepage`、`license`、`repository`、`readme`、`dist`、`maintainers`,插件需要的 `dsh`(即 `dsh.bundle.patch`)会被丢掉;而 DSH 的安装预检要求清单里声明 `dsh.bundle`,于是会报:
|
DSH 暂不支持插件自动更新,升级就是用新标签重装一次(建议先移除旧版本)。安装或升级后:**宿主半变更需要重启 DSH**(完全退出再打开,仅关闭窗口不一定退出进程);只改浏览器半时刷新页面即可。
|
||||||
|
|
||||||
```text
|
|
||||||
这个包没有声明组合包,无法作为插件安装:@dsh-plugin/session-delete declares no dsh.bundle
|
|
||||||
```
|
|
||||||
|
|
||||||
Git 地址与 tgz 这两条路没有这个问题——它们是先把包取到本地、再读它自己的 `package.json`,字段完整。若将来把它发到保留自定义字段的注册表(npmjs 等),包名那条路才走得通。
|
|
||||||
|
|
||||||
安装或升级后:**宿主半变更需要重启 DSH**(完全退出再打开,仅关闭窗口不一定退出进程);只改浏览器半时刷新页面即可。DSH 暂不支持插件自动更新,升级就是用新标签重装一次(建议先移除旧版本)。
|
|
||||||
|
|
||||||
## 兼容性
|
## 兼容性
|
||||||
|
|
||||||
基于 **DeepSeek Harness 0.2.0-rc.2** 编写。DSH 仍在演进,其它版本与平台组合未逐一验证。
|
基于 **DeepSeek Harness 0.2.0-rc.2** 编写。DSH 仍在演进,其它版本与平台组合未逐一验证。
|
||||||
|
|
||||||
- 浏览器半不 import 任何 Harness 客户端包(上游规范如此要求:这些包随时可能变化,渲染抛错会让整个插槽条目变空白),而是复刻 `dsh-client-ui-primitives` 的 markup、CSS 声明、图标 path 数据与键盘行为,只保留 `--dsw-*` 主题 token 引用。上游若改动这些内部结构,界面细节可能失配(例如悬停气泡不再出现),但不会导致插件加载失败。
|
- **浏览器半**不 import 任何 Harness 客户端包(上游规范如此要求:这些包随时可能变化,渲染抛错会让整个插槽条目变空白),而是复刻 `dsh-client-ui-primitives` 的 markup、CSS 声明、图标 path 数据与键盘行为,只保留 `--dsw-*` 主题 token 引用。它**不读写宿主的 DOM**,也不占用官方槽位:行内按钮用自己的 id 与官方「归档」「置顶」并列,不是顶替。上游若改动这些内部结构,界面细节可能失配(例如悬停气泡不再出现),但不会导致插件加载失败。
|
||||||
- 宿主半只依赖稳定的服务契约:`sessionPersistence`、`sessionQuery`、`workspaceRegistry`、`sessions`、`agents`、`connection.fetch`,以及 spill 后端的目录规则。
|
- **宿主半**只依赖稳定的服务契约:`sessionQuery`(`traceSession` 取血缘、`listSessions` 取语料)、`sessionPersistence`(含其 JSONL 后端的 `locate()` 诊断钩子,用于定位会话目录)、`workspaceRegistry`、`connection.fetch`(注册受认证的 exact 路由),以及 spill 后端的目录规则。
|
||||||
|
- **跨半边通信**走 `connection.fetch` 注册的 `POST /api/plugin/session-delete/delete` 与 `.../orphans` 两条路由,浏览器半用文档相对路径请求(支持挂载前缀)。这是静态客户端包与宿主半通信的正规通道;`host.call` 属于动态客户端运行器,不适用于包形式的插件。
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,27 @@
|
|||||||
/**
|
/**
|
||||||
* Browser half of the session-delete bundle.
|
* Browser half of the session-delete bundle.
|
||||||
*
|
*
|
||||||
* One row in a conversation's "..." menu opens an irreversible-deletion dialog.
|
* One row in a conversation's "..." menu and one hover button on the
|
||||||
* Confirming posts to the Host half's authenticated route; the Host owns every
|
* conversation's sidebar row open an irreversible-deletion dialog. Confirming
|
||||||
* policy decision and the Client only renders its answer.
|
* posts to the Host half's authenticated route; the Host owns every policy
|
||||||
|
* decision and the Client only renders its answer.
|
||||||
*
|
*
|
||||||
* Harness Client packages may change without notice, so this bundle imports no
|
* Two client-authoring rules shape this file:
|
||||||
* Harness Client module: its menu row and dialog copy the shipped primitives'
|
*
|
||||||
* markup, stylesheet declarations, and focus/Escape behavior, keeping only
|
* - No Harness Client module is imported. Those packages change without notice,
|
||||||
* `--dsw-*` theme-token references. React comes from the page's module table.
|
* 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
|
||||||
|
* General-settings row 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({
|
window.__ModuleLoader__.load({
|
||||||
id: '@dsh-plugin/session-delete',
|
id: '@dsh-plugin/session-delete',
|
||||||
@@ -18,14 +31,12 @@ window.__ModuleLoader__.load({
|
|||||||
const NS = 'session-delete'
|
const NS = 'session-delete'
|
||||||
const ROUTE = 'api/plugin/session-delete/delete'
|
const ROUTE = 'api/plugin/session-delete/delete'
|
||||||
const ORPHAN_ROUTE = 'api/plugin/session-delete/orphans'
|
const ORPHAN_ROUTE = 'api/plugin/session-delete/orphans'
|
||||||
|
/** The shipped menu rows occupy 100–400; this one follows them. */
|
||||||
const MENU_ORDER = 500
|
const MENU_ORDER = 500
|
||||||
/** The shipped hover button's id; reusing its id takes that cell over. */
|
/** Between the shipped `archive` (100) and `pin` (200) hover buttons. */
|
||||||
const ROW_ACTION_ID = 'archive'
|
const ROW_ACTION_ORDER = 150
|
||||||
const ROW_ACTION_ORDER = 100
|
|
||||||
/** Below the shipped entry's implicit 0: the lowest priority renders. */
|
|
||||||
const ROW_ACTION_PRIORITY = -1
|
|
||||||
/** General-settings position: after the log-upload row, before the version. */
|
/** General-settings position: after the log-upload row, before the version. */
|
||||||
const SETTINGS_ORDER = 95
|
const SETTINGS_ORDER = 96
|
||||||
const TOOLTIP_DELAY_MS = 500
|
const TOOLTIP_DELAY_MS = 500
|
||||||
const TOOLTIP_GAP = 8
|
const TOOLTIP_GAP = 8
|
||||||
/** Document base captured at bundle registration, before any routing. */
|
/** Document base captured at bundle registration, before any routing. */
|
||||||
@@ -83,8 +94,9 @@ window.__ModuleLoader__.load({
|
|||||||
* Menu-row declarations mirror `dsh-client-ui-primitives`' Menu.module.css
|
* Menu-row declarations mirror `dsh-client-ui-primitives`' Menu.module.css
|
||||||
* `.item/.itemIcon/.itemLabel`; dialog declarations mirror its
|
* `.item/.itemIcon/.itemLabel`; dialog declarations mirror its
|
||||||
* Modal.module.css and Button.module.css plus the workspace dialog's
|
* Modal.module.css and Button.module.css plus the workspace dialog's
|
||||||
* destructive accent and secondary status lines. Class names are renamed
|
* destructive accent and secondary status lines; the settings row copies
|
||||||
* under `dsd-`, and every color, radius, elevation, and transition stays a
|
* the General-section row pattern. Class names are renamed under `dsd-`,
|
||||||
|
* and every color, radius, elevation, and transition stays a
|
||||||
* `--dsw-*`/`--ds-*` token reference.
|
* `--dsw-*`/`--ds-*` token reference.
|
||||||
*/
|
*/
|
||||||
const CSS = `
|
const CSS = `
|
||||||
@@ -172,8 +184,7 @@ window.__ModuleLoader__.load({
|
|||||||
/**
|
/**
|
||||||
* Icon paths are the shipped `IconTrashOutline` and `IconCloseOutline`
|
* Icon paths are the shipped `IconTrashOutline` and `IconCloseOutline`
|
||||||
* artwork verbatim: a 16-unit viewBox the glyph fills, stroked at the
|
* artwork verbatim: a 16-unit viewBox the glyph fills, stroked at the
|
||||||
* regular weight of 1. Redrawing a smaller glyph in a 24-unit box is what
|
* regular weight of 1.
|
||||||
* made an earlier version of this row look undersized.
|
|
||||||
*/
|
*/
|
||||||
function TrashIcon({ size = 16 }) {
|
function TrashIcon({ size = 16 }) {
|
||||||
return h('svg', {
|
return h('svg', {
|
||||||
@@ -244,42 +255,15 @@ window.__ModuleLoader__.load({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Withdraw the session row's hover preview while this button's own tooltip
|
* The row's hover button. It carries its own id in the row-action list, so
|
||||||
* is showing.
|
* the shipped `archive` and `pin` buttons keep their cells instead of being
|
||||||
|
* shadowed.
|
||||||
*
|
*
|
||||||
* The shipped row buttons get this for free: `Tooltip` reports itself
|
* Its tooltip copies the primitive's bubble — the same delay, the same
|
||||||
* through the primitives' private `TooltipSuppression` context, and the
|
* bottom/end placement, the same fade — and is placed from this button's own
|
||||||
* enclosing `HoverCard` — whose anchor is the whole row, so a button inside
|
* rect alone. While the pointer rests on the row, the row's own hover
|
||||||
* it counts as hovering the row — hides its card while that bubble is up.
|
* preview may appear alongside it; that is accepted rather than suppressed
|
||||||
* That context object belongs to another module instance and cannot be
|
* by reaching into the row's DOM.
|
||||||
* reached from here, so this reproduces the same outcome through the DOM:
|
|
||||||
* a `pointerout` on the card's anchor wrapper makes React deliver
|
|
||||||
* `onPointerLeave` to that wrapper alone, which is exactly the cancel the
|
|
||||||
* hover card performs on a real leave. Nothing else in the tree is affected
|
|
||||||
* because the related target is the wrapper's own parent.
|
|
||||||
*
|
|
||||||
* @param button - the hovered row button.
|
|
||||||
*/
|
|
||||||
function withdrawRowPreview(button) {
|
|
||||||
try {
|
|
||||||
const wrapper = button.closest('[data-row-key]')?.parentElement
|
|
||||||
if (wrapper === null || wrapper === undefined) return
|
|
||||||
wrapper.dispatchEvent(new PointerEvent('pointerout', {
|
|
||||||
bubbles: true,
|
|
||||||
cancelable: true,
|
|
||||||
composed: true,
|
|
||||||
pointerType: 'mouse',
|
|
||||||
relatedTarget: wrapper.parentElement,
|
|
||||||
}))
|
|
||||||
} catch {
|
|
||||||
/* an unchanged host simply keeps showing its card */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The row's hover button, in the cell the shipped archive action occupied.
|
|
||||||
* Its tooltip copies the primitive's bubble: the same delay, the same
|
|
||||||
* bottom/end placement, and the same fade.
|
|
||||||
*/
|
*/
|
||||||
function DeleteSessionRowButton(props) {
|
function DeleteSessionRowButton(props) {
|
||||||
const t = translate(props.t)
|
const t = translate(props.t)
|
||||||
@@ -289,7 +273,6 @@ window.__ModuleLoader__.load({
|
|||||||
const [anchor, setAnchor] = React.useState(null)
|
const [anchor, setAnchor] = React.useState(null)
|
||||||
|
|
||||||
const show = () => {
|
const show = () => {
|
||||||
if (buttonRef.current !== null) withdrawRowPreview(buttonRef.current)
|
|
||||||
clearTimeout(timerRef.current)
|
clearTimeout(timerRef.current)
|
||||||
timerRef.current = setTimeout(() => {
|
timerRef.current = setTimeout(() => {
|
||||||
const rect = buttonRef.current?.getBoundingClientRect()
|
const rect = buttonRef.current?.getBoundingClientRect()
|
||||||
@@ -549,8 +532,11 @@ window.__ModuleLoader__.load({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Post to one of this package's Host routes. Routes sit behind the
|
* Post to one of this package's Host routes. The registration path on the
|
||||||
* connection's trust fence, so the page's own credentials apply.
|
* 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 route - document-relative route path.
|
||||||
* @param body - JSON body to send.
|
* @param body - JSON body to send.
|
||||||
* @returns the Host's result value.
|
* @returns the Host's result value.
|
||||||
@@ -569,7 +555,9 @@ window.__ModuleLoader__.load({
|
|||||||
payload = null
|
payload = null
|
||||||
}
|
}
|
||||||
if (!response.ok || payload?.ok === false) {
|
if (!response.ok || payload?.ok === false) {
|
||||||
throw new Error(payload?.error?.message ?? `HTTP ${response.status}`)
|
const code = payload?.error?.code
|
||||||
|
const detail = payload?.error?.message ?? `HTTP ${response.status}`
|
||||||
|
throw new Error(code === undefined ? detail : `${detail} (${code})`)
|
||||||
}
|
}
|
||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
@@ -611,15 +599,13 @@ window.__ModuleLoader__.load({
|
|||||||
inject: () => ({ requestSessionDelete }),
|
inject: () => ({ requestSessionDelete }),
|
||||||
}, DeleteSessionMenuItem))
|
}, DeleteSessionMenuItem))
|
||||||
|
|
||||||
// This entry reuses the shipped archive action's id, so the row keeps
|
// This entry carries its own id, so the shipped `archive` and `pin`
|
||||||
// one hover button and archiving stays available from the "..." menu.
|
// hover buttons keep their cells and are not shadowed by a lower
|
||||||
// Same id at the same priority is refused as a duplicate, and the
|
// priority. Archiving therefore stays exactly where the harness put it.
|
||||||
// lowest priority renders, so this row must sit below the shipped 0.
|
|
||||||
ctx.slots.inject('sidebar.workspaces.session.row.action', () => ctx.slots.register({
|
ctx.slots.inject('sidebar.workspaces.session.row.action', () => ctx.slots.register({
|
||||||
name: 'sidebar.workspaces.session.row.action',
|
name: 'sidebar.workspaces.session.row.action',
|
||||||
id: ROW_ACTION_ID,
|
id: NS,
|
||||||
order: ROW_ACTION_ORDER,
|
order: ROW_ACTION_ORDER,
|
||||||
priority: ROW_ACTION_PRIORITY,
|
|
||||||
locale: NS,
|
locale: NS,
|
||||||
inject: () => ({ requestSessionDelete }),
|
inject: () => ({ requestSessionDelete }),
|
||||||
}, DeleteSessionRowButton))
|
}, DeleteSessionRowButton))
|
||||||
|
|||||||
+10
-2
@@ -1,5 +1,13 @@
|
|||||||
# Profile patch layer for the session-delete bundle: one Host row that owns the
|
# Bundle patch layer for @dsh-plugin/session-delete.
|
||||||
# deletion operation and the authenticated route the browser half calls.
|
#
|
||||||
|
# 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:
|
- insert:
|
||||||
- id: session-delete
|
- id: session-delete
|
||||||
name: '@dsh-plugin/session-delete'
|
name: '@dsh-plugin/session-delete'
|
||||||
|
|||||||
@@ -8,14 +8,21 @@
|
|||||||
* subagent descendants' logs and their spilled tool output, and drop the
|
* subagent descendants' logs and their spilled tool output, and drop the
|
||||||
* registry references to it.
|
* 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
|
* Spilled tool output is the session's one other session-scoped artifact: the
|
||||||
* spill backend stores oversized results at `<root>/session-<hash>/…`, where
|
* spill backend stores oversized results at `<root>/session-<hash>/…`, where
|
||||||
* the directory is `sha256(session id)` truncated to 12 hex characters and the
|
* 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
|
* root is either the configured one or a `dsh-spill-*` directory under the OS
|
||||||
* temp directory. Those files are otherwise reclaimed only by the backend's own
|
* temp directory.
|
||||||
* age sweep, so deletion removes them here. Content-addressed attachments are
|
|
||||||
* deliberately left alone: one blob can be referenced by several sessions, and
|
|
||||||
* nothing in the harness counts those references.
|
|
||||||
*
|
*
|
||||||
* A session that is still live in this process cannot be removed from the
|
* 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
|
* in-memory Session and Agent stores by another plugin, and deleting a log
|
||||||
@@ -23,9 +30,10 @@
|
|||||||
* artifact. Such a session is archived (durably gated and stopped) now and its
|
* 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.
|
* artifacts are removed on the next activation, when nothing is live yet.
|
||||||
*
|
*
|
||||||
* The browser half reaches this operation through the authenticated exact
|
* Cross-half contract: the browser half reaches both operations through the
|
||||||
* Fetch route registered below; it is the only cross-half entry point this
|
* authenticated exact Fetch routes registered below (`ctx.connection.fetch`),
|
||||||
* package needs.
|
* 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
|
* @module @dsh-plugin/session-delete
|
||||||
*/
|
*/
|
||||||
@@ -47,17 +55,19 @@ const SPILL_SESSION_DIRECTORY = /^session-[0-9a-f]{12}$/
|
|||||||
|
|
||||||
/** Services this Host half needs before it may run at all. */
|
/** Services this Host half needs before it may run at all. */
|
||||||
export const inject = [
|
export const inject = [
|
||||||
'agents',
|
|
||||||
'connection',
|
'connection',
|
||||||
'sessionPersistence',
|
'sessionPersistence',
|
||||||
'sessionQuery',
|
'sessionQuery',
|
||||||
'sessions',
|
|
||||||
'workspaceRegistry',
|
'workspaceRegistry',
|
||||||
]
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Install the deletion route and finish deletions deferred to this activation.
|
* Install the deletion routes and finish deletions deferred to this activation.
|
||||||
* @param ctx - Host context carrying the Session, Agent, storage, and Workspace services.
|
*
|
||||||
|
* @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) {
|
export function apply(ctx) {
|
||||||
const inFlight = new Set()
|
const inFlight = new Set()
|
||||||
@@ -76,15 +86,18 @@ export function apply(ctx) {
|
|||||||
fetch: () => handleOrphanSweep(ctx),
|
fetch: () => handleOrphanSweep(ctx),
|
||||||
}), 'session-delete: orphan sweep route')
|
}), 'session-delete: orphan sweep route')
|
||||||
|
|
||||||
|
let swept
|
||||||
ctx.effect(() => {
|
ctx.effect(() => {
|
||||||
let disposed = false
|
let disposed = false
|
||||||
sweepPending(ctx, () => disposed).catch((error) => {
|
swept = sweepPending(ctx, () => disposed).catch((error) => {
|
||||||
warn(ctx, `could not finish deferred deletions: ${message(error)}`)
|
warn(ctx, `could not finish deferred deletions: ${message(error)}`)
|
||||||
})
|
})
|
||||||
return () => {
|
return () => {
|
||||||
disposed = true
|
disposed = true
|
||||||
}
|
}
|
||||||
}, 'session-delete: deferred deletions')
|
}, 'session-delete: deferred deletions')
|
||||||
|
|
||||||
|
return swept
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -112,7 +125,7 @@ async function handleRequest(ctx, request, inFlight) {
|
|||||||
inFlight.add(sessionId)
|
inFlight.add(sessionId)
|
||||||
try {
|
try {
|
||||||
const value = await deleteConversation(ctx, sessionId)
|
const value = await deleteConversation(ctx, sessionId)
|
||||||
return Response.json(value, { headers: { 'cache-control': 'no-store' } })
|
return json(value)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
warn(ctx, `deleting "${sessionId}" failed: ${message(error)}`)
|
warn(ctx, `deleting "${sessionId}" failed: ${message(error)}`)
|
||||||
return failure(500, 'delete-failed', message(error))
|
return failure(500, 'delete-failed', message(error))
|
||||||
@@ -126,28 +139,21 @@ async function handleRequest(ctx, request, inFlight) {
|
|||||||
* @param ctx - Host context.
|
* @param ctx - Host context.
|
||||||
* @param sessionId - conversation the user asked to delete.
|
* @param sessionId - conversation the user asked to delete.
|
||||||
* @returns `deleted` when the artifacts are gone, `scheduled` when a live
|
* @returns `deleted` when the artifacts are gone, `scheduled` when a live
|
||||||
* session forced the removal to this run's end.
|
* session forced the removal to the next activation.
|
||||||
*/
|
*/
|
||||||
async function deleteConversation(ctx, sessionId) {
|
async function deleteConversation(ctx, sessionId) {
|
||||||
const headers = await collectHeaders(ctx)
|
const lineage = await resolveLineage(ctx, sessionId)
|
||||||
const target = headers.get(sessionId)
|
const ids = lineage.ids
|
||||||
if (target === undefined) throw new Error(`conversation "${sessionId}" was not found`)
|
|
||||||
if (target.origin === 'subagent') {
|
|
||||||
throw new Error('a subagent conversation is removed together with the conversation that owns it')
|
|
||||||
}
|
|
||||||
|
|
||||||
const ids = [sessionId, ...subagentDescendants(headers, sessionId)]
|
if (lineage.liveIds.length > 0) {
|
||||||
const live = ids.filter((id) => isLive(ctx, id))
|
for (const id of lineage.ids) await ctx.workspaceRegistry.archiveSession(id, { stopActivity: true })
|
||||||
|
await rememberPending(ctx, sessionId)
|
||||||
if (live.length > 0) {
|
return { ok: true, status: 'scheduled', sessionIds: ids, live: lineage.liveIds }
|
||||||
for (const id of ids) await ctx.workspaceRegistry.archiveSession(id, { stopActivity: true })
|
|
||||||
await rememberPending(ctx, ids)
|
|
||||||
return { ok: true, status: 'scheduled', sessionIds: ids, live }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const removed = []
|
const removed = []
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
const header = headers.get(id)
|
const header = lineage.headers.get(id)
|
||||||
if (header === undefined) continue
|
if (header === undefined) continue
|
||||||
if (await removeArtifacts(ctx, header)) removed.push(id)
|
if (await removeArtifacts(ctx, header)) removed.push(id)
|
||||||
}
|
}
|
||||||
@@ -164,6 +170,65 @@ async function deleteConversation(ctx, sessionId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
* Derive one session's spill directory name exactly as the local spill backend
|
||||||
* does: `sha256(session id)` truncated to 12 hex characters.
|
* does: `sha256(session id)` truncated to 12 hex characters.
|
||||||
@@ -246,7 +311,7 @@ async function removeSpillArtifacts(ctx, sessionIds) {
|
|||||||
async function handleOrphanSweep(ctx) {
|
async function handleOrphanSweep(ctx) {
|
||||||
try {
|
try {
|
||||||
const report = await sweepOrphanSpill(ctx)
|
const report = await sweepOrphanSpill(ctx)
|
||||||
return Response.json({ ok: true, ...report }, { headers: { 'cache-control': 'no-store' } })
|
return json({ ok: true, ...report })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
warn(ctx, `orphan sweep failed: ${message(error)}`)
|
warn(ctx, `orphan sweep failed: ${message(error)}`)
|
||||||
return failure(500, 'sweep-failed', message(error))
|
return failure(500, 'sweep-failed', message(error))
|
||||||
@@ -259,16 +324,22 @@ async function handleOrphanSweep(ctx) {
|
|||||||
*
|
*
|
||||||
* A spill directory is orphaned only when its name has exactly the
|
* 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-<sha256(session id)[:12]>` shape the spill backend derives AND no
|
||||||
* session known to this process — stored or live — hashes to it. Anything else,
|
* session known to this process hashes to it. `listSessions()` already merges
|
||||||
* including a symlink or an entry the backend would never create, is left alone.
|
* 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.
|
* @param ctx - Host context.
|
||||||
* @returns counts for the settings row.
|
* @returns counts for the settings row.
|
||||||
*/
|
*/
|
||||||
async function sweepOrphanSpill(ctx) {
|
async function sweepOrphanSpill(ctx) {
|
||||||
const headers = await collectHeaders(ctx)
|
const records = await ctx.sessionQuery.listSessions()
|
||||||
const known = new Set([...headers.keys()].map(spillSessionDirectory))
|
const known = new Set()
|
||||||
const report = { directories: 0, files: 0, bytes: 0, roots: 0, sessions: headers.size }
|
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)) {
|
for (const root of await spillRoots(ctx)) {
|
||||||
report.roots += 1
|
report.roots += 1
|
||||||
let entries
|
let entries
|
||||||
@@ -325,71 +396,24 @@ async function measureDirectory(directory) {
|
|||||||
return { files, bytes }
|
return { files, bytes }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Read every session header this process knows: stored ones plus live ones.
|
|
||||||
* @param ctx - Host context.
|
|
||||||
* @returns headers keyed by session id.
|
|
||||||
*/
|
|
||||||
async function collectHeaders(ctx) {
|
|
||||||
const headers = new Map()
|
|
||||||
for (const record of await ctx.sessionQuery.listSessions()) {
|
|
||||||
const header = record?.header
|
|
||||||
if (header !== undefined) headers.set(String(header.id), header)
|
|
||||||
}
|
|
||||||
for (const session of ctx.get('sessions')?.list() ?? []) {
|
|
||||||
headers.set(String(session.id), session.header)
|
|
||||||
}
|
|
||||||
return headers
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Collect the subagent conversations stored underneath one conversation.
|
|
||||||
* @param headers - every known session header.
|
|
||||||
* @param rootId - the conversation being deleted.
|
|
||||||
* @returns descendant ids, nearest first.
|
|
||||||
*/
|
|
||||||
function subagentDescendants(headers, rootId) {
|
|
||||||
const children = new Map()
|
|
||||||
for (const header of headers.values()) {
|
|
||||||
if (header.origin !== 'subagent' || header.parentSession === undefined) continue
|
|
||||||
const parent = String(header.parentSession)
|
|
||||||
const rows = children.get(parent)
|
|
||||||
if (rows === undefined) children.set(parent, [String(header.id)])
|
|
||||||
else rows.push(String(header.id))
|
|
||||||
}
|
|
||||||
const found = []
|
|
||||||
const seen = new Set([rootId])
|
|
||||||
const queue = [rootId]
|
|
||||||
while (queue.length > 0) {
|
|
||||||
for (const child of children.get(queue.shift()) ?? []) {
|
|
||||||
if (seen.has(child)) continue
|
|
||||||
seen.add(child)
|
|
||||||
found.push(child)
|
|
||||||
queue.push(child)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return found
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether this process currently holds the session or its agent in memory.
|
|
||||||
* @param ctx - Host context.
|
|
||||||
* @param sessionId - candidate identity.
|
|
||||||
* @returns true while the session is live.
|
|
||||||
*/
|
|
||||||
function isLive(ctx, sessionId) {
|
|
||||||
return ctx.agents.get(sessionId) !== undefined || ctx.get('sessions')?.get(sessionId) !== undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove every stored generation of one session, keeping its write lock file.
|
* 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 ctx - Host context.
|
||||||
* @param header - the session's stored header, which names its artifact.
|
* @param header - the session's stored header, which names its artifact.
|
||||||
* @returns whether anything was removed.
|
* @returns whether anything was removed.
|
||||||
*/
|
*/
|
||||||
async function removeArtifacts(ctx, header) {
|
async function removeArtifacts(ctx, header) {
|
||||||
const persistence = ctx.sessionPersistence
|
const persistence = ctx.get('sessionPersistence')
|
||||||
if (typeof persistence.locate !== 'function') {
|
if (typeof persistence?.locate !== 'function') {
|
||||||
throw new Error('this session storage backend cannot locate stored artifacts, so nothing was deleted')
|
throw new Error('this session storage backend cannot locate stored artifacts, so nothing was deleted')
|
||||||
}
|
}
|
||||||
const located = persistence.locate(header)
|
const located = persistence.locate(header)
|
||||||
@@ -433,7 +457,9 @@ async function syncDirectory(directory) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Drop every durable reference to a session whose artifacts are gone:
|
* Drop every durable reference to a session whose artifacts are gone:
|
||||||
* Workspace accounting, the archive set, and the pin set.
|
* 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 ctx - Host context.
|
||||||
* @param sessionId - removed identity.
|
* @param sessionId - removed identity.
|
||||||
*/
|
*/
|
||||||
@@ -468,9 +494,15 @@ function announceRemoval(ctx, sessionId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finish deletions deferred by a live session. Each id is deleted only while
|
* Finish deletions deferred by a live session.
|
||||||
* it is still archived, so restoring a conversation in the sidebar cancels its
|
*
|
||||||
* pending deletion.
|
* 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 ctx - Host context.
|
||||||
* @param isDisposed - whether this plugin already unloaded.
|
* @param isDisposed - whether this plugin already unloaded.
|
||||||
*/
|
*/
|
||||||
@@ -478,24 +510,39 @@ async function sweepPending(ctx, isDisposed) {
|
|||||||
const pending = await readLedger(ctx)
|
const pending = await readLedger(ctx)
|
||||||
if (pending.length === 0) return
|
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 registry = ctx.workspaceRegistry
|
||||||
const headers = await collectHeaders(ctx)
|
const archivedSessionIds = registry.archivedSessionIds
|
||||||
const remaining = []
|
const remaining = []
|
||||||
for (const sessionId of pending) {
|
for (const sessionId of pending) {
|
||||||
if (isDisposed()) {
|
if (isDisposed()) {
|
||||||
remaining.push(sessionId)
|
remaining.push(sessionId)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (isLive(ctx, sessionId)) {
|
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)
|
remaining.push(sessionId)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (!registry.archivedSessionIds.includes(sessionId)) continue
|
if (!archivedSessionIds.includes(sessionId)) continue
|
||||||
try {
|
try {
|
||||||
const header = headers.get(sessionId)
|
for (const id of lineage.ids) {
|
||||||
|
const header = lineage.headers.get(id)
|
||||||
if (header !== undefined) await removeArtifacts(ctx, header)
|
if (header !== undefined) await removeArtifacts(ctx, header)
|
||||||
await removeSpillArtifacts(ctx, [sessionId])
|
}
|
||||||
await forgetReferences(ctx, sessionId)
|
await removeSpillArtifacts(ctx, lineage.ids)
|
||||||
|
for (const id of lineage.ids) await forgetReferences(ctx, id)
|
||||||
announceRemoval(ctx, sessionId)
|
announceRemoval(ctx, sessionId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
warn(ctx, `could not finish deleting "${sessionId}": ${message(error)}`)
|
warn(ctx, `could not finish deleting "${sessionId}": ${message(error)}`)
|
||||||
@@ -536,9 +583,21 @@ async function writeLedger(ctx, sessionIds) {
|
|||||||
await writeFile(path, `${JSON.stringify({ version: 1, sessions: unique }, null, 2)}\n`, 'utf8')
|
await writeFile(path, `${JSON.stringify({ version: 1, sessions: unique }, null, 2)}\n`, 'utf8')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Record the ids whose artifacts are removed on the next activation. */
|
/**
|
||||||
function rememberPending(ctx, sessionIds) {
|
* Record the root conversation whose artifacts are removed on the next
|
||||||
return readLedger(ctx).then((pending) => writeLedger(ctx, [...pending, ...sessionIds]))
|
* 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])
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -548,19 +607,21 @@ function rememberPending(ctx, sessionIds) {
|
|||||||
* @returns absolute ledger path.
|
* @returns absolute ledger path.
|
||||||
*/
|
*/
|
||||||
function ledgerPath(ctx) {
|
function ledgerPath(ctx) {
|
||||||
const root = ctx.sessionPersistence?.root
|
const root = ctx.get('sessionPersistence')?.root
|
||||||
if (typeof root === 'string' && root !== '') return join(dirname(root), LEDGER_FILENAME)
|
if (typeof root === 'string' && root !== '') return join(dirname(root), LEDGER_FILENAME)
|
||||||
const home = process.env.DSH_HOME
|
const home = process.env.DSH_HOME
|
||||||
const base = typeof home === 'string' && home !== '' ? home : join(homedir(), '.dsh')
|
const base = typeof home === 'string' && home !== '' ? home : join(homedir(), '.dsh')
|
||||||
return join(base, LEDGER_FILENAME)
|
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. */
|
/** Build one stable JSON failure the Client half surfaces verbatim. */
|
||||||
function failure(status, code, text) {
|
function failure(status, code, text) {
|
||||||
return Response.json(
|
return json({ ok: false, error: { code, message: text } }, status)
|
||||||
{ ok: false, error: { code, message: text } },
|
|
||||||
{ status, headers: { 'cache-control': 'no-store' } },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Log one contained diagnostic. */
|
/** Log one contained diagnostic. */
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"meta": {
|
"meta": {
|
||||||
"title": "Session delete",
|
"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. Settings → General can sweep orphaned temporary files left by conversations that are gone."
|
"description": "Permanently delete a conversation: stop it, remove its session log, subagent logs, and spilled tool output, then drop every registry reference. Settings → General's “Clean up temporary files” sweeps what conversations that are gone left behind."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"meta": {
|
"meta": {
|
||||||
"title": "会话彻底删除",
|
"title": "会话彻底删除",
|
||||||
"description": "彻底删除一个会话:先停止它,再删除会话日志、子智能体日志与工具输出溢出文件,并清理相关注册表引用;设置 → 通用设置里可清理已删除会话遗留的孤立临时文件。"
|
"description": "彻底删除一个会话:先停止它,再删除会话日志、子智能体日志与工具输出溢出文件,并清理相关注册表引用;设置 → 通用设置里的「清理临时文件」可清理已删除会话遗留的孤立临时文件。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-5
@@ -1,17 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "@dsh-plugin/session-delete",
|
"name": "@dsh-plugin/session-delete",
|
||||||
"version": "1.0.2",
|
"version": "2.0.0",
|
||||||
"description": "Permanently delete a conversation: stop it, remove its session log, subagent logs, and spilled tool output, and drop every registry reference. Settings → General can sweep orphaned temporary files left by conversations that are gone.",
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"description": "Permanently delete a conversation: stop it, remove its session log, subagent logs, and spilled tool output, and drop every registry reference. Settings → General can sweep orphaned temporary files left by conversations that are gone.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://gitea.iwake.top/dsh-plugin/session-delete.git"
|
"url": "git+https://gitea.iwake.top/dsh-plugin/session-delete.git"
|
||||||
},
|
},
|
||||||
"homepage": "https://gitea.iwake.top/dsh-plugin/session-delete",
|
"homepage": "https://gitea.iwake.top/dsh-plugin/session-delete",
|
||||||
"publishConfig": {
|
|
||||||
"registry": "https://gitea.iwake.top/api/packages/dsh-plugin/npm/"
|
|
||||||
},
|
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./index.js",
|
".": "./index.js",
|
||||||
"./client": "./client.js",
|
"./client": "./client.js",
|
||||||
@@ -32,7 +30,12 @@
|
|||||||
"README.md",
|
"README.md",
|
||||||
"LICENSE"
|
"LICENSE"
|
||||||
],
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22",
|
||||||
|
"dsh": "0.2.0-rc.2"
|
||||||
|
},
|
||||||
"dsh": {
|
"dsh": {
|
||||||
|
"manifestVersion": 1,
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"patch": "./cordis.patch.yml"
|
"patch": "./cordis.patch.yml"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user