dsh-sessions-manager 3.5.0 → 3.5.2
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.en.md +12 -4
- package/README.md +12 -4
- package/lib/client.js +130 -92
- package/lib/client.js.map +2 -2
- package/lib/index.js +904 -241
- package/lib/index.js.map +4 -4
- package/package.json +1 -1
- package/src/client/index.jsx +154 -90
- package/src/client/logic.js +13 -1
- package/src/compat/capabilities.js +64 -8
- package/src/compat/persistence.js +137 -18
- package/src/handle-era-ops.js +185 -0
- package/src/handle-era-paths.js +126 -0
- package/src/index.js +373 -96
- package/src/markdown.js +122 -71
- package/src/path-guard.js +59 -0
- package/src/session-meta-cache.js +40 -13
- package/src/title-persist-index.js +6 -1
- package/src/zstd-frame.js +68 -34
|
@@ -2,6 +2,30 @@
|
|
|
2
2
|
// dsh-sessions-manager. Business code consumes normalized headers and complete
|
|
3
3
|
// inspections; it never needs to know whether DSH returned a legacy header or
|
|
4
4
|
// a handle-era SessionPersistenceSnapshot.
|
|
5
|
+
//
|
|
6
|
+
// Handle-era notes (official contract, dsh-v0.1.3-alpha.1):
|
|
7
|
+
// - `SessionHandle.read(offset?, length?, options?)` returns a bounded slice
|
|
8
|
+
// of the valid contiguous log; an offset at/past the end returns [].
|
|
9
|
+
// - Every handle is single-owner state: `close()` MUST run exactly once on
|
|
10
|
+
// every path, including throws and aborts (the contract exposes
|
|
11
|
+
// `SessionHandleClosedError` for operations after close).
|
|
12
|
+
// - `stat(id)` → `SessionPersistenceSnapshot | undefined`; `snapshot.revision`
|
|
13
|
+
// is an opaque change token valid ONLY within one service instance and one
|
|
14
|
+
// session id (see src/session-meta-cache.js).
|
|
15
|
+
|
|
16
|
+
const DEFAULT_CHUNK = 400
|
|
17
|
+
// 防御上限:一次 inspect 的分块循环绝不能无限自旋(后端 read 行为异常时快速失败)。
|
|
18
|
+
const MAX_CHUNKS = 20000
|
|
19
|
+
|
|
20
|
+
function normalizeReadResult(events) {
|
|
21
|
+
if (Array.isArray(events)) return events
|
|
22
|
+
if (events && typeof events[Symbol.iterator] === 'function') return [...events]
|
|
23
|
+
return []
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function closeQuietly(handle) {
|
|
27
|
+
try { if (handle && typeof handle.close === 'function') await handle.close() } catch (e) { /* close 是幂等兜底,二次失败忽略 */ }
|
|
28
|
+
}
|
|
5
29
|
|
|
6
30
|
function asHeader(value) {
|
|
7
31
|
if (!value || typeof value !== 'object') return null
|
|
@@ -19,7 +43,7 @@ export function normalizePersistenceEntry(value) {
|
|
|
19
43
|
id: String(header.id),
|
|
20
44
|
sizeBytes: snapshot && Number.isFinite(snapshot.sizeBytes) ? Number(snapshot.sizeBytes) : null,
|
|
21
45
|
eventCount: snapshot && Number.isSafeInteger(snapshot.eventCount) ? snapshot.eventCount : null,
|
|
22
|
-
revision: snapshot ? snapshot.revision : null,
|
|
46
|
+
revision: snapshot && typeof snapshot.revision === 'string' && snapshot.revision ? snapshot.revision : null,
|
|
23
47
|
}
|
|
24
48
|
}
|
|
25
49
|
|
|
@@ -31,34 +55,116 @@ export function normalizePersistenceList(values) {
|
|
|
31
55
|
export function createPersistenceAdapter(service) {
|
|
32
56
|
if (!service || typeof service.list !== 'function') throw new TypeError('sessionPersistence.list is required')
|
|
33
57
|
|
|
58
|
+
const hasStat = typeof service.stat === 'function'
|
|
59
|
+
const kind = typeof service.open === 'function' ? 'session-handle' : 'legacy'
|
|
60
|
+
|
|
34
61
|
async function listEntries(options) {
|
|
35
62
|
return normalizePersistenceList(await service.list(options))
|
|
36
63
|
}
|
|
37
64
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
65
|
+
// Handle-era only: the official lightweight observation. Returns the
|
|
66
|
+
// normalized snapshot entry, or null when the session does not exist.
|
|
67
|
+
// Never falls back to reading the log — callers use it for existence
|
|
68
|
+
// checks and revision-based cache validation only.
|
|
69
|
+
async function statSession(id) {
|
|
70
|
+
if (!hasStat) return null
|
|
71
|
+
const snapshot = await service.stat(id)
|
|
72
|
+
return snapshot ? normalizePersistenceEntry(snapshot) : null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Read one bounded slice through a SessionHandle. The caller owns the
|
|
76
|
+
// handle lifecycle; this helper only guarantees close on read failure —
|
|
77
|
+
// the surrounding try/finally in the chunk drivers below is authoritative.
|
|
78
|
+
async function readChunk(handle, offset, length, signal) {
|
|
79
|
+
if (signal && signal.aborted) {
|
|
80
|
+
const error = new Error('会话读取已取消')
|
|
81
|
+
error.code = 'DSM_READ_ABORTED'
|
|
82
|
+
throw error
|
|
46
83
|
}
|
|
84
|
+
const events = await handle.read(offset, length, signal ? { signal } : undefined)
|
|
85
|
+
return normalizeReadResult(events)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Sequential chunk driver shared by inspectSession / readSession. Opens the
|
|
89
|
+
// handle itself so every code path (success, mid-chunk throw, abort) closes
|
|
90
|
+
// it exactly once in `finally`.
|
|
91
|
+
async function readChunks(id, { offset = 0, chunkSize = DEFAULT_CHUNK, signal, onEvents }) {
|
|
47
92
|
if (typeof service.open !== 'function') throw new Error('当前 DSH 持久化服务不支持读取会话')
|
|
48
93
|
const handle = await service.open(id, 'read')
|
|
49
94
|
if (!handle || typeof handle.read !== 'function' || typeof handle.close !== 'function') {
|
|
50
|
-
|
|
95
|
+
await closeQuietly(handle)
|
|
51
96
|
throw new Error('DSH 返回了无效的 SessionHandle')
|
|
52
97
|
}
|
|
98
|
+
let cursor = Number.isSafeInteger(offset) && offset >= 0 ? offset : 0
|
|
99
|
+
let total = 0
|
|
53
100
|
try {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
101
|
+
for (let round = 0; round < MAX_CHUNKS; round++) {
|
|
102
|
+
const events = await readChunk(handle, cursor, chunkSize, signal)
|
|
103
|
+
if (events.length === 0) break
|
|
104
|
+
cursor += events.length
|
|
105
|
+
total += events.length
|
|
106
|
+
if (onEvents) await onEvents(events, { offset: cursor - events.length, total })
|
|
107
|
+
if (events.length < chunkSize) break
|
|
59
108
|
}
|
|
60
109
|
} finally {
|
|
61
|
-
await handle
|
|
110
|
+
await closeQuietly(handle)
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
meta: handle.header || handle.meta || null,
|
|
114
|
+
inheritedEventCount: Number.isSafeInteger(handle.inheritedEventCount) ? handle.inheritedEventCount : 0,
|
|
115
|
+
eventCount: total,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Streamed full inspection: folds the log chunk-by-chunk through `onEvents`
|
|
120
|
+
// so 详情 / 导出 never materialize a whole large log in memory. `signal`
|
|
121
|
+
// (AbortSignal) cancels before the next chunk; the handle closes on every
|
|
122
|
+
// path. Legacy runtimes have no bounded read — readFrom already returns the
|
|
123
|
+
// complete log, which becomes a single onEvents batch.
|
|
124
|
+
async function inspectSession(id, opts = {}) {
|
|
125
|
+
const chunkSize = Number.isSafeInteger(opts.chunkSize) && opts.chunkSize > 0 ? opts.chunkSize : DEFAULT_CHUNK
|
|
126
|
+
if (typeof service.open === 'function') {
|
|
127
|
+
// 取消发生在 open 之前:连 handle 都不去开。
|
|
128
|
+
if (opts.signal && opts.signal.aborted) {
|
|
129
|
+
const error = new Error('会话读取已取消')
|
|
130
|
+
error.code = 'DSM_READ_ABORTED'
|
|
131
|
+
throw error
|
|
132
|
+
}
|
|
133
|
+
return readChunks(id, { offset: opts.offset || 0, chunkSize, signal: opts.signal, onEvents: opts.onEvents })
|
|
134
|
+
}
|
|
135
|
+
if (typeof service.readFrom !== 'function') throw new Error('当前 DSH 持久化服务不支持读取会话')
|
|
136
|
+
if (opts.signal && opts.signal.aborted) {
|
|
137
|
+
const error = new Error('会话读取已取消')
|
|
138
|
+
error.code = 'DSM_READ_ABORTED'
|
|
139
|
+
throw error
|
|
140
|
+
}
|
|
141
|
+
const result = await service.readFrom(id, opts.offset || 0)
|
|
142
|
+
const events = normalizeReadResult(result && result.events)
|
|
143
|
+
if (opts.onEvents && events.length) await opts.onEvents(events, { offset: opts.offset || 0, total: events.length })
|
|
144
|
+
return {
|
|
145
|
+
meta: result && result.meta ? result.meta : null,
|
|
146
|
+
inheritedEventCount: result && Number.isSafeInteger(result.inheritedEventCount) ? result.inheritedEventCount : 0,
|
|
147
|
+
eventCount: events.length,
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Complete read (legacy convenience shape). Internally chunked; callers that
|
|
152
|
+
// stream should prefer inspectSession so large logs never buffer whole.
|
|
153
|
+
async function readSession(id, offset = 0) {
|
|
154
|
+
if (typeof service.readFrom === 'function') {
|
|
155
|
+
const result = await service.readFrom(id, offset)
|
|
156
|
+
return {
|
|
157
|
+
meta: result && result.meta ? result.meta : null,
|
|
158
|
+
inheritedEventCount: result && Number.isSafeInteger(result.inheritedEventCount) ? result.inheritedEventCount : 0,
|
|
159
|
+
events: result && Array.isArray(result.events) ? result.events : [],
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const events = []
|
|
163
|
+
const summary = await readChunks(id, { offset, onEvents: (batch) => { events.push(...batch) } })
|
|
164
|
+
return {
|
|
165
|
+
meta: summary.meta,
|
|
166
|
+
inheritedEventCount: summary.inheritedEventCount,
|
|
167
|
+
events,
|
|
62
168
|
}
|
|
63
169
|
}
|
|
64
170
|
|
|
@@ -67,6 +173,19 @@ export function createPersistenceAdapter(service) {
|
|
|
67
173
|
return null
|
|
68
174
|
}
|
|
69
175
|
|
|
70
|
-
|
|
71
|
-
|
|
176
|
+
// 落盘校验版定位:legacy 走官方 locate;handle 时代官方收走了 locate,改由
|
|
177
|
+
// handle-era-paths 的三层守卫推导(root 实例字段 → 目录结构 → id 归属),
|
|
178
|
+
// 任一层失败返回 null,调用方安全降级。返回 { path, sessionDir|null }。
|
|
179
|
+
async function locateVerified(header) {
|
|
180
|
+
if (typeof service.locate === 'function') {
|
|
181
|
+
try {
|
|
182
|
+
const loc = service.locate(header)
|
|
183
|
+
if (loc && typeof loc.path === 'string') return { path: loc.path, sessionDir: null }
|
|
184
|
+
} catch (e) { /* 落到推导 */ }
|
|
185
|
+
}
|
|
186
|
+
const artifacts = await locateSessionArtifacts(service, header)
|
|
187
|
+
return artifacts ? { path: artifacts.logPath, sessionDir: artifacts.sessionDir } : null
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return { kind, listEntries, readSession, inspectSession, statSession, locate, locateVerified, hasStat }
|
|
72
191
|
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Handle-era destructive/moving operations (dsh-v0.1.3-alpha.1).
|
|
2
|
+
//
|
|
3
|
+
// 2026-09-06 用户决策:这两类能力在 legacy 时代本就是「官方 locate 查路径 +
|
|
4
|
+
// 直接文件系统操作」的半官方实现;handle 时代官方收走 locate 后,改为由
|
|
5
|
+
// src/handle-era-paths.js 的三层守卫推导路径。本模块实现两个操作核心,主路径
|
|
6
|
+
// 尽量走官方公共 API(create/append/flush/close/stat/open),文件系统操作仅限
|
|
7
|
+
// 于「把旧日志改名备份 / 删除会话目录」这两步,并且全部有备份回滚或前置探测:
|
|
8
|
+
//
|
|
9
|
+
// - moveSessionToCwd: revision 前后校验(调用方)→ 官方 create+append 重放
|
|
10
|
+
// 为主路径;后端已有同 id 幽灵时回退到 frame0 cwd 改写搬运(复用
|
|
11
|
+
// zstd-frame.js,与 legacy relocateLog 同一套校验)。任何失败都会把备份
|
|
12
|
+
// 改名回原位并清理目标目录,绝不留下半移动状态。
|
|
13
|
+
// - purgeSessionArtifacts: 官方 open(id,'write') 探测并短暂接管写所有权
|
|
14
|
+
// (活跃写者 → 409 拒绝),然后整目录删除会话目录(basename 已由路径
|
|
15
|
+
// 守卫验证),最后以官方 stat 复核该 id 已消失。
|
|
16
|
+
//
|
|
17
|
+
// 活跃写者策略:两个操作都拒绝「正在进行中」的会话,而不是照 legacy 那样
|
|
18
|
+
// 改写活跃对象——handle 时代的写句柄所有权在官方 tracker 内部,与其打补丁
|
|
19
|
+
// 不如如实拒绝,风险面更小。
|
|
20
|
+
|
|
21
|
+
import { mkdir, readFile, rename, rm, unlink, writeFile } from 'node:fs/promises'
|
|
22
|
+
import { join } from 'node:path'
|
|
23
|
+
import { rewriteFrame0CwdInMemory, scanZstdFrames } from './zstd-frame.js'
|
|
24
|
+
import { deriveSessionDir, locateSessionArtifacts } from './handle-era-paths.js'
|
|
25
|
+
|
|
26
|
+
const MOVE_BATCH = 400
|
|
27
|
+
|
|
28
|
+
function conflictError(message) {
|
|
29
|
+
const error = new Error(message)
|
|
30
|
+
error.status = 409
|
|
31
|
+
error.code = 'DSM_SESSION_BUSY'
|
|
32
|
+
return error
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function failureText(e) {
|
|
36
|
+
return `${(e && e.name) || ''} ${(e && e.message) || e}`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isAlreadyOwned(e) {
|
|
40
|
+
return /already owned/i.test(failureText(e))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isAlreadyExists(e) {
|
|
44
|
+
return /already exists/i.test(failureText(e))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function closeQuietly(handle) {
|
|
48
|
+
try { if (handle && typeof handle.close === 'function') await handle.close() } catch (e) { /* 目录可能已被删,lease 释放失败可忽略 */ }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 官方写所有权探测:能 open(id,'write') 就证明当前没有活跃写者(顺带让官方
|
|
52
|
+
// 路径 flush 一次),拿到后立即释放。真正的并发保护来自随后的 rename-aside
|
|
53
|
+
// (旧日志消失后,迟到的写者会在官方 open 处干净地 NotFound,而不是写坏数据)。
|
|
54
|
+
export async function ensureNoActiveWriter(sp, sid) {
|
|
55
|
+
let handle = null
|
|
56
|
+
try {
|
|
57
|
+
handle = await sp.open(sid, 'write')
|
|
58
|
+
} catch (e) {
|
|
59
|
+
if (isAlreadyOwned(e)) throw conflictError('该会话正在进行中(存在活跃写入),请先切换到别的会话再操作。')
|
|
60
|
+
throw e
|
|
61
|
+
}
|
|
62
|
+
await closeQuietly(handle)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 彻底删除一个会话的全部物理产物。header 必须来自官方 list/stat(携带真实 cwd)。
|
|
66
|
+
// 返回被删除的 artifacts(供上层记录 originalPath 等)。
|
|
67
|
+
export async function purgeSessionArtifacts(sp, sid, header) {
|
|
68
|
+
const artifacts = await locateSessionArtifacts(sp, header)
|
|
69
|
+
if (!artifacts) {
|
|
70
|
+
const error = new Error('无法定位该会话的物理日志目录,已停止永久删除')
|
|
71
|
+
error.status = 409
|
|
72
|
+
throw error
|
|
73
|
+
}
|
|
74
|
+
let writer = null
|
|
75
|
+
try {
|
|
76
|
+
writer = await sp.open(sid, 'write')
|
|
77
|
+
} catch (e) {
|
|
78
|
+
if (isAlreadyOwned(e)) throw conflictError('该会话正在进行中(存在活跃写入),无法彻底删除。')
|
|
79
|
+
throw e
|
|
80
|
+
}
|
|
81
|
+
// 句柄从未 append 过,先释放再删目录(Windows 上打开中的文件无法删除)。
|
|
82
|
+
await closeQuietly(writer)
|
|
83
|
+
writer = null
|
|
84
|
+
try {
|
|
85
|
+
// 整目录移除(含 lease 等会话本地文件)。basename === encodeSegment(id)
|
|
86
|
+
// 与「规范 generation 在位」都已在 locateSessionArtifacts 验证过。
|
|
87
|
+
await rm(artifacts.sessionDir, { recursive: true, force: true })
|
|
88
|
+
} catch (e) {
|
|
89
|
+
const error = new Error('删除会话日志失败:' + String((e && e.message) || e))
|
|
90
|
+
error.status = 500
|
|
91
|
+
throw error
|
|
92
|
+
}
|
|
93
|
+
// 官方视角复核:该 id 必须已从后端消失。
|
|
94
|
+
if (typeof sp.stat === 'function') {
|
|
95
|
+
const after = await sp.stat(sid).catch(() => undefined)
|
|
96
|
+
if (after) {
|
|
97
|
+
const error = new Error('删除后官方 stat 仍能看到该会话,已中止(目录可能被并发重建)')
|
|
98
|
+
error.status = 500
|
|
99
|
+
throw error
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return artifacts
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// frame0 改写回退:把备份日志的 frame0 cwd 改写为目标工作区后搬入目标会话目录。
|
|
106
|
+
// 校验与 legacy relocateLog 完全一致:帧数不变 + frame0 之外字节逐位相等。
|
|
107
|
+
async function relocateRewrittenBackup({ sid, canonical, backupPath, artifacts }) {
|
|
108
|
+
const original = await readFile(backupPath)
|
|
109
|
+
const frames = scanZstdFrames(original).frames
|
|
110
|
+
if (frames.length === 0) throw new Error('移动前校验失败:会话日志没有完整 zstd 帧')
|
|
111
|
+
const rewritten = rewriteFrame0CwdInMemory(original, canonical)
|
|
112
|
+
const rewrittenFrames = scanZstdFrames(rewritten).frames
|
|
113
|
+
if (rewrittenFrames.length !== frames.length) throw new Error('移动后校验失败:会话日志帧数发生变化')
|
|
114
|
+
if (!original.subarray(frames[0].end).equals(rewritten.subarray(rewrittenFrames[0].end))) {
|
|
115
|
+
throw new Error('移动后校验失败:会话事件内容发生变化')
|
|
116
|
+
}
|
|
117
|
+
const targetDir = deriveSessionDir(artifacts.root, canonical, sid)
|
|
118
|
+
await mkdir(targetDir, { recursive: true })
|
|
119
|
+
const staged = join(targetDir, `.move-stage-${process.pid}-${Date.now()}`)
|
|
120
|
+
await writeFile(staged, rewritten, { mode: 0o600 })
|
|
121
|
+
await rename(staged, join(targetDir, artifacts.generationFiles[0]))
|
|
122
|
+
return targetDir
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 跨工作区移动核心。events 为完整事件数组(官方 read 路径读回,seq 保持原值)。
|
|
126
|
+
// seeded(fork 溯源)日志的物理事件 seq 不从 0 起步时,官方 assertContiguous
|
|
127
|
+
// 会拒绝直录——此时把副本日志的 seq 重排为 0 起步(仅副本的存储序,事件内容
|
|
128
|
+
// 不变),并在 create 时如实携带 inheritedEventCount 溯源。
|
|
129
|
+
export async function moveSessionToCwd({ sp, sid, header, canonical, events = [], inheritedEventCount = 0 }) {
|
|
130
|
+
const artifacts = await locateSessionArtifacts(sp, header)
|
|
131
|
+
if (!artifacts) {
|
|
132
|
+
const error = new Error('无法定位该会话的物理日志,已停止移动')
|
|
133
|
+
error.status = 409
|
|
134
|
+
throw error
|
|
135
|
+
}
|
|
136
|
+
await ensureNoActiveWriter(sp, sid)
|
|
137
|
+
const newHeader = Object.assign({}, header, { cwd: canonical })
|
|
138
|
+
const firstSeq = events.length ? Number(events[0].seq) : 0
|
|
139
|
+
const replay = firstSeq !== 0 ? events.map((event, index) => ({ ...event, seq: index })) : events
|
|
140
|
+
const createOptions = header.isSeeded && Number.isSafeInteger(inheritedEventCount) && inheritedEventCount > 0
|
|
141
|
+
? { inheritedEventCount }
|
|
142
|
+
: undefined
|
|
143
|
+
const backupPath = `${artifacts.logPath}.move-backup-${process.pid}-${Date.now()}`
|
|
144
|
+
await rename(artifacts.logPath, backupPath)
|
|
145
|
+
let writer = null
|
|
146
|
+
try {
|
|
147
|
+
try {
|
|
148
|
+
writer = await sp.create(newHeader, createOptions)
|
|
149
|
+
for (let i = 0; i < replay.length; i += MOVE_BATCH) {
|
|
150
|
+
await writer.append(replay.slice(i, i + MOVE_BATCH))
|
|
151
|
+
}
|
|
152
|
+
await writer.flush()
|
|
153
|
+
await writer.close()
|
|
154
|
+
writer = null
|
|
155
|
+
} catch (e) {
|
|
156
|
+
if (isAlreadyExists(e)) {
|
|
157
|
+
// 后端内存里已有同 id 记录(created-but-unmaterialized 幽灵等):
|
|
158
|
+
// 回退到 frame0 改写搬运,不再走 create。
|
|
159
|
+
await relocateRewrittenBackup({ sid, canonical, backupPath, artifacts })
|
|
160
|
+
} else {
|
|
161
|
+
throw e
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// 官方视角校验:新 cwd 必须生效;事件数一致(snapshot.eventCount 缺省时跳过)。
|
|
165
|
+
if (typeof sp.stat !== 'function') throw new Error('移动后无法校验:后端未提供 stat')
|
|
166
|
+
const after = await sp.stat(sid)
|
|
167
|
+
if (!after || !after.header || after.header.cwd !== canonical) {
|
|
168
|
+
throw new Error('移动后校验失败:会话工作目录未正确更新')
|
|
169
|
+
}
|
|
170
|
+
if (Number.isSafeInteger(after.eventCount) && events.length > 0 && after.eventCount !== events.length) {
|
|
171
|
+
throw new Error(`移动后校验失败:事件数不一致(源 ${events.length},副本 ${after.eventCount})`)
|
|
172
|
+
}
|
|
173
|
+
} catch (e) {
|
|
174
|
+
// 回滚:清掉目标目录里的半成品,把备份改名回原位。
|
|
175
|
+
await closeQuietly(writer)
|
|
176
|
+
try { await rm(deriveSessionDir(artifacts.root, canonical, sid), { recursive: true, force: true }) } catch (_) {}
|
|
177
|
+
try { await rename(backupPath, artifacts.logPath) } catch (_) {}
|
|
178
|
+
if (e && e.status) throw e
|
|
179
|
+
const error = new Error('移动会话日志失败:' + String((e && e.message) || e))
|
|
180
|
+
error.status = 500
|
|
181
|
+
throw error
|
|
182
|
+
}
|
|
183
|
+
try { await unlink(backupPath) } catch (e) { /* 备份清理失败不阻塞成功结果 */ }
|
|
184
|
+
return { sessionDir: deriveSessionDir(artifacts.root, canonical, sid) }
|
|
185
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Private-path derivation for the SessionHandle era (dsh-v0.1.3-alpha.1).
|
|
2
|
+
//
|
|
3
|
+
// 设计裁决(2026-09-06,用户明确决策):官方公共契约不含 delete/move,而这两类
|
|
4
|
+
// 能力在 legacy 时代本来就是「官方 locate 查路径 + 直接文件系统操作」的半官方
|
|
5
|
+
// 实现。用户据此放弃 v3.5.2 早前「只走公共契约」的自我限制,要求沿用同一思路
|
|
6
|
+
// 在 handle 时代恢复「彻底删除」与「跨工作区移动」。本模块把官方
|
|
7
|
+
// session-persistence-jsonl 后端的确定性目录布局移植为可校验的路径推导,并配
|
|
8
|
+
// 三层守卫,任何一层不满足都视为「推导失败」返回 null(调用方安全降级为禁用):
|
|
9
|
+
//
|
|
10
|
+
// 1. root 必须直接读自后端实例字段(`sp.root`,构建产物里是普通实例属性),
|
|
11
|
+
// 绝不猜测、绝不扫描磁盘反推。
|
|
12
|
+
// 2. 会话目录 basename 必须等于 encodeSegment(id),且目录内必须存在至少一个
|
|
13
|
+
// 规范 generation 文件(session.vN.jsonl[.zstd];临时/非规范名不算)。
|
|
14
|
+
// 3. 最终日志路径还要过 pathOwnsSession 的 id 归属校验(含子串碰撞拒绝)——
|
|
15
|
+
// 因此含异体字符的 id(编码后目录名 ≠ id)会安全降级为不可用。
|
|
16
|
+
//
|
|
17
|
+
// 布局规则移植自官方构建产物(session-persistence-jsonl/lib/index.js):
|
|
18
|
+
// projectDir(root, cwd) = root / projectKey(cwd) (cwd 缺省 → _no-cwd)
|
|
19
|
+
// sessionDir(root, cwd, id) = projectDir / encodeSegment(id)
|
|
20
|
+
// generationLogFilename = `session.vN.jsonl` + `.zstd`(compression=zstd)
|
|
21
|
+
// projectKey: 分隔符与 `:` → `-`;[A-Za-z0-9._-] 保留;其余 → `~XXXX`
|
|
22
|
+
// (charCode 的四位大写十六进制);去前导 `-`;截断 251;空串回退 `root`。
|
|
23
|
+
// encodeSegment: 同样的 `~XXXX` 转义(`.`/`..` 例外)。
|
|
24
|
+
|
|
25
|
+
import { readdir, stat } from 'node:fs/promises'
|
|
26
|
+
import { basename, join } from 'node:path'
|
|
27
|
+
import { pathOwnsSession } from './path-guard.js'
|
|
28
|
+
|
|
29
|
+
const GENERATION_LOG_RE = /^session\.v\d+\.jsonl(\.zst(d)?)?$/
|
|
30
|
+
|
|
31
|
+
function isSafeChar(ch) {
|
|
32
|
+
return ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function projectKeyFor(cwd) {
|
|
36
|
+
const s = String(cwd)
|
|
37
|
+
if (s.length === 0) throw new Error('cannot encode an empty project path')
|
|
38
|
+
let readable = ''
|
|
39
|
+
let separatorRun = false
|
|
40
|
+
for (let i = 0; i < s.length; i++) {
|
|
41
|
+
const ch = s[i]
|
|
42
|
+
if (ch === '/' || ch === '\\' || ch === ':') {
|
|
43
|
+
if (!separatorRun) readable += '-'
|
|
44
|
+
separatorRun = true
|
|
45
|
+
} else if (isSafeChar(ch)) {
|
|
46
|
+
readable += ch
|
|
47
|
+
separatorRun = false
|
|
48
|
+
} else {
|
|
49
|
+
readable += '~' + s.charCodeAt(i).toString(16).toUpperCase().padStart(4, '0')
|
|
50
|
+
separatorRun = false
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return '--' + ((readable.replace(/^-+/, '') || 'root').slice(0, 251)) + '--'
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function encodeSegmentFor(raw) {
|
|
57
|
+
const s = String(raw)
|
|
58
|
+
if (s.length === 0) throw new Error('cannot encode an empty path segment')
|
|
59
|
+
if (s === '.') return '~002E'
|
|
60
|
+
if (s === '..') return '~002E~002E'
|
|
61
|
+
let out = ''
|
|
62
|
+
for (let i = 0; i < s.length; i++) {
|
|
63
|
+
const ch = s[i]
|
|
64
|
+
out += isSafeChar(ch) ? ch : '~' + s.charCodeAt(i).toString(16).toUpperCase().padStart(4, '0')
|
|
65
|
+
}
|
|
66
|
+
return out
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 后端实例的 root 只在「实例字段确实携带非空字符串」时可信;拿不到就整体降级。
|
|
70
|
+
export function resolveSessionRoot(sp) {
|
|
71
|
+
const root = sp && typeof sp === 'object' ? sp.root : undefined
|
|
72
|
+
return typeof root === 'string' && root.length > 0 ? root : null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function deriveSessionDir(root, cwd, id) {
|
|
76
|
+
const project = cwd === undefined || cwd === null || cwd === ''
|
|
77
|
+
? join(root, '_no-cwd')
|
|
78
|
+
: join(root, projectKeyFor(cwd))
|
|
79
|
+
return join(project, encodeSegmentFor(id))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 纯推导(不做磁盘校验)。供测试与上层组合使用。
|
|
83
|
+
export function deriveGenerationLogPath(root, cwd, id, { compression = 'zstd' } = {}) {
|
|
84
|
+
return join(deriveSessionDir(root, cwd, id), `session.v2.jsonl${compression === 'zstd' ? '.zstd' : ''}`)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 定位一个已落盘会话的全部物理坐标;三层守卫在此汇合。返回
|
|
88
|
+
// { root, projectDir, sessionDir, logPath, generationFiles }
|
|
89
|
+
// 或 null(root 不可用 / 目录不存在 / 无规范 generation / id 归属校验拒绝)。
|
|
90
|
+
export async function locateSessionArtifacts(sp, header) {
|
|
91
|
+
const root = resolveSessionRoot(sp)
|
|
92
|
+
if (!root || !header || header.id == null) return null
|
|
93
|
+
const sid = String(header.id)
|
|
94
|
+
let sessionDir
|
|
95
|
+
try {
|
|
96
|
+
sessionDir = deriveSessionDir(root, header.cwd, sid)
|
|
97
|
+
} catch (e) {
|
|
98
|
+
return null
|
|
99
|
+
}
|
|
100
|
+
if (basename(sessionDir) !== encodeSegmentFor(sid)) return null
|
|
101
|
+
let entries
|
|
102
|
+
try {
|
|
103
|
+
const st = await stat(sessionDir)
|
|
104
|
+
if (!st.isDirectory()) return null
|
|
105
|
+
entries = await readdir(sessionDir)
|
|
106
|
+
} catch (e) {
|
|
107
|
+
return null
|
|
108
|
+
}
|
|
109
|
+
const generationFiles = entries.filter((name) => GENERATION_LOG_RE.test(name))
|
|
110
|
+
if (generationFiles.length === 0) return null
|
|
111
|
+
// 优先 current generation(v2 → 最高版本号),保持确定性。
|
|
112
|
+
generationFiles.sort((a, b) => {
|
|
113
|
+
const va = Number((a.match(/^session\.v(\d+)\./) || [])[1] || 0)
|
|
114
|
+
const vb = Number((b.match(/^session\.v(\d+)\./) || [])[1] || 0)
|
|
115
|
+
return vb - va
|
|
116
|
+
})
|
|
117
|
+
const logPath = join(sessionDir, generationFiles[0])
|
|
118
|
+
if (!pathOwnsSession(logPath, sid)) return null
|
|
119
|
+
return {
|
|
120
|
+
root,
|
|
121
|
+
projectDir: join(root, header.cwd === undefined || header.cwd === null || header.cwd === '' ? '_no-cwd' : projectKeyFor(header.cwd)),
|
|
122
|
+
sessionDir,
|
|
123
|
+
logPath,
|
|
124
|
+
generationFiles,
|
|
125
|
+
}
|
|
126
|
+
}
|