diff --git a/.gitea/workflows/publish.yml b/.gitea/workflows/publish.yml deleted file mode 100644 index a066a4e..0000000 --- a/.gitea/workflows/publish.yml +++ /dev/null @@ -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 diff --git a/README.md b/README.md index c141667..f524608 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ DeepSeek Harness(DSH)插件:给会话加上**真正不可逆的删除**,并清理已删除会话遗留的临时文件。 -DSH 原本只能归档会话——归档只是把会话从列表里移开,日志仍然留在磁盘上。这个插件提供两处删除入口,以及设置页里的一次性孤儿文件清理。 +DSH 原本只能归档会话——归档只是把会话从列表里移开,日志仍然留在磁盘上(DSH 官方的 JSONL 会话存储后端明确写着「没有任何东西会删除会话文件,日志会一直堆在 root 下直到被外部移除」,会话存储契约里也没有删除 API)。这个插件补上的正是这一块:两处删除入口,以及设置页里的一次性孤儿文件清理。 ## 功能 @@ -11,14 +11,14 @@ DSH 原本只能归档会话——归档只是把会话从列表里移开,日 两个入口,行为一致,都会先弹出确认弹窗(复刻系统 Modal 的样式与键盘行为:Esc 关闭、Tab 焦点圈定、打开时焦点进入、关闭后焦点归位): - 会话行右侧 **⋯** 菜单 → **删除会话** -- 会话行悬停时的 🗑 快捷按钮(占用原「归档会话」的位置,归档改从菜单进入) +- 会话行悬停时的 🗑 按钮(在官方的「归档」「置顶」之前,是悬停区的第一个按钮) 确认后一次性处理: | 对象 | 处理方式 | | --- | --- | | 会话日志目录 | 整个删除,仅保留 `session.lock` | -| 子智能体会话 | 按 `parentSession` 递归收集,连同日志与溢出文件一并删除 | +| 子智能体会话 | 按血缘递归收集,连同日志与溢出文件一并删除 | | 工具输出溢出文件(spill) | 删除该会话(含子会话)对应的 spill 目录 | | 工作区登记项 | 解除绑定、取消置顶、取消归档 | | 客户端列表 | 广播 `api-session/removed`,列表行立即消失 | @@ -33,7 +33,7 @@ DSH 原本只能归档会话——归档只是把会话从列表里移开,日 设置 → 通用设置 → **清理临时文件**,清理 spill 后端留下的**孤儿**目录。判定偏保守: 1. 目录名必须精确等于 `session-`——这是 spill 后端唯一会生成的形状; -2. 该名字不属于任何已知会话(存量会话与内存中正在运行的会话都算); +2. 该名字不属于任何已知会话(`sessionQuery.listSessions()` 已经把存量会话与内存中正在运行的会话合并成同一份语料,所以「已知」只需要一个来源); 3. 只在 spill 的 root 内操作(后端当前 root + 系统临时目录下的 `dsh-spill*`); 4. 符号链接一律跳过、不跟随;单项失败只记警告。 @@ -45,6 +45,7 @@ DSH 原本只能归档会话——归档只是把会话从列表里移开,日 - 删除**不可恢复**,但**不是安全擦除**:按普通文件删除处理,底层介质上可能有残留。 - 工作区里的代码文件不受影响。 - 溢出文件被删除后,fork 出来的会话日志里若仍保留指向它的路径文本,那些路径会失效。 +- 行内按钮的悬停气泡由按钮自身定位。指针停在会话行上时,行自己的悬停预览卡可能和这个气泡同时出现——插件不会去动宿主的行 DOM 来避免这一点(那是插件不该依赖的自有渲染结构)。若观感不能接受,可把气泡改为只在键盘 focus 时显示:删掉 `DeleteSessionRowButton` 里的 `onMouseEnter: show` / `onMouseLeave: hide` 两行即可。 ## 安装 @@ -58,35 +59,28 @@ DSH 的 **插件 → 添加插件** 里有两个输入,含义不同: 在上面的输入框里填: ```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 -把 [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`,于是会报: - -```text -这个包没有声明组合包,无法作为插件安装:@dsh-plugin/session-delete declares no dsh.bundle -``` - -Git 地址与 tgz 这两条路没有这个问题——它们是先把包取到本地、再读它自己的 `package.json`,字段完整。若将来把它发到保留自定义字段的注册表(npmjs 等),包名那条路才走得通。 - -安装或升级后:**宿主半变更需要重启 DSH**(完全退出再打开,仅关闭窗口不一定退出进程);只改浏览器半时刷新页面即可。DSH 暂不支持插件自动更新,升级就是用新标签重装一次(建议先移除旧版本)。 +DSH 暂不支持插件自动更新,升级就是用新标签重装一次(建议先移除旧版本)。安装或升级后:**宿主半变更需要重启 DSH**(完全退出再打开,仅关闭窗口不一定退出进程);只改浏览器半时刷新页面即可。 ## 兼容性 基于 **DeepSeek Harness 0.2.0-rc.2** 编写。DSH 仍在演进,其它版本与平台组合未逐一验证。 -- 浏览器半不 import 任何 Harness 客户端包(上游规范如此要求:这些包随时可能变化,渲染抛错会让整个插槽条目变空白),而是复刻 `dsh-client-ui-primitives` 的 markup、CSS 声明、图标 path 数据与键盘行为,只保留 `--dsw-*` 主题 token 引用。上游若改动这些内部结构,界面细节可能失配(例如悬停气泡不再出现),但不会导致插件加载失败。 -- 宿主半只依赖稳定的服务契约:`sessionPersistence`、`sessionQuery`、`workspaceRegistry`、`sessions`、`agents`、`connection.fetch`,以及 spill 后端的目录规则。 +- **浏览器半**不 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` 属于动态客户端运行器,不适用于包形式的插件。 ## License diff --git a/client.js b/client.js index fd038d1..9e91a54 100644 --- a/client.js +++ b/client.js @@ -1,14 +1,27 @@ /** * Browser half of the session-delete bundle. * - * One row in a conversation's "..." menu opens 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. + * 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. * - * Harness Client packages may change without notice, so this bundle imports no - * Harness Client module: its menu row and dialog copy the shipped primitives' - * markup, stylesheet declarations, and focus/Escape behavior, keeping only - * `--dsw-*` theme-token references. React comes from the page's module table. + * 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 + * 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({ id: '@dsh-plugin/session-delete', @@ -18,14 +31,12 @@ window.__ModuleLoader__.load({ 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 - /** The shipped hover button's id; reusing its id takes that cell over. */ - const ROW_ACTION_ID = 'archive' - const ROW_ACTION_ORDER = 100 - /** Below the shipped entry's implicit 0: the lowest priority renders. */ - const ROW_ACTION_PRIORITY = -1 + /** Between the shipped `archive` (100) and `pin` (200) hover buttons. */ + const ROW_ACTION_ORDER = 150 /** 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_GAP = 8 /** 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 * `.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. Class names are renamed - * under `dsd-`, and every color, radius, elevation, and transition stays a + * 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 = ` @@ -172,8 +184,7 @@ window.__ModuleLoader__.load({ /** * Icon paths are the shipped `IconTrashOutline` and `IconCloseOutline` * 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 - * made an earlier version of this row look undersized. + * regular weight of 1. */ function TrashIcon({ size = 16 }) { return h('svg', { @@ -244,42 +255,15 @@ window.__ModuleLoader__.load({ } /** - * Withdraw the session row's hover preview while this button's own tooltip - * is showing. + * 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. * - * The shipped row buttons get this for free: `Tooltip` reports itself - * through the primitives' private `TooltipSuppression` context, and the - * enclosing `HoverCard` — whose anchor is the whole row, so a button inside - * it counts as hovering the row — hides its card while that bubble is up. - * That context object belongs to another module instance and cannot be - * 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. + * 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) @@ -289,7 +273,6 @@ window.__ModuleLoader__.load({ const [anchor, setAnchor] = React.useState(null) const show = () => { - if (buttonRef.current !== null) withdrawRowPreview(buttonRef.current) clearTimeout(timerRef.current) timerRef.current = setTimeout(() => { 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 - * connection's trust fence, so the page's own credentials apply. + * 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. @@ -569,7 +555,9 @@ window.__ModuleLoader__.load({ payload = null } 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 } @@ -611,15 +599,13 @@ window.__ModuleLoader__.load({ inject: () => ({ requestSessionDelete }), }, DeleteSessionMenuItem)) - // This entry reuses the shipped archive action's id, so the row keeps - // one hover button and archiving stays available from the "..." menu. - // Same id at the same priority is refused as a duplicate, and the - // lowest priority renders, so this row must sit below the shipped 0. + // 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: ROW_ACTION_ID, + id: NS, order: ROW_ACTION_ORDER, - priority: ROW_ACTION_PRIORITY, locale: NS, inject: () => ({ requestSessionDelete }), }, DeleteSessionRowButton)) diff --git a/cordis.patch.yml b/cordis.patch.yml index ca926df..6729be8 100644 --- a/cordis.patch.yml +++ b/cordis.patch.yml @@ -1,5 +1,13 @@ -# Profile patch layer for the session-delete bundle: one Host row that owns the -# deletion operation and the authenticated route the browser half calls. +# 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' diff --git a/index.js b/index.js index 751052e..e4afff9 100644 --- a/index.js +++ b/index.js @@ -8,14 +8,21 @@ * 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 `/session-/…`, 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. Those files are otherwise reclaimed only by the backend's own - * 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. + * 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 @@ -23,9 +30,10 @@ * 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. * - * The browser half reaches this operation through the authenticated exact - * Fetch route registered below; it is the only cross-half entry point this - * package needs. + * 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 */ @@ -47,17 +55,19 @@ const SPILL_SESSION_DIRECTORY = /^session-[0-9a-f]{12}$/ /** Services this Host half needs before it may run at all. */ export const inject = [ - 'agents', 'connection', 'sessionPersistence', 'sessionQuery', - 'sessions', 'workspaceRegistry', ] /** - * Install the deletion route and finish deletions deferred to this activation. - * @param ctx - Host context carrying the Session, Agent, storage, and Workspace services. + * 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() @@ -76,15 +86,18 @@ export function apply(ctx) { fetch: () => handleOrphanSweep(ctx), }), 'session-delete: orphan sweep route') + let swept ctx.effect(() => { let disposed = false - sweepPending(ctx, () => disposed).catch((error) => { + swept = sweepPending(ctx, () => disposed).catch((error) => { warn(ctx, `could not finish deferred deletions: ${message(error)}`) }) return () => { disposed = true } }, 'session-delete: deferred deletions') + + return swept } /** @@ -112,7 +125,7 @@ async function handleRequest(ctx, request, inFlight) { inFlight.add(sessionId) try { const value = await deleteConversation(ctx, sessionId) - return Response.json(value, { headers: { 'cache-control': 'no-store' } }) + return json(value) } catch (error) { warn(ctx, `deleting "${sessionId}" 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 sessionId - conversation the user asked to delete. * @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) { - const headers = await collectHeaders(ctx) - const target = headers.get(sessionId) - 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 lineage = await resolveLineage(ctx, sessionId) + const ids = lineage.ids - const ids = [sessionId, ...subagentDescendants(headers, sessionId)] - const live = ids.filter((id) => isLive(ctx, id)) - - if (live.length > 0) { - for (const id of ids) await ctx.workspaceRegistry.archiveSession(id, { stopActivity: true }) - await rememberPending(ctx, ids) - return { ok: true, status: 'scheduled', sessionIds: ids, live } + 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 = headers.get(id) + const header = lineage.headers.get(id) if (header === undefined) continue 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 * does: `sha256(session id)` truncated to 12 hex characters. @@ -246,7 +311,7 @@ async function removeSpillArtifacts(ctx, sessionIds) { async function handleOrphanSweep(ctx) { try { const report = await sweepOrphanSpill(ctx) - return Response.json({ ok: true, ...report }, { headers: { 'cache-control': 'no-store' } }) + return json({ ok: true, ...report }) } catch (error) { warn(ctx, `orphan 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 * `session-` shape the spill backend derives AND no - * session known to this process — stored or live — hashes to it. Anything else, - * including a symlink or an entry the backend would never create, is left alone. + * 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 headers = await collectHeaders(ctx) - const known = new Set([...headers.keys()].map(spillSessionDirectory)) - const report = { directories: 0, files: 0, bytes: 0, roots: 0, sessions: headers.size } + 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 @@ -325,71 +396,24 @@ async function measureDirectory(directory) { 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. + * + * 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.sessionPersistence - if (typeof persistence.locate !== 'function') { + 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) @@ -433,7 +457,9 @@ async function syncDirectory(directory) { /** * 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 sessionId - removed identity. */ @@ -468,9 +494,15 @@ function announceRemoval(ctx, sessionId) { } /** - * Finish deletions deferred by a live session. Each id is deleted only while - * it is still archived, so restoring a conversation in the sidebar cancels its - * pending deletion. + * 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. */ @@ -478,24 +510,39 @@ 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 headers = await collectHeaders(ctx) + const archivedSessionIds = registry.archivedSessionIds const remaining = [] for (const sessionId of pending) { if (isDisposed()) { remaining.push(sessionId) 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) continue } - if (!registry.archivedSessionIds.includes(sessionId)) continue + if (!archivedSessionIds.includes(sessionId)) continue try { - const header = headers.get(sessionId) - if (header !== undefined) await removeArtifacts(ctx, header) - await removeSpillArtifacts(ctx, [sessionId]) - await forgetReferences(ctx, sessionId) + 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)}`) @@ -536,9 +583,21 @@ async function writeLedger(ctx, sessionIds) { 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) { - return readLedger(ctx).then((pending) => writeLedger(ctx, [...pending, ...sessionIds])) +/** + * 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]) } /** @@ -548,19 +607,21 @@ function rememberPending(ctx, sessionIds) { * @returns absolute ledger path. */ 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) 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 Response.json( - { ok: false, error: { code, message: text } }, - { status, headers: { 'cache-control': 'no-store' } }, - ) + return json({ ok: false, error: { code, message: text } }, status) } /** Log one contained diagnostic. */ diff --git a/locale/en.json b/locale/en.json index 28b8b27..875fb7b 100644 --- a/locale/en.json +++ b/locale/en.json @@ -1,6 +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. 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." } } diff --git a/locale/zh.json b/locale/zh.json index c437191..2a483eb 100644 --- a/locale/zh.json +++ b/locale/zh.json @@ -1,6 +1,6 @@ { "meta": { "title": "会话彻底删除", - "description": "彻底删除一个会话:先停止它,再删除会话日志、子智能体日志与工具输出溢出文件,并清理相关注册表引用;设置 → 通用设置里可清理已删除会话遗留的孤立临时文件。" + "description": "彻底删除一个会话:先停止它,再删除会话日志、子智能体日志与工具输出溢出文件,并清理相关注册表引用;设置 → 通用设置里的「清理临时文件」可清理已删除会话遗留的孤立临时文件。" } } diff --git a/package.json b/package.json index 66b958a..f372112 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,15 @@ { "name": "@dsh-plugin/session-delete", - "version": "1.0.2", - "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.", + "version": "2.0.0", + "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. Settings → General can sweep orphaned temporary files left by conversations that are gone.", "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", - "publishConfig": { - "registry": "https://gitea.iwake.top/api/packages/dsh-plugin/npm/" - }, "exports": { ".": "./index.js", "./client": "./client.js", @@ -32,7 +30,12 @@ "README.md", "LICENSE" ], + "engines": { + "node": ">=22", + "dsh": "0.2.0-rc.2" + }, "dsh": { + "manifestVersion": 1, "bundle": { "patch": "./cordis.patch.yml" },