dsh-session-ops 0.0.0-stage → 0.2.0
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/LICENSE +21 -0
- package/README.md +121 -2
- package/README.zh.md +115 -0
- package/cordis.patch.yml +38 -0
- package/docs/DETAIL.md +24 -0
- package/docs/DETAIL.zh.md +23 -0
- package/icon.svg +1 -0
- package/lib/admin.js +904 -0
- package/lib/client.js +1869 -0
- package/lib/engine.js +236 -0
- package/lib/index.js +248 -0
- package/lib/memory.js +210 -0
- package/lib/move.js +830 -0
- package/lib/sessionlog.js +14 -0
- package/lib/store/frames.js +274 -0
- package/lib/store/host.js +104 -0
- package/lib/store/http.js +133 -0
- package/lib/store/index.js +30 -0
- package/lib/store/paths.js +166 -0
- package/lib/store/registry.js +234 -0
- package/lib/store/util.js +19 -0
- package/lib/types/admin.d.ts +187 -0
- package/lib/types/engine.d.ts +136 -0
- package/lib/types/index.d.ts +281 -0
- package/lib/types/memory.d.ts +106 -0
- package/lib/types/move.d.ts +272 -0
- package/lib/types/sessionlog.d.ts +14 -0
- package/lib/types/store/frames.d.ts +128 -0
- package/lib/types/store/host.d.ts +88 -0
- package/lib/types/store/http.d.ts +105 -0
- package/lib/types/store/index.d.ts +30 -0
- package/lib/types/store/paths.d.ts +100 -0
- package/lib/types/store/registry.d.ts +206 -0
- package/lib/types/store/util.d.ts +16 -0
- package/locale/en.json +7 -0
- package/locale/zh.json +7 -0
- package/package.json +89 -4
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话存储的**路径命名**知识:项目目录键(`--C-proj--`)、会话目录、generation 文件名。
|
|
3
|
+
*
|
|
4
|
+
* 这些命名事实原本分散在两个插件里,而且是同一份知识的**两个方向**:
|
|
5
|
+
* - `dsh-session-move` 需要**正向**编码(`projectKey` / `encodeSegment`)——搬迁时
|
|
6
|
+
* 要把会话日志放到目标 cwd 对应的 project 目录;
|
|
7
|
+
* - `dsh-session-admin` 需要**反向**还原(`decodeProjectKey`)——删除面板要给出
|
|
8
|
+
* 「这条会话大概是哪个目录的」,而头部读不出来时只有目录键可猜。
|
|
9
|
+
* 两份实现各自维护同一套规则,改动任何一边都要记得另一边——这就是本模块存在的理由。
|
|
10
|
+
*
|
|
11
|
+
* 规则(与 `dsh-session-persistence-jsonl` 的 `format.js` 逐字一致):
|
|
12
|
+
* - 路径里每个非 `[A-Za-z0-9._-]` 的码位写成 `~XXXX`(大写十六进制、四位);
|
|
13
|
+
* - `/` `\` `:` 三种分隔符**折叠成 `-`**(盘符的冒号也因此变成 `-`);
|
|
14
|
+
* - 目录键形如 `--<readable>--`,`readable` 截断到 251 字符;
|
|
15
|
+
* - 会话目录 = `<root>/<projectKey(cwd)>/<encodeSegment(id)>`。
|
|
16
|
+
*
|
|
17
|
+
* @module dsh-session-ops/store/paths
|
|
18
|
+
*/
|
|
19
|
+
import { readdirSync } from 'node:fs';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { messageOf } from './util.js';
|
|
22
|
+
/** generation 规范名:`session.jsonl[.zstd]` 或 `session.vN.jsonl[.zstd]`。 */
|
|
23
|
+
const GENERATION_NAME = /^session(?:\.v(\d+))?\.jsonl(\.zstd)?$/;
|
|
24
|
+
/**
|
|
25
|
+
* 把任意字符串编码成一个安全路径段(与会话 id 共用同一套转义)。
|
|
26
|
+
* @param raw - 原始字符串。
|
|
27
|
+
* @returns 单个文件系统安全路径段。
|
|
28
|
+
*/
|
|
29
|
+
export function encodeSegment(raw) {
|
|
30
|
+
if (raw.length === 0)
|
|
31
|
+
throw new Error('cannot encode an empty path segment');
|
|
32
|
+
if (raw === '.')
|
|
33
|
+
return '~002E';
|
|
34
|
+
if (raw === '..')
|
|
35
|
+
return '~002E~002E';
|
|
36
|
+
let out = '';
|
|
37
|
+
for (let i = 0; i < raw.length; i++) {
|
|
38
|
+
const code = raw.charCodeAt(i);
|
|
39
|
+
const ch = String.fromCharCode(code);
|
|
40
|
+
if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch))
|
|
41
|
+
out += ch;
|
|
42
|
+
else
|
|
43
|
+
out += '~' + code.toString(16).toUpperCase().padStart(4, '0');
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* 项目目录键:分隔符折叠成 `-`,不安全码位 `~XXXX`,截断到 251 字符。
|
|
49
|
+
* @param cwd - 会话的项目目录。
|
|
50
|
+
* @returns 单个文件系统安全的项目目录名。
|
|
51
|
+
*/
|
|
52
|
+
export function projectKey(cwd) {
|
|
53
|
+
if (cwd.length === 0)
|
|
54
|
+
throw new Error('cannot encode an empty project path');
|
|
55
|
+
let readable = '';
|
|
56
|
+
let separatorRun = false;
|
|
57
|
+
for (let i = 0; i < cwd.length; i++) {
|
|
58
|
+
const code = cwd.charCodeAt(i);
|
|
59
|
+
const ch = String.fromCharCode(code);
|
|
60
|
+
if (ch === '/' || ch === '\\' || ch === ':') {
|
|
61
|
+
if (!separatorRun)
|
|
62
|
+
readable += '-';
|
|
63
|
+
separatorRun = true;
|
|
64
|
+
}
|
|
65
|
+
else if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) {
|
|
66
|
+
readable += ch;
|
|
67
|
+
separatorRun = false;
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
readable += '~' + code.toString(16).toUpperCase().padStart(4, '0');
|
|
71
|
+
separatorRun = false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return `--${(readable.replace(/^-+/, '') || 'root').slice(0, 251)}--`;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* 把 `sessions/` 下的项目目录键还原成路径。
|
|
78
|
+
*
|
|
79
|
+
* 只做「够用」的反解:分隔符在磁盘上已经全部退化成 `-`,无法区分 `/` 与 `\`
|
|
80
|
+
* (这里一律还原成 `\`),盘符的冒号也没有单独转义(`C:\a` → `--C-a--`,这里
|
|
81
|
+
* 按 `^[A-Za-z]\\` 补回来)。**真正的 cwd 以会话头部里的记录为准**,这个只作为兜底。
|
|
82
|
+
* @param key - 项目目录名。
|
|
83
|
+
* @returns 反解出的路径。
|
|
84
|
+
*/
|
|
85
|
+
export function decodeProjectKey(key) {
|
|
86
|
+
let s = key;
|
|
87
|
+
if (s.startsWith('--'))
|
|
88
|
+
s = s.slice(2);
|
|
89
|
+
if (s.endsWith('--'))
|
|
90
|
+
s = s.slice(0, -2);
|
|
91
|
+
s = s.replace(/~([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
|
|
92
|
+
s = s.replace(/-/g, '\\');
|
|
93
|
+
// 盘符的冒号在目录键里没有单独转义(`C:\a` → `--C-a--`),补回来。
|
|
94
|
+
return /^[A-Za-z]\\/.test(s) ? `${s[0]}:${s.slice(1)}` : s;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* **宽松**的会话日志文件名判定:`.jsonl.zstd` 或 `.jsonl` 结尾就算。
|
|
98
|
+
*
|
|
99
|
+
* 与 {@link parseGenerationName}(**规范** generation 名)是两件事,别互相替换:
|
|
100
|
+
* 删除路径要能看见并清掉任何形状的日志文件(哪怕是手工改名或半截写入的),
|
|
101
|
+
* 而搬迁路径必须只认规范名,否则会把临时文件当成真日志搬走。
|
|
102
|
+
* @param name - 目录项名。
|
|
103
|
+
* @returns 是否像一份会话日志。
|
|
104
|
+
*/
|
|
105
|
+
export function isSessionLogFile(name) {
|
|
106
|
+
return name.endsWith('.jsonl.zstd') || name.endsWith('.jsonl');
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* 目录里是不是规范 generation 名。
|
|
110
|
+
* @param name - 目录项名。
|
|
111
|
+
* @returns 版本号(v0 为 0)与是否压缩;不是规范名时 `undefined`。
|
|
112
|
+
*/
|
|
113
|
+
export function parseGenerationName(name) {
|
|
114
|
+
const match = GENERATION_NAME.exec(name);
|
|
115
|
+
if (match === null)
|
|
116
|
+
return undefined;
|
|
117
|
+
return { version: match[1] === undefined ? 0 : Number(match[1]), compressed: match[2] === '.zstd' };
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* 选出会话目录里版本最高的规范 generation 文件。
|
|
121
|
+
*
|
|
122
|
+
* 临时名与备份名(`*.session-move-tmp`、`*.session-move-old`)不匹配规范名,
|
|
123
|
+
* 一律进 `others`——搬迁必须保证「任何时刻都不存在两份规范名日志」。
|
|
124
|
+
* @param dir - 会话目录。
|
|
125
|
+
* @returns `{selected, all, others}`;列不了目录或没有规范名时 `{error, others?}`
|
|
126
|
+
* (错误文案被两个插件的用户可见报告引用,改动前先看测试)。
|
|
127
|
+
*/
|
|
128
|
+
export function findGeneration(dir) {
|
|
129
|
+
let entries;
|
|
130
|
+
try {
|
|
131
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
return { error: `cannot list session directory ${dir}: ${messageOf(error)}` };
|
|
135
|
+
}
|
|
136
|
+
const generations = [];
|
|
137
|
+
const others = [];
|
|
138
|
+
for (const entry of entries) {
|
|
139
|
+
const parsed = parseGenerationName(entry.name);
|
|
140
|
+
if (parsed === undefined) {
|
|
141
|
+
others.push(entry.name);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
generations.push({ name: entry.name, version: parsed.version, compressed: parsed.compressed });
|
|
145
|
+
}
|
|
146
|
+
if (generations.length === 0)
|
|
147
|
+
return { error: `no canonical session log in ${dir}`, others };
|
|
148
|
+
generations.sort((left, right) => right.version - left.version);
|
|
149
|
+
return { selected: generations[0], all: generations, others };
|
|
150
|
+
}
|
|
151
|
+
/** 物理编码后缀。 */
|
|
152
|
+
export function compressionSuffix(compression) {
|
|
153
|
+
return compression === 'zstd' ? '.zstd' : '';
|
|
154
|
+
}
|
|
155
|
+
/** 一个 generation 的规范文件名(v0 不带 `vN` 段)。 */
|
|
156
|
+
export function generationLogFilename(version, compression) {
|
|
157
|
+
return `${version === 0 ? 'session' : `session.v${version}`}.jsonl${compressionSuffix(compression)}`;
|
|
158
|
+
}
|
|
159
|
+
/** 会话目录:`<root>/<projectKey>/<encodeSegment(id)>`。 */
|
|
160
|
+
export function sessionDir(root, cwd, id) {
|
|
161
|
+
return join(join(root, cwd === undefined ? '_no-cwd' : projectKey(cwd)), encodeSegment(id));
|
|
162
|
+
}
|
|
163
|
+
/** 一个 generation 的绝对日志路径。 */
|
|
164
|
+
export function logPath(root, cwd, id, version, compression) {
|
|
165
|
+
return join(sessionDir(root, cwd, id), generationLogFilename(version, compression));
|
|
166
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话**归属**知识:工作区注册表(`ctx.workspaceRegistry`)、驻留会话、以及「正在跑一轮」。
|
|
3
|
+
*
|
|
4
|
+
* 这份知识在两个插件里各有一半,写在一处才不会走形:
|
|
5
|
+
* - `dsh-session-admin` 要判断「这条会话属于哪个工作区 / 归档没有」才能决定能不能删,
|
|
6
|
+
* 并在删文件**之前**把它从登记表里摘掉;
|
|
7
|
+
* - `dsh-session-move` 要判断「属于谁 / 目标工作区是哪一个 / 正在跑没有」才能搬,
|
|
8
|
+
* 并在搬完之后 attach 目标、detach 原属、把内存里缓存的 cwd 一起改掉。
|
|
9
|
+
*
|
|
10
|
+
* ## 三个容易踩的坑(都是实测结论,不是推测)
|
|
11
|
+
*
|
|
12
|
+
* 1. **`workspace.sessionIds` 是「按会话头索引过滤过的视图」**,不是完整记账:
|
|
13
|
+
* DSH 每次读取都用 `realpath(header.cwd) === workspace.path` 过滤一遍。完整记账在
|
|
14
|
+
* `workspace.record.sessionIds`(见 {@link accountedSessionIds})。
|
|
15
|
+
* 会话目录一删,`sessionIds` 就再也查不到这条会话——所以「摘登记表」必须早于「删文件」。
|
|
16
|
+
* 2. **「挂着」≠「正在跑」**:会话在创建 agent 时被 enter,只在 agent dispose 时才 detach,
|
|
17
|
+
* 长期运行的 `dsh web` 里只要碰过就基本一直挂着。删/搬的守卫只认「正在跑一轮」。
|
|
18
|
+
* 3. **`workspace.json` 里的工作区对象自身不带 id**,id 只存在于外层键上;从磁盘读表时
|
|
19
|
+
* 必须重新组装视图,否则所有守卫会因为 `owner.workspaceId === undefined` 静默失效。
|
|
20
|
+
*
|
|
21
|
+
* 出错时的策略刻意**不统一**,两边各自保留(见各函数的文档):
|
|
22
|
+
* 拿不到 `agents` 服务时 admin 保守地把全部驻留会话当成「在跑」(宁可拒绝),
|
|
23
|
+
* move 则把状态当成未知(`undefined`,界面照常显示)。
|
|
24
|
+
*
|
|
25
|
+
* 这里声明的都是**最小本地接口**:Cordis/DSH 的宿主对象在编译期拿不到类型,
|
|
26
|
+
* 但本模块只用到这几个字段,把它们写清楚比整片 `any` 更能挡住拼写错误。
|
|
27
|
+
*
|
|
28
|
+
* @module dsh-session-ops/store/registry
|
|
29
|
+
*/
|
|
30
|
+
import { messageOf } from './util.js';
|
|
31
|
+
/** agent 表示「这一轮还在跑」的状态值。 */
|
|
32
|
+
export const SESSION_RUNNING_STATUS = 'running';
|
|
33
|
+
/**
|
|
34
|
+
* Host 进程里仍挂着的会话 id 集合(`ctx.sessions.list()`)。
|
|
35
|
+
*
|
|
36
|
+
* 服务缺失或抛错时退化成空集合——**这个集合只用于界面提示,不要拿它当删除守卫**
|
|
37
|
+
* (理由见模块注释第 2 条)。
|
|
38
|
+
* @param sessions - `ctx.get('sessions')` 拿到的会话服务(可为 undefined)。
|
|
39
|
+
* @returns `Set<string>`。
|
|
40
|
+
*/
|
|
41
|
+
export function residentSessionIds(sessions) {
|
|
42
|
+
const ids = new Set();
|
|
43
|
+
try {
|
|
44
|
+
const list = typeof sessions?.list === 'function' ? sessions.list() : [];
|
|
45
|
+
for (const session of list)
|
|
46
|
+
ids.add(String(session.id));
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
/* 没有会话存储时视为无驻留 */
|
|
50
|
+
}
|
|
51
|
+
return ids;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* 一条会话的 agent 状态(`ctx.get('agents').get(id)?.status`)。
|
|
55
|
+
* @param agents - `ctx.get('agents')` 拿到的 agent 服务(可为 undefined)。
|
|
56
|
+
* @param sessionId - 会话 id。
|
|
57
|
+
* @returns 状态字符串;没有 agent、拿不到服务或抛错时 `undefined`(**未知**)。
|
|
58
|
+
*/
|
|
59
|
+
export function agentSessionStatus(agents, sessionId) {
|
|
60
|
+
try {
|
|
61
|
+
const agent = typeof agents?.get === 'function' ? agents.get(sessionId) : undefined;
|
|
62
|
+
return agent !== undefined && typeof agent.status === 'string' ? agent.status : undefined;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* 真正「正在跑一轮」的会话:驻留集合里 agent 状态为 {@link SESSION_RUNNING_STATUS} 的那部分。
|
|
70
|
+
*
|
|
71
|
+
* 拿不到 agents 服务时**保守退化**为「全部驻留会话都算在跑」——宁可拒绝,不可误删/误搬。
|
|
72
|
+
* 单条查询抛错也算在跑(同一条原则)。
|
|
73
|
+
* @param residents - {@link residentSessionIds} 的结果。
|
|
74
|
+
* @param agents - `ctx.get('agents')`。
|
|
75
|
+
* @returns `Set<string>`(可能就是 `residents` 本身)。
|
|
76
|
+
*/
|
|
77
|
+
export function runningSessionIds(residents, agents) {
|
|
78
|
+
if (agents === undefined || typeof agents.get !== 'function')
|
|
79
|
+
return residents;
|
|
80
|
+
const running = new Set();
|
|
81
|
+
for (const id of residents) {
|
|
82
|
+
try {
|
|
83
|
+
if (agents.get(id)?.status === SESSION_RUNNING_STATUS)
|
|
84
|
+
running.add(id);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
running.add(id);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return running;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* 工作区的**完整记账**成员表(`workspace.record.sessionIds`)。
|
|
94
|
+
* @param workspace - 工作区实体。
|
|
95
|
+
* @returns id 数组;实体没有 record 视图时 `undefined`。
|
|
96
|
+
*/
|
|
97
|
+
export function accountedSessionIds(workspace) {
|
|
98
|
+
const record = workspace?.record;
|
|
99
|
+
return record !== undefined && Array.isArray(record.sessionIds) ? record.sessionIds : undefined;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* 归档集合(`registry.archivedSessionIds`)。
|
|
103
|
+
* @param registry - `ctx.workspaceRegistry`。
|
|
104
|
+
* @returns `Set<string>`(id 归一成字符串)。
|
|
105
|
+
*/
|
|
106
|
+
export function archivedSessionIds(registry) {
|
|
107
|
+
return new Set(Array.isArray(registry.archivedSessionIds) ? registry.archivedSessionIds.map(String) : []);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* 按**完整记账**找出会话所属的工作区。
|
|
111
|
+
* @param workspaces - 工作区实体数组(通常是 `registry.list()`)。
|
|
112
|
+
* @param sessionId - 会话 id。
|
|
113
|
+
* @returns 工作区实体,或 `undefined`。
|
|
114
|
+
*/
|
|
115
|
+
export function workspaceOwningSession(workspaces, sessionId) {
|
|
116
|
+
for (const workspace of workspaces) {
|
|
117
|
+
const ids = accountedSessionIds(workspace);
|
|
118
|
+
if (ids !== undefined && ids.includes(sessionId))
|
|
119
|
+
return workspace;
|
|
120
|
+
}
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* 按 id → path → title 的顺序找一个工作区(后两者不区分大小写)。
|
|
125
|
+
* @param workspaces - 工作区实体数组(通常是 `registry.list()`)。
|
|
126
|
+
* @param key - id、绝对路径或标题;空值一律不匹配。
|
|
127
|
+
* @returns 工作区实体,或 `undefined`。
|
|
128
|
+
*/
|
|
129
|
+
export function findWorkspace(workspaces, key) {
|
|
130
|
+
if (typeof key !== 'string' || key.length === 0)
|
|
131
|
+
return undefined;
|
|
132
|
+
for (const workspace of workspaces)
|
|
133
|
+
if (String(workspace.id) === key)
|
|
134
|
+
return workspace;
|
|
135
|
+
const lowered = key.toLowerCase();
|
|
136
|
+
for (const workspace of workspaces)
|
|
137
|
+
if (String(workspace.path).toLowerCase() === lowered)
|
|
138
|
+
return workspace;
|
|
139
|
+
for (const workspace of workspaces)
|
|
140
|
+
if (String(workspace.title).toLowerCase() === lowered)
|
|
141
|
+
return workspace;
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* 把一个即将被删除的会话从它登记过的每个持久结构里摘掉。
|
|
146
|
+
*
|
|
147
|
+
* 走 `workspaceRegistry`(宿主的权威登记表)而不是直接改 `storages/workspace.json`:
|
|
148
|
+
* 宿主自己也把这份表放在内存里,手改文件要么被下一次写入覆盖,要么让内存与磁盘脱节。
|
|
149
|
+
*
|
|
150
|
+
* 覆盖三处:工作区成员表、归档集合、置顶集合。后两个是幂等的(不在集合里就不写盘),
|
|
151
|
+
* 所以无条件调用它们不会产生多余的写入。
|
|
152
|
+
*
|
|
153
|
+
* **必须在删文件之前调用**:`Workspace.sessionIds` 是按会话头索引过滤过的视图,
|
|
154
|
+
* 目录一删,成员关系就再也查不到了。
|
|
155
|
+
* @param registry - `ctx.workspaceRegistry`。
|
|
156
|
+
* @param sessionId - 即将删除的会话 id。
|
|
157
|
+
* @returns 全部持久写入完成之后 resolve;值是真正被 detach 的工作区 id 列表。
|
|
158
|
+
*/
|
|
159
|
+
export async function releaseSessionEverywhere(registry, sessionId) {
|
|
160
|
+
const detached = [];
|
|
161
|
+
for (const workspace of registry.list()) {
|
|
162
|
+
if (!workspace.sessionIds.includes(sessionId))
|
|
163
|
+
continue;
|
|
164
|
+
await workspace.detachSession(sessionId);
|
|
165
|
+
detached.push(String(workspace.id));
|
|
166
|
+
}
|
|
167
|
+
await registry.unarchiveSession(sessionId);
|
|
168
|
+
await registry.unpinSession(sessionId);
|
|
169
|
+
return detached;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* 持久化后端的**写入句柄表**(`persistence.tracker.writers`)。
|
|
173
|
+
*
|
|
174
|
+
* 有句柄 = 这条会话随时可能落盘。搬迁必须抢在它下一次 append 之前把 header 改掉。
|
|
175
|
+
* @param persistence - `ctx.sessionPersistence`(可为 undefined)。
|
|
176
|
+
* @returns `Map<id, handle>`,或 `undefined`(后端不暴露 tracker 时)。
|
|
177
|
+
*/
|
|
178
|
+
export function sessionWriteHandles(persistence) {
|
|
179
|
+
const tracker = persistence?.tracker;
|
|
180
|
+
return tracker !== undefined && tracker.writers instanceof Map
|
|
181
|
+
? tracker.writers
|
|
182
|
+
: undefined;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* 把「这条会话以后往哪写」在**内存里**也改成新 cwd。
|
|
186
|
+
*
|
|
187
|
+
* 搬迁只搬了磁盘上的目录,内存里还有三处记着旧 cwd,漏掉任何一处都会让下一次 append
|
|
188
|
+
* 写到不存在的位置(ENOENT):
|
|
189
|
+
* 1. 持久化写入句柄的 `header`——它每次 append 都从自己的 header 重新推 logPath
|
|
190
|
+
* (`appendLines → logPath(meta.cwd)`),所以改 header 就够了;改之前先 `flush()`,
|
|
191
|
+
* 免得把还没落盘的缓冲写到旧路径;
|
|
192
|
+
* 2. 驻留会话对象自身的 `header`(工具/界面从它解析 cwd);
|
|
193
|
+
* 3. 注册表里的头部缓存(`registry.headers`,`Map<id, header>`)。
|
|
194
|
+
*
|
|
195
|
+
* 每一步都独立尽力而为:句柄 flush 失败只记录,不阻断其余改写。
|
|
196
|
+
* @param options - `{writers, sessions, registry, sessionId, cwd}`;`writers` 来自
|
|
197
|
+
* {@link sessionWriteHandles},`sessions` 是 `ctx.get('sessions')`。
|
|
198
|
+
* @returns `{writeHandleRedirected, liveHeaderUpdated, registryHeaderCacheUpdated, flushError?, liveSession}`
|
|
199
|
+
* ——`flushError` 只在 flush 抛错时出现;`liveSession` 是驻留会话对象(没有则 undefined)。
|
|
200
|
+
*/
|
|
201
|
+
export async function redirectSessionWriters(options) {
|
|
202
|
+
const { writers, sessions, registry, sessionId, cwd } = options;
|
|
203
|
+
const outcome = {
|
|
204
|
+
writeHandleRedirected: false,
|
|
205
|
+
liveHeaderUpdated: false,
|
|
206
|
+
registryHeaderCacheUpdated: false,
|
|
207
|
+
liveSession: undefined,
|
|
208
|
+
};
|
|
209
|
+
const handle = writers === undefined ? undefined : writers.get(sessionId);
|
|
210
|
+
if (handle !== undefined && handle !== null && typeof handle.header === 'object') {
|
|
211
|
+
try {
|
|
212
|
+
await handle.flush();
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
outcome.flushError = messageOf(error);
|
|
216
|
+
}
|
|
217
|
+
handle.header = { ...handle.header, cwd };
|
|
218
|
+
outcome.writeHandleRedirected = true;
|
|
219
|
+
}
|
|
220
|
+
const live = typeof sessions?.get === 'function' ? sessions.get(sessionId) : undefined;
|
|
221
|
+
outcome.liveSession = live;
|
|
222
|
+
if (live !== undefined) {
|
|
223
|
+
live.header = { ...live.header, cwd };
|
|
224
|
+
outcome.liveHeaderUpdated = true;
|
|
225
|
+
}
|
|
226
|
+
if (registry.headers instanceof Map) {
|
|
227
|
+
const cached = registry.headers.get(sessionId);
|
|
228
|
+
if (cached !== undefined) {
|
|
229
|
+
registry.headers.set(sessionId, { ...cached, cwd });
|
|
230
|
+
outcome.registryHeaderCacheUpdated = true;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return outcome;
|
|
234
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 本包内部的小工具:只放「宿主边界上重复出现、但语义必须逐字一致」的那几行。
|
|
3
|
+
*
|
|
4
|
+
* 目前只有一处:把 `unknown` 类型抛出的错误取成可读文案。JS 里的写法是
|
|
5
|
+
* `String(error?.message ?? error)`——它同时容忍 Error、任意带 `message` 的对象、
|
|
6
|
+
* 以及 `throw 'oops'` 这样的原始值。TS 的 `catch` 变量是 `unknown`,这份语义必须
|
|
7
|
+
* 原样保留(错误文案会被用户可见的报告与日志引用),所以收成一个函数。
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-session-ops/store/util
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* 取一个 unknown 错误的可读文案,语义与 `String(error?.message ?? error)` 逐字一致。
|
|
13
|
+
* @param error - catch 到的错误。
|
|
14
|
+
* @returns 可读文案。
|
|
15
|
+
*/
|
|
16
|
+
export function messageOf(error) {
|
|
17
|
+
const candidate = error;
|
|
18
|
+
return String(candidate?.message ?? error);
|
|
19
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `dsh-session-ops` 的**删除半边**(原 `dsh-session-admin` 插件的 Host 半边)。
|
|
3
|
+
*
|
|
4
|
+
* 这一半的代码搬自原 `dsh-session-admin` 的 `lib/index.js`,只有四处差异:
|
|
5
|
+
* - 插件 entry 的 `name` / `inject` / `SETTINGS_NS` / `Config` / `resolveConfig` / `apply`
|
|
6
|
+
* 上移到 `lib/index.js`——合并后的包只有一个 entry、一份配置 schema、一个 `apply`;
|
|
7
|
+
* - `API_PREFIX` 改名 `ADMIN_API_PREFIX`(搬迁半边占着 `MOVE_API_PREFIX`);
|
|
8
|
+
* - 配置键 `allowHosts` 改名 `adminAllowHosts`(两半的信任栅栏语义不同,见 `lib/index.js`);
|
|
9
|
+
* - 原 `apply` 里"建配置/建日志/订阅 volatile/看 enabled"那段上移到入口,剩下的接线
|
|
10
|
+
* 成为 `mountAdmin(ctx, config, log)`。
|
|
11
|
+
* 其余一字未动:扫描与范围筛选、逐条守卫、`/session-admin/api` 的方法表、
|
|
12
|
+
* `session_admin` 工具定义、Host 头版信任栅栏,连注释都保持原样。
|
|
13
|
+
*
|
|
14
|
+
* 能力:把会话的持久化数据真正从磁盘移除。一个会话在磁盘上最多占三处:
|
|
15
|
+
* - `<home>/sessions/<项目目录键>/<session-id>/` ← 会话日志(jsonl.zstd)
|
|
16
|
+
* - `<home>/storages/session_projcache/sessions/<session-id>.json` ← 投影缓存
|
|
17
|
+
* - `<home>/rewind-snapshots/<session-id>/` ← 回退快照(可有可无)
|
|
18
|
+
* 三处一起删,并顺手清掉因此变空的项目目录。
|
|
19
|
+
*
|
|
20
|
+
* 刻意零 `@deepseek-ai/*` 运行时依赖:只用 Node 内置模块和一份 schemastery 配置
|
|
21
|
+
* schema。工具注册用普通对象(与 `dsh-subagent-in-process-driver` 同款),不做
|
|
22
|
+
* `defineTool` 导入——profile 的 node_modules 里未必解析得到 `@deepseek-ai/dsh-tools`。
|
|
23
|
+
* 代价是 `parameters` / `output.schema` 都必须自己写成**已编译的 JSON Schema**:
|
|
24
|
+
* register 不做编译,只把对象原样透传给 provider(详见 `TOOL_PARAMETERS` 注释)。
|
|
25
|
+
*
|
|
26
|
+
* @module dsh-session-ops/admin
|
|
27
|
+
*/
|
|
28
|
+
import { dshHome } from './store/host.js';
|
|
29
|
+
import type { LogFn } from './store/host.js';
|
|
30
|
+
import type { HttpRequest } from './store/http.js';
|
|
31
|
+
import { decodeProjectKey } from './store/paths.js';
|
|
32
|
+
import type { WorkspaceRegistryLike } from './store/registry.js';
|
|
33
|
+
import type { SessionOpsConfig } from './index.js';
|
|
34
|
+
/** HTTP 路由前缀(两半各一条,合并后仍然分开)。 */
|
|
35
|
+
export declare const ADMIN_API_PREFIX = "/session-admin/api";
|
|
36
|
+
/**
|
|
37
|
+
* `$DSH_HOME`(环境变量优先,默认 `~/.dsh`)。
|
|
38
|
+
*
|
|
39
|
+
* 实现在 `lib/store/host.js`——它与搬迁半边用的是同一份;
|
|
40
|
+
* 这里再导出,保持本仓库的公开面不变。
|
|
41
|
+
*/
|
|
42
|
+
export { dshHome };
|
|
43
|
+
/**
|
|
44
|
+
* 把 `sessions/` 下的项目目录键还原成路径。
|
|
45
|
+
*
|
|
46
|
+
* 实现在 `lib/store/paths.js`:它与搬迁半边的 `projectKey`
|
|
47
|
+
* 是同一套规则的两个方向,规则只该有一份。这里再导出,保持本仓库的公开面不变。
|
|
48
|
+
*/
|
|
49
|
+
export { decodeProjectKey };
|
|
50
|
+
/** 路径是否落在某个根目录内部(防目录穿越)。 */
|
|
51
|
+
export declare function isInside(root: string, candidate: string): boolean;
|
|
52
|
+
/** 扫描出来的一条会话记录。 */
|
|
53
|
+
export interface SessionRecord {
|
|
54
|
+
id: string;
|
|
55
|
+
dir: string;
|
|
56
|
+
project: string;
|
|
57
|
+
cwd: string | null;
|
|
58
|
+
title: string | null;
|
|
59
|
+
origin: string | null;
|
|
60
|
+
parentSessionId: string | null;
|
|
61
|
+
isSeeded: boolean | null;
|
|
62
|
+
headerReadable: boolean;
|
|
63
|
+
workspaceId: string | null;
|
|
64
|
+
workspaceTitle: string | null;
|
|
65
|
+
ungrouped: boolean;
|
|
66
|
+
archived: boolean;
|
|
67
|
+
subagent: boolean;
|
|
68
|
+
cwdMissing: boolean;
|
|
69
|
+
temp: boolean;
|
|
70
|
+
bytes: number;
|
|
71
|
+
files: number;
|
|
72
|
+
updatedAt: number;
|
|
73
|
+
hasProjCache: boolean;
|
|
74
|
+
hasRewind: boolean;
|
|
75
|
+
}
|
|
76
|
+
/** {@link SessionAdmin.scan} 的选项。 */
|
|
77
|
+
export interface ScanOptions {
|
|
78
|
+
/** `false` 跳过逐会话的体积统计(只要个数的调用方用它压开销)。 */
|
|
79
|
+
measure?: boolean;
|
|
80
|
+
}
|
|
81
|
+
/** {@link SessionAdmin.select} 的范围查询。 */
|
|
82
|
+
export interface SelectQuery {
|
|
83
|
+
scope?: string;
|
|
84
|
+
cwdPrefix?: string;
|
|
85
|
+
search?: string;
|
|
86
|
+
}
|
|
87
|
+
/** {@link SessionAdmin.remove} 的选项。 */
|
|
88
|
+
export interface RemoveOptions {
|
|
89
|
+
dryRun?: boolean;
|
|
90
|
+
includeWorkspace?: boolean;
|
|
91
|
+
includeArchived?: boolean;
|
|
92
|
+
includeSubagent?: boolean;
|
|
93
|
+
includeLive?: boolean;
|
|
94
|
+
/** **正在跑**、因此现在不能删的 id 集合。 */
|
|
95
|
+
live?: Set<string>;
|
|
96
|
+
callerSessionId?: string;
|
|
97
|
+
maxBatch?: number;
|
|
98
|
+
}
|
|
99
|
+
/** {@link SessionAdmin.remove} 里逐条的结果。 */
|
|
100
|
+
export interface RemoveResult {
|
|
101
|
+
id: string;
|
|
102
|
+
status: 'skipped' | 'would-remove' | 'failed' | 'removed';
|
|
103
|
+
reason?: string;
|
|
104
|
+
bytes?: number;
|
|
105
|
+
}
|
|
106
|
+
/** {@link SessionAdmin.remove} 的汇总。 */
|
|
107
|
+
export interface RemoveReport {
|
|
108
|
+
/** 早退(目标集合为空)时这个键不存在——与改动前的返回形状一致。 */
|
|
109
|
+
dryRun?: boolean;
|
|
110
|
+
removed: number;
|
|
111
|
+
reclaimed: number;
|
|
112
|
+
pruned: string[];
|
|
113
|
+
results: RemoveResult[];
|
|
114
|
+
}
|
|
115
|
+
/** {@link createSessionAdmin} 的选项。 */
|
|
116
|
+
export interface CreateSessionAdminOptions {
|
|
117
|
+
/** DSH 主目录(默认 `dshHome()`)。 */
|
|
118
|
+
home?: string;
|
|
119
|
+
/** 视为「临时目录」的前缀。 */
|
|
120
|
+
tempRoots?: string[];
|
|
121
|
+
/** 删文件**之前**把一个会话从登记表里摘掉的异步回调。 */
|
|
122
|
+
releaseSession?: (sessionId: string) => Promise<unknown>;
|
|
123
|
+
}
|
|
124
|
+
/** 会话管理员(扫描 / 过滤 / 删除三个入口)。 */
|
|
125
|
+
export interface SessionAdmin {
|
|
126
|
+
home: string;
|
|
127
|
+
sessionsRoot: string;
|
|
128
|
+
projcacheRoot: string;
|
|
129
|
+
rewindRoot: string;
|
|
130
|
+
scan(options?: ScanOptions): SessionRecord[];
|
|
131
|
+
select(records: SessionRecord[], query?: SelectQuery): SessionRecord[];
|
|
132
|
+
remove(ids: string[], options?: RemoveOptions): Promise<RemoveReport>;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* 构造一个会话管理员。所有根目录都可注入,便于在测试里指向沙箱。
|
|
136
|
+
* @param options - `home`(DSH 主目录)、`tempRoots`(视为「临时目录」的前缀)、
|
|
137
|
+
* `releaseSession`(删文件**之前**把一个会话从登记表里摘掉的异步回调;见 `remove`)。
|
|
138
|
+
* @returns 扫描 / 过滤 / 删除三个入口。
|
|
139
|
+
*/
|
|
140
|
+
export declare function createSessionAdmin(options?: CreateSessionAdminOptions): SessionAdmin;
|
|
141
|
+
/**
|
|
142
|
+
* 判断一个请求是否有权访问本插件的路由。
|
|
143
|
+
*
|
|
144
|
+
* 本机 `dsh web` 常被绑到 `0.0.0.0`(局域网访问),所以「能连上端口」不等于
|
|
145
|
+
* 「是这台机器上的人」:非回环 authority 默认一律拒绝,除非在配置的
|
|
146
|
+
* `allowHosts` 里显式列出。同时拒绝 `sec-fetch-site: cross-site` 和跨源 Origin。
|
|
147
|
+
*
|
|
148
|
+
* **这份栅栏刻意留在本仓库**:`dsh-session-move` 那份按 socket 的 `remoteAddress`
|
|
149
|
+
* 判断回环、`allowHosts` 只做精确字符串匹配,既不解析 authority 也不看
|
|
150
|
+
* `sec-fetch-site`/`Origin`——两边语义不同,合并必然改掉其中一边的对外行为。
|
|
151
|
+
* 共享包只提供路由骨架(`createApiHandler`),栅栏以回调形式传进去。
|
|
152
|
+
* @param req - Node HTTP 请求。
|
|
153
|
+
* @param allowHosts - 额外信任的 authority 列表。
|
|
154
|
+
* @returns 是否放行。
|
|
155
|
+
*/
|
|
156
|
+
export declare function isTrustedRequest(req: HttpRequest, allowHosts?: string[]): boolean;
|
|
157
|
+
/**
|
|
158
|
+
* Host cordis 上下文里删除半边用到的那部分(最小本地接口,见 `src/index.ts` 的说明)。
|
|
159
|
+
*/
|
|
160
|
+
export interface AdminContext {
|
|
161
|
+
logger?: Record<string, ((message: string) => unknown) | undefined> | undefined;
|
|
162
|
+
effect: (fn: () => unknown, label?: string) => unknown;
|
|
163
|
+
get: (name: string) => any;
|
|
164
|
+
webServer: {
|
|
165
|
+
register(options: unknown): unknown;
|
|
166
|
+
};
|
|
167
|
+
tools: {
|
|
168
|
+
register(definition: unknown): unknown;
|
|
169
|
+
};
|
|
170
|
+
workspaceRegistry: WorkspaceRegistryLike;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* 挂载删除半边:路由 + 工具。
|
|
174
|
+
*
|
|
175
|
+
* 与搬迁半边刻意不同的三点(原样保留的理由):
|
|
176
|
+
* - 信任栅栏按 `Host` 头判断(`isTrustedRequest`),额外拒绝 `sec-fetch-site: cross-site`
|
|
177
|
+
* 与跨源 `Origin`;搬迁半边看 socket 的 `remoteAddress`;
|
|
178
|
+
* - 响应带 `content-length`;
|
|
179
|
+
* - 请求体按**宽松模式**读(空白 body 走 `JSON.parse` 报错,非对象不拦)。
|
|
180
|
+
*
|
|
181
|
+
* @param ctx - Host cordis 上下文(`webServer` / `tools` / `sessions` / `workspaceRegistry`)。
|
|
182
|
+
* @param config - 已解包的**活配置对象**:身份由入口保持,volatile 更新时原地覆盖,
|
|
183
|
+
* 所以下面这些闭包自动看到最新值。
|
|
184
|
+
* @param log - `(level, message) => void`(入口传的是 admin 那套行格式的文件日志)。
|
|
185
|
+
* @returns 无。
|
|
186
|
+
*/
|
|
187
|
+
export declare function mountAdmin(ctx: AdminContext, config: SessionOpsConfig, log: LogFn): void;
|