dsh-sessions-manager 3.3.0 → 3.4.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/lib/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  // src/index.js
2
- import { mkdir as mkdir2, realpath, rename as rename2, stat, unlink, writeFile as writeFile2 } from "node:fs/promises";
3
- import { basename, dirname, isAbsolute, join as join2 } from "node:path";
4
- import { readFileSync as readFileSync3 } from "node:fs";
5
- import { homedir as homedir2 } from "node:os";
2
+ import { mkdir as mkdir3, realpath, rename as rename3, stat, unlink, writeFile as writeFile3 } from "node:fs/promises";
3
+ import { basename, dirname, isAbsolute, join as join3 } from "node:path";
4
+ import { readFileSync as readFileSync4 } from "node:fs";
5
+ import { homedir as homedir3 } from "node:os";
6
6
 
7
7
  // src/zstd-frame.js
8
8
  import zlib from "node:zlib";
@@ -250,17 +250,179 @@ function createStarIndex(options = {}) {
250
250
  return { read, write, mutate, setStarred, removeIds, indexPath, dir };
251
251
  }
252
252
 
253
+ // src/storage-stats.js
254
+ var UNGROUPED_KEY = "__ungrouped__";
255
+ function isFiniteSize(value) {
256
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
257
+ }
258
+ function aggregateStorage(items, options = {}) {
259
+ const topN = Number.isInteger(options.topN) && options.topN > 0 ? options.topN : 10;
260
+ const list = Array.isArray(items) ? items : [];
261
+ const buckets = /* @__PURE__ */ new Map();
262
+ const sized = [];
263
+ let totalBytes = 0;
264
+ let unknownSessions = 0;
265
+ let counted = 0;
266
+ for (const item of list) {
267
+ if (!item || item.sessionId == null) continue;
268
+ counted++;
269
+ const id = String(item.sessionId);
270
+ const path = item.workspacePath ? String(item.workspacePath) : null;
271
+ const key = path || UNGROUPED_KEY;
272
+ let bucket = buckets.get(key);
273
+ if (!bucket) {
274
+ bucket = { key, path, title: item.workspaceTitle ? String(item.workspaceTitle) : null, bytes: 0, sessions: 0 };
275
+ buckets.set(key, bucket);
276
+ }
277
+ bucket.sessions++;
278
+ if (isFiniteSize(item.sizeBytes)) {
279
+ bucket.bytes += item.sizeBytes;
280
+ totalBytes += item.sizeBytes;
281
+ sized.push({
282
+ sessionId: id,
283
+ title: item.title || null,
284
+ workspacePath: path,
285
+ workspaceTitle: bucket.title,
286
+ sizeBytes: item.sizeBytes
287
+ });
288
+ } else {
289
+ unknownSessions++;
290
+ }
291
+ }
292
+ const workspaces = [...buckets.values()].sort((a, b) => b.bytes - a.bytes || b.sessions - a.sessions || a.key.localeCompare(b.key)).map((bucket) => ({ ...bucket, share: totalBytes > 0 ? bucket.bytes / totalBytes : 0 }));
293
+ const top = sized.sort((a, b) => b.sizeBytes - a.sizeBytes || a.sessionId.localeCompare(b.sessionId)).slice(0, topN);
294
+ return {
295
+ totalBytes,
296
+ sessionCount: counted,
297
+ sizedSessions: sized.length,
298
+ unknownSessions,
299
+ workspaces,
300
+ top
301
+ };
302
+ }
303
+
304
+ // src/auto-archive.js
305
+ import { mkdir as mkdir2, rename as rename2, writeFile as writeFile2 } from "node:fs/promises";
306
+ import { readFileSync as readFileSync3 } from "node:fs";
307
+ import { homedir as homedir2 } from "node:os";
308
+ import { join as join2 } from "node:path";
309
+ var AUTO_ARCHIVE_SCHEMA_VERSION = 4;
310
+ var INACTIVE_DAY_OPTIONS = Object.freeze([0, 30, 60, 90]);
311
+ var DAY_MS = 864e5;
312
+ var RUN_INTERVAL_MS = DAY_MS;
313
+ var DEFAULT_DIR = join2(homedir2(), ".dsh", "sessions-manager");
314
+ function normalizeAutoArchiveStore(raw) {
315
+ const source = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
316
+ const settings = source.settings && typeof source.settings === "object" ? source.settings : {};
317
+ const inactiveDays = INACTIVE_DAY_OPTIONS.includes(settings.inactiveDays) ? settings.inactiveDays : 0;
318
+ return {
319
+ schemaVersion: AUTO_ARCHIVE_SCHEMA_VERSION,
320
+ settings: {
321
+ inactiveDays,
322
+ // Starred sessions are an explicit "keep" mark, so they are skipped
323
+ // unless the user opts out.
324
+ skipStarred: settings.skipStarred !== false
325
+ },
326
+ lastRunAt: Number.isFinite(source.lastRunAt) ? source.lastRunAt : null,
327
+ lastArchivedCount: Number.isInteger(source.lastArchivedCount) && source.lastArchivedCount >= 0 ? source.lastArchivedCount : 0
328
+ };
329
+ }
330
+ function pickInactiveCandidates(items, options = {}) {
331
+ const days = options.inactiveDays;
332
+ if (!INACTIVE_DAY_OPTIONS.includes(days) || days === 0) return [];
333
+ const now = Number.isFinite(options.now) ? options.now : Date.now();
334
+ const cutoff = now - days * DAY_MS;
335
+ const skipStarred = options.skipStarred !== false;
336
+ const activeId = options.activeSessionId != null ? String(options.activeSessionId) : null;
337
+ const list = Array.isArray(items) ? items : [];
338
+ const out = [];
339
+ const seen = /* @__PURE__ */ new Set();
340
+ for (const item of list) {
341
+ if (!item || item.sessionId == null) continue;
342
+ const id = String(item.sessionId);
343
+ if (seen.has(id)) continue;
344
+ if (item.archived) continue;
345
+ if (skipStarred && item.starred) continue;
346
+ if (activeId !== null && id === activeId) continue;
347
+ const updatedAt = Number(item.updatedAt);
348
+ if (!Number.isFinite(updatedAt) || updatedAt <= 0) continue;
349
+ if (updatedAt < cutoff) {
350
+ seen.add(id);
351
+ out.push(id);
352
+ }
353
+ }
354
+ return out;
355
+ }
356
+ function createAutoArchiveStore(options = {}) {
357
+ const dir = options.dir || process.env.DSH_SESSIONS_MANAGER_AUTO_ARCHIVE_DIR || DEFAULT_DIR;
358
+ const indexPath = options.indexPath || join2(dir, "auto-archive.json");
359
+ let mutation = Promise.resolve();
360
+ async function read() {
361
+ try {
362
+ return normalizeAutoArchiveStore(JSON.parse(readFileSync3(indexPath, "utf8")));
363
+ } catch {
364
+ return normalizeAutoArchiveStore(null);
365
+ }
366
+ }
367
+ async function write(store) {
368
+ await mkdir2(dir, { recursive: true });
369
+ const tmp = join2(dir, `.auto-archive-${process.pid}-${Date.now()}.tmp`);
370
+ await writeFile2(tmp, JSON.stringify(normalizeAutoArchiveStore(store), null, 2), { encoding: "utf8", mode: 384 });
371
+ await rename2(tmp, indexPath);
372
+ }
373
+ function mutate(mutator) {
374
+ const operation = mutation.then(async () => {
375
+ const store = await read();
376
+ const result = await mutator(store);
377
+ await write(store);
378
+ return result;
379
+ });
380
+ mutation = operation.catch(() => {
381
+ });
382
+ return operation;
383
+ }
384
+ function update(patch = {}) {
385
+ return mutate((store) => {
386
+ if (Object.prototype.hasOwnProperty.call(patch, "inactiveDays")) {
387
+ const days = Number(patch.inactiveDays);
388
+ if (!INACTIVE_DAY_OPTIONS.includes(days)) {
389
+ const error = new Error(`inactiveDays \u4EC5\u652F\u6301 ${INACTIVE_DAY_OPTIONS.join("\u3001")}`);
390
+ error.status = 400;
391
+ throw error;
392
+ }
393
+ store.settings.inactiveDays = days;
394
+ }
395
+ if (Object.prototype.hasOwnProperty.call(patch, "skipStarred")) {
396
+ store.settings.skipStarred = !!patch.skipStarred;
397
+ }
398
+ return store.settings;
399
+ });
400
+ }
401
+ function recordRun(count, at = Date.now()) {
402
+ return mutate((store) => {
403
+ store.lastRunAt = at;
404
+ store.lastArchivedCount = Number.isInteger(count) && count >= 0 ? count : 0;
405
+ return store;
406
+ });
407
+ }
408
+ function isFresh(store, now = Date.now()) {
409
+ return Number.isFinite(store && store.lastRunAt) && now - store.lastRunAt < RUN_INTERVAL_MS;
410
+ }
411
+ return { read, write, mutate, update, recordRun, isFresh, indexPath, dir };
412
+ }
413
+
253
414
  // src/index.js
