@charara97/dsh-session-sync 0.0.0-stage → 1.0.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 +234 -2
- package/client.js +1017 -0
- package/cordis.patch.yml +5 -0
- package/docs/design-sync-model.md +182 -0
- package/icon.svg +7 -0
- package/index.js +628 -0
- package/lib/engine.js +2613 -0
- package/lib/progress.js +96 -0
- package/lib/session.js +239 -0
- package/lib/util.js +214 -0
- package/locale/en.json +4 -0
- package/locale/zh.json +4 -0
- package/package.json +56 -4
- package/tests/client-render.test.mjs +588 -0
- package/tests/delivery.test.mjs +399 -0
- package/tests/engine.test.mjs +373 -0
package/lib/progress.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 运行日志(过程可见性的核心)。
|
|
3
|
+
*
|
|
4
|
+
* 每次 push/pull 都是一个 Run:引擎把每一步写进带序号的环形缓冲,
|
|
5
|
+
* 浏览器按 `since` 增量拉取,所以过程是「流式可见」的,而不是结束后一句话。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const DEFAULT_LIMIT = 4000;
|
|
9
|
+
|
|
10
|
+
export class ProgressLog {
|
|
11
|
+
constructor(limit = DEFAULT_LIMIT) {
|
|
12
|
+
this.limit = limit;
|
|
13
|
+
this.entries = [];
|
|
14
|
+
this.next = 1;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 追加一条。
|
|
19
|
+
* @param {'info'|'done'|'warn'|'error'|'step'} level
|
|
20
|
+
* @param {string} stage 机器可读阶段名(fetch/merge/push/ws…)
|
|
21
|
+
* @param {string} text 给人看的一行
|
|
22
|
+
* @param {object} [extra] 附加结构化信息(如 { files: 3 })
|
|
23
|
+
*/
|
|
24
|
+
push(level, stage, text, extra = undefined) {
|
|
25
|
+
const entry = { n: this.next++, t: Date.now(), level, stage, text: String(text) };
|
|
26
|
+
if (extra && typeof extra === 'object') entry.extra = extra;
|
|
27
|
+
this.entries.push(entry);
|
|
28
|
+
if (this.entries.length > this.limit) this.entries.splice(0, this.entries.length - this.limit);
|
|
29
|
+
return entry;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
info(stage, text, extra) { return this.push('info', stage, text, extra); }
|
|
33
|
+
step(stage, text, extra) { return this.push('step', stage, text, extra); }
|
|
34
|
+
warn(stage, text, extra) { return this.push('warn', stage, text, extra); }
|
|
35
|
+
error(stage, text, extra) { return this.push('error', stage, text, extra); }
|
|
36
|
+
done(stage, text, extra) { return this.push('done', stage, text, extra); }
|
|
37
|
+
|
|
38
|
+
/** 序号大于 n 的条目(增量拉取) */
|
|
39
|
+
since(n) {
|
|
40
|
+
const from = Number(n) || 0;
|
|
41
|
+
return this.entries.filter((e) => e.n > from);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 最后一条(用于「当前正在做什么」) */
|
|
45
|
+
last() {
|
|
46
|
+
return this.entries.length ? this.entries[this.entries.length - 1] : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
all() {
|
|
50
|
+
return this.entries.slice();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
clear() {
|
|
54
|
+
this.entries = [];
|
|
55
|
+
this.next = 1;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 一次操作(push 或 pull)的运行状态。
|
|
61
|
+
* 同一个 Run 既服务 /api/progress 的轮询,也服务同步等待的 agent 工具。
|
|
62
|
+
*/
|
|
63
|
+
export class RunState {
|
|
64
|
+
constructor({ runId, direction, scope, reason }) {
|
|
65
|
+
this.runId = runId;
|
|
66
|
+
this.direction = direction; // 'push' | 'pull'
|
|
67
|
+
this.scope = scope; // 'session' | 'all'
|
|
68
|
+
this.reason = reason || '';
|
|
69
|
+
this.startedAt = Date.now();
|
|
70
|
+
this.finishedAt = 0;
|
|
71
|
+
this.running = true;
|
|
72
|
+
this.result = null; // 完成后的汇总对象
|
|
73
|
+
this.error = null;
|
|
74
|
+
this.progress = new ProgressLog();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
get durationMs() {
|
|
78
|
+
return (this.finishedAt || Date.now()) - this.startedAt;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
summary() {
|
|
82
|
+
return {
|
|
83
|
+
runId: this.runId,
|
|
84
|
+
direction: this.direction,
|
|
85
|
+
scope: this.scope,
|
|
86
|
+
reason: this.reason,
|
|
87
|
+
running: this.running,
|
|
88
|
+
startedAt: this.startedAt,
|
|
89
|
+
finishedAt: this.finishedAt || null,
|
|
90
|
+
durationMs: this.durationMs,
|
|
91
|
+
error: this.error,
|
|
92
|
+
last: this.progress.last(),
|
|
93
|
+
result: this.result,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
}
|
package/lib/session.js
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话日志的定位、读取与「同 id 会话」的一致性判断。
|
|
3
|
+
*
|
|
4
|
+
* 会话在磁盘上的布局(DSH HOME 下):
|
|
5
|
+
* sessions/<cwd-slug>/<session-id>/session[.vN].jsonl[.zstd]
|
|
6
|
+
* 目录名不一定带 `session-` 前缀,所以 **表头行里的 id 才是权威**。
|
|
7
|
+
* 表头行是 v4 日志的第一行:{"type":"session","version":4,"id":"session-…","cwd":…}
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { randomUUID } from 'node:crypto';
|
|
12
|
+
import { readFirstFrame, zstdDecode, zstdEncode, ZSTD_MAGIC, normRel } from './util.js';
|
|
13
|
+
|
|
14
|
+
export const SESSION_LOG_RE = /^session(?:\.v(\d+))?\.jsonl(?:\.zstd)?$/i;
|
|
15
|
+
|
|
16
|
+
/** 会话日志文件的优先级:版本号大的优先,未压缩优先(与 DSH 自身的扫描顺序一致) */
|
|
17
|
+
export function sessionLogRank(name) {
|
|
18
|
+
const m = SESSION_LOG_RE.exec(name);
|
|
19
|
+
if (!m) return -1;
|
|
20
|
+
const ver = m[1] ? Number(m[1]) : 0;
|
|
21
|
+
const zstd = /\.zstd$/i.test(name) ? 0 : 1;
|
|
22
|
+
return ver * 10 + zstd;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 由会话 cwd 推导它所在的 sessions 子目录名。
|
|
27
|
+
* 观察到的规则:去掉前导 `/`,`/` → `-`,其余非 [A-Za-z0-9-] 字符写成 `~` + 大写 UTF-16 码元十六进制,
|
|
28
|
+
* 整体用 `--` 包裹。例如 /home/me/实验报告 → --home-me-~5B9E~9A8C~62A5~544A--
|
|
29
|
+
*/
|
|
30
|
+
export function sessionSlugFromCwd(cwd) {
|
|
31
|
+
const s = String(cwd ?? '').replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, '');
|
|
32
|
+
let out = '';
|
|
33
|
+
for (const ch of s) {
|
|
34
|
+
if (/[A-Za-z0-9-]/.test(ch)) out += ch;
|
|
35
|
+
else if (ch === '/') out += '-';
|
|
36
|
+
else {
|
|
37
|
+
const code = ch.codePointAt(0);
|
|
38
|
+
out += '~' + code.toString(16).toUpperCase().padStart(4, '0');
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return '--' + out + '--';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 会话投影缓存文件(标题 / cwd 的可再生缓存,也是跨机还原归属关系的线索) */
|
|
45
|
+
export function projCachePath(home, id) {
|
|
46
|
+
return path.join(home, 'storages', 'session_projcache', 'sessions', String(id) + '.json');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function readSessionTitle(home, id) {
|
|
50
|
+
try {
|
|
51
|
+
const j = JSON.parse(fs.readFileSync(projCachePath(home, id), 'utf8'));
|
|
52
|
+
const val = j?.record?.rows?.title?.val;
|
|
53
|
+
if (typeof val === 'string' && val.trim()) return val.trim();
|
|
54
|
+
} catch { /* 缓存缺失/损坏都只是没有标题 */ }
|
|
55
|
+
return '';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 解码一份会话日志:返回逐行原始 JSON 文本(保序、保字节语义)与表头对象 */
|
|
59
|
+
export function decodeSessionLog(buf) {
|
|
60
|
+
if (!Buffer.isBuffer(buf) || buf.length === 0) return null;
|
|
61
|
+
const text = buf.subarray(0, 4).equals(ZSTD_MAGIC) ? zstdDecode(buf) : buf;
|
|
62
|
+
if (!text) return null;
|
|
63
|
+
let str;
|
|
64
|
+
try {
|
|
65
|
+
str = Buffer.isBuffer(text) ? text.toString('utf8') : String(text);
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
const rows = str.split('\n').filter((l) => l.trim());
|
|
71
|
+
if (!rows.length) return null;
|
|
72
|
+
const parsed = rows.map((l) => JSON.parse(l));
|
|
73
|
+
if (!parsed[0] || parsed[0].type !== 'session') return null;
|
|
74
|
+
if (parsed.some((r) => !r || typeof r !== 'object' || Array.isArray(r))) return null;
|
|
75
|
+
if (parsed.slice(1).some((r) => r.type === 'session')) return null;
|
|
76
|
+
return { rows, parsed, header: parsed[0] };
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 读取文件的表头行;失败返回 null */
|
|
83
|
+
export function readHeader(abs) {
|
|
84
|
+
const frame = readFirstFrame(abs);
|
|
85
|
+
if (!frame) return null;
|
|
86
|
+
const nl = frame.indexOf(0x0a);
|
|
87
|
+
const line = nl >= 0 ? frame.subarray(0, nl) : frame;
|
|
88
|
+
try {
|
|
89
|
+
const j = JSON.parse(line.toString('utf8'));
|
|
90
|
+
return j && j.type === 'session' ? j : null;
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 扫描本机会话日志。
|
|
98
|
+
* @returns {Array<{id,slug,dirName,relDir,relLog,absLog,size,mtime,cwd,headerOk}>}
|
|
99
|
+
*/
|
|
100
|
+
export function listLocalSessions(home, headerCache = new Map()) {
|
|
101
|
+
const root = path.join(home, 'sessions');
|
|
102
|
+
const out = [];
|
|
103
|
+
let slugs;
|
|
104
|
+
try {
|
|
105
|
+
slugs = fs.readdirSync(root, { withFileTypes: true }).filter((d) => d.isDirectory());
|
|
106
|
+
} catch {
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
for (const slugEnt of slugs) {
|
|
110
|
+
const slug = slugEnt.name;
|
|
111
|
+
const slugDir = path.join(root, slug);
|
|
112
|
+
let sessionDirs;
|
|
113
|
+
try {
|
|
114
|
+
sessionDirs = fs.readdirSync(slugDir, { withFileTypes: true }).filter((d) => d.isDirectory());
|
|
115
|
+
} catch {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
for (const ent of sessionDirs) {
|
|
119
|
+
const dir = path.join(slugDir, ent.name);
|
|
120
|
+
let files;
|
|
121
|
+
try {
|
|
122
|
+
files = fs.readdirSync(dir).filter((f) => SESSION_LOG_RE.test(f));
|
|
123
|
+
} catch {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (!files.length) continue;
|
|
127
|
+
// 一个会话目录里可能同时存在 session.jsonl.zstd 与 session.v4.jsonl.zstd:取版本最高的
|
|
128
|
+
files.sort((a, b) => sessionLogRank(b) - sessionLogRank(a));
|
|
129
|
+
const name = files[0];
|
|
130
|
+
const absLog = path.join(dir, name);
|
|
131
|
+
let stat;
|
|
132
|
+
try { stat = fs.statSync(absLog); } catch { continue; }
|
|
133
|
+
const key = absLog + '|' + stat.size + '|' + stat.mtimeMs;
|
|
134
|
+
let header = headerCache.get(key);
|
|
135
|
+
if (header === undefined) {
|
|
136
|
+
header = readHeader(absLog);
|
|
137
|
+
headerCache.set(key, header);
|
|
138
|
+
if (headerCache.size > 2000) headerCache.clear();
|
|
139
|
+
}
|
|
140
|
+
const relLog = normRel(path.relative(home, absLog));
|
|
141
|
+
const id = (header && typeof header.id === 'string' && header.id)
|
|
142
|
+
|| (/^session-/.test(ent.name) ? ent.name : '')
|
|
143
|
+
|| ent.name;
|
|
144
|
+
out.push({
|
|
145
|
+
id,
|
|
146
|
+
slug,
|
|
147
|
+
dirName: ent.name,
|
|
148
|
+
relDir: normRel(path.relative(home, dir)),
|
|
149
|
+
relLog,
|
|
150
|
+
absLog,
|
|
151
|
+
size: stat.size,
|
|
152
|
+
mtime: stat.mtimeMs,
|
|
153
|
+
cwd: header && typeof header.cwd === 'string' ? header.cwd : '',
|
|
154
|
+
headerOk: Boolean(header),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* 会话日志合并:只接受「严格追加」这一种本质无冲突的情况。
|
|
163
|
+
* 分叉(两边各自往后写了不同事件)不是冲突判定错误,而是真实的两个版本 ——
|
|
164
|
+
* 调用方必须把它抛给用户裁决,不能自己挑一边。
|
|
165
|
+
*
|
|
166
|
+
* @returns {{ok:true, buf:Buffer, mode:'equal'|'ours'|'theirs'}|{ok:false, reason:string}}
|
|
167
|
+
*/
|
|
168
|
+
export function mergeSessionLog(oursBuf, theirsBuf) {
|
|
169
|
+
if (oursBuf.equals(theirsBuf)) return { ok: true, buf: oursBuf, mode: 'equal' };
|
|
170
|
+
const ours = decodeSessionLog(oursBuf);
|
|
171
|
+
const theirs = decodeSessionLog(theirsBuf);
|
|
172
|
+
if (!ours || !theirs) return { ok: false, reason: 'unreadable' };
|
|
173
|
+
if (JSON.stringify(ours.header) !== JSON.stringify(theirs.header)) {
|
|
174
|
+
// 表头不同(例如 cwd/agentPreset 被改过)不是行级冲突,但两边确实是不同会话根;
|
|
175
|
+
// 交给用户裁决,避免把两份历史混成一份无法解码的日志。
|
|
176
|
+
return { ok: false, reason: 'header-mismatch' };
|
|
177
|
+
}
|
|
178
|
+
const prefix = (a, b) => a.length <= b.length && a.every((row, i) => row === b[i]);
|
|
179
|
+
if (prefix(ours.rows, theirs.rows)) return { ok: true, buf: theirsBuf, mode: 'theirs' };
|
|
180
|
+
if (prefix(theirs.rows, ours.rows)) return { ok: true, buf: oursBuf, mode: 'ours' };
|
|
181
|
+
return { ok: false, reason: 'divergent' };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* 把一份会话日志复制成一个新会话(新 id)。
|
|
186
|
+
* 只改表头行的 id,其余事件原样保留(seq/turn/step 是格式自己的关系,重排会毁掉日志)。
|
|
187
|
+
*
|
|
188
|
+
* @param home DSH HOME
|
|
189
|
+
* @param src Buffer(内存里的日志)或字符串(磁盘上的绝对路径)
|
|
190
|
+
* @param newId 新会话 id
|
|
191
|
+
* @param baseName 新日志的文件名;缺省沿用源文件名(如 session.v4.jsonl.zstd)
|
|
192
|
+
* @returns {string|null} 新日志的绝对路径
|
|
193
|
+
*/
|
|
194
|
+
export function duplicateSessionLog(home, src, newId, baseName = null) {
|
|
195
|
+
let name = baseName;
|
|
196
|
+
let buf;
|
|
197
|
+
if (Buffer.isBuffer(src)) {
|
|
198
|
+
buf = src;
|
|
199
|
+
} else {
|
|
200
|
+
try { buf = fs.readFileSync(src); } catch { return null; }
|
|
201
|
+
if (!name) name = path.basename(String(src));
|
|
202
|
+
}
|
|
203
|
+
if (!name || !SESSION_LOG_RE.test(name)) name = 'session.v4.jsonl.zstd';
|
|
204
|
+
const decoded = decodeSessionLog(buf);
|
|
205
|
+
if (!decoded) return null;
|
|
206
|
+
const header = { ...decoded.header, id: newId };
|
|
207
|
+
const rows = [JSON.stringify(header), ...decoded.rows.slice(1)];
|
|
208
|
+
const text = rows.join('\n') + '\n';
|
|
209
|
+
const isCompressed = buf.subarray(0, 4).equals(ZSTD_MAGIC);
|
|
210
|
+
const payload = isCompressed ? zstdEncode(text) : Buffer.from(text, 'utf8');
|
|
211
|
+
if (!payload) return null;
|
|
212
|
+
const slug = sessionSlugFromCwd(header.cwd || '');
|
|
213
|
+
const dir = path.join(home, 'sessions', slug, newId);
|
|
214
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
215
|
+
const target = path.join(dir, name);
|
|
216
|
+
fs.writeFileSync(target, payload);
|
|
217
|
+
// 投影缓存是可再生的,但复制一份能让新会话立刻带上标题;里面出现的旧 id 一并替换。
|
|
218
|
+
try {
|
|
219
|
+
const oldId = decoded.header.id;
|
|
220
|
+
const cache = projCachePath(home, oldId);
|
|
221
|
+
if (fs.existsSync(cache)) {
|
|
222
|
+
const text2 = fs.readFileSync(cache, 'utf8').split(oldId).join(newId);
|
|
223
|
+
const dest = projCachePath(home, newId);
|
|
224
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
225
|
+
fs.writeFileSync(dest, text2, 'utf8');
|
|
226
|
+
}
|
|
227
|
+
} catch { /* 缓存复制失败不影响会话本体 */ }
|
|
228
|
+
return target;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** 生成一个新的合法会话 id(与 DSH 的 session-<uuid> 形式一致) */
|
|
232
|
+
export function newSessionId() {
|
|
233
|
+
return 'session-' + randomUUID();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** 消息条数粗估:用事件行数近似(不含表头),用于冲突两侧的对比展示 */
|
|
237
|
+
export function eventCount(rows) {
|
|
238
|
+
return Array.isArray(rows) ? Math.max(0, rows.length - 1) : 0;
|
|
239
|
+
}
|
package/lib/util.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 通用工具:子进程执行、zstd 编解码、路径/时间/体积格式化。
|
|
3
|
+
*
|
|
4
|
+
* 这里只放与 DSH 无关的纯工具,方便脱离宿主单独测试(tests/ 用同一份实现)。
|
|
5
|
+
*/
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
|
|
10
|
+
/* ================= zstd ================= */
|
|
11
|
+
|
|
12
|
+
let zstdCompressSync = null;
|
|
13
|
+
let zstdDecompressSync = null;
|
|
14
|
+
try {
|
|
15
|
+
const zlib = await import('node:zlib');
|
|
16
|
+
zstdCompressSync = typeof zlib.zstdCompressSync === 'function' ? zlib.zstdCompressSync : null;
|
|
17
|
+
zstdDecompressSync = typeof zlib.zstdDecompressSync === 'function' ? zlib.zstdDecompressSync : null;
|
|
18
|
+
} catch { /* 旧 Node 没有 zstd:会话日志按不可合并处理 */ }
|
|
19
|
+
|
|
20
|
+
/** zstd 帧魔数(小端 0xFD2FB528) */
|
|
21
|
+
export const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
|
|
22
|
+
|
|
23
|
+
export function hasZstd() {
|
|
24
|
+
return typeof zstdCompressSync === 'function' && typeof zstdDecompressSync === 'function';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isZstd(buf) {
|
|
28
|
+
return Buffer.isBuffer(buf) && buf.length >= 4 && buf.subarray(0, 4).equals(ZSTD_MAGIC);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 计算从 offset 开始的第一个完整 zstd 帧的结束位置。
|
|
33
|
+
* 帧结构见 zstandard 格式规范;Node 对截断帧可能返回部分输出,所以先校验分帧。
|
|
34
|
+
*/
|
|
35
|
+
export function zstdFrameEnd(buf, offset = 0) {
|
|
36
|
+
if (offset + 5 > buf.length || !buf.subarray(offset, offset + 4).equals(ZSTD_MAGIC)) throw new Error('bad-frame');
|
|
37
|
+
const descriptor = buf[offset + 4];
|
|
38
|
+
if (descriptor & 0x08) throw new Error('reserved-frame-bit');
|
|
39
|
+
const single = Boolean(descriptor & 0x20);
|
|
40
|
+
const contentSize = [single ? 1 : 0, 2, 4, 8][descriptor >>> 6];
|
|
41
|
+
let cursor = offset + 5 + (single ? 0 : 1) + [0, 1, 2, 4][descriptor & 3] + contentSize;
|
|
42
|
+
let last = false;
|
|
43
|
+
while (!last) {
|
|
44
|
+
if (cursor + 3 > buf.length) throw new Error('truncated-block-header');
|
|
45
|
+
const block = buf.readUIntLE(cursor, 3);
|
|
46
|
+
const type = (block >>> 1) & 3;
|
|
47
|
+
if (type === 3) throw new Error('reserved-block-type');
|
|
48
|
+
last = Boolean(block & 1);
|
|
49
|
+
cursor += 3 + (type === 1 ? 1 : block >>> 3);
|
|
50
|
+
if (cursor > buf.length) throw new Error('truncated-block');
|
|
51
|
+
}
|
|
52
|
+
cursor += descriptor & 0x04 ? 4 : 0;
|
|
53
|
+
if (cursor > buf.length) throw new Error('truncated-checksum');
|
|
54
|
+
return cursor;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 解码完整 zstd 流(可能由多个帧组成);任一帧损坏返回 null,绝不静默丢事件。 */
|
|
58
|
+
export function zstdDecode(buf) {
|
|
59
|
+
if (typeof zstdDecompressSync !== 'function') return null;
|
|
60
|
+
if (!isZstd(buf)) return null;
|
|
61
|
+
try {
|
|
62
|
+
const chunks = [];
|
|
63
|
+
let offset = 0;
|
|
64
|
+
while (offset < buf.length) {
|
|
65
|
+
// 跳过 skippable frame(magic 0x184D2A5?)
|
|
66
|
+
if (offset + 8 <= buf.length && (buf.readUInt32LE(offset) & 0xfffffff0) === 0x184d2a50) {
|
|
67
|
+
const end = offset + 8 + buf.readUInt32LE(offset + 4);
|
|
68
|
+
if (end > buf.length) return null;
|
|
69
|
+
offset = end;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const end = zstdFrameEnd(buf, offset);
|
|
73
|
+
chunks.push(zstdDecompressSync(buf.subarray(offset, end)));
|
|
74
|
+
offset = end;
|
|
75
|
+
}
|
|
76
|
+
return Buffer.concat(chunks.map((c) => (Buffer.isBuffer(c) ? c : Buffer.from(c))));
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 压缩成单个 zstd 帧;zstd 不可用时返回 null。 */
|
|
83
|
+
export function zstdEncode(buf) {
|
|
84
|
+
if (typeof zstdCompressSync !== 'function') return null;
|
|
85
|
+
try {
|
|
86
|
+
const out = zstdCompressSync(Buffer.isBuffer(buf) ? buf : Buffer.from(String(buf), 'utf8'));
|
|
87
|
+
return Buffer.isBuffer(out) ? out : Buffer.from(out);
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 只读文件开头,解出会话日志的第一帧(表头行),避免为了一行表头把整个日志读进内存。
|
|
95
|
+
* 第一次读 64KiB,不够就按 4 倍增长,直到 maxBytes。
|
|
96
|
+
*/
|
|
97
|
+
export function readFirstFrame(file, maxBytes = 4 * 1024 * 1024) {
|
|
98
|
+
if (typeof zstdDecompressSync !== 'function') return null;
|
|
99
|
+
let fd = -1;
|
|
100
|
+
try {
|
|
101
|
+
fd = fs.openSync(file, 'r');
|
|
102
|
+
let want = 64 * 1024;
|
|
103
|
+
for (;;) {
|
|
104
|
+
const size = Math.min(want, maxBytes);
|
|
105
|
+
const raw = Buffer.alloc(size);
|
|
106
|
+
const n = fs.readSync(fd, raw, 0, size, 0);
|
|
107
|
+
const buf = raw.subarray(0, n);
|
|
108
|
+
try {
|
|
109
|
+
const end = zstdFrameEnd(buf, 0);
|
|
110
|
+
return zstdDecompressSync(buf.subarray(0, end));
|
|
111
|
+
} catch {
|
|
112
|
+
if (n < size || size >= maxBytes) return null;
|
|
113
|
+
want = size * 4;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
} finally {
|
|
119
|
+
if (fd >= 0) { try { fs.closeSync(fd); } catch { /* ignore */ } }
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/* ================= 子进程 ================= */
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 执行一条外部命令,永远 resolve(不抛),返回 { code, out, err }。
|
|
127
|
+
* - binary=true → out 为 Buffer
|
|
128
|
+
* - onStderr → 每收到一段 stderr 就回调(用于 git --progress 的实时进度)
|
|
129
|
+
* - missing → 命中 ENOENT 时置 true(用于识别未安装 git/gh)
|
|
130
|
+
* - stdin → 写进子进程的标准输入后关闭(git hash-object --stdin 用)
|
|
131
|
+
*/
|
|
132
|
+
export function run(cmd, args, {
|
|
133
|
+
cwd = undefined,
|
|
134
|
+
env = undefined,
|
|
135
|
+
timeoutMs = 120000,
|
|
136
|
+
binary = false,
|
|
137
|
+
onStderr = null,
|
|
138
|
+
stdin = null,
|
|
139
|
+
} = {}) {
|
|
140
|
+
return new Promise((resolve) => {
|
|
141
|
+
const chunks = [];
|
|
142
|
+
let err = '';
|
|
143
|
+
let settled = false;
|
|
144
|
+
let child;
|
|
145
|
+
try {
|
|
146
|
+
child = spawn(cmd, args, { cwd, env, windowsHide: true });
|
|
147
|
+
} catch (e) {
|
|
148
|
+
resolve({ code: -1, out: binary ? Buffer.alloc(0) : '', err: String(e?.message || e), missing: true });
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const timer = setTimeout(() => {
|
|
152
|
+
if (!settled) { try { child.kill('SIGKILL'); } catch { /* ignore */ } }
|
|
153
|
+
}, Math.max(1000, timeoutMs));
|
|
154
|
+
// 永远把 stdin 关掉:既喂给 hash-object,也避免子进程等一个永远不来的 EOF
|
|
155
|
+
try {
|
|
156
|
+
if (stdin !== null && stdin !== undefined) child.stdin.end(stdin);
|
|
157
|
+
else child.stdin.end();
|
|
158
|
+
} catch { /* 子进程可能已经退出 */ }
|
|
159
|
+
child.stdout?.on('data', (d) => { chunks.push(d); });
|
|
160
|
+
child.stderr?.on('data', (d) => { err += d.toString(); if (onStderr) { try { onStderr(d.toString()); } catch { /* ignore */ } } });
|
|
161
|
+
child.on('error', (e) => {
|
|
162
|
+
if (settled) return;
|
|
163
|
+
settled = true;
|
|
164
|
+
clearTimeout(timer);
|
|
165
|
+
const msg = String(e?.message || e);
|
|
166
|
+
resolve({ code: -1, out: binary ? Buffer.alloc(0) : chunks.map(String).join(''), err: msg, missing: e?.code === 'ENOENT' });
|
|
167
|
+
});
|
|
168
|
+
child.on('close', (code) => {
|
|
169
|
+
if (settled) return;
|
|
170
|
+
settled = true;
|
|
171
|
+
clearTimeout(timer);
|
|
172
|
+
resolve({ code, out: binary ? Buffer.concat(chunks) : chunks.map(String).join(''), err: err + (code === null ? '\n(killed: timeout)' : '') });
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/* ================= 格式化 ================= */
|
|
178
|
+
|
|
179
|
+
export function humanSize(bytes) {
|
|
180
|
+
const n = Number(bytes);
|
|
181
|
+
if (!Number.isFinite(n) || n <= 0) return '0 B';
|
|
182
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
183
|
+
let i = 0;
|
|
184
|
+
let v = n;
|
|
185
|
+
while (v >= 1024 && i < units.length - 1) { v /= 1024; i += 1; }
|
|
186
|
+
return (i === 0 ? String(Math.round(v)) : v.toFixed(v < 10 ? 1 : 0)) + ' ' + units[i];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** 文件/分支名安全的时间戳,如 2026-10-15T21-40-02 */
|
|
190
|
+
export function ts(date = new Date()) {
|
|
191
|
+
return date.toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** 归一化相对路径:统一分隔符、去掉前导 ./ 与 / */
|
|
195
|
+
export function normRel(p) {
|
|
196
|
+
return String(p ?? '').replace(/\\/g, '/').replace(/^\.?\//, '').replace(/\/{2,}/g, '/');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** 把绝对路径转成相对 home 的 posix 相对路径;不在 home 下返回 null */
|
|
200
|
+
export function relTo(home, abs) {
|
|
201
|
+
const rel = path.relative(home, abs);
|
|
202
|
+
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
|
|
203
|
+
return normRel(rel);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function firstLine(text) {
|
|
207
|
+
return String(text ?? '').split('\n').find((l) => l.trim()) || '';
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** 截断长文本,用于日志/摘要 */
|
|
211
|
+
export function clip(text, max = 200) {
|
|
212
|
+
const s = String(text ?? '').replace(/\s+/g, ' ').trim();
|
|
213
|
+
return s.length > max ? s.slice(0, max - 1) + '…' : s;
|
|
214
|
+
}
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,58 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@charara97/dsh-session-sync",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "DSH 会话与工作区文件同步:显式推送/取回、单个或全部会话、逐条判断、部分交付、冲突交由人工裁决。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "node tests/engine.test.mjs && node tests/delivery.test.mjs && node tests/client-render.test.mjs"
|
|
9
|
+
},
|
|
10
|
+
"icon": "./icon.svg",
|
|
11
|
+
"meta": {
|
|
12
|
+
"title": "会话同步",
|
|
13
|
+
"description": "把 DSH 会话与工作区文件托管在你自己的 GitHub 私有仓库:手动选择 push 或 pull、单个会话或全部会话,全程显示过程,冲突不自动吞掉。"
|
|
14
|
+
},
|
|
15
|
+
"exports": {
|
|
16
|
+
".": "./index.js",
|
|
17
|
+
"./client": "./client.js",
|
|
18
|
+
"./package.json": "./package.json",
|
|
19
|
+
"./locale/*.json": "./locale/*.json",
|
|
20
|
+
"./icon.svg": "./icon.svg"
|
|
21
|
+
},
|
|
22
|
+
"dsh": {
|
|
23
|
+
"bundle": {
|
|
24
|
+
"patch": "./cordis.patch.yml"
|
|
25
|
+
},
|
|
26
|
+
"client": {
|
|
27
|
+
"platform": "web",
|
|
28
|
+
"inject": [
|
|
29
|
+
"slots"
|
|
30
|
+
]
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=20"
|
|
35
|
+
},
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/CHARARA97/dsh-session-sync.git"
|
|
40
|
+
},
|
|
41
|
+
"homepage": "https://github.com/CHARARA97/dsh-session-sync#readme",
|
|
42
|
+
"bugs": {
|
|
43
|
+
"url": "https://github.com/CHARARA97/dsh-session-sync/issues"
|
|
44
|
+
},
|
|
45
|
+
"keywords": [
|
|
46
|
+
"dsh",
|
|
47
|
+
"deepseek-harness",
|
|
48
|
+
"cordis",
|
|
49
|
+
"plugin",
|
|
50
|
+
"sync",
|
|
51
|
+
"github",
|
|
52
|
+
"session"
|
|
53
|
+
],
|
|
54
|
+
"author": "CHARARA97",
|
|
55
|
+
"publishConfig": {
|
|
56
|
+
"access": "public"
|
|
57
|
+
}
|
|
58
|
+
}
|