dsh-session-drag-move 0.0.0-stage → 0.1.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/README.md +97 -2
- package/cordis.patch.yml +14 -0
- package/lib/client.js +256 -0
- package/lib/fork-tree.js +100 -0
- package/lib/index.js +358 -0
- package/lib/session-log.js +461 -0
- package/package.json +47 -3
package/README.md
CHANGED
|
@@ -1,3 +1,98 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-session-drag-move
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
左侧栏「工作区」面板里,把**会话拖到别的工作区**——真正改归属,不只是改个名字分组。
|
|
4
|
+
|
|
5
|
+
官方只支持同一工作区内部拖排序;跨工作区拖拽是**完全不响应**的(`onDrop` 只在
|
|
6
|
+
`drag.accountKey === group.key` 时生效,`insertSessionBefore` 也拒绝不属于本工作区的会话)。
|
|
7
|
+
这个插件只接管这段本来就是空的行为,同工作区拖拽照旧交给官方。
|
|
8
|
+
|
|
9
|
+
## 它到底做了什么
|
|
10
|
+
|
|
11
|
+
DSH 里会话的归属是**派生的,不是存出来的**:
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
工作区拥有「会话日志 header 的 cwd 等于本工作区路径」的那些会话
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`~/.dsh/storages/workspace.json` 里的 `sessionIds` 只是顺序账本,path 不匹配的 id
|
|
18
|
+
会在下次写入时被剪掉。所以换工作区必须改**会话日志 header 里的 cwd**。
|
|
19
|
+
|
|
20
|
+
会话日志是独立 Zstandard 帧的拼接,第一帧就是 header:
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
<root>/--<projectKey(cwd)>--/<sessionId>/session.v4.jsonl.zstd
|
|
24
|
+
└ 项目目录由 cwd 决定 └ 首帧 = header,其后每帧 = 一个追加批次
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
帧彼此独立,所以移动 = **重新压缩新的第一帧 + 原文件第二个 magic 之后的字节原样复制**,
|
|
28
|
+
事件数据一个字节都不动。写完再自行解压校验一遍,然后把旧目录移到备份区。
|
|
29
|
+
|
|
30
|
+
最后同步正在运行的进程(否则要重启才生效):更新 registry 的 header 缓存与
|
|
31
|
+
session-path 索引,再走官方的 `attachSession` / `insertSessionBefore` / `detachSession`。
|
|
32
|
+
`attachSession` 会重新读 header 并校验 cwd,它成功就证明存储层改动真的落盘了。
|
|
33
|
+
|
|
34
|
+
## 安装
|
|
35
|
+
|
|
36
|
+
```powershell
|
|
37
|
+
# 1. 放进 profile 的 node_modules
|
|
38
|
+
Copy-Item 'E:\dsh插件开发' "$env:USERPROFILE\.dsh\profiles\desktop\node_modules\dsh-session-drag-move" -Recurse -Force
|
|
39
|
+
|
|
40
|
+
# 2. 在 profile 的 cordis.patch.yml 里加一行
|
|
41
|
+
# - insert:
|
|
42
|
+
# - id: session-drag-move
|
|
43
|
+
# name: 'dsh-session-drag-move'
|
|
44
|
+
|
|
45
|
+
# 3. 重启 DSH
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
也可以用官方 CLI:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
dsh plugin --profile desktop add dsh-session-drag-move
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## 配置(host 半边)
|
|
55
|
+
|
|
56
|
+
| 字段 | 默认 | 含义 |
|
|
57
|
+
|---|---|---|
|
|
58
|
+
| `root` | `~/.dsh/sessions` | 会话存储根目录 |
|
|
59
|
+
| `backupRoot` | `<root>/../session-drag-move-backup` | 移动前的会话目录停放处 |
|
|
60
|
+
| `allowRemote` | `false` | 默认只接受 loopback 请求;局域网部署需显式打开 |
|
|
61
|
+
|
|
62
|
+
## 使用
|
|
63
|
+
|
|
64
|
+
- 拖会话行到**另一个工作区的头行** → 归入该工作区(追加到末尾)
|
|
65
|
+
- 拖会话行到**另一个工作区的某个会话行**上半 → 插到它前面;下半 → 插到它后面
|
|
66
|
+
- 同工作区内拖拽 → 不拦截,仍是官方排序
|
|
67
|
+
- 运行中 / 当前会话会被拒绝
|
|
68
|
+
|
|
69
|
+
## 安全边界
|
|
70
|
+
|
|
71
|
+
- 只重写第一帧;尾部原样复制;写完重新解压自检(id + cwd + 能解出 session header)
|
|
72
|
+
- 旧目录**移出** sessions 根(留着会被扫到重复 id),停在备份区,可回滚
|
|
73
|
+
- 目标文件已存在则整体拒绝,不做半截写入
|
|
74
|
+
- 跨区移动前检查会话是否在运行
|
|
75
|
+
|
|
76
|
+
## 已知限制
|
|
77
|
+
|
|
78
|
+
- 依赖 `workspaceRegistry` 的内部字段(`headers` / `sessionPaths` / `host.rememberSessionPath`)
|
|
79
|
+
同步运行状态。这些是普通字段不是 `#private`,能用,但**版本敏感**——所有访问都做了
|
|
80
|
+
feature-probe,探测不到会返回结构化错误而不是抛栈。
|
|
81
|
+
- 目标针对官方原版左侧栏。若启用 `dsh-better-workspace`(它会整区替换
|
|
82
|
+
`sidebar.workspaces`,且不迁移工作区),其自绘 DOM 是另一套结构,需要另加适配分支。
|
|
83
|
+
- 投影缓存 `~/.dsh/storages/session_projcache/sessions/<id>.json` 里的
|
|
84
|
+
`record.identity.cwd` 目前未改写(可重建缓存,不影响归属)。
|
|
85
|
+
|
|
86
|
+
## 开发
|
|
87
|
+
|
|
88
|
+
```powershell
|
|
89
|
+
$node = "$env:USERPROFILE\.dsh\dsh-runtimes\dsh-primary-runtime\dependencies\node\bin\node.exe"
|
|
90
|
+
& $node --test tests\smoke.mjs
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
测试覆盖路径编码(与本机磁盘目录逐字比对)、header 帧定位与校验、
|
|
94
|
+
真实会话日志的 cwd 改写 round-trip(含尾部字节完全一致断言)。
|
|
95
|
+
|
|
96
|
+
## 许可证
|
|
97
|
+
|
|
98
|
+
MIT
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# dsh-session-drag-move bundle patch.
|
|
2
|
+
#
|
|
3
|
+
# Manual mount equivalent (profile cordis.patch.yml):
|
|
4
|
+
#
|
|
5
|
+
# - insert:
|
|
6
|
+
# - id: session-drag-move
|
|
7
|
+
# name: 'dsh-session-drag-move'
|
|
8
|
+
#
|
|
9
|
+
# Only the Node half loads through this row; the web client half is served
|
|
10
|
+
# through the package's exports["./client"] entry and mounted by the browser
|
|
11
|
+
# boot graph (window.__DSH_BOOT__).
|
|
12
|
+
- insert:
|
|
13
|
+
- id: session-drag-move
|
|
14
|
+
name: 'dsh-session-drag-move'
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-session-drag-move — client half (plain JavaScript, no build step).
|
|
3
|
+
*
|
|
4
|
+
* The shipped workspace browser only handles drags INSIDE one workspace: its
|
|
5
|
+
* `onDrop` acts when the drag's account key equals the group's key, and
|
|
6
|
+
* `insertSessionBefore` rejects any session the workspace does not already
|
|
7
|
+
* account for. A cross-workspace drag is therefore inert today.
|
|
8
|
+
*
|
|
9
|
+
* This half intercepts only that inert case — in the CAPTURE phase, ahead of
|
|
10
|
+
* React's delegated handlers — and asks the host half to re-home the session.
|
|
11
|
+
* Same-workspace drags are deliberately left alone so official ordering keeps
|
|
12
|
+
* working exactly as it does now.
|
|
13
|
+
*
|
|
14
|
+
* Anchors come from the official DOM contract: every row carries
|
|
15
|
+
* `data-row-key`, `session:<id>` for sessions and `workspace:<workspaceId>`
|
|
16
|
+
* for workspace headers (the official buildGroup passes workspaceId as the
|
|
17
|
+
* group key). Nothing here depends on hashed CSS module class names.
|
|
18
|
+
*/
|
|
19
|
+
window.__ModuleLoader__.load({
|
|
20
|
+
id: 'dsh-session-drag-move',
|
|
21
|
+
factory: (_require) => {
|
|
22
|
+
const NS = 'dshSessionDragMove'
|
|
23
|
+
const API = '/session-drag-move/api/'
|
|
24
|
+
const SESSION_PREFIX = 'session:'
|
|
25
|
+
const WORKSPACE_PREFIX = 'workspace:'
|
|
26
|
+
|
|
27
|
+
const CSS = `
|
|
28
|
+
[data-dsh-sm-dragging] { opacity: .45; }
|
|
29
|
+
[data-dsh-sm-target] .dsh-sm-row,
|
|
30
|
+
[data-dsh-sm-target] > * {
|
|
31
|
+
outline: 1px dashed var(--dsh-accent, #4c8dff);
|
|
32
|
+
outline-offset: -1px;
|
|
33
|
+
border-radius: 6px;
|
|
34
|
+
background: color-mix(in srgb, var(--dsh-accent, #4c8dff) 10%, transparent);
|
|
35
|
+
}
|
|
36
|
+
.${NS}-toast {
|
|
37
|
+
position: fixed; left: 50%; bottom: 28px; transform: translateX(-50%);
|
|
38
|
+
z-index: 2147483000; max-width: 78vw;
|
|
39
|
+
padding: 8px 14px; border-radius: 8px;
|
|
40
|
+
background: rgba(28,28,30,.94); color: #fff;
|
|
41
|
+
font: 12px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
42
|
+
box-shadow: 0 6px 24px rgba(0,0,0,.28);
|
|
43
|
+
}
|
|
44
|
+
`
|
|
45
|
+
|
|
46
|
+
/* ------------------------------ helpers ------------------------------ */
|
|
47
|
+
|
|
48
|
+
const rowKey = (el) => (el instanceof Element ? (el.dataset?.rowKey ?? '') : '')
|
|
49
|
+
|
|
50
|
+
const closestKey = (el, prefix) => {
|
|
51
|
+
if (!(el instanceof Element)) return null
|
|
52
|
+
const hit = el.closest(`[data-row-key^="${prefix}"]`)
|
|
53
|
+
return hit
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The session row an event was raised on, if any. */
|
|
57
|
+
const sessionRowOf = (target) => closestKey(target, SESSION_PREFIX)
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Which workspace a session row belongs to: walk up until an ancestor has
|
|
61
|
+
* a workspace header as one of its own children. The workspace header is
|
|
62
|
+
* the first child of the group section, so the first hit is the owner.
|
|
63
|
+
*/
|
|
64
|
+
function owningWorkspaceId(row) {
|
|
65
|
+
let node = row?.parentElement
|
|
66
|
+
while (node instanceof Element) {
|
|
67
|
+
const own = node.querySelector?.(`:scope > [data-row-key^="${WORKSPACE_PREFIX}"]`)
|
|
68
|
+
if (own instanceof Element) return rowKey(own).slice(WORKSPACE_PREFIX.length)
|
|
69
|
+
for (const child of node.children) {
|
|
70
|
+
const key = rowKey(child)
|
|
71
|
+
if (key.startsWith(WORKSPACE_PREFIX)) return key.slice(WORKSPACE_PREFIX.length)
|
|
72
|
+
const nested = child.querySelector?.(`[data-row-key^="${WORKSPACE_PREFIX}"]`)
|
|
73
|
+
if (nested instanceof Element) return rowKey(nested).slice(WORKSPACE_PREFIX.length)
|
|
74
|
+
}
|
|
75
|
+
node = node.parentElement
|
|
76
|
+
}
|
|
77
|
+
return ''
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Every session row of a group, in DOM order. */
|
|
81
|
+
function siblingSessionRows(row) {
|
|
82
|
+
let node = row?.parentElement
|
|
83
|
+
while (node instanceof Element) {
|
|
84
|
+
const rows = node.querySelectorAll(`[data-row-key^="${SESSION_PREFIX}"]`)
|
|
85
|
+
if (rows.length > 0) return Array.from(rows)
|
|
86
|
+
node = node.parentElement
|
|
87
|
+
}
|
|
88
|
+
return []
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Resolve a drop target from the element under the pointer.
|
|
93
|
+
* @returns {{workspaceId: string, beforeSessionId?: string}|null}
|
|
94
|
+
*/
|
|
95
|
+
function dropTargetOf(target, fromWorkspaceId) {
|
|
96
|
+
if (!(target instanceof Element)) return null
|
|
97
|
+
|
|
98
|
+
const workspaceRow = closestKey(target, WORKSPACE_PREFIX)
|
|
99
|
+
if (workspaceRow instanceof Element) {
|
|
100
|
+
const workspaceId = rowKey(workspaceRow).slice(WORKSPACE_PREFIX.length)
|
|
101
|
+
if (workspaceId === '' || workspaceId === fromWorkspaceId) return null
|
|
102
|
+
return { workspaceId }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const row = sessionRowOf(target)
|
|
106
|
+
if (!(row instanceof Element)) return null
|
|
107
|
+
const workspaceId = owningWorkspaceId(row)
|
|
108
|
+
if (workspaceId === '' || workspaceId === fromWorkspaceId) return null
|
|
109
|
+
|
|
110
|
+
const rect = row.getBoundingClientRect()
|
|
111
|
+
const below = target instanceof Element
|
|
112
|
+
? (target.getBoundingClientRect().top + target.getBoundingClientRect().height / 2) > rect.top + rect.height / 2
|
|
113
|
+
: false
|
|
114
|
+
const anchorId = rowKey(row).slice(SESSION_PREFIX.length)
|
|
115
|
+
|
|
116
|
+
if (!below) return { workspaceId, beforeSessionId: anchorId }
|
|
117
|
+
const rows = siblingSessionRows(row)
|
|
118
|
+
const next = rows[rows.indexOf(row) + 1]
|
|
119
|
+
const nextId = next instanceof Element ? rowKey(next).slice(SESSION_PREFIX.length) : undefined
|
|
120
|
+
return { workspaceId, beforeSessionId: nextId }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/* ------------------------------- state ------------------------------- */
|
|
124
|
+
|
|
125
|
+
let drag = null
|
|
126
|
+
let marker = null
|
|
127
|
+
let toastTimer = null
|
|
128
|
+
|
|
129
|
+
function ensureStyle() {
|
|
130
|
+
if (document.head.querySelector(`style[data-plugin-css="${NS}"]`) !== null) return
|
|
131
|
+
const style = document.createElement('style')
|
|
132
|
+
style.setAttribute('data-plugin-css', NS)
|
|
133
|
+
style.textContent = CSS
|
|
134
|
+
document.head.appendChild(style)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function clearMarker() {
|
|
138
|
+
if (marker instanceof Element) marker.removeAttribute('data-dsh-sm-target')
|
|
139
|
+
marker = null
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function markTarget(target) {
|
|
143
|
+
if (marker === target) return
|
|
144
|
+
clearMarker()
|
|
145
|
+
marker = target
|
|
146
|
+
if (target instanceof Element) target.setAttribute('data-dsh-sm-target', '')
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function toast(message) {
|
|
150
|
+
const existing = document.querySelector(`.${NS}-toast`)
|
|
151
|
+
if (existing instanceof Element) existing.remove()
|
|
152
|
+
const el = document.createElement('div')
|
|
153
|
+
el.className = `${NS}-toast`
|
|
154
|
+
el.textContent = message
|
|
155
|
+
document.body.appendChild(el)
|
|
156
|
+
clearTimeout(toastTimer)
|
|
157
|
+
toastTimer = setTimeout(() => el.remove(), 3200)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function call(method, payload) {
|
|
161
|
+
const response = await fetch(API + method, {
|
|
162
|
+
method: 'POST',
|
|
163
|
+
headers: { 'content-type': 'application/json' },
|
|
164
|
+
body: JSON.stringify(payload ?? {})
|
|
165
|
+
})
|
|
166
|
+
const body = await response.json().catch(() => ({ ok: false, error: { message: 'bad response' } }))
|
|
167
|
+
if (!response.ok || body.ok !== true) {
|
|
168
|
+
throw new Error(body?.error?.message ?? `request failed (${response.status})`)
|
|
169
|
+
}
|
|
170
|
+
return body.result
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/* ------------------------------ listeners ---------------------------- */
|
|
174
|
+
|
|
175
|
+
function onDragStart(event) {
|
|
176
|
+
const row = sessionRowOf(event.target)
|
|
177
|
+
if (!(row instanceof Element)) return
|
|
178
|
+
const workspaceId = owningWorkspaceId(row)
|
|
179
|
+
if (workspaceId === '') return
|
|
180
|
+
drag = {
|
|
181
|
+
row,
|
|
182
|
+
sessionId: rowKey(row).slice(SESSION_PREFIX.length),
|
|
183
|
+
fromWorkspaceId: workspaceId
|
|
184
|
+
}
|
|
185
|
+
row.setAttribute('data-dsh-sm-dragging', '')
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function onDragOver(event) {
|
|
189
|
+
if (drag === null) return
|
|
190
|
+
const target = dropTargetOf(event.target, drag.fromWorkspaceId)
|
|
191
|
+
if (target === null) return
|
|
192
|
+
// The official handler never preventDefaults a cross-workspace drag, so
|
|
193
|
+
// the drop would be rejected as "no drop target" without this.
|
|
194
|
+
event.preventDefault()
|
|
195
|
+
event.stopPropagation()
|
|
196
|
+
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'
|
|
197
|
+
markTarget(sessionRowOf(event.target) ?? closestKey(event.target, WORKSPACE_PREFIX) ?? null)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function onDrop(event) {
|
|
201
|
+
if (drag === null) return
|
|
202
|
+
const target = dropTargetOf(event.target, drag.fromWorkspaceId)
|
|
203
|
+
if (target === null) return
|
|
204
|
+
event.preventDefault()
|
|
205
|
+
event.stopPropagation()
|
|
206
|
+
const payload = {
|
|
207
|
+
sessionId: drag.sessionId,
|
|
208
|
+
targetWorkspaceId: target.workspaceId,
|
|
209
|
+
beforeSessionId: target.beforeSessionId
|
|
210
|
+
}
|
|
211
|
+
const source = drag
|
|
212
|
+
drag = null
|
|
213
|
+
clearMarker()
|
|
214
|
+
source.row?.removeAttribute('data-dsh-sm-dragging')
|
|
215
|
+
|
|
216
|
+
call('move', payload).catch((error) => {
|
|
217
|
+
toast(`移动会话失败:${error.message}`)
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function onDragEnd(event) {
|
|
222
|
+
if (drag !== null) drag.row?.removeAttribute('data-dsh-sm-dragging')
|
|
223
|
+
drag = null
|
|
224
|
+
clearMarker()
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function apply(ctx) {
|
|
228
|
+
ensureStyle()
|
|
229
|
+
|
|
230
|
+
// Capture phase: run before React's delegated listeners at the root.
|
|
231
|
+
const options = { capture: true }
|
|
232
|
+
document.addEventListener('dragstart', onDragStart, options)
|
|
233
|
+
document.addEventListener('dragover', onDragOver, options)
|
|
234
|
+
document.addEventListener('drop', onDrop, options)
|
|
235
|
+
document.addEventListener('dragend', onDragEnd, options)
|
|
236
|
+
|
|
237
|
+
ctx?.effect?.(() => () => {
|
|
238
|
+
document.removeEventListener('dragstart', onDragStart, options)
|
|
239
|
+
document.removeEventListener('dragover', onDragOver, options)
|
|
240
|
+
document.removeEventListener('drop', onDrop, options)
|
|
241
|
+
document.removeEventListener('dragend', onDragEnd, options)
|
|
242
|
+
clearMarker()
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
name: 'dsh-session-drag-move',
|
|
248
|
+
// Deliberately NO inject. This half is a pure DOM enhancement bound to
|
|
249
|
+
// the official row contract; it consumes no client service. Declaring
|
|
250
|
+
// one it does not use makes the boot graph wait forever for a service
|
|
251
|
+
// that never activates, and web boot then fails with
|
|
252
|
+
// "1 entry did not activate".
|
|
253
|
+
apply
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
})
|
package/lib/fork-tree.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fork family resolution.
|
|
3
|
+
*
|
|
4
|
+
* A fork records where it came from in its session header's `parentSession`,
|
|
5
|
+
* so the family is derived from stored headers — never from a workspace
|
|
6
|
+
* ledger. This is what lets a move keep a branch with its origin: the rule
|
|
7
|
+
* this plugin enforces is that a fork lives in its parent's workspace, and the
|
|
8
|
+
* only way to honour it while moving is to move the subtree together.
|
|
9
|
+
*
|
|
10
|
+
* Pure functions: no filesystem, no services.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Build the parent → children relation.
|
|
15
|
+
*
|
|
16
|
+
* A header whose `parentSession` names an id that is not in `headers` is
|
|
17
|
+
* treated as a root: the parent may have been deleted, or may live outside
|
|
18
|
+
* this store. Either way there is nothing to follow.
|
|
19
|
+
*
|
|
20
|
+
* @param {Array<{id?: string, parentSession?: string}>} headers - stored headers.
|
|
21
|
+
* @returns {{byId: Map<string, object>, children: Map<string, string[]>}}
|
|
22
|
+
*/
|
|
23
|
+
export function forkTree(headers) {
|
|
24
|
+
const byId = new Map()
|
|
25
|
+
for (const header of headers) {
|
|
26
|
+
if (typeof header?.id === 'string' && header.id !== '') byId.set(header.id, header)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const children = new Map()
|
|
30
|
+
for (const header of byId.values()) {
|
|
31
|
+
const parent = header.parentSession
|
|
32
|
+
if (typeof parent !== 'string' || !byId.has(parent) || parent === header.id) continue
|
|
33
|
+
const siblings = children.get(parent)
|
|
34
|
+
if (siblings === undefined) children.set(parent, [header.id])
|
|
35
|
+
else siblings.push(header.id)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return { byId, children }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Every session forked from `sessionId`, transitively — the branches to carry
|
|
43
|
+
* along. Breadth-first so the result reads parent-before-child.
|
|
44
|
+
*
|
|
45
|
+
* @param {{children: Map<string, string[]>}} tree - from {@link forkTree}.
|
|
46
|
+
* @param {string} sessionId - the session being moved.
|
|
47
|
+
* @returns {string[]} descendants, excluding `sessionId` itself.
|
|
48
|
+
*/
|
|
49
|
+
export function descendantsOf(tree, sessionId) {
|
|
50
|
+
const found = []
|
|
51
|
+
const seen = new Set([sessionId])
|
|
52
|
+
const queue = [sessionId]
|
|
53
|
+
while (queue.length > 0) {
|
|
54
|
+
for (const child of tree.children.get(queue.shift()) ?? []) {
|
|
55
|
+
if (seen.has(child)) continue
|
|
56
|
+
seen.add(child)
|
|
57
|
+
found.push(child)
|
|
58
|
+
queue.push(child)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return found
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Every ancestor of `sessionId`, nearest first.
|
|
66
|
+
*
|
|
67
|
+
* Not used to move anything: dragging a branch must not silently drag its
|
|
68
|
+
* origin out from under other branches. It exists so a move can REPORT an
|
|
69
|
+
* origin left in another workspace instead of leaving the caller unaware.
|
|
70
|
+
*
|
|
71
|
+
* @param {{byId: Map<string, object>}} tree - from {@link forkTree}.
|
|
72
|
+
* @param {string} sessionId
|
|
73
|
+
* @returns {string[]} ancestors, nearest first.
|
|
74
|
+
*/
|
|
75
|
+
export function ancestorsOf(tree, sessionId) {
|
|
76
|
+
const found = []
|
|
77
|
+
const seen = new Set([sessionId])
|
|
78
|
+
let current = tree.byId.get(sessionId)?.parentSession
|
|
79
|
+
while (typeof current === 'string' && tree.byId.has(current) && !seen.has(current)) {
|
|
80
|
+
seen.add(current)
|
|
81
|
+
found.push(current)
|
|
82
|
+
current = tree.byId.get(current)?.parentSession
|
|
83
|
+
}
|
|
84
|
+
return found
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The whole connected family: every ancestor, `sessionId`, and every
|
|
89
|
+
* descendant. Available for callers that want the invariant "one family, one
|
|
90
|
+
* workspace" enforced in a single move.
|
|
91
|
+
*
|
|
92
|
+
* @param {object} tree - from {@link forkTree}.
|
|
93
|
+
* @param {string} sessionId
|
|
94
|
+
* @returns {string[]} family members, root-first order not guaranteed.
|
|
95
|
+
*/
|
|
96
|
+
export function familyOf(tree, sessionId) {
|
|
97
|
+
const ancestors = ancestorsOf(tree, sessionId)
|
|
98
|
+
const root = ancestors.length > 0 ? ancestors[ancestors.length - 1] : sessionId
|
|
99
|
+
return [root, ...descendantsOf(tree, root)]
|
|
100
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-session-drag-move — host half.
|
|
3
|
+
*
|
|
4
|
+
* Adds the one move the shipped workspace domain refuses: re-homing a session
|
|
5
|
+
* into another workspace. Membership is derived, never stored — a workspace
|
|
6
|
+
* owns exactly the sessions whose STORED HEADER cwd is its path — so the move
|
|
7
|
+
* is a storage operation (rewrite that header's cwd and park the log under the
|
|
8
|
+
* new project directory) followed by re-attaching the session on the registry.
|
|
9
|
+
*
|
|
10
|
+
* Exposes `POST /session-drag-move/api/<method>` over the host web server; the
|
|
11
|
+
* browser half calls `move`.
|
|
12
|
+
*/
|
|
13
|
+
import { homedir } from 'node:os'
|
|
14
|
+
import { dirname, join } from 'node:path'
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
findSessionDirs,
|
|
18
|
+
indexStore,
|
|
19
|
+
readHeaderPrefix,
|
|
20
|
+
readSessionState,
|
|
21
|
+
relocateSession,
|
|
22
|
+
sessionDir
|
|
23
|
+
} from './session-log.js'
|
|
24
|
+
import { forkTree, descendantsOf } from './fork-tree.js'
|
|
25
|
+
|
|
26
|
+
export const name = 'dsh-session-drag-move'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Cordis inject is a LIST of service names. Only `webServer` is declared:
|
|
30
|
+
* without it there is nowhere to hang the move route. `workspaceRegistry` is
|
|
31
|
+
* resolved lazily inside the handler instead — declaring it here would leave
|
|
32
|
+
* this entry pending forever (and web boot failing with "1 entry did not
|
|
33
|
+
* activate") on any profile where that service is absent or renamed, which is
|
|
34
|
+
* exactly the failure mode a hard dependency must not have.
|
|
35
|
+
*/
|
|
36
|
+
export const inject = ['webServer']
|
|
37
|
+
|
|
38
|
+
/** Load-time defaults; a patch entry's `config` overrides them field by field. */
|
|
39
|
+
const DEFAULTS = {
|
|
40
|
+
/** Session store root; the shipped default is `~/.dsh/sessions`. */
|
|
41
|
+
root: '',
|
|
42
|
+
/** Where pre-move session directories are parked. */
|
|
43
|
+
backupRoot: '',
|
|
44
|
+
/** Refuse requests whose Host header is not loopback (LAN deploys opt in). */
|
|
45
|
+
allowRemote: false
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const API_PREFIX = '/session-drag-move/api/'
|
|
49
|
+
|
|
50
|
+
class ApiError extends Error {
|
|
51
|
+
constructor(code, message, status = 400) {
|
|
52
|
+
super(message)
|
|
53
|
+
this.code = code
|
|
54
|
+
this.status = status
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const json = (res, status, body) => {
|
|
59
|
+
const payload = JSON.stringify(body)
|
|
60
|
+
res.writeHead(status, {
|
|
61
|
+
'content-type': 'application/json; charset=utf-8',
|
|
62
|
+
'content-length': Buffer.byteLength(payload)
|
|
63
|
+
})
|
|
64
|
+
res.end(payload)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Loopback-only by default; a LAN deployment flips `allowRemote`. */
|
|
68
|
+
function trusted(req, allowRemote) {
|
|
69
|
+
if (allowRemote) return true
|
|
70
|
+
const authority = (req.headers.host ?? '').replace(/:\d+$/, '').replace(/^\[|\]$/g, '')
|
|
71
|
+
return authority === '' || authority === 'localhost' || authority === '127.0.0.1' || authority === '::1'
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function readBody(req) {
|
|
75
|
+
const chunks = []
|
|
76
|
+
for await (const chunk of req) chunks.push(chunk)
|
|
77
|
+
if (chunks.length === 0) return {}
|
|
78
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
79
|
+
return text === '' ? {} : JSON.parse(text)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Resolve a service this plugin did not declare in `inject`. `ctx.get` is the
|
|
84
|
+
* documented accessor for undeclared services (the shipped workspace registry
|
|
85
|
+
* itself reads `sessions` this way); a plain property read can miss behind a
|
|
86
|
+
* context proxy.
|
|
87
|
+
*/
|
|
88
|
+
const serviceOf = (ctx, name) => {
|
|
89
|
+
try {
|
|
90
|
+
return ctx.get?.(name)
|
|
91
|
+
} catch {
|
|
92
|
+
return undefined
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const isActive = (ctx, sessionId) => {
|
|
97
|
+
const sessions = serviceOf(ctx, 'sessions')
|
|
98
|
+
if (sessions === undefined) return false
|
|
99
|
+
try {
|
|
100
|
+
const live = sessions.get?.(sessionId)
|
|
101
|
+
if (live === undefined) return false
|
|
102
|
+
return live.running === true || live.active === true || live.state === 'running'
|
|
103
|
+
} catch {
|
|
104
|
+
return false
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Make the running process agree with the bytes that just moved.
|
|
110
|
+
*
|
|
111
|
+
* `WorkspaceEntity.attachSession` cannot be used here. It validates the
|
|
112
|
+
* session's cwd by calling `registry.host.readSessionHeader`, which prefers a
|
|
113
|
+
* LIVE session's in-memory header, and a live session keeps the cwd it was
|
|
114
|
+
* opened with. After a move that check therefore compares the OLD cwd against
|
|
115
|
+
* the new workspace path and throws — leaving the bytes moved and the ledger
|
|
116
|
+
* unchanged, which is the inconsistency this function exists to avoid.
|
|
117
|
+
*
|
|
118
|
+
* So the header index is taught the new truth first, and the ledger is then
|
|
119
|
+
* written through the entity's public mutators. `detachSession` runs last: it
|
|
120
|
+
* prunes by `sessionPath(id) === path`, so the source drops the id only once
|
|
121
|
+
* the index already says the session lives elsewhere.
|
|
122
|
+
*/
|
|
123
|
+
async function reconcile(ctx, registry, target, source, sessionId, header, beforeSessionId) {
|
|
124
|
+
registry.headers?.set(sessionId, header)
|
|
125
|
+
registry.host?.rememberSessionPath?.(sessionId, target.path)
|
|
126
|
+
registry.invalidSessionPaths?.delete(sessionId)
|
|
127
|
+
|
|
128
|
+
const accounted = (entity) => entity.record.sessionIds.includes(sessionId)
|
|
129
|
+
|
|
130
|
+
if (!accounted(target)) {
|
|
131
|
+
try {
|
|
132
|
+
await target.attachSession(sessionId)
|
|
133
|
+
} catch (error) {
|
|
134
|
+
// The official path re-validates against the live in-memory header and
|
|
135
|
+
// refuses when that header is stale. The index is already correct, so
|
|
136
|
+
// write the ledger through the entity's own single write path instead;
|
|
137
|
+
// `mutate` applies the same idempotence and path pruning as attach.
|
|
138
|
+
ctx.logger?.warn(`dsh-session-drag-move: attachSession refused '${sessionId}' (${error?.message ?? error}); writing the ledger directly`)
|
|
139
|
+
await target.mutate((record) => record.sessionIds.includes(sessionId)
|
|
140
|
+
? record
|
|
141
|
+
: { ...record, sessionIds: [sessionId, ...record.sessionIds] })
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (accounted(target)) await target.insertSessionBefore(sessionId, beforeSessionId)
|
|
146
|
+
if (source !== undefined && accounted(source)) await source.detachSession(sessionId)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Re-home one session into another workspace.
|
|
151
|
+
*
|
|
152
|
+
* Bytes first, ledger second: the stored header's cwd is what a workspace
|
|
153
|
+
* derives membership from, so the storage rewrite is the real move, and the
|
|
154
|
+
* ledger write is bookkeeping that follows it (see {@link reconcile}).
|
|
155
|
+
*/
|
|
156
|
+
async function move(ctx, config, request) {
|
|
157
|
+
const registry = serviceOf(ctx, 'workspaceRegistry')
|
|
158
|
+
if (registry === undefined) throw new ApiError('no-registry', 'workspace registry is unavailable', 503)
|
|
159
|
+
|
|
160
|
+
const sessionId = String(request?.sessionId ?? '')
|
|
161
|
+
const targetWorkspaceId = String(request?.targetWorkspaceId ?? '')
|
|
162
|
+
const beforeSessionId = request?.beforeSessionId === undefined || request?.beforeSessionId === null
|
|
163
|
+
? undefined
|
|
164
|
+
: String(request.beforeSessionId)
|
|
165
|
+
|
|
166
|
+
if (sessionId === '' || !sessionId.startsWith('session-')) throw new ApiError('bad-session', `not a session id: '${sessionId}'`)
|
|
167
|
+
if (targetWorkspaceId === '') throw new ApiError('bad-workspace', 'targetWorkspaceId is required')
|
|
168
|
+
|
|
169
|
+
const target = registry.get(targetWorkspaceId)
|
|
170
|
+
if (target === undefined) throw new ApiError('no-workspace', `unknown workspace '${targetWorkspaceId}'`, 404)
|
|
171
|
+
|
|
172
|
+
// The ledger is NOT membership: a session can sit in one workspace's ledger
|
|
173
|
+
// while its bytes already belong to another (an interrupted move leaves
|
|
174
|
+
// exactly that). Membership is the stored cwd, so source is resolved from
|
|
175
|
+
// the ledger only to know what to detach afterwards.
|
|
176
|
+
const source = registry.list().find((entity) => entity.id !== targetWorkspaceId && entity.record.sessionIds.includes(sessionId))
|
|
177
|
+
|
|
178
|
+
const root = config.root === '' ? join(homedir(), '.dsh', 'sessions') : config.root
|
|
179
|
+
const backupRoot = config.backupRoot === '' ? join(dirname(root), 'session-drag-move-backup') : config.backupRoot
|
|
180
|
+
|
|
181
|
+
// Where are the bytes, really? Scan the store instead of trusting the
|
|
182
|
+
// in-memory header, which a live session never refreshes after a move.
|
|
183
|
+
const dirs = await findSessionDirs(root, sessionId)
|
|
184
|
+
|
|
185
|
+
if (dirs.length === 0) {
|
|
186
|
+
// Bytes are nowhere under this root: either the session never persisted,
|
|
187
|
+
// or its cwd is a directory this profile cannot see. Both are refused
|
|
188
|
+
// loudly rather than guessed at.
|
|
189
|
+
if (target.record.sessionIds.includes(sessionId)) {
|
|
190
|
+
await target.insertSessionBefore(sessionId, beforeSessionId)
|
|
191
|
+
return { moved: false, reordered: true, workspaceId: targetWorkspaceId }
|
|
192
|
+
}
|
|
193
|
+
throw new ApiError('no-session-dir', `no session directory for '${sessionId}' under '${root}'`, 404)
|
|
194
|
+
}
|
|
195
|
+
if (dirs.length > 1) {
|
|
196
|
+
throw new ApiError('duplicate-session', `'${sessionId}' exists in ${dirs.length} project directories (${dirs.join(', ')}); resolve the duplicate first`, 409)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const from = dirs[0]
|
|
200
|
+
const state = await readSessionState(from, sessionId)
|
|
201
|
+
if (state === undefined) throw new ApiError('unreadable-session', `session directory '${from}' holds no readable session log`, 422)
|
|
202
|
+
const oldCwd = state.header.cwd
|
|
203
|
+
|
|
204
|
+
const alreadyThere = from === sessionDir(root, target.path, sessionId)
|
|
205
|
+
|
|
206
|
+
// Which sessions travel with this one. A fork belongs in its parent's
|
|
207
|
+
// workspace, so dragging a session carries its whole branch subtree — a
|
|
208
|
+
// branch left behind would be a fork in a different workspace from the
|
|
209
|
+
// conversation it came out of. Only DESCENDANTS move: dragging a branch must
|
|
210
|
+
// never drag its origin (and therefore its origin's other branches) along.
|
|
211
|
+
const store = await indexStore(root)
|
|
212
|
+
const tree = forkTree(await readHeadersFor(store))
|
|
213
|
+
const branch = descendantsOf(tree, sessionId)
|
|
214
|
+
|
|
215
|
+
// A pure ordering request inside one workspace — and the already-in-place
|
|
216
|
+
// case — never touches storage.
|
|
217
|
+
if (alreadyThere) {
|
|
218
|
+
if (oldCwd !== target.path) {
|
|
219
|
+
// Bytes are in the target project directory but the header still names
|
|
220
|
+
// the old one: repair the header, then fix the ledger.
|
|
221
|
+
const result = await relocateSession({ root, dir: from, newCwd: target.path, sessionId, backupRoot })
|
|
222
|
+
await reconcile(ctx, registry, target, source, sessionId, result.header, beforeSessionId)
|
|
223
|
+
return { moved: true, repaired: true, sessionId, workspaceId: targetWorkspaceId, from: source?.id, cwd: { from: oldCwd, to: target.path } }
|
|
224
|
+
}
|
|
225
|
+
await target.insertSessionBefore(sessionId, beforeSessionId)
|
|
226
|
+
if (source !== undefined) await source.detachSession(sessionId)
|
|
227
|
+
const carried = await carryBranches(ctx, registry, root, backupRoot, target, branch)
|
|
228
|
+
return { moved: false, reordered: true, workspaceId: targetWorkspaceId, branches: carried }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (isActive(ctx, sessionId)) throw new ApiError('session-active', `session '${sessionId}' is running; stop it before moving`, 409)
|
|
232
|
+
|
|
233
|
+
const result = await relocateSession({ root, dir: from, newCwd: target.path, sessionId, backupRoot })
|
|
234
|
+
await reconcile(ctx, registry, target, source, sessionId, result.header, beforeSessionId)
|
|
235
|
+
const carried = await carryBranches(ctx, registry, root, backupRoot, target, branch)
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
moved: true,
|
|
239
|
+
sessionId,
|
|
240
|
+
workspaceId: targetWorkspaceId,
|
|
241
|
+
from: source?.id,
|
|
242
|
+
cwd: { from: oldCwd, to: target.path },
|
|
243
|
+
backup: result.backup,
|
|
244
|
+
branches: carried
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Read every stored header, so one session can be placed in its fork family.
|
|
250
|
+
*
|
|
251
|
+
* `indexStore` keys are already unique, so this reads each log once. Only a
|
|
252
|
+
* bounded prefix of each file is touched: a header is its first frame, while
|
|
253
|
+
* the log behind it may be many megabytes.
|
|
254
|
+
*
|
|
255
|
+
* @param {Map<string, string>} store - sessionId → directory, from indexStore.
|
|
256
|
+
* @returns {Promise<object[]>} readable headers (unreadable logs are skipped).
|
|
257
|
+
*/
|
|
258
|
+
async function readHeadersFor(store) {
|
|
259
|
+
const headers = []
|
|
260
|
+
for (const [id, dir] of store) {
|
|
261
|
+
const header = await readHeaderPrefix(dir, id)
|
|
262
|
+
if (header !== undefined) headers.push(header)
|
|
263
|
+
}
|
|
264
|
+
return headers
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Move the branch subtree into the target workspace alongside its origin.
|
|
269
|
+
*
|
|
270
|
+
* Each branch is an independent session, so a failure on one is reported
|
|
271
|
+
* rather than aborting the rest: the dragged session has already moved by the
|
|
272
|
+
* time this runs, and rolling that back because a branch could not follow
|
|
273
|
+
* would be worse than leaving a branch behind with a reason.
|
|
274
|
+
*
|
|
275
|
+
* @returns {Promise<Array<{sessionId: string, moved: boolean, error?: string}>>}
|
|
276
|
+
*/
|
|
277
|
+
async function carryBranches(ctx, registry, root, backupRoot, target, branchIds) {
|
|
278
|
+
const carried = []
|
|
279
|
+
for (const branchId of branchIds) {
|
|
280
|
+
try {
|
|
281
|
+
const dirs = await findSessionDirs(root, branchId)
|
|
282
|
+
if (dirs.length !== 1) {
|
|
283
|
+
carried.push({ sessionId: branchId, moved: false, error: dirs.length === 0 ? 'no session directory' : `${dirs.length} session directories` })
|
|
284
|
+
continue
|
|
285
|
+
}
|
|
286
|
+
const state = await readSessionState(dirs[0], branchId)
|
|
287
|
+
if (state === undefined) {
|
|
288
|
+
carried.push({ sessionId: branchId, moved: false, error: 'unreadable session log' })
|
|
289
|
+
continue
|
|
290
|
+
}
|
|
291
|
+
if (state.header.cwd === target.path) {
|
|
292
|
+
carried.push({ sessionId: branchId, moved: false, error: undefined, alreadyThere: true })
|
|
293
|
+
continue
|
|
294
|
+
}
|
|
295
|
+
if (isActive(ctx, branchId)) {
|
|
296
|
+
carried.push({ sessionId: branchId, moved: false, error: 'session is running' })
|
|
297
|
+
continue
|
|
298
|
+
}
|
|
299
|
+
const source = registry.list().find((entity) => entity.id !== target.id && entity.record.sessionIds.includes(branchId))
|
|
300
|
+
const result = await relocateSession({ root, dir: dirs[0], newCwd: target.path, sessionId: branchId, backupRoot })
|
|
301
|
+
await reconcile(ctx, registry, target, source, branchId, result.header, undefined)
|
|
302
|
+
carried.push({ sessionId: branchId, moved: true })
|
|
303
|
+
} catch (error) {
|
|
304
|
+
ctx.logger?.warn(`dsh-session-drag-move: could not carry branch '${branchId}': ${error?.message ?? error}`)
|
|
305
|
+
carried.push({ sessionId: branchId, moved: false, error: error?.message ?? String(error) })
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return carried
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const methods = {
|
|
312
|
+
async move(ctx, config, payload) {
|
|
313
|
+
return await move(ctx, config, payload)
|
|
314
|
+
},
|
|
315
|
+
async status(ctx, config) {
|
|
316
|
+
const registry = serviceOf(ctx, 'workspaceRegistry')
|
|
317
|
+
return {
|
|
318
|
+
ok: registry !== undefined,
|
|
319
|
+
root: config.root === '' ? join(homedir(), '.dsh', 'sessions') : config.root
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function apply(ctx, config = {}) {
|
|
325
|
+
const resolved = { ...DEFAULTS, ...config }
|
|
326
|
+
const webServer = ctx.webServer
|
|
327
|
+
if (webServer === undefined) {
|
|
328
|
+
ctx.logger?.warn('dsh-session-drag-move: no webServer service; the move API is not mounted')
|
|
329
|
+
return
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
ctx.effect(() => webServer.register({
|
|
333
|
+
kind: 'prefix',
|
|
334
|
+
path: '/session-drag-move/api',
|
|
335
|
+
handler: async (req, res) => {
|
|
336
|
+
if (!trusted(req, resolved.allowRemote)) return json(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } })
|
|
337
|
+
if (req.method !== 'POST') return json(res, 405, { ok: false, error: { code: 'method-error', message: 'method not allowed' } })
|
|
338
|
+
|
|
339
|
+
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
|
|
340
|
+
const method = pathname.startsWith(API_PREFIX) ? pathname.slice(API_PREFIX.length) : undefined
|
|
341
|
+
if (method === undefined || method.includes('/')) return json(res, 404, { ok: false, error: { code: 'not-found', message: 'unknown method' } })
|
|
342
|
+
|
|
343
|
+
try {
|
|
344
|
+
const handler = methods[method]
|
|
345
|
+
if (handler === undefined) throw new ApiError('not-found', `unknown method "${method}"`, 404)
|
|
346
|
+
const payload = await readBody(req)
|
|
347
|
+
json(res, 200, { ok: true, result: await handler(ctx, resolved, payload) })
|
|
348
|
+
} catch (error) {
|
|
349
|
+
const status = error instanceof ApiError ? error.status : 500
|
|
350
|
+
const code = error instanceof ApiError ? error.code : 'internal'
|
|
351
|
+
ctx.logger?.warn(`dsh-session-drag-move: ${method} failed: ${error?.message ?? error}`)
|
|
352
|
+
json(res, status, { ok: false, error: { code, message: error?.message ?? String(error) } })
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}), 'dsh-session-drag-move: /session-drag-move/api routes')
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export { apply }
|
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-session-drag-move — session log file helpers.
|
|
3
|
+
*
|
|
4
|
+
* The shipped session store is a per-session directory of Zstandard frames:
|
|
5
|
+
*
|
|
6
|
+
* <root>/--<projectKey(cwd)>--/<sessionId>/session.vN.jsonl[.zstd]
|
|
7
|
+
*
|
|
8
|
+
* The FIRST frame is the physical header (one JSONL line carrying `cwd`),
|
|
9
|
+
* every later frame is an appended batch. Frames are independent, so moving a
|
|
10
|
+
* session to another project is "re-encode frame 0 with a new cwd, then copy
|
|
11
|
+
* every byte after it verbatim" — no event is ever re-serialized.
|
|
12
|
+
*
|
|
13
|
+
* `projectKey` and `encodeSegment` are transcribed from
|
|
14
|
+
* @deepseek-ai/dsh-session-persistence-jsonl (lib/index.js) so a target
|
|
15
|
+
* directory is byte-identical to the one the backend itself would choose.
|
|
16
|
+
*/
|
|
17
|
+
import { constants } from 'node:fs'
|
|
18
|
+
import { access, mkdir, open, readFile, readdir, rename, stat, writeFile } from 'node:fs/promises'
|
|
19
|
+
import { join } from 'node:path'
|
|
20
|
+
import { zstdCompressSync, zstdDecompressSync } from 'node:zlib'
|
|
21
|
+
|
|
22
|
+
/** Zstandard frame magic number, `0x28B52FFD` little-endian. */
|
|
23
|
+
const MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd])
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Build the readable directory key for a project path.
|
|
27
|
+
* @param {string} cwd - absolute project directory.
|
|
28
|
+
* @returns {string} one filesystem-safe directory name, `--…--`.
|
|
29
|
+
*/
|
|
30
|
+
export function projectKey(cwd) {
|
|
31
|
+
if (cwd.length === 0) throw new Error('cannot encode an empty project path')
|
|
32
|
+
let readable = ''
|
|
33
|
+
let separatorRun = false
|
|
34
|
+
for (let i = 0; i < cwd.length; i++) {
|
|
35
|
+
const code = cwd.charCodeAt(i)
|
|
36
|
+
const ch = String.fromCharCode(code)
|
|
37
|
+
if (ch === '/' || ch === '\\' || ch === ':') {
|
|
38
|
+
if (!separatorRun) readable += '-'
|
|
39
|
+
separatorRun = true
|
|
40
|
+
} else if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) {
|
|
41
|
+
readable += ch
|
|
42
|
+
separatorRun = false
|
|
43
|
+
} else {
|
|
44
|
+
readable += '~' + code.toString(16).toUpperCase().padStart(4, '0')
|
|
45
|
+
separatorRun = false
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return `--${(readable.replace(/^-+/, '') || 'root').slice(0, 251)}--`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Encode a string as one safe path segment (session ids are unbranded input).
|
|
53
|
+
* @param {string} raw - non-empty string to encode.
|
|
54
|
+
* @returns {string} escaped segment; `session-<uuid>` round-trips unchanged.
|
|
55
|
+
*/
|
|
56
|
+
export function encodeSegment(raw) {
|
|
57
|
+
if (raw.length === 0) throw new Error('cannot encode an empty path segment')
|
|
58
|
+
if (raw === '.') return '~002E'
|
|
59
|
+
if (raw === '..') return '~002E~002E'
|
|
60
|
+
let out = ''
|
|
61
|
+
for (let i = 0; i < raw.length; i++) {
|
|
62
|
+
const code = raw.charCodeAt(i)
|
|
63
|
+
const ch = String.fromCharCode(code)
|
|
64
|
+
if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) out += ch
|
|
65
|
+
else out += '~' + code.toString(16).toUpperCase().padStart(4, '0')
|
|
66
|
+
}
|
|
67
|
+
return out
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Inverse of {@link encodeSegment}: recover the id a directory name encodes.
|
|
72
|
+
*
|
|
73
|
+
* Exact inverse only for strings this encoder produced; `undefined` means the
|
|
74
|
+
* segment is not valid encoded output, which is how a foreign directory under
|
|
75
|
+
* the store root is skipped rather than mistaken for a session.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} segment - one directory name under a project directory.
|
|
78
|
+
* @returns {string|undefined} the decoded id, or `undefined` when malformed.
|
|
79
|
+
*/
|
|
80
|
+
export function decodeSegment(segment) {
|
|
81
|
+
if (segment.length === 0) return undefined
|
|
82
|
+
let out = ''
|
|
83
|
+
for (let i = 0; i < segment.length; ) {
|
|
84
|
+
const ch = segment[i]
|
|
85
|
+
if (ch !== '~') {
|
|
86
|
+
if (!/^[A-Za-z0-9._-]$/.test(ch)) return undefined
|
|
87
|
+
out += ch
|
|
88
|
+
i += 1
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
const hex = segment.slice(i + 1, i + 5)
|
|
92
|
+
if (!/^[0-9A-F]{4}$/.test(hex)) return undefined
|
|
93
|
+
out += String.fromCharCode(Number.parseInt(hex, 16))
|
|
94
|
+
i += 5
|
|
95
|
+
}
|
|
96
|
+
// Re-encode to confirm this segment is canonical (e.g. `~0041` must be `A`).
|
|
97
|
+
return encodeSegment(out) === segment ? out : undefined
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** @param {string} root @param {string} cwd @returns {string} project directory. */
|
|
101
|
+
export function projectDir(root, cwd) {
|
|
102
|
+
return join(root, projectKey(cwd))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** @param {string} root @param {string} cwd @param {string} id @returns {string} */
|
|
106
|
+
export function sessionDir(root, cwd, id) {
|
|
107
|
+
return join(projectDir(root, cwd), encodeSegment(id))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Whether a path exists at all. */
|
|
111
|
+
const exists = async (p) => {
|
|
112
|
+
try {
|
|
113
|
+
await access(p, constants.F_OK)
|
|
114
|
+
return true
|
|
115
|
+
} catch {
|
|
116
|
+
return false
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Whether a path exists and is a directory. */
|
|
121
|
+
const isDir = async (p) => {
|
|
122
|
+
try {
|
|
123
|
+
return (await stat(p)).isDirectory()
|
|
124
|
+
} catch {
|
|
125
|
+
return false
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Locate a session's directory on disk. The direct `projectKey(cwd)` guess is
|
|
131
|
+
* tried first; a scan of `<root>/*` covers a cwd that was normalized
|
|
132
|
+
* differently when the log was written.
|
|
133
|
+
*
|
|
134
|
+
* @param {string} root - session store root.
|
|
135
|
+
* @param {string} cwd - the session's recorded cwd.
|
|
136
|
+
* @param {string} sessionId - session id.
|
|
137
|
+
* @returns {Promise<string|undefined>} the directory, when it exists.
|
|
138
|
+
*/
|
|
139
|
+
export async function locateSessionDir(root, cwd, sessionId) {
|
|
140
|
+
const direct = sessionDir(root, cwd, sessionId)
|
|
141
|
+
if (await isDir(direct)) return direct
|
|
142
|
+
let entries
|
|
143
|
+
try {
|
|
144
|
+
entries = await readdir(root, { withFileTypes: true })
|
|
145
|
+
} catch {
|
|
146
|
+
return undefined
|
|
147
|
+
}
|
|
148
|
+
for (const entry of entries) {
|
|
149
|
+
if (!entry.isDirectory()) continue
|
|
150
|
+
const candidate = join(root, entry.name, encodeSegment(sessionId))
|
|
151
|
+
if (await isDir(candidate)) return candidate
|
|
152
|
+
}
|
|
153
|
+
return undefined
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Physical generation number parsed out of `session.vN.jsonl[.zstd]`. */
|
|
157
|
+
const generationOf = (name) => {
|
|
158
|
+
const match = /^session(?:\.v(\d+))?\.jsonl(?:\.zstd)?$/.exec(name)
|
|
159
|
+
if (match === null) return undefined
|
|
160
|
+
return match[1] === undefined ? 0 : Number(match[1])
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The session's current log file — highest released generation, like the
|
|
165
|
+
* backend's own selection.
|
|
166
|
+
* @param {string} dir - session directory.
|
|
167
|
+
* @returns {Promise<{path: string, compressed: boolean}|undefined>}
|
|
168
|
+
*/
|
|
169
|
+
export async function currentLogFile(dir) {
|
|
170
|
+
const entries = await readdir(dir, { withFileTypes: true })
|
|
171
|
+
let best
|
|
172
|
+
let bestGeneration = -1
|
|
173
|
+
for (const entry of entries) {
|
|
174
|
+
if (!entry.isFile()) continue
|
|
175
|
+
const generation = generationOf(entry.name)
|
|
176
|
+
if (generation === undefined || generation <= bestGeneration) continue
|
|
177
|
+
best = entry.name
|
|
178
|
+
bestGeneration = generation
|
|
179
|
+
}
|
|
180
|
+
if (best === undefined) return undefined
|
|
181
|
+
return { path: join(dir, best), compressed: best.endsWith('.zstd') }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Every offset where a Zstandard frame magic begins. */
|
|
185
|
+
function magicOffsets(buf) {
|
|
186
|
+
const offsets = []
|
|
187
|
+
let at = 0
|
|
188
|
+
for (;;) {
|
|
189
|
+
const hit = buf.indexOf(MAGIC, at)
|
|
190
|
+
if (hit === -1) return offsets
|
|
191
|
+
offsets.push(hit)
|
|
192
|
+
at = hit + 4
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Read the log's physical header without touching anything after it.
|
|
198
|
+
*
|
|
199
|
+
* Frame boundaries are found by magic scan and then VERIFIED: a candidate end
|
|
200
|
+
* counts only when the bytes before it decompress to a JSONL line that is a
|
|
201
|
+
* valid session header for `expectId`. A magic that happens to occur inside
|
|
202
|
+
* compressed data is therefore skipped instead of truncating the log.
|
|
203
|
+
*
|
|
204
|
+
* @param {Buffer} buf - whole log file.
|
|
205
|
+
* @param {string} expectId - session id the header must name.
|
|
206
|
+
* @returns {{frameEnd: number, header: object}|undefined}
|
|
207
|
+
*/
|
|
208
|
+
export function readHeader(buf, expectId) {
|
|
209
|
+
const candidates = magicOffsets(buf).slice(1)
|
|
210
|
+
candidates.push(buf.length)
|
|
211
|
+
for (const end of candidates) {
|
|
212
|
+
try {
|
|
213
|
+
const text = zstdDecompressSync(buf.subarray(0, end)).toString('utf8')
|
|
214
|
+
const line = text.split('\n')[0]
|
|
215
|
+
const header = JSON.parse(line)
|
|
216
|
+
if (header?.type !== 'session') continue
|
|
217
|
+
if (typeof header.id === 'string' && header.id !== expectId) continue
|
|
218
|
+
if (typeof header.cwd !== 'string') continue
|
|
219
|
+
return { frameEnd: end, header }
|
|
220
|
+
} catch {
|
|
221
|
+
/* not a frame boundary, or not a session header: try the next candidate */
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return undefined
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Every project directory currently holding this session.
|
|
229
|
+
*
|
|
230
|
+
* A cwd-agnostic scan, deliberately: the stored header is what says where a
|
|
231
|
+
* session *belongs*, but the directory listing is what says where its bytes
|
|
232
|
+
* *are*, and a move that failed mid-way leaves those two disagreeing. Returns
|
|
233
|
+
* every hit so the caller can notice a duplicate instead of silently picking
|
|
234
|
+
* one.
|
|
235
|
+
*
|
|
236
|
+
* @param {string} root - session store root.
|
|
237
|
+
* @param {string} sessionId - session id.
|
|
238
|
+
* @returns {Promise<string[]>} matching session directories, in scan order.
|
|
239
|
+
*/
|
|
240
|
+
export async function findSessionDirs(root, sessionId) {
|
|
241
|
+
let entries
|
|
242
|
+
try {
|
|
243
|
+
entries = await readdir(root, { withFileTypes: true })
|
|
244
|
+
} catch {
|
|
245
|
+
return []
|
|
246
|
+
}
|
|
247
|
+
const found = []
|
|
248
|
+
for (const entry of entries) {
|
|
249
|
+
if (!entry.isDirectory()) continue
|
|
250
|
+
const candidate = join(root, entry.name, encodeSegment(sessionId))
|
|
251
|
+
if (await isDir(candidate)) found.push(candidate)
|
|
252
|
+
}
|
|
253
|
+
return found
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Every session id present in the store, with the directory holding it.
|
|
258
|
+
*
|
|
259
|
+
* Reads directory names only — no header decode — so it stays cheap enough to
|
|
260
|
+
* run on every move. `encodeSegment` is injective, so a directory name decodes
|
|
261
|
+
* back to exactly one id.
|
|
262
|
+
*
|
|
263
|
+
* @param {string} root - session store root.
|
|
264
|
+
* @returns {Promise<Map<string, string>>} sessionId → session directory.
|
|
265
|
+
*/
|
|
266
|
+
export async function indexStore(root) {
|
|
267
|
+
const index = new Map()
|
|
268
|
+
let projects
|
|
269
|
+
try {
|
|
270
|
+
projects = await readdir(root, { withFileTypes: true })
|
|
271
|
+
} catch {
|
|
272
|
+
return index
|
|
273
|
+
}
|
|
274
|
+
for (const project of projects) {
|
|
275
|
+
if (!project.isDirectory()) continue
|
|
276
|
+
let sessions
|
|
277
|
+
try {
|
|
278
|
+
sessions = await readdir(join(root, project.name), { withFileTypes: true })
|
|
279
|
+
} catch {
|
|
280
|
+
continue
|
|
281
|
+
}
|
|
282
|
+
for (const session of sessions) {
|
|
283
|
+
if (!session.isDirectory()) continue
|
|
284
|
+
const id = decodeSegment(session.name)
|
|
285
|
+
if (id === undefined) continue
|
|
286
|
+
// First hit wins; a duplicate id in two project directories is the
|
|
287
|
+
// caller's business (relocateSession reports it).
|
|
288
|
+
if (!index.has(id)) index.set(id, join(root, project.name, session.name))
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return index
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Read only a log's header, from a bounded prefix of the file.
|
|
296
|
+
*
|
|
297
|
+
* The header is the log's first frame and runs to a couple hundred bytes, so
|
|
298
|
+
* building the fork tree over a whole store must not read every session's full
|
|
299
|
+
* multi-megabyte log. {@link readHeader} verifies the frame boundary, so a
|
|
300
|
+
* truncated read either yields the header or yields nothing — never a
|
|
301
|
+
* half-decoded one.
|
|
302
|
+
*
|
|
303
|
+
* @param {string} dir - session directory.
|
|
304
|
+
* @param {string} sessionId - session id.
|
|
305
|
+
* @param {number} [maxBytes] - how much of the file to read.
|
|
306
|
+
* @returns {Promise<object|undefined>} the stored header, when readable.
|
|
307
|
+
*/
|
|
308
|
+
export async function readHeaderPrefix(dir, sessionId, maxBytes = 8192) {
|
|
309
|
+
const log = await currentLogFile(dir)
|
|
310
|
+
if (log === undefined) return undefined
|
|
311
|
+
let handle
|
|
312
|
+
try {
|
|
313
|
+
handle = await open(log.path, 'r')
|
|
314
|
+
const buffer = Buffer.alloc(maxBytes)
|
|
315
|
+
const { bytesRead } = await handle.read(buffer, 0, maxBytes, 0)
|
|
316
|
+
const prefix = buffer.subarray(0, bytesRead)
|
|
317
|
+
if (log.compressed) return readHeader(prefix, sessionId)?.header
|
|
318
|
+
return readRawHeader(prefix, sessionId).header
|
|
319
|
+
} catch {
|
|
320
|
+
return undefined
|
|
321
|
+
} finally {
|
|
322
|
+
await handle?.close()
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Read a session directory's current log and stored header, without writing.
|
|
328
|
+
*
|
|
329
|
+
* @param {string} dir - session directory.
|
|
330
|
+
* @param {string} sessionId - session id.
|
|
331
|
+
* @returns {Promise<{log: {path: string, compressed: boolean}, header: object, buf: Buffer}|undefined>}
|
|
332
|
+
*/
|
|
333
|
+
export async function readSessionState(dir, sessionId) {
|
|
334
|
+
const log = await currentLogFile(dir)
|
|
335
|
+
if (log === undefined) return undefined
|
|
336
|
+
const buf = await readFile(log.path)
|
|
337
|
+
try {
|
|
338
|
+
const found = log.compressed ? readHeader(buf, sessionId) : readRawHeader(buf, sessionId)
|
|
339
|
+
if (found === undefined) return undefined
|
|
340
|
+
return { log, header: found.header, buf }
|
|
341
|
+
} catch {
|
|
342
|
+
return undefined
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Rebuild a log with the same header except for a new `cwd`.
|
|
348
|
+
*
|
|
349
|
+
* @param {Buffer} buf - original log bytes.
|
|
350
|
+
* @param {string} newCwd - the project directory the session moves to.
|
|
351
|
+
* @param {string} sessionId - session id.
|
|
352
|
+
* @returns {{content: Buffer, header: object, frameEnd: number}}
|
|
353
|
+
*/
|
|
354
|
+
export function rewriteHeaderCwd(buf, newCwd, sessionId) {
|
|
355
|
+
const found = readHeader(buf, sessionId)
|
|
356
|
+
if (found === undefined) throw new Error(`session log holds no readable header for '${sessionId}'`)
|
|
357
|
+
const header = { ...found.header, cwd: newCwd }
|
|
358
|
+
const line = Buffer.from(`${JSON.stringify(header)}\n`, 'utf8')
|
|
359
|
+
return {
|
|
360
|
+
content: Buffer.concat([zstdCompressSync(line), buf.subarray(found.frameEnd)]),
|
|
361
|
+
header,
|
|
362
|
+
frameEnd: found.frameEnd
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Read the plain-text (compression: none) first line as a header.
|
|
368
|
+
* @param {Buffer} buf @param {string} sessionId
|
|
369
|
+
*/
|
|
370
|
+
export function readRawHeader(buf, sessionId) {
|
|
371
|
+
const newline = buf.indexOf(0x0a)
|
|
372
|
+
const text = buf.subarray(0, newline === -1 ? buf.length : newline).toString('utf8')
|
|
373
|
+
const header = JSON.parse(text)
|
|
374
|
+
if (header?.type !== 'session' || header.id !== sessionId) throw new Error('not a session header')
|
|
375
|
+
return { frameEnd: newline === -1 ? buf.length : newline + 1, header }
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Re-encode a log's header with a new cwd.
|
|
380
|
+
* @param {Buffer} buf @param {string} newCwd @param {string} sessionId
|
|
381
|
+
* @param {boolean} compressed
|
|
382
|
+
* @returns {{content: Buffer, header: object}}
|
|
383
|
+
*/
|
|
384
|
+
function rewriteHeader(buf, newCwd, sessionId, compressed) {
|
|
385
|
+
if (compressed) return rewriteHeaderCwd(buf, newCwd, sessionId)
|
|
386
|
+
const found = readRawHeader(buf, sessionId)
|
|
387
|
+
return {
|
|
388
|
+
content: Buffer.concat([
|
|
389
|
+
Buffer.from(`${JSON.stringify({ ...found.header, cwd: newCwd })}\n`, 'utf8'),
|
|
390
|
+
buf.subarray(found.frameEnd)
|
|
391
|
+
]),
|
|
392
|
+
header: { ...found.header, cwd: newCwd }
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Move a session's bytes into the project directory of `newCwd`, rewriting the
|
|
398
|
+
* stored header's cwd on the way.
|
|
399
|
+
*
|
|
400
|
+
* Idempotent by construction: when the session is already in the target
|
|
401
|
+
* directory it rewrites the header in place instead of copying onto itself,
|
|
402
|
+
* and an occupied destination is a reported conflict rather than an
|
|
403
|
+
* overwrite. The old directory is relocated (never copied-and-deleted), so a
|
|
404
|
+
* crash can leave at most one of the two.
|
|
405
|
+
*
|
|
406
|
+
* @param {object} args
|
|
407
|
+
* @param {string} args.root - session store root.
|
|
408
|
+
* @param {string} args.dir - the session directory found on disk.
|
|
409
|
+
* @param {string} args.newCwd - project directory to move the session to.
|
|
410
|
+
* @param {string} args.sessionId
|
|
411
|
+
* @param {string} args.backupRoot - where the pre-move directory is parked.
|
|
412
|
+
* @returns {Promise<{header: object, from: string, to: string, backup?: string, relocated: boolean}>}
|
|
413
|
+
*/
|
|
414
|
+
export async function relocateSession({ root, dir, newCwd, sessionId, backupRoot }) {
|
|
415
|
+
const state = await readSessionState(dir, sessionId)
|
|
416
|
+
if (state === undefined) throw new Error(`session directory '${dir}' holds no readable session log`)
|
|
417
|
+
|
|
418
|
+
const { log, buf } = state
|
|
419
|
+
const name = log.path.slice(dir.length + 1)
|
|
420
|
+
const to = sessionDir(root, newCwd, sessionId)
|
|
421
|
+
|
|
422
|
+
// Bytes already sit in the target project directory: rewrite the header
|
|
423
|
+
// where it lies, whether or not the header agrees yet. This is the repair
|
|
424
|
+
// path for a move that landed the bytes and then failed before the header
|
|
425
|
+
// (or the ledger) caught up — the exact state that used to collide with
|
|
426
|
+
// itself and fail EEXIST on a re-drop.
|
|
427
|
+
if (to === dir) {
|
|
428
|
+
const { content, header } = rewriteHeader(buf, newCwd, sessionId, log.compressed)
|
|
429
|
+
if (!buf.equals(content)) await writeFile(log.path, content)
|
|
430
|
+
return { header, from: dir, to, relocated: false }
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// A different directory already holds a log for this session: refuse rather
|
|
434
|
+
// than clobber it.
|
|
435
|
+
if ((await isDir(to)) && (await exists(join(to, name)))) {
|
|
436
|
+
throw new Error(`destination already holds a log for '${sessionId}': ${join(to, name)}`)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const { content, header } = rewriteHeader(buf, newCwd, sessionId, log.compressed)
|
|
440
|
+
if (log.compressed) {
|
|
441
|
+
const again = readHeader(content, sessionId)
|
|
442
|
+
if (again === undefined || again.header.cwd !== newCwd || again.header.id !== sessionId) {
|
|
443
|
+
throw new Error('rewritten session log failed its own header re-read')
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
await mkdir(to, { recursive: true })
|
|
448
|
+
await writeFile(join(to, name), content, { flag: 'wx' })
|
|
449
|
+
|
|
450
|
+
// Everything else the session owns (older generations, side artifacts).
|
|
451
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
452
|
+
if (entry.name === name) continue
|
|
453
|
+
await rename(join(dir, entry.name), join(to, entry.name))
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
await mkdir(backupRoot, { recursive: true })
|
|
457
|
+
const backup = join(backupRoot, `${sessionId}-${Date.now()}`)
|
|
458
|
+
await rename(dir, backup)
|
|
459
|
+
|
|
460
|
+
return { header, from: dir, to, backup, relocated: true }
|
|
461
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,50 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-session-drag-move",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "宸︿晶鏍忓伐浣滃尯锛氭妸浼氳瘽鎷栧埌鍒殑宸ヤ綔鍖猴紝鐪熸鏀瑰彉褰掑睘锛堟敼鍐欎細璇濇棩蹇?header 鐨?cwd 骞惰縼绉讳細璇濈洰褰曪級銆侱rag a session onto another workspace in the DSH sidebar and it really moves: the session log header's cwd is rewritten and the log is re-homed under the target project.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./lib/index.js",
|
|
9
|
+
"./client": "./lib/client.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=22",
|
|
14
|
+
"dsh": ">=0.1.6-alpha.2"
|
|
15
|
+
},
|
|
16
|
+
"dsh": {
|
|
17
|
+
"bundle": {
|
|
18
|
+
"patch": "./cordis.patch.yml"
|
|
19
|
+
},
|
|
20
|
+
"client": {
|
|
21
|
+
"platform": "web"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"lib/index.js",
|
|
26
|
+
"lib/client.js",
|
|
27
|
+
"lib/session-log.js",
|
|
28
|
+
"lib/fork-tree.js",
|
|
29
|
+
"cordis.patch.yml",
|
|
30
|
+
"README.md"
|
|
31
|
+
],
|
|
32
|
+
"keywords": [
|
|
33
|
+
"dsh",
|
|
34
|
+
"deepseek-harness",
|
|
35
|
+
"dsh-plugin",
|
|
36
|
+
"sidebar",
|
|
37
|
+
"workspace",
|
|
38
|
+
"session",
|
|
39
|
+
"drag-and-drop"
|
|
40
|
+
],
|
|
41
|
+
"license": "MIT",
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "git+https://github.com/FurryBear2025/dsh-session-drag-move.git"
|
|
45
|
+
},
|
|
46
|
+
"homepage": "https://github.com/FurryBear2025/dsh-session-drag-move#readme",
|
|
47
|
+
"bugs": {
|
|
48
|
+
"url": "https://github.com/FurryBear2025/dsh-session-drag-move/issues"
|
|
49
|
+
}
|
|
6
50
|
}
|