dsh-session-manager 0.4.11 → 0.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/CHANGELOG.md +60 -2
- package/README.md +73 -44
- package/README.zh.md +137 -112
- package/lib/annotation-store.js +199 -0
- package/lib/client.js +2772 -1196
- package/lib/clipboard-parser.js +208 -0
- package/lib/compat/dsh-adapter.js +17 -0
- package/lib/compat/zstd-frames.js +2 -2
- package/lib/index.js +1243 -1247
- package/lib/session-files.js +193 -0
- package/package.json +10 -2
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/** User annotations live outside session artifacts: no history rewriting, no
|
|
2
|
+
* workspace changes, and no model calls. Atomic publication + an advisory
|
|
3
|
+
* inter-process lock protect independent browser/profile updates. */
|
|
4
|
+
import * as fs from "node:fs/promises";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
|
+
import { randomBytes } from "node:crypto";
|
|
7
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
8
|
+
import { assertSessionId, writeTempFile } from "./session-files.js";
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_ANNOTATION = Object.freeze({ favorite: false, reviewLater: false, tags: Object.freeze([]), note: "", priority: null, revision: 0 });
|
|
11
|
+
export const ANNOTATION_LIMITS = Object.freeze({ tags: 20, tagLength: 32, noteLength: 2000 });
|
|
12
|
+
const FIELDS = new Set(["favorite", "reviewLater", "tags", "note", "priority"]);
|
|
13
|
+
const fail = (message, code = "bad-request") => Object.assign(new Error(message), { code });
|
|
14
|
+
const isRecord = value => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
15
|
+
|
|
16
|
+
export function normalizeAnnotationPatch(patch) {
|
|
17
|
+
if (!isRecord(patch) || Object.keys(patch).length === 0) throw fail("标记更新不能为空");
|
|
18
|
+
const out = {};
|
|
19
|
+
for (const key of Object.keys(patch)) {
|
|
20
|
+
if (!FIELDS.has(key)) throw fail("未知的标记字段: " + key);
|
|
21
|
+
const value = patch[key];
|
|
22
|
+
if (key === "favorite" || key === "reviewLater") {
|
|
23
|
+
if (typeof value !== "boolean") throw fail("收藏和待回看必须是布尔值");
|
|
24
|
+
out[key] = value;
|
|
25
|
+
} else if (key === "priority") {
|
|
26
|
+
if (value !== null && (!Number.isInteger(value) || value < 1 || value > 5)) throw fail("优先级必须为 1–5 的整数,或未设置(null)");
|
|
27
|
+
out.priority = value;
|
|
28
|
+
} else if (key === "note") {
|
|
29
|
+
if (typeof value !== "string" || value.length > ANNOTATION_LIMITS.noteLength || value.includes("\0")) throw fail("备注最多 2000 字符,不能包含空字符");
|
|
30
|
+
out.note = value;
|
|
31
|
+
} else {
|
|
32
|
+
if (!Array.isArray(value)) throw fail("标签必须是数组");
|
|
33
|
+
const tags = []; const seen = new Set();
|
|
34
|
+
for (const item of value) {
|
|
35
|
+
if (typeof item !== "string") throw fail("标签必须是文本");
|
|
36
|
+
const tag = item.trim();
|
|
37
|
+
if (!tag) continue;
|
|
38
|
+
if (tag.length > ANNOTATION_LIMITS.tagLength || /[,,\x00-\x1f]/.test(tag)) throw fail("每个标签最多 32 字符,不能包含逗号或控制字符");
|
|
39
|
+
const key = tag.toLowerCase();
|
|
40
|
+
if (!seen.has(key)) { tags.push(tag); seen.add(key); }
|
|
41
|
+
}
|
|
42
|
+
if (tags.length > ANNOTATION_LIMITS.tags) throw fail("每个会话最多 20 个标签");
|
|
43
|
+
out.tags = tags;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function createAnnotationStore(directory, { io = fs, lockWaitMs = 1500 } = {}) {
|
|
50
|
+
if (typeof directory !== "string" || !directory) throw fail("无法定位会话标记目录", "annotations-unavailable");
|
|
51
|
+
const root = resolve(directory);
|
|
52
|
+
const path = join(root, "annotations.v1.json");
|
|
53
|
+
const lockPath = join(root, "annotations.v1.lock");
|
|
54
|
+
let queue = Promise.resolve();
|
|
55
|
+
const enqueue = operation => {
|
|
56
|
+
const result = queue.then(operation);
|
|
57
|
+
queue = result.catch(() => {});
|
|
58
|
+
return result;
|
|
59
|
+
};
|
|
60
|
+
const read = async () => {
|
|
61
|
+
let content;
|
|
62
|
+
try {
|
|
63
|
+
const parent = await io.lstat(root);
|
|
64
|
+
if (!parent.isDirectory() || parent.isSymbolicLink()) throw fail("会话标记目录不能是符号链接", "annotations-unavailable");
|
|
65
|
+
const info = await io.lstat(path);
|
|
66
|
+
if (!info.isFile() || info.isSymbolicLink()) throw fail("会话标记文件必须是普通文件", "annotations-unavailable");
|
|
67
|
+
content = await io.readFile(path, "utf8");
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (error.code === "ENOENT") return { version: 1, sessions: {} };
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const state = JSON.parse(content);
|
|
74
|
+
if (state?.version !== 1 || !isRecord(state.sessions)) throw new Error("schema");
|
|
75
|
+
for (const [id, item] of Object.entries(state.sessions)) {
|
|
76
|
+
assertSessionId(id);
|
|
77
|
+
if (!isRecord(item) || !Number.isSafeInteger(item.revision) || item.revision < 1) throw new Error("revision");
|
|
78
|
+
const { revision, updatedAt, ...fields } = item;
|
|
79
|
+
if (Object.keys(fields).length !== FIELDS.size || typeof updatedAt !== "number" || !Number.isFinite(updatedAt)) throw new Error("fields");
|
|
80
|
+
normalizeAnnotationPatch(fields);
|
|
81
|
+
}
|
|
82
|
+
return state;
|
|
83
|
+
} catch {
|
|
84
|
+
// Never include note text or JSON parser excerpts in HTTP errors/logs.
|
|
85
|
+
throw fail("会话标记文件损坏或版本不兼容,已停止写入;请先备份并检查 " + path, "annotations-corrupt");
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
const withLock = async operation => {
|
|
89
|
+
await io.mkdir(root, { recursive: true, mode: 0o700 });
|
|
90
|
+
const parent = await io.lstat(root);
|
|
91
|
+
if (!parent.isDirectory() || parent.isSymbolicLink()) throw fail("会话标记目录不能是符号链接", "annotations-unavailable");
|
|
92
|
+
const token = JSON.stringify({ pid: process.pid, token: randomBytes(12).toString("hex") });
|
|
93
|
+
const deadline = Date.now() + lockWaitMs;
|
|
94
|
+
let handle;
|
|
95
|
+
while (!handle) {
|
|
96
|
+
try { handle = await io.open(lockPath, "wx", 0o600); }
|
|
97
|
+
catch (error) {
|
|
98
|
+
if (error.code !== "EEXIST") throw error;
|
|
99
|
+
if (Date.now() >= deadline) throw fail("会话标记正由其他进程写入,请稍后重试;若进程异常退出,请确认无写入后检查锁文件 " + lockPath, "annotations-busy");
|
|
100
|
+
await delay(Math.min(25, Math.max(1, deadline - Date.now())));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
let stamped = false;
|
|
104
|
+
try {
|
|
105
|
+
await handle.writeFile(token);
|
|
106
|
+
stamped = true;
|
|
107
|
+
return await operation();
|
|
108
|
+
} finally {
|
|
109
|
+
await handle.close();
|
|
110
|
+
// Never unlink a lock that somebody replaced while we were working.
|
|
111
|
+
if (!stamped || await io.readFile(lockPath, "utf8").catch(() => undefined) === token) await io.rm(lockPath, { force: true });
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
const publish = async state => {
|
|
115
|
+
const temp = await writeTempFile(path, JSON.stringify(state, null, 2) + "\n", io);
|
|
116
|
+
try {
|
|
117
|
+
// Replace directly: a failed rename leaves the old file intact; readers
|
|
118
|
+
// never see a backup/publication gap or a half-written JSON document.
|
|
119
|
+
await io.rename(temp, path);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
try { await io.rm(temp, { force: true }); } catch { /* uncommitted temp only */ }
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
return {
|
|
126
|
+
path,
|
|
127
|
+
async list() { return (await read()).sessions; },
|
|
128
|
+
update(id, patch, expectedRevision) {
|
|
129
|
+
assertSessionId(id);
|
|
130
|
+
const normalized = normalizeAnnotationPatch(patch);
|
|
131
|
+
if (expectedRevision !== undefined && (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0)) throw fail("标记版本号无效");
|
|
132
|
+
return enqueue(() => withLock(async () => {
|
|
133
|
+
const state = await read();
|
|
134
|
+
const previous = Object.hasOwn(state.sessions, id) ? state.sessions[id] : DEFAULT_ANNOTATION;
|
|
135
|
+
if (expectedRevision !== undefined && expectedRevision !== previous.revision) {
|
|
136
|
+
throw fail("标记已被其他窗口修改,请载入最新内容后再保存;当前输入尚未丢弃", "annotation-conflict");
|
|
137
|
+
}
|
|
138
|
+
if (Object.keys(normalized).every(key => JSON.stringify(previous[key]) === JSON.stringify(normalized[key]))) return { ...previous };
|
|
139
|
+
const next = { ...previous, ...normalized, revision: previous.revision + 1, updatedAt: Date.now() };
|
|
140
|
+
if (!Number.isSafeInteger(next.revision)) throw fail("标记版本号已超出范围", "annotations-unavailable");
|
|
141
|
+
await publish({ version: 1, sessions: { ...state.sessions, [id]: next } });
|
|
142
|
+
return next;
|
|
143
|
+
}));
|
|
144
|
+
},
|
|
145
|
+
batchUpdate(ids, patch, expectedRevisions) {
|
|
146
|
+
const normalized = normalizeAnnotationPatch(patch);
|
|
147
|
+
const idList = Array.from(new Set((Array.isArray(ids) ? ids : []).filter(id => typeof id === 'string' && id.trim() !== '').map(id => id.trim())));
|
|
148
|
+
if (idList.length === 0) throw fail('sessionIds 必须是非空数组');
|
|
149
|
+
if (idList.length > 200) throw fail('单次批量最多 200 条');
|
|
150
|
+
for (const id of idList) assertSessionId(id);
|
|
151
|
+
return enqueue(() => withLock(async () => {
|
|
152
|
+
const state = await read();
|
|
153
|
+
const nextSessions = { ...state.sessions };
|
|
154
|
+
const items = [];
|
|
155
|
+
let success = 0, failed = 0, skipped = 0;
|
|
156
|
+
for (const id of idList) {
|
|
157
|
+
try {
|
|
158
|
+
const previous = Object.hasOwn(nextSessions, id) ? nextSessions[id] : DEFAULT_ANNOTATION;
|
|
159
|
+
const expected = expectedRevisions instanceof Map ? expectedRevisions.get(id) : undefined;
|
|
160
|
+
if (expected !== undefined) {
|
|
161
|
+
if (!Number.isSafeInteger(expected) || expected < 0) throw fail('标记版本号无效');
|
|
162
|
+
if (expected !== previous.revision) throw fail('标记已被其他窗口修改,请载入最新内容后再保存', 'annotation-conflict');
|
|
163
|
+
}
|
|
164
|
+
if (Object.keys(normalized).every(key => JSON.stringify(previous[key]) === JSON.stringify(normalized[key]))) {
|
|
165
|
+
items.push({ sessionId: id, status: 'success', annotation: { ...previous }, unchanged: true });
|
|
166
|
+
success++;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const next = { ...previous, ...normalized, revision: previous.revision + 1, updatedAt: Date.now() };
|
|
170
|
+
if (!Number.isSafeInteger(next.revision)) throw fail('标记版本号已超出范围', 'annotations-unavailable');
|
|
171
|
+
nextSessions[id] = next;
|
|
172
|
+
items.push({ sessionId: id, status: 'success', annotation: next });
|
|
173
|
+
success++;
|
|
174
|
+
} catch (error) {
|
|
175
|
+
const code = error && typeof error.code === 'string' ? error.code : undefined;
|
|
176
|
+
items.push({ sessionId: id, status: 'failed', code, error: error && error.message ? error.message : String(error) });
|
|
177
|
+
failed++;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const anyChanged = items.some(item => item.status === 'success' && !item.unchanged);
|
|
181
|
+
if (anyChanged) await publish({ version: 1, sessions: nextSessions });
|
|
182
|
+
return { summary: { total: idList.length, success, failed, skipped }, items };
|
|
183
|
+
}));
|
|
184
|
+
},
|
|
185
|
+
async remove(id) {
|
|
186
|
+
assertSessionId(id);
|
|
187
|
+
// A plugin that has never used annotations should not create a directory
|
|
188
|
+
// just because an ordinary session was deleted.
|
|
189
|
+
if (!Object.hasOwn((await read()).sessions, id)) return false;
|
|
190
|
+
return enqueue(() => withLock(async () => {
|
|
191
|
+
const state = await read();
|
|
192
|
+
if (!Object.hasOwn(state.sessions, id)) return false;
|
|
193
|
+
const sessions = { ...state.sessions }; delete sessions[id];
|
|
194
|
+
await publish({ version: 1, sessions });
|
|
195
|
+
return true;
|
|
196
|
+
}));
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|