dsh-session-manager 0.7.4 → 0.7.6
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 +20 -0
- package/README.md +11 -12
- package/README.zh.md +10 -11
- package/lib/client.js +268 -38
- package/lib/compat/projection-cache-delete.js +110 -0
- package/lib/compat/zstd-frames.js +78 -1
- package/lib/index.js +260 -10
- package/lib/session-files.js +6 -9
- package/package.json +1 -1
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
5
|
+
import { assertSessionId, replaceSessionFile } from "../session-files.js";
|
|
6
|
+
|
|
7
|
+
// rc.1 cache.write() may await sessions.flush() before reaching table.put().
|
|
8
|
+
// A queue barrier alone cannot drain such a delayed writer. Keep a lifecycle
|
|
9
|
+
// tombstone at the table entry point: queued puts precede delete(), later puts
|
|
10
|
+
// of the deleted lifecycle are ignored. A recreated id with a new identity is
|
|
11
|
+
// independent. The guard lives with the table (including across plugin reloads).
|
|
12
|
+
const guards = new WeakMap();
|
|
13
|
+
const lifecycleKey = identity => Number.isSafeInteger(identity?.createdAt)
|
|
14
|
+
? JSON.stringify([identity.createdAt, identity.cwd ?? null, identity.isSeeded ?? false])
|
|
15
|
+
: undefined;
|
|
16
|
+
export function beginProjectionCacheDeletion(cache, id, header) {
|
|
17
|
+
assertSessionId(id);
|
|
18
|
+
const table = typeof cache?.requireTable === "function" ? cache.requireTable() : cache?.table;
|
|
19
|
+
if (!table || !["get", "put", "delete"].every(key => typeof table[key] === "function")) return undefined;
|
|
20
|
+
let guard = guards.get(table);
|
|
21
|
+
if (!guard) {
|
|
22
|
+
const removed = new Map();
|
|
23
|
+
const put = table.put;
|
|
24
|
+
table.put = function(key, record) {
|
|
25
|
+
if (removed.get(key)?.has(lifecycleKey(record?.identity))) return Promise.resolve();
|
|
26
|
+
return put.call(this, key, record);
|
|
27
|
+
};
|
|
28
|
+
guard = { removed };
|
|
29
|
+
guards.set(table, guard);
|
|
30
|
+
}
|
|
31
|
+
const identity = table.get(id)?.identity;
|
|
32
|
+
const stamps = [header, identity].map(lifecycleKey).filter(key => key !== undefined);
|
|
33
|
+
if (!stamps.length && (header || identity)) throw new Error("无法确认投影缓存的会话生命周期");
|
|
34
|
+
const previous = guard.removed.get(id);
|
|
35
|
+
const blocked = new Set([...(previous ?? []), ...stamps]);
|
|
36
|
+
guard.removed.set(id, blocked);
|
|
37
|
+
return {
|
|
38
|
+
remove: () => table.delete(id),
|
|
39
|
+
legacyRetained: table.host?.unit?.descriptor?.layout === "per-record",
|
|
40
|
+
legacyManaged: Boolean(table.host?.unit?.descriptor) && table.host.unit.descriptor.layout !== "per-record",
|
|
41
|
+
rollback() {
|
|
42
|
+
if (guard.removed.get(id) !== blocked) return;
|
|
43
|
+
if (previous) guard.removed.set(id, previous); else guard.removed.delete(id);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function regular(path, directory = false) {
|
|
49
|
+
const info = await fs.lstat(path);
|
|
50
|
+
if (info.isSymbolicLink() || !(directory ? info.isDirectory() : info.isFile())) {
|
|
51
|
+
throw new Error("投影缓存路径不是安全的普通" + (directory ? "目录" : "文件") + ": " + path);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function assertProjectionCachePath(home, id) {
|
|
56
|
+
assertSessionId(id);
|
|
57
|
+
const paths = [home, join(home, "storages"), join(home, "storages", "session_projcache"),
|
|
58
|
+
join(home, "storages", "session_projcache", "sessions"), join(home, "storages", "session_projcache", "sessions", `${id}.json`)];
|
|
59
|
+
for (let i = 0; i < paths.length; i++) {
|
|
60
|
+
try { await regular(paths[i], i < paths.length - 1); }
|
|
61
|
+
catch (error) { if (error.code === "ENOENT") return; throw error; }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** rc.1 bootstraps but never writes its retained legacy unit. Serialize our
|
|
66
|
+
* read/modify/publish across deletes and processes; preserve its stamp/schema
|
|
67
|
+
* and every unrelated table/record. Unknown shapes fail without publication.
|
|
68
|
+
*/
|
|
69
|
+
export async function removeLegacyProjectionCache(home, id) {
|
|
70
|
+
assertSessionId(id);
|
|
71
|
+
const root = join(home, "storages");
|
|
72
|
+
const path = join(root, "session_projcache.json");
|
|
73
|
+
try {
|
|
74
|
+
await regular(home, true);
|
|
75
|
+
await regular(root, true);
|
|
76
|
+
await regular(path);
|
|
77
|
+
} catch (error) { if (error.code === "ENOENT") return; throw error; }
|
|
78
|
+
const lockPath = join(root, "session_projcache.session-manager.lock");
|
|
79
|
+
const token = randomBytes(16).toString("hex");
|
|
80
|
+
const deadline = Date.now() + 3000;
|
|
81
|
+
let lock;
|
|
82
|
+
while (!lock) {
|
|
83
|
+
try { lock = await fs.open(lockPath, "wx", 0o600); }
|
|
84
|
+
catch (error) {
|
|
85
|
+
if (error.code !== "EEXIST") throw error;
|
|
86
|
+
if (Date.now() >= deadline) throw new Error("投影缓存清理锁正被占用: " + lockPath);
|
|
87
|
+
await delay(25);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
let stamped = false;
|
|
91
|
+
try {
|
|
92
|
+
await lock.writeFile(token);
|
|
93
|
+
stamped = true;
|
|
94
|
+
await regular(root, true);
|
|
95
|
+
await regular(path);
|
|
96
|
+
const document = JSON.parse(await fs.readFile(path, "utf8"));
|
|
97
|
+
const records = document?.tables?.sessions;
|
|
98
|
+
if (document?.unit?.name !== "session_projcache" || ![3, 4, 5, 6, 7].includes(document.unit.version)
|
|
99
|
+
|| !records || typeof records !== "object" || Array.isArray(records)) {
|
|
100
|
+
throw new Error("未知的旧版投影缓存格式,未修改文件");
|
|
101
|
+
}
|
|
102
|
+
if (!Object.hasOwn(records, id)) return;
|
|
103
|
+
delete records[id];
|
|
104
|
+
const result = await replaceSessionFile(path, JSON.stringify(document) + "\n");
|
|
105
|
+
if (result.backupPath) throw new Error("旧版投影缓存已更新,但备份清理失败: " + result.backupPath);
|
|
106
|
+
} finally {
|
|
107
|
+
await lock.close();
|
|
108
|
+
if (!stamped || await fs.readFile(lockPath, "utf8").catch(() => undefined) === token) await fs.rm(lockPath, { force: true });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -12,8 +12,10 @@
|
|
|
12
12
|
*
|
|
13
13
|
* @module dsh-session-manager/compat/zstd-frames
|
|
14
14
|
*/
|
|
15
|
-
import { zstdDecompress } from "node:zlib";
|
|
15
|
+
import { zstdDecompress, createZstdDecompress } from "node:zlib";
|
|
16
16
|
import { promisify } from "node:util";
|
|
17
|
+
import { Readable } from "node:stream";
|
|
18
|
+
import { pipeline } from "node:stream/promises";
|
|
17
19
|
|
|
18
20
|
const zstdDecompressAsync = promisify(zstdDecompress);
|
|
19
21
|
const ZSTD_MAGIC = 0xfd2fb528;
|
|
@@ -76,6 +78,81 @@ export async function decompressZstdFrame(frame, options) {
|
|
|
76
78
|
return zstdDecompressAsync(frame, options);
|
|
77
79
|
}
|
|
78
80
|
|
|
81
|
+
export function decompressZstdFirstLine(frame, limit) {
|
|
82
|
+
return new Promise((resolve, reject) => {
|
|
83
|
+
const stream = createZstdDecompress();
|
|
84
|
+
const chunks = [];
|
|
85
|
+
let size = 0;
|
|
86
|
+
let settled = false;
|
|
87
|
+
let lineFound = false;
|
|
88
|
+
const finish = (error, value) => {
|
|
89
|
+
if (settled) return;
|
|
90
|
+
settled = true;
|
|
91
|
+
stream.destroy();
|
|
92
|
+
if (error) reject(error); else resolve(value);
|
|
93
|
+
};
|
|
94
|
+
stream.on("error", error => finish(error));
|
|
95
|
+
stream.on("end", () => lineFound
|
|
96
|
+
? finish(null, Buffer.concat(chunks, size))
|
|
97
|
+
: finish(new Error("会话头行不完整或超过大小限制")));
|
|
98
|
+
stream.on("data", chunk => {
|
|
99
|
+
if (lineFound) return;
|
|
100
|
+
const newline = chunk.indexOf(10);
|
|
101
|
+
const part = newline < 0 ? chunk : chunk.subarray(0, newline + 1);
|
|
102
|
+
size += part.length;
|
|
103
|
+
if (size > limit) return finish(new Error("会话头行不完整或超过大小限制"));
|
|
104
|
+
chunks.push(part);
|
|
105
|
+
if (newline >= 0) lineFound = true;
|
|
106
|
+
});
|
|
107
|
+
if (Buffer.isBuffer(frame)) stream.end(frame);
|
|
108
|
+
else pipeline(Readable.from(frame), stream).catch(error => finish(error));
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Feed exactly the first complete frame, with bounded reads even for huge batches.
|
|
113
|
+
* Keep consuming after the first line to validate the frame/checksum before discovery.
|
|
114
|
+
*/
|
|
115
|
+
export async function* firstZstdFrameChunks(handle) {
|
|
116
|
+
let position = 0;
|
|
117
|
+
const exact = async size => {
|
|
118
|
+
const bytes = Buffer.alloc(size);
|
|
119
|
+
let offset = 0;
|
|
120
|
+
while (offset < size) {
|
|
121
|
+
const { bytesRead } = await handle.read(bytes, offset, size - offset, position);
|
|
122
|
+
if (!bytesRead) throw new Error("会话日志存在截断的 Zstd 帧");
|
|
123
|
+
offset += bytesRead;
|
|
124
|
+
position += bytesRead;
|
|
125
|
+
}
|
|
126
|
+
return bytes;
|
|
127
|
+
};
|
|
128
|
+
const prefix = await exact(5);
|
|
129
|
+
if (prefix.readUInt32LE(0) !== ZSTD_MAGIC) throw new Error("corrupt Zstandard session log: invalid frame magic");
|
|
130
|
+
const descriptor = prefix[4];
|
|
131
|
+
if (descriptor & 0x18) throw new Error("corrupt Zstandard session log: reserved frame-header bit");
|
|
132
|
+
yield prefix;
|
|
133
|
+
const sizeFlag = descriptor >>> 6;
|
|
134
|
+
const singleSegment = Boolean(descriptor & 0x20);
|
|
135
|
+
const dictionaryFlag = descriptor & 3;
|
|
136
|
+
const headerSize = (singleSegment ? 0 : 1) + (dictionaryFlag === 3 ? 4 : dictionaryFlag)
|
|
137
|
+
+ (sizeFlag === 0 ? (singleSegment ? 1 : 0) : 1 << sizeFlag);
|
|
138
|
+
if (headerSize) yield await exact(headerSize);
|
|
139
|
+
for (;;) {
|
|
140
|
+
const header = await exact(3);
|
|
141
|
+
const block = header.readUIntLE(0, 3);
|
|
142
|
+
const type = (block >>> 1) & 3;
|
|
143
|
+
if (type === 3) throw new Error("corrupt Zstandard session log: reserved block type");
|
|
144
|
+
yield header;
|
|
145
|
+
let remaining = type === 1 ? 1 : block >>> 3;
|
|
146
|
+
while (remaining) {
|
|
147
|
+
const count = Math.min(remaining, 64 * 1024);
|
|
148
|
+
yield await exact(count);
|
|
149
|
+
remaining -= count;
|
|
150
|
+
}
|
|
151
|
+
if (block & 1) break;
|
|
152
|
+
}
|
|
153
|
+
if (descriptor & 4) yield await exact(4);
|
|
154
|
+
}
|
|
155
|
+
|
|
79
156
|
export async function decompressAllZstdFrames(buffer) {
|
|
80
157
|
const scan = scanZstdFrames(buffer);
|
|
81
158
|
if (scan.frames.length === 0) {
|
package/lib/index.js
CHANGED
|
@@ -28,7 +28,8 @@ import { basename, dirname, join } from "node:path";
|
|
|
28
28
|
import { randomBytes } from "node:crypto";
|
|
29
29
|
import { createAnnotationStore } from "./annotation-store.js";
|
|
30
30
|
import { createWorkspaceArchiveStore, assertWorkspaceId } from "./workspace-archive-store.js";
|
|
31
|
-
import { createDshAdapter, normalizeSessionHeader, compareSemver, detectPluginSource, isHmrActive, resolveProfileDir } from "./compat/dsh-adapter.js";
|
|
31
|
+
import { createDshAdapter, getService, normalizeSessionHeader, compareSemver, detectPluginSource, isHmrActive, resolveProfileDir } from "./compat/dsh-adapter.js";
|
|
32
|
+
import { assertProjectionCachePath, beginProjectionCacheDeletion, removeLegacyProjectionCache } from "./compat/projection-cache-delete.js";
|
|
32
33
|
import { ARTIFACT_NAMES, assertSessionId, assertSessionDirectory, candidateSessionDirs, encodeFsSessionSegment, decodeFsSessionSegment, encodeProjectSegment, encodeSessionSegment, parseSessionGenerationFromFilename, readSessionHeader, readSessionFile, writeTempFile, replaceSessionFile } from "./session-files.js";
|
|
33
34
|
|
|
34
35
|
export const name = "dsh-session-manager";
|
|
@@ -39,7 +40,8 @@ export const inject = [
|
|
|
39
40
|
"sessions",
|
|
40
41
|
"agents",
|
|
41
42
|
"sessionPersistence",
|
|
42
|
-
"agentPresets"
|
|
43
|
+
"agentPresets",
|
|
44
|
+
"connection"
|
|
43
45
|
];
|
|
44
46
|
|
|
45
47
|
/** Like JSON.stringify but swallows circular-reference/BigInt errors so the
|
|
@@ -385,7 +387,7 @@ function verifyInstalledPackage(profileDir, expectedVersion) {
|
|
|
385
387
|
|
|
386
388
|
export function apply(ctx) {
|
|
387
389
|
try {
|
|
388
|
-
const buildId = "dsh-session-manager v0.7.
|
|
390
|
+
const buildId = "dsh-session-manager v0.7.6 (workspace-archive)";
|
|
389
391
|
if (typeof ctx?.logger?.info === "function") ctx.logger.info(buildId);
|
|
390
392
|
else if (typeof console !== "undefined" && console?.info) console.info(buildId);
|
|
391
393
|
} catch (_) { /* ignore logging failures */ }
|
|
@@ -1044,6 +1046,115 @@ ${rest}`, "utf8");
|
|
|
1044
1046
|
return await run();
|
|
1045
1047
|
};
|
|
1046
1048
|
|
|
1049
|
+
/**
|
|
1050
|
+
* Issue #42: record the working-directory change a move implies.
|
|
1051
|
+
*
|
|
1052
|
+
* A cross-workspace move rewrites the artifact header's `cwd`, but DSH
|
|
1053
|
+
* folds the `workingDirectory` projection from the session log: the unit
|
|
1054
|
+
* initializes from `header.cwd` only while it has no checkpoint, and both
|
|
1055
|
+
* the projection cache and every `working-directory/change` event
|
|
1056
|
+
* supersede that initial value. A live session therefore keeps reporting
|
|
1057
|
+
* its pre-move directory -- the projection state is never re-initialized --
|
|
1058
|
+
* and the cache checkpoints that stale state under the NEW header identity,
|
|
1059
|
+
* so a rebuild cannot recover the target either. Route the change through
|
|
1060
|
+
* the host's own path (the `workingDirectory` service is what a user `cd`
|
|
1061
|
+
* uses) so the log gains the event the projection folds from; without that
|
|
1062
|
+
* service, append the equivalent event directly, which is the same shape
|
|
1063
|
+
* the host's `commit()` writes.
|
|
1064
|
+
*
|
|
1065
|
+
* @param agent - the live Agent owning the moved session, when one exists.
|
|
1066
|
+
* @param session - the live Session owning the moved log.
|
|
1067
|
+
* @param targetPath - the destination workspace directory.
|
|
1068
|
+
* @returns `{status}`: "synced" (host service committed the change),
|
|
1069
|
+
* "recorded" (event appended directly) or "restart-required" (the host
|
|
1070
|
+
* rejected the change; the move itself has already succeeded).
|
|
1071
|
+
*/
|
|
1072
|
+
const rehomeLiveWorkingDirectory = async ({ agent, session, targetPath }) => {
|
|
1073
|
+
const service = getService(ctx, "workingDirectory");
|
|
1074
|
+
if (service !== undefined && typeof service.set === "function" && agent !== undefined) {
|
|
1075
|
+
try {
|
|
1076
|
+
const cwd = await service.set(agent, targetPath);
|
|
1077
|
+
return { status: "synced", cwd: typeof cwd === "string" && cwd !== "" ? cwd : targetPath };
|
|
1078
|
+
} catch (error) {
|
|
1079
|
+
const isFiberError = error?.code === "INACTIVE_EFFECT" ||
|
|
1080
|
+
String(error?.message || error).toLowerCase().includes("inactive") ||
|
|
1081
|
+
String(error?.message || error).toLowerCase().includes("fiber");
|
|
1082
|
+
if (!isFiberError) {
|
|
1083
|
+
ctx.logger.warn(`session-manager: moved "${safeJson(session?.id)}" but the host rejected the working-directory change: ${safeErrorMessage(error)}`);
|
|
1084
|
+
return { status: "restart-required", reason: safeErrorMessage(error) };
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
if (session === undefined || session === null || typeof session.append !== "function") {
|
|
1089
|
+
return { status: "restart-required", reason: "no live session to record the working-directory change" };
|
|
1090
|
+
}
|
|
1091
|
+
try {
|
|
1092
|
+
session.append("working-directory/change", { cwd: targetPath });
|
|
1093
|
+
return { status: "recorded", cwd: targetPath };
|
|
1094
|
+
} catch (error) {
|
|
1095
|
+
ctx.logger.warn(`session-manager: moved "${safeJson(session.id)}" but could not record the working-directory change: ${safeErrorMessage(error)}`);
|
|
1096
|
+
return { status: "restart-required", reason: safeErrorMessage(error) };
|
|
1097
|
+
}
|
|
1098
|
+
};
|
|
1099
|
+
|
|
1100
|
+
/**
|
|
1101
|
+
* Issue #42 (cold sessions): append the `working-directory/change` event a
|
|
1102
|
+
* live move records, but only when the stored log already carries one --
|
|
1103
|
+
* otherwise the rewritten `header.cwd` alone initializes the projection on
|
|
1104
|
+
* the next resume and touching the log would be gratuitous. A stale change
|
|
1105
|
+
* event would otherwise fold the pre-move directory back in. Rows whose
|
|
1106
|
+
* `seq` does not match their position (packed physical rows) cannot be
|
|
1107
|
+
* extended without the persistence codec, so the body is left untouched and
|
|
1108
|
+
* the caller reports that a restart is required.
|
|
1109
|
+
*
|
|
1110
|
+
* @param rest - the artifact body (everything after the header line).
|
|
1111
|
+
* @param targetPath - the destination workspace directory.
|
|
1112
|
+
* @returns `{rest, status}` with the body to publish.
|
|
1113
|
+
*/
|
|
1114
|
+
const rehomeColdWorkingDirectory = (rest, targetPath) => {
|
|
1115
|
+
const nl = String.fromCharCode(10);
|
|
1116
|
+
const terminated = rest.endsWith(nl);
|
|
1117
|
+
const body = terminated ? rest.slice(0, -1) : rest;
|
|
1118
|
+
if (body === "") return { rest, status: "header-only" };
|
|
1119
|
+
const lines = body.split(nl);
|
|
1120
|
+
let lastChange;
|
|
1121
|
+
let hasPackedOrUnparseableRows = false;
|
|
1122
|
+
let hasNonSequentialSeq = false;
|
|
1123
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
1124
|
+
let event;
|
|
1125
|
+
try {
|
|
1126
|
+
event = JSON.parse(lines[index]);
|
|
1127
|
+
} catch {
|
|
1128
|
+
hasPackedOrUnparseableRows = true;
|
|
1129
|
+
continue;
|
|
1130
|
+
}
|
|
1131
|
+
if (event === null || typeof event !== "object") {
|
|
1132
|
+
hasPackedOrUnparseableRows = true;
|
|
1133
|
+
continue;
|
|
1134
|
+
}
|
|
1135
|
+
if (event.seq !== index) {
|
|
1136
|
+
hasNonSequentialSeq = true;
|
|
1137
|
+
}
|
|
1138
|
+
if (event.type === "working-directory/change") {
|
|
1139
|
+
lastChange = event.data?.cwd;
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
if (lastChange === undefined) return { rest, status: "header-only" };
|
|
1143
|
+
if (lastChange === targetPath) return { rest, status: "already-target" };
|
|
1144
|
+
if (hasPackedOrUnparseableRows || hasNonSequentialSeq) {
|
|
1145
|
+
const error = new Error("无法安全同步会话工作目录(会话日志包含压缩行且已有工作目录变更历史),为防止工作目录不一致,已取消移动。原始会话保持原状");
|
|
1146
|
+
error.code = "working-directory-sync-unsafe";
|
|
1147
|
+
throw error;
|
|
1148
|
+
}
|
|
1149
|
+
const appended = JSON.stringify({
|
|
1150
|
+
type: "working-directory/change",
|
|
1151
|
+
seq: lines.length,
|
|
1152
|
+
time: Date.now(),
|
|
1153
|
+
data: { cwd: targetPath }
|
|
1154
|
+
});
|
|
1155
|
+
return { rest: `${body}${nl}${appended}${nl}`, status: "recorded" };
|
|
1156
|
+
};
|
|
1157
|
+
|
|
1047
1158
|
/**
|
|
1048
1159
|
* TRUE cross-workspace move. See the module docstring for the full design:
|
|
1049
1160
|
* re-home the stored cwd, migrate the artifact, then swap accounting.
|
|
@@ -1196,7 +1307,14 @@ ${rest}`, "utf8");
|
|
|
1196
1307
|
throw new Error("目标位置已有会话工件,拒绝覆盖");
|
|
1197
1308
|
} catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
1198
1309
|
if (oldPath === newPath) throw new Error("会话源路径与目标路径相同,但 cwd 不一致,拒绝覆盖");
|
|
1199
|
-
|
|
1310
|
+
// Issue #42: a cold log is the only authority its working-directory
|
|
1311
|
+
// projection folds from on the next resume, so re-home it together
|
|
1312
|
+
// with the artifact header. Live sessions get the equivalent event
|
|
1313
|
+
// through the host path once the artifact has been published.
|
|
1314
|
+
const bodyRehome = liveSession === void 0
|
|
1315
|
+
? rehomeColdWorkingDirectory(rest, targetPath)
|
|
1316
|
+
: { rest, status: "live" };
|
|
1317
|
+
const bytes = await encodeArtifact(JSON.stringify(newHeader), bodyRehome.rest, newPath.endsWith(".zstd"));
|
|
1200
1318
|
await mkdir(dirname(newPath), { recursive: true });
|
|
1201
1319
|
const tempNew = await writeTempFile(newPath, bytes);
|
|
1202
1320
|
const oldHidden = `${oldPath}.${randomBytes(6).toString("hex")}.tmp`;
|
|
@@ -1297,7 +1415,7 @@ ${rest}`, "utf8");
|
|
|
1297
1415
|
await rm(dirname(oldPath), { recursive: true, force: true });
|
|
1298
1416
|
} catch (error) { ctx.logger.warn(`session-manager: old directory retained: ${String(error)}`); }
|
|
1299
1417
|
|
|
1300
|
-
movedArtifact = { header: newHeader, rest };
|
|
1418
|
+
movedArtifact = { header: newHeader, rest: bodyRehome.rest, workingDirectory: bodyRehome.status };
|
|
1301
1419
|
|
|
1302
1420
|
return {
|
|
1303
1421
|
ok: true,
|
|
@@ -1329,7 +1447,34 @@ ${rest}`, "utf8");
|
|
|
1329
1447
|
ctx.logger.warn(`session-manager: post-move cache refresh failed for "${sessionId}": ${String(error)}`);
|
|
1330
1448
|
}
|
|
1331
1449
|
}
|
|
1332
|
-
|
|
1450
|
+
// Issue #42: the artifact header was rewritten, but the folded
|
|
1451
|
+
// `workingDirectory` projection was not. Align it now -- after the
|
|
1452
|
+
// artifact has been published (a live session's change event must land in
|
|
1453
|
+
// the moved log) and after the registry swap.
|
|
1454
|
+
let workingDirectory = movedArtifact?.workingDirectory ?? "not-needed";
|
|
1455
|
+
let restartRequired = false;
|
|
1456
|
+
if (liveSession !== void 0) {
|
|
1457
|
+
const rehomed = await rehomeLiveWorkingDirectory({ agent: liveAgent, session: liveSession, targetPath });
|
|
1458
|
+
workingDirectory = rehomed.status;
|
|
1459
|
+
if (rehomed.status === "restart-required") restartRequired = true;
|
|
1460
|
+
if (rehomed.status === "synced" || rehomed.status === "recorded") {
|
|
1461
|
+
// The refold above seeds from the projection cache, so it cannot see
|
|
1462
|
+
// a change committed after the move: checkpoint what the live
|
|
1463
|
+
// session now actually folds (this flushes the new event first).
|
|
1464
|
+
if (cache !== void 0 && typeof cache.write === "function") {
|
|
1465
|
+
try {
|
|
1466
|
+
await cache.write(liveSession);
|
|
1467
|
+
} catch (error) {
|
|
1468
|
+
ctx.logger.warn(`session-manager: post-move working-directory checkpoint failed for "${sessionId}": ${safeErrorMessage(error)}`);
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
} else if (workingDirectory === "restart-required") {
|
|
1473
|
+
restartRequired = true;
|
|
1474
|
+
}
|
|
1475
|
+
result.workingDirectory = workingDirectory;
|
|
1476
|
+
result.restartRequired = restartRequired;
|
|
1477
|
+
// The agent is still live (no teardown). The session's in-memory
|
|
1333
1478
|
// header was rewritten above and the workspace registry updated;
|
|
1334
1479
|
// future writes go through persistence.locate() against the new
|
|
1335
1480
|
// cwd, so the file at the new location accumulates new events.
|
|
@@ -1914,11 +2059,20 @@ ${rest}`, "utf8");
|
|
|
1914
2059
|
await assertAndStopActiveSession(sessionId, true);
|
|
1915
2060
|
}
|
|
1916
2061
|
|
|
2062
|
+
let projectionDeletion;
|
|
2063
|
+
let artifactsDeleted = false;
|
|
1917
2064
|
try {
|
|
1918
2065
|
// Resolve/validate before cancelling the agent or changing registry state.
|
|
1919
2066
|
let dir = await sessionDirOf(sessionId);
|
|
1920
2067
|
const session = ctx.sessions?.get?.(sessionId);
|
|
1921
2068
|
let agent = safeGetAgent(sessionId);
|
|
2069
|
+
try {
|
|
2070
|
+
await assertProjectionCachePath(dirname(adapter.homePath("storages")), sessionId);
|
|
2071
|
+
const artifact = await readSessionArtifact(sessionId, undefined, { headerOnly: true });
|
|
2072
|
+
projectionDeletion = beginProjectionCacheDeletion(adapter.projectionCache, sessionId, session?.header ?? artifact?.meta);
|
|
2073
|
+
} catch (error) {
|
|
2074
|
+
cleanupWarnings.push("投影缓存回写防护不可用: " + safeErrorMessage(error));
|
|
2075
|
+
}
|
|
1922
2076
|
|
|
1923
2077
|
if (agent !== void 0) {
|
|
1924
2078
|
// Stop any running turn (disposed-kind suppresses re-wake).
|
|
@@ -1979,6 +2133,29 @@ ${rest}`, "utf8");
|
|
|
1979
2133
|
await assertSessionDirectory(adapter.homePath("sessions"), dir, sessionId);
|
|
1980
2134
|
await rm(dir, { recursive: true, force: true });
|
|
1981
2135
|
}
|
|
2136
|
+
artifactsDeleted = true;
|
|
2137
|
+
|
|
2138
|
+
// Use the host queue to remove both its in-memory row and durable record.
|
|
2139
|
+
// Tombstones installed before disposal also stop writes still awaiting flush.
|
|
2140
|
+
try {
|
|
2141
|
+
await assertProjectionCachePath(dirname(adapter.homePath("storages")), sessionId);
|
|
2142
|
+
if (projectionDeletion) await projectionDeletion.remove();
|
|
2143
|
+
else {
|
|
2144
|
+
const file = adapter.homePath("storages", "session_projcache", "sessions", `${sessionId}.json`);
|
|
2145
|
+
if (existsSync(file)) throw new Error("宿主投影缓存删除接口不可用,缓存尚未清理");
|
|
2146
|
+
}
|
|
2147
|
+
} catch (error) {
|
|
2148
|
+
cleanupWarnings.push("会话已删除,但投影缓存清理失败: " + safeErrorMessage(error));
|
|
2149
|
+
}
|
|
2150
|
+
try {
|
|
2151
|
+
if (projectionDeletion?.legacyRetained) await removeLegacyProjectionCache(dirname(adapter.homePath("storages")), sessionId);
|
|
2152
|
+
else if (!projectionDeletion?.legacyManaged && existsSync(adapter.homePath("storages", "session_projcache.json"))) {
|
|
2153
|
+
throw new Error("无法确认旧版缓存已停止写入,未修改文件");
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
catch (error) {
|
|
2157
|
+
cleanupWarnings.push("会话已删除,但旧版投影缓存清理失败: " + safeErrorMessage(error));
|
|
2158
|
+
}
|
|
1982
2159
|
|
|
1983
2160
|
try {
|
|
1984
2161
|
await annotations().remove(sessionId);
|
|
@@ -2008,6 +2185,7 @@ ${rest}`, "utf8");
|
|
|
2008
2185
|
subagentCount: deletedSubagentIds.length
|
|
2009
2186
|
};
|
|
2010
2187
|
} catch (parentErr) {
|
|
2188
|
+
if (!artifactsDeleted) projectionDeletion?.rollback();
|
|
2011
2189
|
const err = deletedSubagentIds.length > 0
|
|
2012
2190
|
? new Error(`删除主会话 '${sessionId}' 失败: ${safeErrorMessage(parentErr)}`)
|
|
2013
2191
|
: parentErr;
|
|
@@ -2295,9 +2473,80 @@ ${rest}`, "utf8");
|
|
|
2295
2473
|
if (isBlank) { const e = new Error("blank"); e.code = "blank-skip"; throw e; }
|
|
2296
2474
|
};
|
|
2297
2475
|
|
|
2298
|
-
|
|
2476
|
+
/**
|
|
2477
|
+
* Issue #43: Validate incoming request with DSH connection authentication.
|
|
2478
|
+
* Reuses DSH official Connection/Gateway authentication and authorization mechanisms.
|
|
2479
|
+
* Strict fail-closed: requires connection.admit() to succeed with a valid Owner peer.
|
|
2480
|
+
* Must execute before request body reading, JSON parsing, or business operations.
|
|
2481
|
+
*/
|
|
2482
|
+
const authenticateApiRequest = (req) => {
|
|
2483
|
+
const connection = getService(ctx, "connection");
|
|
2484
|
+
if (!connection || typeof connection.admit !== "function") {
|
|
2485
|
+
return {
|
|
2486
|
+
ok: false,
|
|
2487
|
+
status: 503,
|
|
2488
|
+
code: "auth-unavailable",
|
|
2489
|
+
error: "DSH 认证服务未就绪或不可用,为保护数据安全已拒绝请求"
|
|
2490
|
+
};
|
|
2491
|
+
}
|
|
2492
|
+
try {
|
|
2493
|
+
const admission = connection.admit(req);
|
|
2494
|
+
if (!admission || typeof admission !== "object") {
|
|
2495
|
+
return {
|
|
2496
|
+
ok: false,
|
|
2497
|
+
status: 401,
|
|
2498
|
+
code: "unauthorized",
|
|
2499
|
+
error: "未授权:需要有效的 DSH 会话凭证"
|
|
2500
|
+
};
|
|
2501
|
+
}
|
|
2502
|
+
if ("rejection" in admission) {
|
|
2503
|
+
const status = typeof admission.rejection === "number" ? admission.rejection : 401;
|
|
2504
|
+
const error = status === 403
|
|
2505
|
+
? "禁止访问:请求未通过 DSH 信任检查或跨站防护"
|
|
2506
|
+
: "未授权:需要有效的 DSH 会话凭证";
|
|
2507
|
+
return { ok: false, status, code: status === 403 ? "forbidden" : "unauthorized", error };
|
|
2508
|
+
}
|
|
2509
|
+
const peer = admission.peer;
|
|
2510
|
+
if (!peer || typeof peer !== "object") {
|
|
2511
|
+
return {
|
|
2512
|
+
ok: false,
|
|
2513
|
+
status: 401,
|
|
2514
|
+
code: "unauthorized",
|
|
2515
|
+
error: "未授权:缺少有效 Peer 凭证"
|
|
2516
|
+
};
|
|
2517
|
+
}
|
|
2518
|
+
const principalKind = peer.principal?.kind;
|
|
2519
|
+
if (principalKind !== "owner") {
|
|
2520
|
+
return {
|
|
2521
|
+
ok: false,
|
|
2522
|
+
status: 403,
|
|
2523
|
+
code: "forbidden",
|
|
2524
|
+
error: "禁止访问:会话管理属于特权操作,需要 owner 权限"
|
|
2525
|
+
};
|
|
2526
|
+
}
|
|
2527
|
+
return { ok: true, peer };
|
|
2528
|
+
} catch (err) {
|
|
2529
|
+
return {
|
|
2530
|
+
ok: false,
|
|
2531
|
+
status: 403,
|
|
2532
|
+
code: "auth-error",
|
|
2533
|
+
error: "认证检查失败: " + safeErrorMessage(err)
|
|
2534
|
+
};
|
|
2535
|
+
}
|
|
2536
|
+
};
|
|
2537
|
+
|
|
2538
|
+
ctx.effect(() => ctx.webServer.register({
|
|
2539
|
+
kind: "prefix",
|
|
2299
2540
|
path: API_PREFIX,
|
|
2300
2541
|
handler: async (req, res) => {
|
|
2542
|
+
const auth = authenticateApiRequest(req);
|
|
2543
|
+
if (!auth.ok) {
|
|
2544
|
+
return send(res, auth.status, {
|
|
2545
|
+
ok: false,
|
|
2546
|
+
code: auth.code,
|
|
2547
|
+
error: auth.error
|
|
2548
|
+
});
|
|
2549
|
+
}
|
|
2301
2550
|
try {
|
|
2302
2551
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
2303
2552
|
const path = url.pathname.startsWith(API_PREFIX) ? url.pathname.slice(API_PREFIX.length) || "/" : "/";
|
|
@@ -2528,7 +2777,7 @@ ${rest}`, "utf8");
|
|
|
2528
2777
|
return send(res, 200, { ok: true, result });
|
|
2529
2778
|
}
|
|
2530
2779
|
if (req.method === "GET" && path === "/update/check") {
|
|
2531
|
-
const currentVersion = adapter.pluginVersion() || "0.7.
|
|
2780
|
+
const currentVersion = adapter.pluginVersion() || "0.7.6";
|
|
2532
2781
|
const runningVersion = currentVersion;
|
|
2533
2782
|
let installedVersion = runningVersion;
|
|
2534
2783
|
const profileDir = resolveProfileDir(adapter, ctx);
|
|
@@ -2712,7 +2961,7 @@ ${rest}`, "utf8");
|
|
|
2712
2961
|
};
|
|
2713
2962
|
setActiveTask(taskRecord);
|
|
2714
2963
|
|
|
2715
|
-
const currentVersion = adapter.pluginVersion() || "0.7.
|
|
2964
|
+
const currentVersion = adapter.pluginVersion() || "0.7.6";
|
|
2716
2965
|
let installedVersion = currentVersion;
|
|
2717
2966
|
if (profileDir) {
|
|
2718
2967
|
const pkgFile = join(profileDir, "node_modules", "dsh-session-manager", "package.json");
|
|
@@ -3242,7 +3491,7 @@ ${rest}`, "utf8");
|
|
|
3242
3491
|
}
|
|
3243
3492
|
if (req.method === "GET" && path === "/update/status") {
|
|
3244
3493
|
const profileDir = resolveProfileDir(adapter, ctx);
|
|
3245
|
-
const currentVersion = adapter.pluginVersion() || "0.7.
|
|
3494
|
+
const currentVersion = adapter.pluginVersion() || "0.7.6";
|
|
3246
3495
|
let installedVersion = currentVersion;
|
|
3247
3496
|
if (profileDir) {
|
|
3248
3497
|
const diskCheck = verifyInstalledPackage(profileDir);
|
|
@@ -3317,6 +3566,7 @@ ${rest}`, "utf8");
|
|
|
3317
3566
|
ctx.logger.warn("session-manager: api error: " + safeErrorMessage(error) + "; json=" + safeJson(error));
|
|
3318
3567
|
const status = error?.code === "bad-request" ? 400 : error?.code === "body-too-large" ? 413
|
|
3319
3568
|
: error?.code === "annotation-conflict" || error?.code === "annotations-busy" ? 409
|
|
3569
|
+
: error?.code === "working-directory-sync-unsafe" ? 409
|
|
3320
3570
|
: error?.code === "session-not-found" ? 404 : error?.code?.startsWith("annotations-") ? 503
|
|
3321
3571
|
: error?.code === "workspace-membership-conflict" ? 409
|
|
3322
3572
|
: error?.code === "workspace-attach-unavailable" ? 501 : 500;
|
package/lib/session-files.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
import * as fs from "node:fs/promises";
|
|
3
3
|
import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
-
import { decompressAllZstdFrames,
|
|
4
|
+
import { decompressAllZstdFrames, decompressZstdFirstLine, firstZstdFrameChunks } from "./compat/zstd-frames.js";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Filenames tried in this order to locate a session's durable artifact.
|
|
@@ -190,20 +190,17 @@ export async function readSessionHeader(path) {
|
|
|
190
190
|
await assertRegularFile(path);
|
|
191
191
|
const handle = await fs.open(path, "r");
|
|
192
192
|
try {
|
|
193
|
+
if (path.endsWith(".zstd")) {
|
|
194
|
+
const decoded = await decompressZstdFirstLine(firstZstdFrameChunks(handle), MAX_HEADER_BYTES);
|
|
195
|
+
return parseHeader(decoded.toString("utf8"));
|
|
196
|
+
}
|
|
193
197
|
let buffer = Buffer.alloc(0);
|
|
194
198
|
while (buffer.length < MAX_HEADER_BYTES) {
|
|
195
199
|
const chunk = Buffer.alloc(Math.min(64 * 1024, MAX_HEADER_BYTES - buffer.length));
|
|
196
200
|
const { bytesRead } = await handle.read(chunk, 0, chunk.length, buffer.length);
|
|
197
201
|
if (bytesRead === 0) break;
|
|
198
202
|
buffer = Buffer.concat([buffer, chunk.subarray(0, bytesRead)]);
|
|
199
|
-
if (
|
|
200
|
-
const { frames } = scanZstdFrames(buffer, 1);
|
|
201
|
-
if (frames.length) {
|
|
202
|
-
const frame = frames[0];
|
|
203
|
-
const decoded = await decompressZstdFrame(buffer.subarray(frame.start, frame.end), { maxOutputLength: MAX_HEADER_BYTES });
|
|
204
|
-
return parseHeader(decoded.toString("utf8"));
|
|
205
|
-
}
|
|
206
|
-
} else if (buffer.includes(10)) {
|
|
203
|
+
if (buffer.includes(10)) {
|
|
207
204
|
return parseHeader(buffer.toString("utf8"));
|
|
208
205
|
}
|
|
209
206
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-session-manager",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.6",
|
|
4
4
|
"description": "DeepSeek Harness session and workspace manager —— Workspaces: archive and visibility. Sessions: move, migrate presets, delete, favorite, review later, prioritize, tag, and annotate. Batch operations. | DSH 会话与工作区管理——工作区:归档与显示。会话:跨工作区移动、预设迁移、删除;收藏、待回看、优先级、标签、备注。批量操作。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|