254
415
  var name = "dsh-sessions-manager";
255
416
  var inject = ["webServer", "workspaceRegistry", "sessionPersistence", "sessionQuery", "storageDomain"];
256
417
  var MAX_TITLE = 80;
257
- var TRASH_DIR = process.env.DSH_SESSIONS_MANAGER_TRASH_DIR || join2(homedir2(), ".dsh", "sessions-manager-trash");
258
- var TRASH_INDEX = join2(TRASH_DIR, "index.json");
418
+ var TRASH_DIR = process.env.DSH_SESSIONS_MANAGER_TRASH_DIR || join3(homedir3(), ".dsh", "sessions-manager-trash");
419
+ var TRASH_INDEX = join3(TRASH_DIR, "index.json");
259
420
  var TRASH_SCHEMA_VERSION = 2;
260
421
  var DEFAULT_TRASH_SETTINGS = Object.freeze({ retentionDays: 0 });
261
422
  var FETCH_TOOL_RE = /search|fetch|download|browse/i;
262
423
  var MAX_FETCHES = 12;
263
424
  var MAX_FILES = 20;
425
+ var MAX_STORAGE_TOP = 50;
264
426
  function json(res, value, status = 200) {
265
427
  res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
266
428
  res.end(JSON.stringify(value));
@@ -372,7 +534,7 @@ function apply(ctx) {
372
534
  return operation;
373
535
  }
374
536
  let wsByPath = {};
375
- async function resolveOne(id) {
537
+ async function resolveOne(id, usage) {
376
538
  let title = null, createdAt = null, cwd = null;
377
539
  try {
378
540
  const o = await sq.readTitleSnapshot(id);
@@ -399,7 +561,7 @@ function apply(ctx) {
399
561
  const ws = cwd ? wsByPath[cwd] : void 0;
400
562
  const workspaceGone = !!(cwd && !ws);
401
563
  const display = title ? String(title).length > MAX_TITLE ? String(title).slice(0, MAX_TITLE) + "\u2026" : String(title) : null;
402
- return {
564
+ const base = {
403
565
  sessionId: id,
404
566
  title: display,
405
567
  createdAt: createdAt || null,
@@ -408,6 +570,39 @@ function apply(ctx) {
408
570
  workspaceGone: workspaceGone ? true : false,
409
571
  hasWorkspace: !!cwd
410
572
  };
573
+ if (usage) {
574
+ if (usage.sizeById && usage.sizeById.has(id)) base.sizeBytes = usage.sizeById.get(id);
575
+ if (usage.mtimeById && usage.mtimeById.has(id)) base.updatedAt = usage.mtimeById.get(id);
576
+ }
577
+ return base;
578
+ }
579
+ async function collectUsage() {
580
+ const sizeById = /* @__PURE__ */ new Map();
581
+ const mtimeById = /* @__PURE__ */ new Map();
582
+ let headers = [];
583
+ try {
584
+ headers = await sp.list();
585
+ } catch (e) {
586
+ headers = [];
587
+ }
588
+ if (!Array.isArray(headers)) headers = [];
589
+ const CHUNK = 8;
590
+ for (let i = 0; i < headers.length; i += CHUNK) {
591
+ await Promise.all(headers.slice(i, i + CHUNK).map(async (header) => {
592
+ const id = header && header.id != null ? String(header.id) : null;
593
+ if (!id) return;
594
+ try {
595
+ const loc = sp.locate(header);
596
+ if (!loc || typeof loc.path !== "string" || !loc.path) return;
597
+ const st = await stat(loc.path);
598
+ if (!st) return;
599
+ if (typeof st.size === "number") sizeById.set(id, st.size);
600
+ if (typeof st.mtimeMs === "number" && st.mtimeMs > 0) mtimeById.set(id, Math.floor(st.mtimeMs));
601
+ } catch (e) {
602
+ }
603
+ }));
604
+ }
605
+ return { sizeById, mtimeById };
411
606
  }
412
607
  async function restoreOne(sid) {
413
608
  requireSessionId(sid);
@@ -427,7 +622,7 @@ function apply(ctx) {
427
622
  }
428
623
  async function readTrashStore() {
429
624
  try {
430
- return normalizeTrashStore(JSON.parse(readFileSync3(TRASH_INDEX, "utf8")));
625
+ return normalizeTrashStore(JSON.parse(readFileSync4(TRASH_INDEX, "utf8")));
431
626
  } catch (e) {
432
627
  return normalizeTrashStore(null);
433
628
  }
@@ -436,10 +631,10 @@ function apply(ctx) {
436
631
  return (await readTrashStore()).items;
437
632
  }
438
633
  async function writeTrashStore(store) {
439
- await mkdir2(TRASH_DIR, { recursive: true });
440
- const tmp = join2(TRASH_DIR, `.index-${process.pid}-${Date.now()}.tmp`);
441
- await writeFile2(tmp, JSON.stringify(normalizeTrashStore(store), null, 2), { encoding: "utf8", mode: 384 });
442
- await rename2(tmp, TRASH_INDEX);
634
+ await mkdir3(TRASH_DIR, { recursive: true });
635
+ const tmp = join3(TRASH_DIR, `.index-${process.pid}-${Date.now()}.tmp`);
636
+ await writeFile3(tmp, JSON.stringify(normalizeTrashStore(store), null, 2), { encoding: "utf8", mode: 384 });
637
+ await rename3(tmp, TRASH_INDEX);
443
638
  }
444
639
  function mutateTrash(mutator) {
445
640
  const operation = trashMutation.then(async () => {
@@ -453,6 +648,7 @@ function apply(ctx) {
453
648
  return operation;
454
649
  }
455
650
  const stars = createStarIndex();
651
+ const autoArchive = createAutoArchiveStore();
456
652
  async function gcStars(validIds) {
457
653
  try {
458
654
  const store = await stars.read();
@@ -637,8 +833,8 @@ function apply(ctx) {
637
833
  async function moveTargetWorkspace(rawPath) {
638
834
  if (typeof rawPath !== "string" || !rawPath.trim()) throw new Error("\u7F3A\u5C11\u76EE\u6807\u5DE5\u4F5C\u533A\u8DEF\u5F84");
639
835
  let p = String(rawPath).trim();
640
- if (p.startsWith("~/")) p = join2(homedir2(), p.slice(2));
641
- if (!isAbsolute(p)) p = join2(homedir2(), p);
836
+ if (p.startsWith("~/")) p = join3(homedir3(), p.slice(2));
837
+ if (!isAbsolute(p)) p = join3(homedir3(), p);
642
838
  let canonical = null;
643
839
  try {
644
840
  canonical = await realpath(p);
@@ -646,7 +842,7 @@ function apply(ctx) {
646
842
  canonical = null;
647
843
  }
648
844
  if (canonical === null) {
649
- await mkdir2(p, { recursive: true });
845
+ await mkdir3(p, { recursive: true });
650
846
  canonical = await realpath(p);
651
847
  }
652
848
  return { canonical, entity: await w.create(canonical, basename(canonical) || "workspace") };
@@ -684,13 +880,13 @@ function apply(ctx) {
684
880
  if (!oldPath || !newPath || oldPath === newPath) return false;
685
881
  const backupPath = `${oldPath}.move-backup-${Date.now()}`;
686
882
  try {
687
- await mkdir2(dirname(newPath), { recursive: true });
688
- await rename2(oldPath, backupPath);
883
+ await mkdir3(dirname(newPath), { recursive: true });
884
+ await rename3(oldPath, backupPath);
689
885
  await rewriteFrame0Cwd(backupPath, canonical);
690
- await rename2(backupPath, newPath);
886
+ await rename3(backupPath, newPath);
691
887
  } catch (e) {
692
888
  try {
693
- await rename2(backupPath, oldPath);
889
+ await rename3(backupPath, oldPath);
694
890
  } catch (_) {
695
891
  }
696
892
  if (e && e.code !== "ENOENT") throw e;
@@ -736,7 +932,7 @@ function apply(ctx) {
736
932
  const backupPath = oldPath ? `${oldPath}.move-backup-${Date.now()}` : null;
737
933
  if (backupPath) {
738
934
  try {
739
- await rename2(oldPath, backupPath);
935
+ await rename3(oldPath, backupPath);
740
936
  } catch (e) {
741
937
  if (e && e.code !== "ENOENT") throw new Error("\u79FB\u52A8\u5931\u8D25\uFF1A\u65E0\u6CD5\u5907\u4EFD\u65E7\u7684\u4F1A\u8BDD\u65E5\u5FD7");
742
938
  }
@@ -744,7 +940,7 @@ function apply(ctx) {
744
940
  const restore = async () => {
745
941
  if (backupPath) {
746
942
  try {
747
- await rename2(backupPath, oldPath);
943
+ await rename3(backupPath, oldPath);
748
944
  } catch (_) {
749
945
  }
750
946
  }
@@ -838,7 +1034,7 @@ function apply(ctx) {
838
1034
  return { next: null, value: { ok: true, archived: true } };
839
1035
  });
840
1036
  }
841
- async function allSessionItems() {
1037
+ async function allSessionItems(opts = {}) {
842
1038
  let materialized = /* @__PURE__ */ new Set();
843
1039
  let live = ctx.get("sessions");
844
1040
  let headersOk = false;
@@ -876,9 +1072,10 @@ function apply(ctx) {
876
1072
  }
877
1073
  const currentArchived = new Set((await archivedState().catch(() => ({ archivedSessionIds: [] }))).archivedSessionIds || []);
878
1074
  const items = [];
1075
+ const usage = opts && opts.usage ? await collectUsage() : null;
879
1076
  const CHUNK = 6;
880
1077
  for (let i = 0; i < visibleIds.length; i += CHUNK) {
881
- const res2 = await Promise.all(visibleIds.slice(i, i + CHUNK).map(resolveOne));
1078
+ const res2 = await Promise.all(visibleIds.slice(i, i + CHUNK).map((id) => resolveOne(id, usage)));
882
1079
  for (const it of res2) items.push({ ...it, archived: currentArchived.has(it.sessionId) });
883
1080
  }
884
1081
  let starredSet = /* @__PURE__ */ new Set();
@@ -890,6 +1087,40 @@ function apply(ctx) {
890
1087
  if (headersOk) await gcStars(ids);
891
1088
  return items;
892
1089
  }
1090
+ async function buildStorage(opts = {}) {
1091
+ const items = await allSessionItems({ usage: true });
1092
+ const raw = Number(opts && opts.topN);
1093
+ const topN = Number.isInteger(raw) && raw > 0 ? Math.min(raw, MAX_STORAGE_TOP) : 10;
1094
+ return aggregateStorage(items, { topN });
1095
+ }
1096
+ async function autoArchiveSweep(opts = {}) {
1097
+ const store = await autoArchive.read();
1098
+ const days = store.settings.inactiveDays;
1099
+ if (!days) return { ok: true, skipped: "disabled", archived: 0 };
1100
+ const now = Date.now();
1101
+ if (!(opts && opts.force) && autoArchive.isFresh(store, now)) {
1102
+ return { ok: true, skipped: "throttled", archived: 0, lastRunAt: store.lastRunAt, lastArchivedCount: store.lastArchivedCount };
1103
+ }
1104
+ const items = await allSessionItems({ usage: true });
1105
+ const candidates = pickInactiveCandidates(items, {
1106
+ inactiveDays: days,
1107
+ skipStarred: store.settings.skipStarred,
1108
+ activeSessionId: getActiveSessionId(ctx),
1109
+ now
1110
+ });
1111
+ let archived = 0;
1112
+ const failed = [];
1113
+ for (const sid of candidates) {
1114
+ try {
1115
+ const result = await archiveOne(sid);
1116
+ if (result && result.archived) archived++;
1117
+ } catch (e) {
1118
+ failed.push({ sessionId: sid, error: String(e && e.message || e) });
1119
+ }
1120
+ }
1121
+ await autoArchive.recordRun(archived, now);
1122
+ return { ok: true, archived, candidates: candidates.length, failed, lastRunAt: now };
1123
+ }
893
1124
  async function sidebarAuthority() {
894
1125
  const ids = [];
895
1126
  try {
@@ -1096,7 +1327,7 @@ function apply(ctx) {
1096
1327
  const items = [];
1097
1328
  const CHUNK = 6;
1098
1329
  for (let i = 0; i < idStrs.length; i += CHUNK) {
1099
- const res2 = await Promise.all(idStrs.slice(i, i + CHUNK).map(resolveOne));
1330
+ const res2 = await Promise.all(idStrs.slice(i, i + CHUNK).map((id) => resolveOne(id)));
1100
1331
  items.push.apply(items, res2);
1101
1332
  }
1102
1333
  json(res, { items });
@@ -1421,6 +1652,55 @@ function apply(ctx) {
1421
1652
  }
1422
1653
  }
1423
1654
  }));
1655
+ disposers.push(ctx.webServer.register({
1656
+ kind: "exact",
1657
+ path: "/archived-sessions/storage",
1658
+ handler: async (req, res) => {
1659
+ try {
1660
+ const body = await readJsonBody(req);
1661
+ json(res, await buildStorage({ topN: body && body.topN }));
1662
+ } catch (e) {
1663
+ json(res, { error: String(e && e.message || e) }, 500);
1664
+ }
1665
+ }
1666
+ }));
1667
+ disposers.push(ctx.webServer.register({
1668
+ kind: "exact",
1669
+ path: "/archived-sessions/auto-archive/settings",
1670
+ handler: async (req, res) => {
1671
+ try {
1672
+ const body = await readJsonBody(req);
1673
+ const patch = {};
1674
+ if (body && Object.prototype.hasOwnProperty.call(body, "inactiveDays")) patch.inactiveDays = body.inactiveDays;
1675
+ if (body && Object.prototype.hasOwnProperty.call(body, "skipStarred")) patch.skipStarred = body.skipStarred;
1676
+ const settings = Object.keys(patch).length ? await autoArchive.update(patch) : (await autoArchive.read()).settings;
1677
+ const sweep = await autoArchiveSweep();
1678
+ const store = await autoArchive.read();
1679
+ json(res, {
1680
+ ok: true,
1681
+ settings,
1682
+ lastRunAt: store.lastRunAt,
1683
+ lastArchivedCount: store.lastArchivedCount,
1684
+ sweep
1685
+ });
1686
+ } catch (e) {
1687
+ json(res, { ok: false, error: String(e && e.message || e) }, errorStatus(e));
1688
+ }
1689
+ }
1690
+ }));
1691
+ disposers.push(ctx.webServer.register({
1692
+ kind: "exact",
1693
+ path: "/archived-sessions/auto-archive/run",
1694
+ handler: async (req, res) => {
1695
+ try {
1696
+ const sweep = await autoArchiveSweep({ force: true });
1697
+ const store = await autoArchive.read();
1698
+ json(res, { ok: true, ...sweep, settings: store.settings, lastRunAt: store.lastRunAt, lastArchivedCount: store.lastArchivedCount });
1699
+ } catch (e) {
1700
+ json(res, { ok: false, error: String(e && e.message || e) }, 500);
1701
+ }
1702
+ }
1703
+ }));
1424
1704
  return () => {
1425
1705
  for (const d of disposers) d();
1426
1706
  };