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/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
- ctx.effect(() => ctx.webServer.register({
1040
- kind: "prefix",
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")
@@ -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
- export const ARTIFACT_NAMES = ["session.v3.jsonl.zstd", "session.v2.jsonl.zstd", "session.jsonl.zstd", "session.jsonl"];
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.1",
4
- "description": "DSH Web session manager: delete, archive, move across workspaces, migrate preset; favorites, review-later, search, sort, priority, add tags and notes (manual / semi-automated). Suggestions are welcome on GitHub. DSH Web 会话管理:删除、归档、跨工作区移动、迁移预设;收藏、待看、搜索、排序、设置优先级、添加(手动/半自动)标签和备注。欢迎至 GitHub 提意见。",
3
+ "version": "0.5.2",
4
+ "description": "DSH Web 会话管理:删除、归档、跨工作区移动、迁移预设;收藏、待看、搜索、排序、设置优先级,添加标签和备注;批量处理。欢迎至 GitHub 提意见。",
5
5
  "license": "MIT",
6
6
  "keywords": [
7
7
  "dsh",