dsh-session-mng 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/README.md +11 -5
- package/ROADMAP.md +3 -3
- package/client.js +63 -10
- package/docs/02-open-source-and-marketplace.md +4 -1
- package/docs/HANDOVER-2026-10-29.md +2 -2
- package/docs/PLAN-2026-11-02-family-move.md +72 -0
- package/docs/RELEASE-CHECKLIST-0.2.0.md +10 -7
- package/docs/RELEASE-CHECKLIST-0.2.1.md +46 -0
- package/lib/service.js +139 -41
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.1 — 2026-11-02
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **整枝移动(移动会话时连同子代理会话)**:根会话的子代理会话此前会阻止移动(`has-descendants`),现在它们跟随根会话一起迁移——先整体校验(任一会话已打开/运行中就整体拒绝,不留半移动状态),再逐个改写 header `cwd`、迁移目录、对账账本,任一步失败按逆序整体回滚;普通分支(fork)仍是独立会话,不跟随。移动成功的提示会写明「连同 N 个子代理会话一起移动」。
|
|
8
|
+
- **批量失败显示原因**:批量操作返回 200 但有条目失败时,toast 直接列出每个失败会话的 `code` 与消息(此前只显示「成功 0,失败 1」,看不出原因)。
|
|
9
|
+
- **「刷新页面」按钮**:设置页头部新增按钮,点击执行 `location.reload()` 重新加载整个 DSH 页面(与插件市场刷新同一个调用)。宿主侧栏的会话列表只在页面加载时拉取并按工作区分组,所以跨工作区移动会话后侧栏仍显示旧分组;点这个按钮即可让侧栏按最新归属重新分组。只重载客户端,不改变宿主进程里已打开的会话状态。
|
|
10
|
+
- **成功提示自动消失**:带操作按钮的成功提示(tone `ok`)现在也会自动隐藏(8 秒;不带按钮 5 秒),此前只要挂了按钮就永不消失;错误/警告提示仍保留到用户手动关闭。
|
|
11
|
+
|
|
3
12
|
## 0.2.0 — 2026-10-29
|
|
4
13
|
|
|
5
14
|
### Added
|
package/README.md
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
## 功能
|
|
18
18
|
|
|
19
19
|
- **统一会话总览**:按工作区分组列出当前 profile 的全部会话(活跃 + 已归档)。显示标题、路径、会话 id、大小、最后活动时间、运行/已打开/已归档/已置顶/分支/子代理标记;支持搜索、工作区筛选、排序、刷新、批量选择。
|
|
20
|
+
- **「刷新页面」按钮**:头部一键 `location.reload()` 重载整个 DSH 页面(与插件市场刷新同一个调用)。宿主侧栏只在页面加载时拉取会话列表并按工作区分组,跨工作区移动会话后侧栏不会自己重新分组,点它即可让侧栏按最新归属刷新(只重载客户端,不动宿主进程里已打开的会话)。
|
|
20
21
|
- **分支会话默认显示**:从某条会话分出来的会话(fork)和主会话一样可见、可管理——缩进挂在父会话下方,父行显示「N 分支」,分支行标注「↳ 来自:父标题」;搜索父标题也能找到它。由委派产生的**子代理会话**仍默认隐藏(勾选「显示子代理会话」查看),总览行会提示「已隐藏 N 个子代理会话」。
|
|
21
22
|
- **按工作区分页(Host 侧)**:首屏只取工作区概览(会话数/归档数/空会话数/大小占位),展开工作区才按 50 条一页向宿主取行,「加载更多」继续追加;会话数 >100 时默认折叠工作区;搜索自动展开命中工作区。过滤工具栏和批量条整体吸顶。
|
|
22
23
|
- **万级会话可用**:Host 新增零会话日志 IO 的 catalog(复用宿主的 header 索引与投影缓存),10000 条会话首屏 overview 约 10 ms / 约 1.3 KB,单页 50 行约 9 ms;`GET /state` 保留给 0.1.x 客户端与回退路径。
|
|
@@ -25,7 +26,7 @@
|
|
|
25
26
|
- **删除范围模态确认**:范围内容在弹窗内独立滚动,底部勾选与确认/取消按钮固定可见;支持遮罩点击/Esc 取消。
|
|
26
27
|
- **生成标题**:选中任意会话(**包括已归档的冷会话**),让模型总结整段对话,给出可编辑的标题提案;**确认后才写入**。活跃会话走官方 `sessionTitle.rename`,冷会话按官方 `session/title` 事件形状追加,不需要恢复会话。
|
|
27
28
|
- **归档 / 恢复**:调用官方 `workspaceRegistry.archiveSession` / `unarchiveSession`。运行中的会话先提示,用户二次确认后才停止并归档;恢复后回到原工作区原位置。
|
|
28
|
-
-
|
|
29
|
+
- **跨工作区移动(整枝)**:把冷会话真正迁移到目标工作区——改写会话日志 header 的 `cwd`、把日志目录迁到目标项目目录、同步 workspace 账本。**根会话的子代理会话会一起搬走**(它们的 `parentSession` 血缘保持不变),普通分支(fork)是独立会话,留在原工作区。整枝先整体校验(任一会话已打开/运行中就整体拒绝)、逐个迁移、失败按逆序整体回滚。SessionId、归档状态、pin 状态不变,事件字节不变。
|
|
29
30
|
- **彻底删除已归档会话**:删除前先预览范围(根会话、子代理会话、目录、字节数);确认后把日志目录移出 store、清理归档集合与 workspace 账本,再删除。**不可恢复。** 子代理会话随父级联删除;**普通分支会话不会**(它们仍是独立会话,来源父会话删除后保留下来),如需一并删除请把分支也勾选进来。
|
|
30
31
|
- **预览最近消息**:不恢复会话即可看最近 10 条用户/助手消息。
|
|
31
32
|
- **失效归档标记清理**:归档集合里有、但磁盘上已无日志的 id,会以横幅提示并允许清理。
|
|
@@ -105,7 +106,7 @@ Host route (lib/api.js) ──► service (lib/service.js)
|
|
|
105
106
|
| Node | `>=20.3` 可运行总览/标题/归档/删除;**移动** 需要 `node:zlib` 的 Zstandard 支持(Node `>=22.15`,当前 Desktop 运行时为 Node 24.21) |
|
|
106
107
|
| 会话后端 | 移动/删除只支持本地 JSONL 后端(`sessionPersistence.locate`);其他后端仍可用总览/标题/归档/恢复 |
|
|
107
108
|
| 官方依赖 | 无 runtime 依赖;只使用 `ctx` 服务 |
|
|
108
|
-
| 已知限制 | 已打开(live)会话不能移动或彻底删除,重启 DSH
|
|
109
|
+
| 已知限制 | 已打开(live)会话不能移动或彻底删除,重启 DSH 后重试;移动会连同子代理会话整枝迁移,普通分支不跟随;子代理会话默认隐藏(可勾选「显示子代理会话」查看);分支会话默认显示,但暂不能单独移动(需与父会话留在同一工作区);删除父会话不级联普通分支,分支会失去来源标识(显示「来源父会话已不存在」) |
|
|
109
110
|
| 规模限制 | 首屏与分页行走 Host catalog,不拉全量行;单页上限 50。**大小**按需异步统计(`/sizes` 复用一次 `sessionPersistence.list()`,10000 条约数秒),首屏/分页不依赖它。依赖宿主的两个内存 seam(`workspaceRegistry.headers`、`sessionProjectionCache`),缺失时自动退回 0.1.x 的 `buildState` 慢路径 |
|
|
110
111
|
|
|
111
112
|
### 版本敏感点(请读)
|
|
@@ -152,22 +153,27 @@ node --check index.js client.js lib/*.js
|
|
|
152
153
|
npm --cache /tmp/dsh-npm-cache pack --dry-run
|
|
153
154
|
```
|
|
154
155
|
|
|
156
|
+
> **改了 `client.js` 之后请重启应用,不要只刷新页面。** 宿主在启动时把每个插件的客户端产物(bundle + `rev`,`rev` 由文件的 mtime/ctime/size 算出)读进内存并组好图;本插件用 `link:` 安装时源文件是活的,运行中被改写后内存里的产物与磁盘不一致,此时页面重载会让 boot 审计把该条目判成「未激活」(桌面版弹「应用无法启动」,日志 `/Users/<you>/Library/Logs/DeepSeek Harness/crash-*-web-boot.log`)。重启应用会重新读盘组图,立刻恢复。这也是应用内插件管理器那条「1 项变更需重启后生效」的同一个道理。
|
|
157
|
+
|
|
155
158
|
实机验收:安装到 `desktop` profile → 确认 `settings.section` 出现 `session-mng-section` → 用**一次性测试会话**验证标题/归档/恢复/移动/删除 → 清理测试数据。0.1.3 的验收与实机记录见 `docs/RELEASE-CHECKLIST-0.1.3.md`。
|
|
156
159
|
|
|
157
160
|
## 当前状态与下一步
|
|
158
161
|
|
|
159
|
-
- `0.2.
|
|
162
|
+
- `0.2.1` 已发布到 npm / GitHub / Release(2026-11-02):**整枝移动**(根会话的子代理会话随根会话一起迁移,先整体校验、逐个迁移、失败逆序回滚;普通分支仍不跟随)、批量失败 toast 显示每条原因、头部「刷新页面」按钮(整页重载)、带操作按钮的成功提示也会自动消失。131 个测试全绿。
|
|
163
|
+
- `0.2.0` 已发布到 npm / GitHub / Release(2026-10-29):Host 侧新增零会话日志 IO 的会话 catalog 与 7 条只读路由(`/overview`、`/sessions`、`/search`、`/session`、`/blanks`、`/sizes`,`/state` 保留);首屏只取概览,工作区展开才按 50 条一页取行;搜索改由宿主完成;选择只作用于已加载行;详情独立取数不再被刷新踢出;分支跨页不再误报父会话已删除。10000 条合成基准:overview ≈ 10 ms / 1.3 KB,单页 50 行 ≈ 9 ms。129 个测试全绿(Host 72 + 客户端 vm 57)。
|
|
160
164
|
- `0.1.4` 已发布到 npm / GitHub / Release:详情页标题行吸顶;操作结果改为浮动 toast(成功 5 秒自动消失);头部按钮移到标题右侧;新增「归档空会话(N)」,「清理空会话(N)」只要有已归档空会话就显示,且超过 50 条自动分批预览/删除。
|
|
161
165
|
- `0.1.3` 已发布到 npm / GitHub / Release:三态会话模型(根/分支/子代理),分支默认显示并缩进在父会话下;删除父会话不再级联删掉普通分支;父会话有历史分支时也能移动;兼容声明增加 DSH `0.2.1-rc.3`。
|
|
162
166
|
- `0.1.2` 已发布:空会话默认隐藏、清理空会话、删除范围模态确认。
|
|
163
|
-
-
|
|
164
|
-
- 市场条目已收录:PR [#9672](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin/pull/9672) 已于 2026-10-27 合并(`check` 与仓库年龄 gate 均通过);卡片版本随 npm 自动更新,当前 0.1
|
|
167
|
+
- 131 个测试全绿;0.2.1 包内容见 `docs/RELEASE-CHECKLIST-0.2.1.md`。
|
|
168
|
+
- 市场条目已收录:PR [#9672](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin/pull/9672) 已于 2026-10-27 合并(`check` 与仓库年龄 gate 均通过);卡片版本随 npm 自动更新,当前 0.2.1。
|
|
165
169
|
- 下一项主任务见 [接手说明(0.2.0 之后)](docs/HANDOVER-2026-10-29.md):先完成 0.2.0 的实机只读验收与发版,再安排全文内容搜索、导出、置顶等候选。
|
|
166
170
|
|
|
167
171
|
## 文档索引
|
|
168
172
|
|
|
169
173
|
- [接手说明(2026-10-27)](docs/HANDOVER-2026-10-27.md)
|
|
170
174
|
- [接手说明(0.2.0 之后)](docs/HANDOVER-2026-10-29.md)
|
|
175
|
+
- [0.2.1 发布检查清单](docs/RELEASE-CHECKLIST-0.2.1.md)
|
|
176
|
+
- [整枝移动实施计划(父会话 + 子代理后代)](docs/PLAN-2026-11-02-family-move.md)
|
|
171
177
|
- [0.2.0 实施计划(overview + 分页 sessions)](docs/PLAN-2026-10-29.md)
|
|
172
178
|
- [0.2.0 发布检查清单](docs/RELEASE-CHECKLIST-0.2.0.md)
|
|
173
179
|
- [实施计划(批准时原样归档)](docs/PLAN-2026-10-26.md)
|
package/ROADMAP.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Roadmap
|
|
2
2
|
|
|
3
|
-
> 当前:`0.2.0`
|
|
3
|
+
> 当前:`0.2.0` 已发布(npm latest / tag `v0.2.0` / Release,2026-10-29):Host 侧 overview + 分页 sessions(1000–10000 条会话)+ 客户端两段式加载。0.2.0 计划与验收见 [docs/PLAN-2026-10-29.md](docs/PLAN-2026-10-29.md) 与 [docs/RELEASE-CHECKLIST-0.2.0.md](docs/RELEASE-CHECKLIST-0.2.0.md)。下一项候选见本文件 Later candidates 与 [docs/HANDOVER-2026-10-29.md](docs/HANDOVER-2026-10-29.md)。
|
|
4
4
|
|
|
5
5
|
## 0.1.0 — delivered
|
|
6
6
|
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
- 头部三个按钮移到会话管理标题右侧;副标题与统计信息独占下方整行。
|
|
41
41
|
- 空会话:「归档空会话(N)」一键分批归档;「清理空会话(N)」只要有已归档空会话就显示;删除范围预览与删除按 `maxBatch` 分批,超过 50 条也能一次确认完成。
|
|
42
42
|
|
|
43
|
-
## 0.2.0 —
|
|
43
|
+
## 0.2.0 — delivered (published)
|
|
44
44
|
|
|
45
45
|
首要目标:真正支撑 1000–10000 条会话,不再首屏拉全量 `state`。计划与实施记录见 [docs/PLAN-2026-10-29.md](docs/PLAN-2026-10-29.md)。
|
|
46
46
|
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
- `(sortValue,id)` keyset 游标,替换 0.2.0 的裸 offset 分页,进一步抗数据变化。
|
|
60
60
|
- 导出所选会话为 Markdown / JSON(删除前的安全网;批量)。
|
|
61
61
|
- 置顶 / 取消置顶(`workspaceRegistry.pinSession` / `unpinSession`)。
|
|
62
|
-
-
|
|
62
|
+
- ~~整枝移动(父会话 + 子代理后代一起迁移)~~ 已实现(2026-11-02,见 [docs/PLAN-2026-11-02-family-move.md](docs/PLAN-2026-11-02-family-move.md));普通分支(fork)仍按独立会话处理,不跟随。
|
|
63
63
|
- 会话体检 / 修复:重复目录、幽灵归档、workspace 账本与 header cwd 不一致、损坏日志报告。
|
|
64
64
|
- 可选回收站(与「彻底删除」并存,默认关闭)。
|
|
65
65
|
- 移动的增量实现(rename 而不是整目录复制),大日志更快。
|
package/client.js
CHANGED
|
@@ -33,6 +33,8 @@ window.__ModuleLoader__.load({
|
|
|
33
33
|
pageTitle: '会话管理',
|
|
34
34
|
pageSubtitle: '管理当前 profile 的全部会话:标题、归档、移动与彻底删除。',
|
|
35
35
|
refresh: '刷新',
|
|
36
|
+
reloadPage: '刷新页面',
|
|
37
|
+
reloadPageHint: '重新加载整个 DSH 页面:侧栏会话列表会按最新的工作区归属重新分组(移动会话后使用)。',
|
|
36
38
|
loading: '正在读取会话…',
|
|
37
39
|
loadFailed: '读取会话失败:{message}',
|
|
38
40
|
retry: '重试',
|
|
@@ -173,6 +175,7 @@ window.__ModuleLoader__.load({
|
|
|
173
175
|
workspaceMissingErr: '目标工作区目录不存在。',
|
|
174
176
|
notFound: '会话或工作区不存在。',
|
|
175
177
|
batchDone: '已处理:成功 {ok},失败 {failed}。',
|
|
178
|
+
movedWithFamily: '连同 {n} 个子代理会话一起移动',
|
|
176
179
|
batchFailed: '有 {failed} 个操作失败。',
|
|
177
180
|
failures: '失败详情',
|
|
178
181
|
detailsToggle: '展开详情',
|
|
@@ -208,6 +211,8 @@ window.__ModuleLoader__.load({
|
|
|
208
211
|
pageTitle: 'Session Manager',
|
|
209
212
|
pageSubtitle: 'Manage every session in this profile: titles, archive, move, and permanent deletion.',
|
|
210
213
|
refresh: 'Refresh',
|
|
214
|
+
reloadPage: 'Reload page',
|
|
215
|
+
reloadPageHint: 'Reload the whole DSH page so the sidebar regroups sessions by their current workspace (use it after a move).',
|
|
211
216
|
loading: 'Loading sessions…',
|
|
212
217
|
loadFailed: 'Could not load sessions: {message}',
|
|
213
218
|
retry: 'Retry',
|
|
@@ -348,6 +353,7 @@ window.__ModuleLoader__.load({
|
|
|
348
353
|
workspaceMissingErr: 'The target workspace directory is missing.',
|
|
349
354
|
notFound: 'The session or workspace was not found.',
|
|
350
355
|
batchDone: 'Processed: {ok} succeeded, {failed} failed.',
|
|
356
|
+
movedWithFamily: 'moved together with {n} subagent session(s)',
|
|
351
357
|
batchFailed: '{failed} operations failed.',
|
|
352
358
|
failures: 'Failure details',
|
|
353
359
|
detailsToggle: 'Show details',
|
|
@@ -718,26 +724,30 @@ window.__ModuleLoader__.load({
|
|
|
718
724
|
/** How long a plain success toast stays before hiding itself. */
|
|
719
725
|
const NOTICE_HIDE_MS = 5000
|
|
720
726
|
|
|
727
|
+
/** A success toast offering one follow-up action stays a little longer. */
|
|
728
|
+
const NOTICE_HIDE_ACTION_MS = 8000
|
|
729
|
+
|
|
721
730
|
/**
|
|
722
731
|
* Operation-result toast.
|
|
723
732
|
*
|
|
724
733
|
* It floats above the settings pane (instead of sitting in the scroll flow),
|
|
725
|
-
* so a result is visible no matter where the list is scrolled. A
|
|
726
|
-
*
|
|
727
|
-
*
|
|
728
|
-
* worth reading.
|
|
734
|
+
* so a result is visible no matter where the list is scrolled. A success
|
|
735
|
+
* toast hides itself after a few seconds, with or without a follow-up
|
|
736
|
+
* action; failures and warnings stay until the user dismisses them, because
|
|
737
|
+
* they carry details worth reading.
|
|
729
738
|
*/
|
|
730
739
|
function Notice({ notice, t, onDismiss }) {
|
|
731
740
|
const dismiss = React.useRef(onDismiss)
|
|
732
741
|
dismiss.current = onDismiss
|
|
733
742
|
const transient = notice !== null && notice !== undefined
|
|
734
|
-
&& notice.
|
|
735
|
-
&& notice.
|
|
743
|
+
&& notice.details === undefined
|
|
744
|
+
&& (notice.tone === 'ok' || (notice.tone !== 'error' && notice.tone !== 'warn' && notice.action === undefined))
|
|
745
|
+
const delay = notice?.action === undefined ? NOTICE_HIDE_MS : NOTICE_HIDE_ACTION_MS
|
|
736
746
|
React.useEffect(() => {
|
|
737
747
|
if (!transient) return undefined
|
|
738
|
-
const timer = setTimeout(() => dismiss.current?.(),
|
|
748
|
+
const timer = setTimeout(() => dismiss.current?.(), delay)
|
|
739
749
|
return () => clearTimeout(timer)
|
|
740
|
-
}, [notice, transient])
|
|
750
|
+
}, [notice, transient, delay])
|
|
741
751
|
if (notice === null || notice === undefined) return null
|
|
742
752
|
return h(
|
|
743
753
|
'div',
|
|
@@ -2154,6 +2164,19 @@ window.__ModuleLoader__.load({
|
|
|
2154
2164
|
setDetailNonce((value) => value + 1)
|
|
2155
2165
|
}
|
|
2156
2166
|
|
|
2167
|
+
/**
|
|
2168
|
+
* Reload the whole DSH page. The sidebar keeps the workspace grouping it
|
|
2169
|
+
* loaded, so a moved session stays under the old workspace until the page
|
|
2170
|
+
* reloads; this is the same call the plugin market makes after a change.
|
|
2171
|
+
*/
|
|
2172
|
+
const reloadPage = () => {
|
|
2173
|
+
try {
|
|
2174
|
+
window.location.reload()
|
|
2175
|
+
} catch {
|
|
2176
|
+
// A host without a reloadable location leaves the page as it is.
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2157
2180
|
React.useEffect(() => {
|
|
2158
2181
|
void loadOverview()
|
|
2159
2182
|
return () => {
|
|
@@ -2406,12 +2429,30 @@ window.__ModuleLoader__.load({
|
|
|
2406
2429
|
const totalBytes = sizes?.totalBytes
|
|
2407
2430
|
const sizeText = Number.isFinite(totalBytes) ? formatBytes(totalBytes) : '—'
|
|
2408
2431
|
|
|
2432
|
+
/** Compact per-session failures a batch reports inside a 200 response. */
|
|
2433
|
+
const failuresOf = (value) => {
|
|
2434
|
+
const failed = (value?.results ?? []).filter((item) => item.ok === false)
|
|
2435
|
+
if (failed.length === 0) return undefined
|
|
2436
|
+
return failed.slice(0, 20).map((item) => ({
|
|
2437
|
+
sessionId: item.sessionId,
|
|
2438
|
+
code: item.error?.code ?? 'failed',
|
|
2439
|
+
message: item.error?.message ?? '',
|
|
2440
|
+
}))
|
|
2441
|
+
}
|
|
2442
|
+
|
|
2409
2443
|
const run = async (operation, successText) => {
|
|
2410
2444
|
setBusy(true)
|
|
2411
2445
|
setNotice(null)
|
|
2412
2446
|
try {
|
|
2413
2447
|
const value = await operation()
|
|
2414
|
-
|
|
2448
|
+
// A batch can answer 200 while every item failed; surface the reasons
|
|
2449
|
+
// instead of reporting a bare "0 succeeded / 1 failed".
|
|
2450
|
+
const failures = failuresOf(value)
|
|
2451
|
+
setNotice(
|
|
2452
|
+
failures === undefined
|
|
2453
|
+
? { tone: 'ok', text: successText(value) }
|
|
2454
|
+
: { tone: 'error', text: successText(value), details: failures },
|
|
2455
|
+
)
|
|
2415
2456
|
await refresh()
|
|
2416
2457
|
return value
|
|
2417
2458
|
} catch (failure) {
|
|
@@ -2425,7 +2466,13 @@ window.__ModuleLoader__.load({
|
|
|
2425
2466
|
const resultText = (value) => {
|
|
2426
2467
|
const valueTotals = value?.totals
|
|
2427
2468
|
if (valueTotals === undefined) return t('working')
|
|
2428
|
-
|
|
2469
|
+
const base = t('batchDone', { ok: valueTotals.succeeded, failed: valueTotals.failed })
|
|
2470
|
+
// Moves carry subagent descendants along; say so, because the count of
|
|
2471
|
+
// moved rows is larger than the number of selected sessions.
|
|
2472
|
+
const family = (value?.results ?? [])
|
|
2473
|
+
.filter((item) => item.ok === true && Number.isFinite(item.family) && item.family > 1)
|
|
2474
|
+
.reduce((sum, item) => sum + (item.family - 1), 0)
|
|
2475
|
+
return family > 0 ? `${base} · ${t('movedWithFamily', { n: family })}` : base
|
|
2429
2476
|
}
|
|
2430
2477
|
|
|
2431
2478
|
const archiveRows = async (ids, stopActivity = false) => {
|
|
@@ -2718,6 +2765,12 @@ window.__ModuleLoader__.load({
|
|
|
2718
2765
|
: null,
|
|
2719
2766
|
h('button', { type: 'button', className: 'dsm-btn', disabled: busy, onClick: () => void refresh() },
|
|
2720
2767
|
busy ? h(Spinner, { t, label: t('working') }) : t('refresh')),
|
|
2768
|
+
h('button', {
|
|
2769
|
+
type: 'button',
|
|
2770
|
+
className: 'dsm-btn',
|
|
2771
|
+
title: t('reloadPageHint'),
|
|
2772
|
+
onClick: reloadPage,
|
|
2773
|
+
}, t('reloadPage')),
|
|
2721
2774
|
),
|
|
2722
2775
|
),
|
|
2723
2776
|
h('p', { className: 'dsm-sub' }, t('pageSubtitle')),
|
|
@@ -162,4 +162,7 @@ curl -s https://awesome-dsh-plugin.com/plugins.json | grep -i session-mng
|
|
|
162
162
|
|
|
163
163
|
- 计划:[PLAN-2026-10-29.md](PLAN-2026-10-29.md);发布清单:[RELEASE-CHECKLIST-0.2.0.md](RELEASE-CHECKLIST-0.2.0.md)。
|
|
164
164
|
- 本地:129 测试全绿;10k 合成基准 overview 9.5 ms / 1338 B、单页 8.8 ms / 25.3 KB;`npm pack --dry-run` 41 文件 / 628.2 kB。
|
|
165
|
-
-
|
|
165
|
+
- 已完成(2026-10-29):npm `dsh-session-mng@0.2.0` 发布(41 文件 / 629.8 kB,`latest`=0.2.0,版本端点 200)、tag `v0.2.0`、GitHub Release https://github.com/ZilongYang/dsh-session-mng/releases/tag/v0.2.0 。
|
|
166
|
+
- 站点 `dsh.zlmix.com/session-mng/` 已更新并部署(站点仓库提交「content: track dsh-session-mng 0.2.0」;自检 0 error / 0 warning;线上 8 条路径全部 200,未知路径 404,index.html 校验一致);线上数据版本 `0.2.0`、DSH tested `0.2.1-rc.3`。
|
|
167
|
+
- awesome-dsh-plugin 的 `plugins.json` 在发布后仍显示 `0.1.4`(它自己的同步/构建缓存),待其刷新后确认卡片版本变为 `0.2.0`;条目 YAML 无需修改。
|
|
168
|
+
- 截图仍为 0.1.x 图片(界面变化不大),需要时可重拍。
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
| 项 | 值 |
|
|
8
8
|
|---|---|
|
|
9
|
-
| 版本 | `
|
|
10
|
-
| npm | https://www.npmjs.com/package/dsh-session-mng
|
|
9
|
+
| 版本 | `0.2.0` 已发布(npm latest / tag `v0.2.0` / Release,2026-10-29) |
|
|
10
|
+
| npm | https://www.npmjs.com/package/dsh-session-mng(`latest` = 0.2.0) |
|
|
11
11
|
| GitHub | https://github.com/ZilongYang/dsh-session-mng |
|
|
12
12
|
| 当前 main | B0 `d251b41` → Host B1+B2 `fc2b881` → sizes 口径 `49eadd8` → Client B3+B4 `f19cd6b`;0.2.0 文档提交见其后 |
|
|
13
13
|
| 测试 | `node --test` **129 个全绿**(Host 72 + 客户端 vm 57) |
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# 整枝移动(父会话 + 子代理后代)实施计划
|
|
2
|
+
|
|
3
|
+
> 批准时间:2026-11-02(子龙在对话里选择「A」)
|
|
4
|
+
> 状态:已实施(见文末事后补记)
|
|
5
|
+
|
|
6
|
+
## 背景
|
|
7
|
+
|
|
8
|
+
实机反馈:`Tauri 封装 pi-web 项目`(`session-0aaf8279-…`,`zilong` 工作区)移动到 `pi-web-desktop`
|
|
9
|
+
时一直返回「成功 0,失败 1」,重启 DSH 两次无效。
|
|
10
|
+
|
|
11
|
+
排查结论:
|
|
12
|
+
|
|
13
|
+
- 会话 header 正常(`cwd` 存在、非分支、非子代理、未归档);
|
|
14
|
+
- 目标工作区已注册且目录存在;
|
|
15
|
+
- 真实原因:它有 **5 个 `origin: subagent` 子会话**(`parentSession` 指向它),
|
|
16
|
+
当时的 `moveOne` 对 `has-descendants` 直接拒绝。
|
|
17
|
+
|
|
18
|
+
「有子代理后代的会话不能单独移动」在 0.1.x 是为了避免父会话搬走、子代理会话留在旧项目目录
|
|
19
|
+
而立的规则。逻辑上子代理会话只服务于根会话,应当跟随根会话一起迁移。
|
|
20
|
+
|
|
21
|
+
## 方案(批准时的内容)
|
|
22
|
+
|
|
23
|
+
1. **整枝语义**
|
|
24
|
+
- 根会话的子代理后代(递归,`partitionDescendants` 已按 `kind` 拆分)随根会话一起迁移。
|
|
25
|
+
- 普通分支(fork)是独立用户会话,**不跟随**,留在原工作区(维持 0.1.3 的结论)。
|
|
26
|
+
- 子代理会话仍然不能单独移动(`subagent-session`),只能通过其根会话整枝移动。
|
|
27
|
+
|
|
28
|
+
2. **先整体校验,再动字节**
|
|
29
|
+
- 逐成员检查:有存储、header 有 `cwd`、非 live、非 running、能定位源目录、目标目录不冲突。
|
|
30
|
+
- 任一成员不满足 → 整个请求在**没有移动任何字节**前失败,并在 `details.sessionId`
|
|
31
|
+
指出是哪一个成员(例如某个子代理会话已打开)。
|
|
32
|
+
|
|
33
|
+
3. **逐个迁移 + 逆序回滚**
|
|
34
|
+
- 每个成员复用现有 `stageMove`(移动→复制→改写 header `cwd`→回读校验)与
|
|
35
|
+
`reconcileMove`(header 缓存、`rememberSessionPath`、`attachSession`/`detachSession`)。
|
|
36
|
+
- 根会话先移,子代理按 trace 顺序跟随。
|
|
37
|
+
- 任一成员失败:已完成的成员按逆序 `rollbackMoveReconcile` + `rollbackMove`;
|
|
38
|
+
回滚自身失败时返回 `manual-recovery` 并列出失败清单。
|
|
39
|
+
|
|
40
|
+
4. **结果与提示**
|
|
41
|
+
- 返回值新增 `family`(本次迁移的会话数)与 `sessions`(id 列表);`bytes` 为各成员之和。
|
|
42
|
+
- 客户端成功提示追加「连同 N 个子代理会话一起移动」。
|
|
43
|
+
|
|
44
|
+
5. **顺带修复的 UX 坑**
|
|
45
|
+
- 批量接口可能返回 200 但每条都失败(本次就是),此前 toast 只说「成功 0,失败 1」。
|
|
46
|
+
现在把每个失败条目的 `code` 与 message 列进 toast 的 details。
|
|
47
|
+
|
|
48
|
+
## 测试
|
|
49
|
+
|
|
50
|
+
- `a root moves together with its subagent descendants`:根 + 子代理都到新目录、header `cwd`
|
|
51
|
+
与 `parentSession` 正确、普通分支留下、两个工作区账本正确。
|
|
52
|
+
- `a live subagent refuses the whole family before any byte moves`:子代理 live 时整个请求失败,
|
|
53
|
+
`details.sessionId` 指向该子代理,根会话一个字节都没动。
|
|
54
|
+
|
|
55
|
+
## 风险
|
|
56
|
+
|
|
57
|
+
- 整枝移动会移动比用户勾选更多的会话(用户勾 1 条,实际搬 1+N 条);已通过提示与文档说明。
|
|
58
|
+
- 子代理会话的状态/归档标记保持不变,与父会话一致性由既有规则保证(整枝只改 `cwd`)。
|
|
59
|
+
- 大日志整枝复制耗时随成员数增加;回滚路径已在单成员上验证过,整枝回滚为逆序复用。
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## 事后补记(实施后回填,非批准时内容)
|
|
64
|
+
|
|
65
|
+
- 实施位置:`lib/service.js` 新增 `planMember` / `relocateMember` / `undoMember`,`moveOne` 改为
|
|
66
|
+
「校验整族 → 逐个迁移 → 失败逆序回滚」;`client.js` 的 `run()` 增加批量失败 details、
|
|
67
|
+
`resultText()` 增加 `movedWithFamily`。
|
|
68
|
+
- 与计划差异:无功能差异;计划中的「回滚自身失败时列出清单」实现为 `manual-recovery` 的
|
|
69
|
+
`details.rollback` 数组。
|
|
70
|
+
- 测试:服务端 2 条新用例替换原来的 `subagent descendants still block moving their source`;
|
|
71
|
+
全套 131 个测试通过。
|
|
72
|
+
- 未做(留到 0.2.1 发版):实机验收整枝移动、README/CHANGELOG 发版记录、版本号提升。
|
|
@@ -55,16 +55,19 @@
|
|
|
55
55
|
- [x] 真机 API:`overview` 200 / 3.9 ms;`sessions` 50 行、total 54、hasMore;`search` 14 组 / 81 条;`sizes` 与 `/state` 的 `totals.bytes` 完全一致(66,385,772 B);per-workspace count 与 `/state` 逐项相等;`overview.totals`(all/hiddenSubagents/forks)与 `/state` 相等;`/sessions?workspaceId=all` 404、缺参 400、POST 405。
|
|
56
56
|
- [x] 真机 UI(ego-browser):首屏只发 `overview + blanks + sizes`(**无 `/state`**);展开最大工作区只发一次 `/sessions?...offset=0&limit=50&sizes=1`,渲染 50 行,组头「54 个会话 · 约 14 MiB · 已加载 50/54」;「加载更多」追加到 54 行;全库搜索只发一次 `/search`,14 个工作区自动展开、81 行;点行进详情只发一次 `/session?id=…`(另有既有 `preview`),返回按钮正常;`显示空会话` 切换后 tabs 108→109、隐藏空会话提示消失,`归档空会话(1)` 按钮保持;分支行显示「↳ 来自:父标题」并缩进 18px,无「来源父会话已不存在」误报。
|
|
57
57
|
- [x] 真机 `/state` 仍 200(旧客户端兼容);运行全程无前端报错、无重复请求风暴。
|
|
58
|
-
- [
|
|
58
|
+
- [x] Desktop 应用重启后的最终确认(2026-10-29 17:44 子龙重启并确认 UI 正常):新 GUI `http://127.0.0.1:62203` 上 `GET /overview` 200 / `source: "catalog"`、`GET /state` 200。
|
|
59
59
|
- [ ] 截图更新(`assets/screenshot-*.png`,重启后按 0.2.0 列表/分页/搜索状态重拍)。
|
|
60
60
|
|
|
61
|
-
## C.
|
|
61
|
+
## C. 发布(已完成,2026-10-29)
|
|
62
62
|
|
|
63
|
-
- [
|
|
64
|
-
- [
|
|
65
|
-
- [
|
|
66
|
-
- [
|
|
67
|
-
- [
|
|
63
|
+
- [x] `package.json` 0.2.0。
|
|
64
|
+
- [x] `npm --cache /tmp/dsh-npm-cache --logs-dir /tmp/dsh-npm-logs publish`:+ dsh-session-mng@0.2.0,41 个文件 / 629.8 kB(unpacked 982.4 kB),shasum `ef3d3fac8467b8fa4e47642ed7c837bad5107ce9`。
|
|
65
|
+
- [x] Git:main push(`63fa300..514f139`);tag `v0.2.0`;GitHub Release https://github.com/ZilongYang/dsh-session-mng/releases/tag/v0.2.0
|
|
66
|
+
- [x] 更新 README 当前状态、ROADMAP(delivered)、`docs/02-open-source-and-marketplace.md`(0.2.0 发布记录)。
|
|
67
|
+
- [x] 在 `docs/PLAN-2026-10-29.md` 末尾补「事后差异(补记)」。
|
|
68
|
+
- [x] 站点 `dsh.zlmix.com/session-mng/` 更新并部署(0.2.0 版本/功能文案/数据流/JSON-LD/sitemap;自检 PASS,线上验证 8 路径 200)。
|
|
69
|
+
- [ ] 市场卡片版本确认:发布后 awesome-dsh-plugin `plugins.json` 仍为 0.1.4(其同步/构建缓存),待刷新后确认变为 0.2.0;条目 YAML 无需修改。
|
|
70
|
+
- [ ] 截图更新(`assets/screenshot-*.png` 仍为 0.1.x;站点 `assets/shots/mng-*.png` 亦为旧图,可在需要时重拍)。
|
|
68
71
|
|
|
69
72
|
## D. 已知口径与差异(写入文档)
|
|
70
73
|
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# dsh-session-mng 0.2.1 发布检查清单
|
|
2
|
+
|
|
3
|
+
> 记录时间:2026-11-02。0.2.1 = 整枝移动(父会话 + 子代理后代)+ 批量失败原因 + 刷新页面按钮 + toast 自动消失。
|
|
4
|
+
> 相关计划:[PLAN-2026-11-02-family-move.md](PLAN-2026-11-02-family-move.md)。本清单随实施回填;发版前需子龙确认。
|
|
5
|
+
|
|
6
|
+
## A. 实现
|
|
7
|
+
|
|
8
|
+
### Host(`lib/service.js`)
|
|
9
|
+
|
|
10
|
+
- [x] `moveOne` 拆成「整族校验 → 逐个迁移 → 失败逆序回滚」:
|
|
11
|
+
- [x] `planMember()`:逐成员检查有存储、header 有 `cwd`、非 live、非 running、源目录可定位、目标目录可定位;任一失败整体拒绝(`details.sessionId` 指出成员)。
|
|
12
|
+
- [x] `relocateMember()`:复用 `stageMove`(移动 → 复制 → 改写 header `cwd` → 回读校验)与 `reconcileMove`(header 缓存、`rememberSessionPath`、`attachSession`/`detachSession`);单成员失败先自行回滚。
|
|
13
|
+
- [x] `undoMember()`:逆序回滚已完成成员(`rollbackMoveReconcile` + `rollbackMove`);回滚自身失败返回 `manual-recovery`(`details.rollback`)。
|
|
14
|
+
- [x] 子代理后代随根会话迁移(`partitionDescendants` 的 `subagents`,递归);普通分支(fork)不跟随;子代理会话仍不能单独移动。
|
|
15
|
+
- [x] 结果新增 `family`(迁移会话数)与 `sessions`(id 列表);`bytes` 为各成员之和。
|
|
16
|
+
- [x] 移除 `has-descendants` 拒绝(该错误码保留在客户端映射里,兼容旧 Host)。
|
|
17
|
+
|
|
18
|
+
### Client(`client.js`)
|
|
19
|
+
|
|
20
|
+
- [x] `run()`:批量接口返回 200 但有条目失败时,toast 改为 `error` 并附 `details`(每条 `sessionId` / `code` / `message`,最多 20 条)。
|
|
21
|
+
- [x] `resultText()`:移动结果带 `family > 1` 时追加「连同 N 个子代理会话一起移动」。
|
|
22
|
+
- [x] 头部「刷新页面」按钮:`location.reload()` 整页重载(与插件市场同一调用)。
|
|
23
|
+
- [x] `Notice`:带操作按钮的成功提示也会自动消失(带按钮 8 s、不带 5 s);错误/警告仍保留。
|
|
24
|
+
- [x] 新增 i18n:`reloadPage` / `reloadPageHint` / `movedWithFamily`(中英各一条)。
|
|
25
|
+
|
|
26
|
+
## B. 验证
|
|
27
|
+
|
|
28
|
+
- [x] `npm test`:**131 / 131 全绿**(新增 2 条 Host 用例:整枝移动、子代理 live 时整体拒绝;客户端 1 条:刷新页面按钮)。
|
|
29
|
+
- [x] `node --check index.js client.js lib/*.js test/*.js` 全部通过。
|
|
30
|
+
- [x] `npm --cache /tmp/dsh-npm-cache pack --dry-run`:42 个文件 / unpacked 997.1 kB。
|
|
31
|
+
- [x] 实机验收(子龙,2026-11-02):把 `Tauri 封装 pi-web 项目`(1 父 + 5 子代理,`zilong`)整枝移动到 `pi-web-desktop`,移动成功;侧栏用「刷新页面」重新分组正常。
|
|
32
|
+
|
|
33
|
+
## C. 发布
|
|
34
|
+
|
|
35
|
+
- [x] `package.json` 0.2.1。
|
|
36
|
+
- [x] `CHANGELOG.md` 0.2.1 段落。
|
|
37
|
+
- [ ] `npm publish`:待执行。
|
|
38
|
+
- [ ] Git:commit + push + tag `v0.2.1` + GitHub Release:待执行。
|
|
39
|
+
- [ ] README「当前状态」、ROADMAP、市场卡片版本确认:随发布回填。
|
|
40
|
+
- [ ] 站点 `dsh.zlmix.com/session-mng/` 文案/版本更新(如需):待定。
|
|
41
|
+
|
|
42
|
+
## D. 已知口径
|
|
43
|
+
|
|
44
|
+
- 整枝移动移动的会话数可能多于用户勾选数(勾 1 条 = 根 + 其全部子代理后代);提示与 README 均写明。
|
|
45
|
+
- 普通分支(fork)仍是独立会话,不跟随;删除父会话也不级联分支(0.1.3 起的口径不变)。
|
|
46
|
+
- `/plugins` 路由与客户端产物在宿主启动时组装:**改了 `client.js` 之后要重启应用**,只刷新页面会让 boot 审计判定该插件条目未激活(README 开发章节已记录)。
|
package/lib/service.js
CHANGED
|
@@ -866,73 +866,81 @@ export function createService(ctx, config) {
|
|
|
866
866
|
return summarize(results)
|
|
867
867
|
}
|
|
868
868
|
|
|
869
|
-
/**
|
|
870
|
-
|
|
869
|
+
/**
|
|
870
|
+
* Validate one member of a move family before any byte moves.
|
|
871
|
+
*
|
|
872
|
+
* Every member is checked first, so a family with one live or running member
|
|
873
|
+
* is refused as a whole instead of leaving a half-moved branch behind.
|
|
874
|
+
*
|
|
875
|
+
* @param {string} id - session id.
|
|
876
|
+
* @param {object} header - stored header of that session.
|
|
877
|
+
* @param {object} target - target workspace entity.
|
|
878
|
+
* @returns {Promise<{id: string, header: object, source: object|undefined, sourceDir: string, targetDir: string}>}
|
|
879
|
+
*/
|
|
880
|
+
async function planMember(id, header, target) {
|
|
871
881
|
const store = persistence()
|
|
872
|
-
if (store === undefined || typeof store.stat !== 'function' || typeof store.locate !== 'function') {
|
|
873
|
-
throw new SessionMngError('unsupported-backend', 'moving a session requires the local JSONL session backend', 501)
|
|
874
|
-
}
|
|
875
|
-
const snapshot = await store.stat(id)
|
|
876
|
-
const header = snapshot?.header
|
|
877
|
-
if (header === undefined) throw new SessionMngError('not-found', `session "${id}" is not stored`, 404)
|
|
878
882
|
if (typeof header.cwd !== 'string' || header.cwd === '') {
|
|
879
|
-
throw new SessionMngError('no-cwd',
|
|
883
|
+
throw new SessionMngError('no-cwd', `session "${id}" has no recorded working directory, so it cannot be moved`, 409, {
|
|
884
|
+
sessionId: id,
|
|
885
|
+
})
|
|
880
886
|
}
|
|
881
887
|
if (ctx.sessions?.get?.(id) !== undefined) {
|
|
882
|
-
throw new SessionMngError('session-live', 'this session is open in the running DSH process; restart DSH and retry the move', 409)
|
|
883
|
-
}
|
|
884
|
-
if (serviceOf(ctx, 'agents')?.get?.(id)?.status === 'running') {
|
|
885
|
-
throw new SessionMngError('session-running', 'this session is running; stop it before moving', 409)
|
|
886
|
-
}
|
|
887
|
-
if (header.origin === 'subagent' || (typeof header.delegationDepth === 'number' && header.delegationDepth >= 1)) {
|
|
888
|
-
throw new SessionMngError('subagent-session', 'subagent sessions are not moved on their own', 409)
|
|
889
|
-
}
|
|
890
|
-
if (header.parentSession !== undefined && header.parentSession !== null) {
|
|
891
888
|
throw new SessionMngError(
|
|
892
|
-
'
|
|
893
|
-
"
|
|
889
|
+
'session-live',
|
|
890
|
+
`session "${id}" is open in the running DSH process; restart DSH and retry the move`,
|
|
894
891
|
409,
|
|
892
|
+
{ sessionId: id },
|
|
895
893
|
)
|
|
896
894
|
}
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
if (lineage.subagents.length > 0) {
|
|
901
|
-
throw new SessionMngError('has-descendants', 'this session has subagent descendants; they cannot follow it yet', 409, {
|
|
902
|
-
descendants: lineage.subagents.length,
|
|
895
|
+
if (serviceOf(ctx, 'agents')?.get?.(id)?.status === 'running') {
|
|
896
|
+
throw new SessionMngError('session-running', `session "${id}" is running; stop it before moving`, 409, {
|
|
897
|
+
sessionId: id,
|
|
903
898
|
})
|
|
904
899
|
}
|
|
905
|
-
const source = workspaceOfId(id)
|
|
906
900
|
const sourceDir = await dirsOf(id, header.cwd)
|
|
907
901
|
const targetDir = sessionDirFromLocate(store, id, target.path)
|
|
908
902
|
if (targetDir === undefined) {
|
|
909
903
|
throw new SessionMngError('unsupported-backend', 'the session backend cannot locate a target directory', 501)
|
|
910
904
|
}
|
|
905
|
+
return { id, header, source: workspaceOfId(id), sourceDir, targetDir }
|
|
906
|
+
}
|
|
911
907
|
|
|
908
|
+
/**
|
|
909
|
+
* Relocate one already-validated cold member: rewrite its log header, move
|
|
910
|
+
* its directory and reconcile the workspace ledger. Throws after restoring
|
|
911
|
+
* its own bytes and cache entry.
|
|
912
|
+
*
|
|
913
|
+
* @param {{id: string, header: object, source: object|undefined, sourceDir: string, targetDir: string}} member
|
|
914
|
+
* @param {object} target - target workspace entity.
|
|
915
|
+
* @returns {Promise<{member: object, staged: object|undefined, alreadyThere: boolean}>}
|
|
916
|
+
*/
|
|
917
|
+
async function relocateMember(member, target) {
|
|
918
|
+
const { id, header, source, sourceDir, targetDir } = member
|
|
912
919
|
if (targetDir === sourceDir) {
|
|
913
920
|
// Bytes are already in the target project directory; only the ledger and
|
|
914
921
|
// the cache may disagree.
|
|
915
922
|
await reconcileMove({ registry: registry(), id, oldHeader: header, newHeader: header, source, target })
|
|
916
|
-
return {
|
|
923
|
+
return { member, staged: undefined, alreadyThere: true }
|
|
917
924
|
}
|
|
918
|
-
|
|
919
|
-
throw new SessionMngError('move-conflict', `destination already holds a session directory: ${targetDir}`, 409)
|
|
920
|
-
}
|
|
921
|
-
|
|
925
|
+
const store = persistence()
|
|
922
926
|
// Prove the log is readable and that no other process is writing it before
|
|
923
927
|
// touching a single byte.
|
|
924
928
|
let original
|
|
925
929
|
try {
|
|
926
930
|
original = await query().readSession(id)
|
|
927
931
|
} catch (error) {
|
|
928
|
-
throw new SessionMngError('unreadable-session', `could not replay session "${id}": ${messageOf(error)}`, 422
|
|
932
|
+
throw new SessionMngError('unreadable-session', `could not replay session "${id}": ${messageOf(error)}`, 422, {
|
|
933
|
+
sessionId: id,
|
|
934
|
+
})
|
|
929
935
|
}
|
|
930
936
|
const originalCount = Array.isArray(original?.events) ? original.events.length : 0
|
|
931
937
|
let lease
|
|
932
938
|
try {
|
|
933
939
|
lease = await store.open(id, 'write')
|
|
934
940
|
} catch (error) {
|
|
935
|
-
throw new SessionMngError('session-writer-held', `session "${id}" is being written by another process: ${messageOf(error)}`, 409
|
|
941
|
+
throw new SessionMngError('session-writer-held', `session "${id}" is being written by another process: ${messageOf(error)}`, 409, {
|
|
942
|
+
sessionId: id,
|
|
943
|
+
})
|
|
936
944
|
}
|
|
937
945
|
await lease.close().catch(() => {})
|
|
938
946
|
|
|
@@ -950,6 +958,7 @@ export function createService(ctx, config) {
|
|
|
950
958
|
const validatedCount = Array.isArray(validation?.events) ? validation.events.length : -1
|
|
951
959
|
if (validatedCwd !== target.path || validatedCount !== originalCount) {
|
|
952
960
|
throw new SessionMngError('move-verify-failed', 'the moved session did not read back with the expected header and event count', 500, {
|
|
961
|
+
sessionId: id,
|
|
953
962
|
expectedCwd: target.path,
|
|
954
963
|
actualCwd: validatedCwd,
|
|
955
964
|
expectedEvents: originalCount,
|
|
@@ -977,6 +986,7 @@ export function createService(ctx, config) {
|
|
|
977
986
|
await rollbackMove({ sourceDir, targetDir, backupDir: staged.backupDir })
|
|
978
987
|
} catch (rollbackError) {
|
|
979
988
|
throw new SessionMngError('manual-recovery', 'the move failed and the automatic rollback also failed', 500, {
|
|
989
|
+
sessionId: id,
|
|
980
990
|
sourceDir,
|
|
981
991
|
targetDir,
|
|
982
992
|
backupDir: staged.backupDir,
|
|
@@ -986,22 +996,110 @@ export function createService(ctx, config) {
|
|
|
986
996
|
}
|
|
987
997
|
throw error
|
|
988
998
|
}
|
|
999
|
+
return { member, staged, alreadyThere: false }
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/** Undo one completed member, restoring bytes, cache and ledger. */
|
|
1003
|
+
async function undoMember(done, target) {
|
|
1004
|
+
const { member, staged } = done
|
|
1005
|
+
await rollbackMoveReconcile({
|
|
1006
|
+
registry: registry(),
|
|
1007
|
+
id: member.id,
|
|
1008
|
+
oldHeader: member.header,
|
|
1009
|
+
source: member.source,
|
|
1010
|
+
target,
|
|
1011
|
+
sourcePath: member.source?.path ?? member.header.cwd,
|
|
1012
|
+
})
|
|
1013
|
+
if (staged !== undefined) {
|
|
1014
|
+
await rollbackMove({ sourceDir: member.sourceDir, targetDir: member.targetDir, backupDir: staged.backupDir })
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* Move one cold session and every subagent descendant it owns.
|
|
1020
|
+
*
|
|
1021
|
+
* Subagent sessions exist only to serve their root, so leaving them behind
|
|
1022
|
+
* would strand them in the old project directory. Ordinary forks are
|
|
1023
|
+
* independent user sessions and stay where they are.
|
|
1024
|
+
*
|
|
1025
|
+
* Throws on any failure; the caller converts it.
|
|
1026
|
+
*/
|
|
1027
|
+
async function moveOne(id, target) {
|
|
1028
|
+
const store = persistence()
|
|
1029
|
+
if (store === undefined || typeof store.stat !== 'function' || typeof store.locate !== 'function') {
|
|
1030
|
+
throw new SessionMngError('unsupported-backend', 'moving a session requires the local JSONL session backend', 501)
|
|
1031
|
+
}
|
|
1032
|
+
const snapshot = await store.stat(id)
|
|
1033
|
+
const header = snapshot?.header
|
|
1034
|
+
if (header === undefined) throw new SessionMngError('not-found', `session "${id}" is not stored`, 404)
|
|
1035
|
+
if (header.origin === 'subagent' || (typeof header.delegationDepth === 'number' && header.delegationDepth >= 1)) {
|
|
1036
|
+
throw new SessionMngError('subagent-session', 'subagent sessions are not moved on their own', 409)
|
|
1037
|
+
}
|
|
1038
|
+
if (header.parentSession !== undefined && header.parentSession !== null) {
|
|
1039
|
+
throw new SessionMngError(
|
|
1040
|
+
'branch-session',
|
|
1041
|
+
"a branch session must stay in its parent's workspace, so it is not moved on its own",
|
|
1042
|
+
409,
|
|
1043
|
+
)
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// Subagent descendants travel with their root; an ordinary fork (historical
|
|
1047
|
+
// or not) is an independent session and stays behind.
|
|
1048
|
+
const lineage = await descendantsOf(id)
|
|
1049
|
+
const members = [
|
|
1050
|
+
{ id, header },
|
|
1051
|
+
...lineage.subagents.map((record) => ({ id: record.header.id, header: record.header })),
|
|
1052
|
+
]
|
|
989
1053
|
|
|
990
|
-
|
|
1054
|
+
// Validate the whole family before touching any bytes.
|
|
1055
|
+
const planned = []
|
|
1056
|
+
for (const member of members) planned.push(await planMember(member.id, member.header, target))
|
|
1057
|
+
|
|
1058
|
+
const completed = []
|
|
991
1059
|
try {
|
|
992
|
-
await
|
|
1060
|
+
for (const member of planned) completed.push(await relocateMember(member, target))
|
|
993
1061
|
} catch (error) {
|
|
994
|
-
|
|
1062
|
+
const rollbackFailures = []
|
|
1063
|
+
for (const done of [...completed].reverse()) {
|
|
1064
|
+
try {
|
|
1065
|
+
await undoMember(done, target)
|
|
1066
|
+
} catch (rollbackError) {
|
|
1067
|
+
rollbackFailures.push({ sessionId: done.member.id, error: messageOf(rollbackError) })
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
if (rollbackFailures.length > 0) {
|
|
1071
|
+
throw new SessionMngError('manual-recovery', 'the move failed and the automatic rollback also failed', 500, {
|
|
1072
|
+
cause: messageOf(error),
|
|
1073
|
+
sessions: completed.map((done) => done.member.id),
|
|
1074
|
+
rollback: rollbackFailures,
|
|
1075
|
+
})
|
|
1076
|
+
}
|
|
1077
|
+
throw error
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
const leftover = []
|
|
1081
|
+
for (const done of completed) {
|
|
1082
|
+
if (done.staged === undefined) continue
|
|
1083
|
+
try {
|
|
1084
|
+
await finalizeMove(done.staged.backupDir)
|
|
1085
|
+
} catch (error) {
|
|
1086
|
+
leftover.push({ path: done.staged.backupDir, reason: messageOf(error) })
|
|
1087
|
+
}
|
|
995
1088
|
}
|
|
1089
|
+
const root = completed[0]
|
|
1090
|
+
const moved = completed.filter((done) => !done.alreadyThere)
|
|
996
1091
|
return {
|
|
997
1092
|
sessionId: id,
|
|
998
1093
|
ok: true,
|
|
999
|
-
moved:
|
|
1000
|
-
from: source?.id ?? null,
|
|
1094
|
+
moved: moved.length > 0,
|
|
1095
|
+
from: root.member.source?.id ?? null,
|
|
1001
1096
|
to: target.id,
|
|
1002
1097
|
cwd: { from: header.cwd, to: target.path },
|
|
1003
|
-
|
|
1004
|
-
|
|
1098
|
+
family: completed.length,
|
|
1099
|
+
sessions: completed.map((done) => done.member.id),
|
|
1100
|
+
...(completed.every((done) => done.alreadyThere) ? { alreadyThere: true } : {}),
|
|
1101
|
+
...(moved.length === 0 ? {} : { bytes: moved.reduce((sum, done) => sum + done.staged.bytes, 0) }),
|
|
1102
|
+
...(leftover.length === 0 ? {} : { leftoverBackup: leftover }),
|
|
1005
1103
|
}
|
|
1006
1104
|
}
|
|
1007
1105
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-session-mng",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Session management for DeepSeek Harness: one Settings page for every session — whole-session title generation, archive/restore, cross-workspace move, and permanent deletion of archived sessions.",
|
|
6
6
|
"license": "MIT",
|