dsh-session-manager 0.5.1 → 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 +247 -218
- package/README.md +139 -158
- package/README.zh.md +137 -166
- package/lib/annotation-store.js +41 -1
- package/lib/client.js +2772 -1953
- package/lib/index.js +134 -2
- package/lib/session-files.js +15 -2
- package/package.json +2 -2
package/lib/index.js
CHANGED
|
@@ -167,6 +167,11 @@ const scanSessionHeaders = async (persistence, dshHome) => {
|
|
|
167
167
|
const listSessionHeaders = async (persistence, dshHome) => (await scanSessionHeaders(persistence, dshHome)).headers;
|
|
168
168
|
|
|
169
169
|
export function apply(ctx) {
|
|
170
|
+
try {
|
|
171
|
+
const buildId = "dsh-session-manager v0.5.2+release-17 (clearSelection-keeps-bulk-mode-on)";
|
|
172
|
+
if (typeof ctx?.logger?.info === "function") ctx.logger.info(buildId);
|
|
173
|
+
else if (typeof console !== "undefined" && console?.info) console.info(buildId);
|
|
174
|
+
} catch (_) { /* ignore logging failures */ }
|
|
170
175
|
const adapter = createDshAdapter(ctx);
|
|
171
176
|
let annotationStore;
|
|
172
177
|
const annotations = () => {
|
|
@@ -222,6 +227,23 @@ export function apply(ctx) {
|
|
|
222
227
|
});
|
|
223
228
|
};
|
|
224
229
|
|
|
230
|
+
/** Add one id to the registry-global archive set (durable, serialized).
|
|
231
|
+
* Mirror of unarchiveSession; both touch workspaceRegistry state directly
|
|
232
|
+
* because the host-side ctx.workspaces service is not available inside
|
|
233
|
+
* the per-request dispatch (Cordis throws "cannot get property
|
|
234
|
+
* 'workspaces' without inject" otherwise). */
|
|
235
|
+
const archiveSession = async (sessionId) => {
|
|
236
|
+
const registry = ctx.workspaceRegistry;
|
|
237
|
+
await registry.enqueueOperation(async () => {
|
|
238
|
+
const state = registry.requireState();
|
|
239
|
+
if (state.archivedSessionIds.includes(sessionId)) return;
|
|
240
|
+
await registry.setState({
|
|
241
|
+
...state,
|
|
242
|
+
archivedSessionIds: [...state.archivedSessionIds, sessionId]
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
};
|
|
246
|
+
|
|
225
247
|
/** Detach one session from every workspace's ordered accounting. */
|
|
226
248
|
const detachFromWorkspaces = async (sessionId) => {
|
|
227
249
|
for (const entity of ctx.workspaceRegistry.list()) {
|
|
@@ -1036,8 +1058,71 @@ ${rest}`, "utf8");
|
|
|
1036
1058
|
}
|
|
1037
1059
|
}, "session-manager: post-boot re-fold stale projection cache rows");
|
|
1038
1060
|
|
|
1039
|
-
|
|
1040
|
-
|
|
1061
|
+
/** Build a normalized annotation patch from one bulk action + payload.
|
|
1062
|
+
* Returns null when the payload is missing or malformed so the caller can
|
|
1063
|
+
* surface a 400 instead of letting the store throw on a bad value.
|
|
1064
|
+
*/
|
|
1065
|
+
const annotationPatchFromBatchAction = (action, payload) => {
|
|
1066
|
+
if (!payload || typeof payload !== "object") return null;
|
|
1067
|
+
if (action === "favorite" || action === "unfavorite") return { favorite: action === "favorite" };
|
|
1068
|
+
if (action === "review" || action === "unreview") return { reviewLater: action === "review" };
|
|
1069
|
+
if (action === "set-priority") {
|
|
1070
|
+
const p = Number(payload.priority);
|
|
1071
|
+
if (!Number.isInteger(p) || p < 1 || p > 5) return null;
|
|
1072
|
+
return { priority: p };
|
|
1073
|
+
}
|
|
1074
|
+
if (action === "add-tags" || action === "remove-tags") {
|
|
1075
|
+
if (!Array.isArray(payload.tags)) return null;
|
|
1076
|
+
return action === "add-tags" ? { tags: payload.tags } : { tags: [] };
|
|
1077
|
+
}
|
|
1078
|
+
return null;
|
|
1079
|
+
};
|
|
1080
|
+
|
|
1081
|
+
/** Run one batch action against a single session id. Annotation-affecting
|
|
1082
|
+
* actions resolve the annotation accessor from the surrounding closure
|
|
1083
|
+
* so it can never be silently undefined. Session-management actions reuse
|
|
1084
|
+
* the existing helpers verbatim so move/preset-migrate validation and
|
|
1085
|
+
* live-session handling stay identical to the per-row buttons.
|
|
1086
|
+
*/
|
|
1087
|
+
const runBatchAction = async ({ action, sessionId, payload, mutate, deleteSession, unarchiveSession, moveSession, migratePreset, adapter }) => {
|
|
1088
|
+
const annotationsAccessor = annotations;
|
|
1089
|
+
if (action === "favorite" || action === "unfavorite" || action === "review" || action === "unreview" || action === "set-priority" || action === "add-tags" || action === "remove-tags") {
|
|
1090
|
+
const patch = annotationPatchFromBatchAction(action, payload);
|
|
1091
|
+
if (patch === null) {
|
|
1092
|
+
const e = new Error("invalid batch payload for " + action); e.code = "bad-request"; throw e;
|
|
1093
|
+
}
|
|
1094
|
+
try {
|
|
1095
|
+
await annotationsAccessor().batchUpdate([sessionId], patch);
|
|
1096
|
+
} catch (error) {
|
|
1097
|
+
throw error;
|
|
1098
|
+
}
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
if (action === "archive") { await mutate(sessionId, () => archiveSession(sessionId)); return; }
|
|
1102
|
+
if (action === "unarchive") { await mutate(sessionId, () => unarchiveSession(sessionId)); return; }
|
|
1103
|
+
if (action === "delete") { await mutate(sessionId, () => deleteSession(sessionId)); return; }
|
|
1104
|
+
if (action === "move") {
|
|
1105
|
+
const targetWorkspaceId = typeof payload?.targetWorkspaceId === "string" ? payload.targetWorkspaceId.trim() : "";
|
|
1106
|
+
if (!targetWorkspaceId) { const e = new Error("targetWorkspaceId required"); e.code = "bad-request"; throw e; }
|
|
1107
|
+
await mutate(sessionId, () => moveSession(sessionId, targetWorkspaceId));
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
if (action === "preset-migrate") {
|
|
1111
|
+
const toPreset = typeof payload?.toPreset === "string" ? payload.toPreset.trim() : "";
|
|
1112
|
+
if (!toPreset) { const e = new Error("toPreset required"); e.code = "bad-request"; throw e; }
|
|
1113
|
+
await mutate(sessionId, () => migratePreset({ sessionId, toPreset }));
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
const snapshot = adapter.workspaceRegistry?.headers?.get?.(sessionId)
|
|
1117
|
+
?? adapter.sessions?.list?.()?.find(s => s?.id === sessionId)?.header
|
|
1118
|
+
?? null;
|
|
1119
|
+
const isSubagent = snapshot?.origin === "subagent";
|
|
1120
|
+
const isBlank = snapshot?.blank === true;
|
|
1121
|
+
if (isSubagent) { const e = new Error("subagent"); e.code = "subagent-skip"; throw e; }
|
|
1122
|
+
if (isBlank) { const e = new Error("blank"); e.code = "blank-skip"; throw e; }
|
|
1123
|
+
};
|
|
1124
|
+
|
|
1125
|
+
ctx.effect(() => ctx.webServer.register({ kind: "prefix",
|
|
1041
1126
|
path: API_PREFIX,
|
|
1042
1127
|
handler: async (req, res) => {
|
|
1043
1128
|
try {
|
|
@@ -1084,6 +1169,53 @@ ${rest}`, "utf8");
|
|
|
1084
1169
|
}
|
|
1085
1170
|
return send(res, 200, { ok: true, result });
|
|
1086
1171
|
}
|
|
1172
|
+
if (req.method === "POST" && path === "/batch") {
|
|
1173
|
+
// Per-action body shape (each action validates its own payload):
|
|
1174
|
+
// archive : no payload
|
|
1175
|
+
// unarchive : no payload
|
|
1176
|
+
// delete : no payload
|
|
1177
|
+
// move : { targetWorkspaceId }
|
|
1178
|
+
// preset-migrate: { toPreset }
|
|
1179
|
+
// favorite : no payload (defaults to true)
|
|
1180
|
+
// unfavorite : no payload
|
|
1181
|
+
// review : no payload
|
|
1182
|
+
// unreview : no payload
|
|
1183
|
+
// set-priority : { priority: 1..5 }
|
|
1184
|
+
// add-tags : { tags: string[] }
|
|
1185
|
+
// remove-tags : { tags: string[] }
|
|
1186
|
+
const rawAction = typeof body.action === "string" ? body.action : "";
|
|
1187
|
+
const rawIds = Array.isArray(body.sessionIds) ? body.sessionIds : [];
|
|
1188
|
+
const ids = Array.from(new Set(rawIds.filter(id => typeof id === "string" && id.trim() !== "").map(id => id.trim())));
|
|
1189
|
+
for (const id of ids) assertSessionId(id);
|
|
1190
|
+
if (ids.length === 0) return send(res, 400, { ok: false, error: "sessionIds 必须是非空数组", code: "bad-request" });
|
|
1191
|
+
if (ids.length > 200) return send(res, 400, { ok: false, error: "单次批量最多 200 条,请缩小筛选范围后重试", code: "batch-too-large" });
|
|
1192
|
+
const payload = body.payload && typeof body.payload === "object" ? body.payload : {};
|
|
1193
|
+
const BATCH_ACTIONS = new Set(["archive", "unarchive", "delete", "move", "preset-migrate", "favorite", "unfavorite", "review", "unreview", "set-priority", "add-tags", "remove-tags"]);
|
|
1194
|
+
if (!BATCH_ACTIONS.has(rawAction)) return send(res, 400, { ok: false, error: "action 不支持: " + rawAction, code: "bad-request" });
|
|
1195
|
+
const items = [];
|
|
1196
|
+
let success = 0, failed = 0, skipped = 0;
|
|
1197
|
+
for (const sessionId of ids) {
|
|
1198
|
+
try {
|
|
1199
|
+
await runBatchAction({ action: rawAction, sessionId, payload, mutate, deleteSession, unarchiveSession, moveSession, migratePreset, adapter });
|
|
1200
|
+
items.push({ sessionId, status: "success" });
|
|
1201
|
+
success++;
|
|
1202
|
+
} catch (error) {
|
|
1203
|
+
const code = error && typeof error.code === "string" ? error.code : undefined;
|
|
1204
|
+
if (code === "subagent-skip" || code === "blank-skip") {
|
|
1205
|
+
items.push({ sessionId, status: "skipped", reason: code });
|
|
1206
|
+
skipped++;
|
|
1207
|
+
} else {
|
|
1208
|
+
items.push({ sessionId, status: "failed", code, error: safeErrorMessage(error) });
|
|
1209
|
+
failed++;
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
return send(res, 200, { ok: true, result: {
|
|
1214
|
+
summary: { total: ids.length, success, failed, skipped },
|
|
1215
|
+
items,
|
|
1216
|
+
aborted: false,
|
|
1217
|
+
} });
|
|
1218
|
+
}
|
|
1087
1219
|
if (req.method === "GET" && path === "/workspaces") {
|
|
1088
1220
|
const sessionCreatedAt = Object.fromEntries(adapter.cachedSessionHeaders()
|
|
1089
1221
|
.filter(header => typeof header.createdAt === "number" || typeof header.createdAt === "string")
|
package/lib/session-files.js
CHANGED
|
@@ -3,7 +3,20 @@ import * as fs from "node:fs/promises";
|
|
|
3
3
|
import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { decompressAllZstdFrames, decompressZstdFrame, scanZstdFrames } from "./compat/zstd-frames.js";
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Filenames tried in this order to locate a session's durable artifact.
|
|
8
|
+
* V4 first: DSH 0.1.7+ writes V4 by default; existing V3/V2/V1 files remain
|
|
9
|
+
* readable. The list-snapshot reader is version-agnostic (it parses the
|
|
10
|
+
* header JSON regardless of declared version), so we only need the filename
|
|
11
|
+
* in the candidate list — no per-version code paths.
|
|
12
|
+
*/
|
|
13
|
+
export const ARTIFACT_NAMES = [
|
|
14
|
+
"session.v4.jsonl.zstd",
|
|
15
|
+
"session.v3.jsonl.zstd",
|
|
16
|
+
"session.v2.jsonl.zstd",
|
|
17
|
+
"session.jsonl.zstd",
|
|
18
|
+
"session.jsonl"
|
|
19
|
+
];
|
|
7
20
|
const MAX_HEADER_BYTES = 1024 * 1024;
|
|
8
21
|
|
|
9
22
|
export function assertSessionId(id) {
|
|
@@ -177,4 +190,4 @@ export async function replaceSessionFile(path, bytes, io = fs) {
|
|
|
177
190
|
try { await io.rm(backup, { force: true }); }
|
|
178
191
|
catch { return { backupPath: backup }; }
|
|
179
192
|
return {};
|
|
180
|
-
}
|
|
193
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-session-manager",
|
|
3
|
-
"version": "0.5.
|
|
4
|
-
"description": "DSH Web
|
|
3
|
+
"version": "0.5.2",
|
|
4
|
+
"description": "DSH Web 会话管理:删除、归档、跨工作区移动、迁移预设;收藏、待看、搜索、排序、设置优先级,添加标签和备注;批量处理。欢迎至 GitHub 提意见。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"dsh",
|