dsh-session-mng 0.1.4 → 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.
@@ -0,0 +1,79 @@
1
+ # dsh-session-mng 0.2.0 发布检查清单
2
+
3
+ > 记录时间:2026-10-29。0.2.0 = Host 侧 overview + 分页 sessions(1000–10000 条会话)+ 客户端两段式加载。
4
+ > 计划:[PLAN-2026-10-29.md](PLAN-2026-10-29.md)。本清单随实施回填;发版前需子龙确认。
5
+
6
+ ## A. 实现
7
+
8
+ ### Host(`lib/`)
9
+
10
+ - [x] 新 `lib/catalog.js`:零会话日志 IO 的 catalog(`workspaceRegistry.headers` ∪ `ctx.sessions.list()` + `sessionProjectionCache` / `sessionProjections` 的 title/blank/lastPromptAt + archived/pinned + workspace ledger;`classifySession` 复用;全域 parentTitle/parentPresent/forkCount;`deletedIds` / `titleOverrides`;慢路径 `createLegacyCatalogCache` 30 s TTL)。
11
+ - [x] `GET /overview?subagents=0|1`:只有工作区计数/归档/空会话、totals、orphans、capabilities、limits,无会话行。
12
+ - [x] `GET /sessions?workspaceId=&tab=&query=&sort=&offset=&limit=&subagents=&blank=&sizes=`:branch-aware 排序 + `id` tie-break + offset 分页(`limit ≤ pageLimitMax`);`parentStub` / `branchDepth`;页内 size 用 locate 推目录 + readdir 选最高代 + stat(≤50 条,不触发全库扫描)。
13
+ - [x] `GET /search?query=&…&limitPerGroup=`:跨工作区标题/路径/id/父标题搜索,每命中组一页 + total。
14
+ - [x] `GET /session?id=`:详情单行(size/formatStatus/can/parentStub)。
15
+ - [x] `GET /blanks`:空会话三组 id 列表(unarchived/archived/deletable)。
16
+ - [x] `GET /sizes?workspaceId=all|ungrouped|<id>&subagents=0|1`:一次 `sessionPersistence.list()` + 60 s 进程内 TTL;**只聚合 catalog 可见行**(与 `buildState.totals.bytes` 对齐);写操作与 `subagents` 开关切换失效。
17
+ - [x] `POST` 写操作的缓存失效与 `deletedIds` / `titleOverrides` 记账;`GET /state` 原样保留。
18
+ - [x] 配置项 `pageLimitMax` / `sizesTtlMs` / `sizeScanMax`(均 clamp)。
19
+
20
+ ### Client(`client.js`)
21
+
22
+ - [x] 首屏 `/overview` → 展开工作区取第一页 → 加载更多 offset 追加;pages/rowCache;scopeKey 失效;并发上限 3;`/overview` 404 一次性回退 `/state`。
23
+ - [x] 计数/tabs/hiddenBlank/hiddenSubagents/orphan/空会话按钮改读 overview 与 `/blanks`;limits/workspaces 来自 overview。
24
+ - [x] 搜索:输入防抖 250 ms → `/search`;命中组自动展开;搜索期间锁定折叠。
25
+ - [x] 选择:只允许已加载且展开行;scope 变化清空;全选仅已加载可见集 + `partialSelection` 提示;批量提交前过滤 rowCache 外的 id。
26
+ - [x] 分支跨页:`branchDepth` 缩进 / `parentStub` 的「来自」/ `parentPresent:false` 的「已不存在」/ `parentNotLoaded` 兜底。
27
+ - [x] 详情:rowCache 秒开 + `/session` 补齐;不再因刷新清空;`DetailView` 入参 `{overview, limits, row}`。
28
+ - [x] 刷新:`refresh()` 只重取 overview + 已加载深度;focus 只静默刷 overview;老 Host 回退保留每组 50 条本地分页。
29
+ - [x] 排序 `id` tie-break 与 `sort` 依赖修复;中英 i18n 各新增文案。
30
+
31
+ ## B. 验证
32
+
33
+ - [x] `npm test`:**129 / 129 全绿**(Host 72:api 13、catalog 9、overview 4、sessions-page 16、search-blanks-sizes 9、service 8、state 2、store-jsonl 5、title 6;客户端 vm 57)。
34
+ - [x] `node --check index.js client.js lib/*.js test/*.js` 全部通过。
35
+ - [x] 10k 合成基准(fake ctx,10k 会话 / 6 工作区,5 次均值):`/overview` **9.5 ms / 1338 B**、`/sessions` 单页 **8.8 ms / 50 行 / 25.3 KB**;`persistence.list`、`sessionController.list`、批量 `readTitleSnapshots` 调用数均为 **0**。
36
+ - [x] `npm --cache /tmp/dsh-npm-cache pack --dry-run`:41 个文件 / 628.2 kB(unpacked 978.2 kB)。
37
+ - [x] 老 Host 回退、`sizes-pending` 降级、搜索组级过滤、legacy 分页均有单测/harness 覆盖。
38
+ - [x] **真实数据离线校验(用本机 241 条 header / 13 工作区 / 409 条投影缓存构造 fake host,调用真实 service)**:
39
+ - `overview.source=catalog`,`totals.all=108` + `hiddenSubagents=133` = 241 条 header;无 `rows/sessions` 字段;`persistence.list` / `sessionController.list` / 批量 `readTitleSnapshots` 调用 **0**。
40
+ - 最大工作区页:`total=54`、本页 50 行、`hasMore=true`、50/50 行有大小、44 行有标题(6 行走 pending,真机由 2s 兜底补齐);全程 0 次 O(n) 调用。
41
+ - `/search`:14 个命中组(13 工作区 + ungrouped)、total 81;`/blanks` 与投影缓存真值一致。
42
+ - `/sizes`:`subagents=0` → 66,169,650 B / 108 行;`subagents=1` → 83,384,681 B / 241 行,均与独立计算的 Σ 完全一致,仅 1 次 `persistence.list()`。
43
+ - 脚本在 `/tmp/verify-real-catalog.mjs`(只读,不落仓库)。
44
+ - [x] **HTTP 级真实数据验收(真实 `createApiHandler` + fake host(241 header/13 工作区/409 投影缓存) + 真实 HTTP 请求)**:
45
+ - `GET /overview?subagents=0`:200 / 3.9 ms / 3.2 KB,`totals.all=108`、`hiddenSubagents=133`,无 `sessions/rows` 字段。
46
+ - `GET /sessions?workspaceId=<最大>&limit=50&sizes=1`:200 / 4.4 ms / 31.5 KB,50 行、`total=54`、`hasMore=true`、`titlesPending=6`;`offset=50` 返回剩余 4 行、`hasMore=false`。
47
+ - `GET /sessions?workspaceId=all` → 404 `workspace-not-found`;缺 `workspaceId` → 400 `bad-request`(与客户端「按组取页」契约一致)。
48
+ - `GET /search?query=session`:200 / 53 KB / 14 组 / total 81;空 query → 400;`query=x y` → 0 组。
49
+ - `GET /session?id=…`:200 / 2.8 ms / 850 B;未知 id → 404 `not-found`;缺 id、`../etc/passwd` → 400。
50
+ - `GET /sizes?subagents=0|1`:200 / ~1.8 ms,`{bytes:66,252,083,count:108}` / `{bytes:83,467,114,count:241}`,与 `/state` totals.bytes 同口径;未知 workspace → 404。
51
+ - `GET /blanks`、`POST /overview`(405)、未知路由(404)、cross-site(403)全部符合契约。
52
+ - `sort=size` 页按「大小排序 + 分支挂父行」返回(父行后紧跟其分支,因此数值不严格单调),与 0.1.x 客户端语义一致。
53
+ - 全部读路由过程中 `sessionController.list` / 批量 `readTitleSnapshots` 调用数为 **0**;`persistence.list` 只由 `/sizes`(一次)与旧 `/state` 触发。
54
+ - [x] **实机只读验收(隔离实例,真实 DSH 0.2.1-rc.2 + 本仓库 symlink 插件 + 真实会话数据副本)**:用 `DSH_HOME=/tmp/dsh-020-verify` 复制 web profile / sessions / storages,`--patch` 注入 `dsh-session-mng` 后启动独立 web 实例(127.0.0.1:58873),插件 Host 与 Client 均加载(`/overview` 返回 `source: "catalog"`;设置页出现「会话管理」)。
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
+ - [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
+ - [x] 真机 `/state` 仍 200(旧客户端兼容);运行全程无前端报错、无重复请求风暴。
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
+ - [ ] 截图更新(`assets/screenshot-*.png`,重启后按 0.2.0 列表/分页/搜索状态重拍)。
60
+
61
+ ## C. 发布(已完成,2026-10-29)
62
+
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` 亦为旧图,可在需要时重拍)。
71
+
72
+ ## D. 已知口径与差异(写入文档)
73
+
74
+ - [x] `/sizes` 的 `count/total` 是**可见行**口径(随 `subagents` 开关变化),与 `overview.totals` 一致;不再统计隐藏子代理与幽灵行。
75
+ - [x] 页内 size 用 `persistence.locate()` 推目录 + readdir 选最高代 + stat,**不用** `findSessionDirs`(miss 时全库扫描)。
76
+ - [x] 页内 stat 失败的行不剔除(保持 total/offset 稳定),由 `/sizes` 对账与下一次请求收敛。
77
+ - [x] `capabilities.search` 0.2.0 恒为 `false`(本部署宿主 FTS 默认关闭且不含标题)。
78
+ - [x] `/search` 的 size 只读已热大小缓存(不逐行 stat,避免跨组请求风暴)。
79
+ - [x] 展开前组头计数用 overview bucket 兜底,页响应到达后以 `page.total` 为最终口径;focus 不做 stale 视觉标记。
@@ -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/api.js CHANGED
@@ -18,6 +18,9 @@ export const API_PREFIX = '/session-mng/api'
18
18
  /** Request body ceiling; these requests are tiny id lists or one title. */
19
19
  const MAX_BODY_BYTES = 64 * 1024
20
20
 
21
+ /** Budget for one read request: a catalog read plus, at worst, one size scan. */
22
+ const GET_TIMEOUT_MS = 20_000
23
+
21
24
  /** Write one JSON response. */
22
25
  function sendJson(res, status, body) {
23
26
  if (res.writableEnded) return
@@ -59,7 +62,16 @@ async function readJsonBody(req) {
59
62
  }
60
63
  }
61
64
 
62
- /** One dispatch table entry. `needsBody` is false only for the state read. */
65
+ /** Read one GET query string into a plain parameter object. */
66
+ function queryOf(url) {
67
+ const params = {}
68
+ for (const [key, value] of url.searchParams) {
69
+ if (!Object.hasOwn(params, key)) params[key] = value
70
+ }
71
+ return params
72
+ }
73
+
74
+ /** One dispatch table entry. `needsBody` is false only for the read routes. */
63
75
  const ROUTES = {
64
76
  preview: { needsBody: true, run: (service, body) => service.preview(body.sessionId, body.count) },
65
77
  'title/propose': {
@@ -74,6 +86,24 @@ const ROUTES = {
74
86
  move: { needsBody: true, run: (service, body) => service.move(body.sessionIds, body.workspaceId) },
75
87
  }
76
88
 
89
+ /**
90
+ * The read surface. `/state` stays byte-compatible for 0.1.x clients; the
91
+ * 0.2.0 routes serve the same data through a paginated, catalog-backed shape.
92
+ * Each entry receives the parsed query object and one request signal.
93
+ */
94
+ const GET_ROUTES = {
95
+ state: (service, params, signal) =>
96
+ service.state({ includeSubagents: params.subagents === '1', signal }),
97
+ overview: (service, params, signal) =>
98
+ service.overview({ includeSubagents: params.subagents === '1', signal }),
99
+ sessions: (service, params, signal) => service.sessions({ ...params, signal }),
100
+ search: (service, params, signal) => service.search({ ...params, signal }),
101
+ session: (service, params, signal) => service.session({ ...params, signal }),
102
+ blanks: (service, params, signal) =>
103
+ service.blanks({ includeSubagents: params.subagents === '1', signal }),
104
+ sizes: (service, params, signal) => service.sizes({ ...params, signal }),
105
+ }
106
+
77
107
  /**
78
108
  * Build the Node request handler for the plugin's prefix route.
79
109
  *
@@ -97,25 +127,28 @@ export function createApiHandler({ ctx, config, service, trustedHosts }) {
97
127
  return
98
128
  }
99
129
 
100
- if (relative === '' || relative === 'state') {
130
+ // A prefix route also answers the bare prefix; it means the legacy `/state`.
131
+ const key = relative === '' ? 'state' : relative
132
+
133
+ const readRoute = GET_ROUTES[key]
134
+ if (readRoute !== undefined) {
101
135
  if (req.method !== 'GET') {
102
- sendJson(res, 405, { ok: false, error: { code: 'method-error', message: 'state is GET-only' } })
136
+ sendJson(res, 405, { ok: false, error: { code: 'method-error', message: `${key} is GET-only` } })
103
137
  return
104
138
  }
105
- const includeSubagents = url.searchParams.get('subagents') === '1'
106
- const value = await service.state({ includeSubagents, signal: AbortSignal.timeout(20_000) })
139
+ const value = await readRoute(service, queryOf(url), AbortSignal.timeout(GET_TIMEOUT_MS))
107
140
  sendJson(res, 200, { ok: true, value })
108
141
  return
109
142
  }
110
143
 
111
- if (req.method !== 'POST') {
112
- sendJson(res, 405, { ok: false, error: { code: 'method-error', message: 'POST only' } })
144
+ const route = ROUTES[key]
145
+ if (route === undefined) {
146
+ sendJson(res, 404, { ok: false, error: { code: 'not-found', message: `unknown route "${key}"` } })
113
147
  return
114
148
  }
115
149
 
116
- const route = ROUTES[relative]
117
- if (route === undefined) {
118
- sendJson(res, 404, { ok: false, error: { code: 'not-found', message: `unknown action "${relative}"` } })
150
+ if (req.method !== 'POST') {
151
+ sendJson(res, 405, { ok: false, error: { code: 'method-error', message: 'POST only' } })
119
152
  return
120
153
  }
121
154
 
@@ -123,7 +156,7 @@ export function createApiHandler({ ctx, config, service, trustedHosts }) {
123
156
  // A closed connection cancels a read-only model call; destructive
124
157
  // operations are deliberately allowed to finish once admitted.
125
158
  const controller = new AbortController()
126
- if (relative === 'title/propose') {
159
+ if (key === 'title/propose') {
127
160
  res.on('close', () => {
128
161
  if (!res.writableEnded) controller.abort()
129
162
  })