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/src/index.js CHANGED
@@ -14,6 +14,9 @@ import { homedir } from 'node:os'
14
14
  import { rewriteFrame0Cwd } from './zstd-frame.js'
15
15
  import { renderSessionMarkdown } from './markdown.js'
16
16
  import { createStarIndex } from './star-index.js'
17
+ import { aggregateStorage } from './storage-stats.js'
18
+ import { createAutoArchiveStore, pickInactiveCandidates } from './auto-archive.js'
19
+
17
20
 
18
21
  export const name = 'dsh-sessions-manager'
19
22
  export const inject = ['webServer', 'workspaceRegistry', 'sessionPersistence', 'sessionQuery', 'storageDomain']
@@ -29,6 +32,7 @@ const DEFAULT_TRASH_SETTINGS = Object.freeze({ retentionDays: 0 })
29
32
  const FETCH_TOOL_RE = /search|fetch|download|browse/i
30
33
  const MAX_FETCHES = 12 // fetch 记录上限(防响应过大)
31
34
  const MAX_FILES = 20 // write/edit 文件列表上限
35
+ const MAX_STORAGE_TOP = 50 // 存储排行返回上限(防响应过大)
32
36
 
33
37
  function json(res, value, status = 200) {
34
38
  res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
@@ -153,7 +157,7 @@ export function apply(ctx) {
153
157
 
154
158
  let wsByPath = {}
155
159
 
156
- async function resolveOne(id) {
160
+ async function resolveOne(id, usage) {
157
161
  let title = null, createdAt = null, cwd = null
158
162
  try {
159
163
  const o = await sq.readTitleSnapshot(id)
@@ -175,7 +179,7 @@ export function apply(ctx) {
175
179
  const ws = cwd ? wsByPath[cwd] : undefined
176
180
  const workspaceGone = !!(cwd && !ws)
177
181
  const display = title ? (String(title).length > MAX_TITLE ? String(title).slice(0, MAX_TITLE) + '…' : String(title)) : null
178
- return {
182
+ const base = {
179
183
  sessionId: id,
180
184
  title: display,
181
185
  createdAt: createdAt || null,
@@ -184,6 +188,45 @@ export function apply(ctx) {
184
188
  workspaceGone: workspaceGone ? true : false,
185
189
  hasWorkspace: !!cwd,
186
190
  }
191
+ // sizeBytes / updatedAt are opt-in: they cost one stat() per session, so
192
+ // the high-frequency list routes stay exactly as cheap as before.
193
+ if (usage) {
194
+ if (usage.sizeById && usage.sizeById.has(id)) base.sizeBytes = usage.sizeById.get(id)
195
+ if (usage.mtimeById && usage.mtimeById.has(id)) base.updatedAt = usage.mtimeById.get(id)
196
+ }
197
+ return base
198
+ }
199
+
200
+ // Disk usage + last-write time for every session, in one pass.
201
+ //
202
+ // sp.locate(header) resolves the log file behind a session header; a single
203
+ // stat() then yields both its size and its mtime. mtime doubles as the
204
+ // session's last-activity time — appending an event rewrites the log, so the
205
+ // file's last write tracks the conversation's last turn. It errs safe: a log
206
+ // we relocated (move) gets a fresh mtime and therefore looks *more* active
207
+ // than it is, which can only delay an auto-archive, never cause a wrong one.
208
+ async function collectUsage() {
209
+ const sizeById = new Map()
210
+ const mtimeById = new Map()
211
+ let headers = []
212
+ try { headers = await sp.list() } catch (e) { headers = [] }
213
+ if (!Array.isArray(headers)) headers = []
214
+ const CHUNK = 8
215
+ for (let i = 0; i < headers.length; i += CHUNK) {
216
+ await Promise.all(headers.slice(i, i + CHUNK).map(async (header) => {
217
+ const id = header && header.id != null ? String(header.id) : null
218
+ if (!id) return
219
+ try {
220
+ const loc = sp.locate(header)
221
+ if (!loc || typeof loc.path !== 'string' || !loc.path) return
222
+ const st = await stat(loc.path)
223
+ if (!st) return
224
+ if (typeof st.size === 'number') sizeById.set(id, st.size)
225
+ if (typeof st.mtimeMs === 'number' && st.mtimeMs > 0) mtimeById.set(id, Math.floor(st.mtimeMs))
226
+ } catch (e) { /* best-effort: an unreadable log just stays unknown */ }
227
+ }))
228
+ }
229
+ return { sizeById, mtimeById }
187
230
  }
188
231
 
189
232
  // Restore (unarchive) one session; throws on failure.
@@ -234,6 +277,9 @@ export function apply(ctx) {
234
277
  // when the session is really gone (purge, or externally removed; the latter
235
278
  // is caught by gcStars during list builds).
236
279
  const stars = createStarIndex()
280
+ // Auto-archive settings live in their own schema-v4 store, off by default.
281
+ const autoArchive = createAutoArchiveStore()
282
+
237
283
  async function gcStars(validIds) {
238
284
  try {
239
285
  const store = await stars.read()
@@ -659,7 +705,9 @@ export function apply(ctx) {
659
705
  })
660
706
  }
661
707
 
662
- async function allSessionItems() {
708
+ // opts.usage: fill sizeBytes + updatedAt (one stat() per session). Off by
709
+ // default so the panel's list route keeps its original cost.
710
+ async function allSessionItems(opts = {}) {
663
711
  let materialized = new Set()
664
712
  let live = ctx.get('sessions')
665
713
  let headersOk = false
@@ -683,9 +731,12 @@ export function apply(ctx) {
683
731
  try { for (const ent of w.list()) wsByPath[ent.path] = ent } catch (e) { wsByPath = {} }
684
732
  const currentArchived = new Set((await archivedState().catch(() => ({ archivedSessionIds: [] }))).archivedSessionIds || [])
685
733
  const items = []
734
+ const usage = (opts && opts.usage) ? await collectUsage() : null
686
735
  const CHUNK = 6
687
736
  for (let i = 0; i < visibleIds.length; i += CHUNK) {
688
- const res2 = await Promise.all(visibleIds.slice(i, i + CHUNK).map(resolveOne))
737
+ // Arrow wrapper on purpose: Array#map passes (value, index, array), and
738
+ // resolveOne's second argument is the usage map.
739
+ const res2 = await Promise.all(visibleIds.slice(i, i + CHUNK).map((id) => resolveOne(id, usage)))
689
740
  for (const it of res2) items.push({ ...it, archived: currentArchived.has(it.sessionId) })
690
741
  }
691
742
  // Annotate stars; GC only when we have a trustworthy id baseline, so a
@@ -697,6 +748,52 @@ export function apply(ctx) {
697
748
  return items
698
749
  }
699
750
 
751
+ // ---- Storage usage + auto-archive ---------------------------------------
752
+
753
+ // Read-only rollup: per-workspace totals plus the largest sessions. The
754
+ // aggregation itself is a pure function (src/storage-stats.js).
755
+ async function buildStorage(opts = {}) {
756
+ const items = await allSessionItems({ usage: true })
757
+ const raw = Number(opts && opts.topN)
758
+ const topN = Number.isInteger(raw) && raw > 0 ? Math.min(raw, MAX_STORAGE_TOP) : 10
759
+ return aggregateStorage(items, { topN })
760
+ }
761
+
762
+ // Archive conversations that have been idle past the configured window.
763
+ //
764
+ // Deliberately lazy — there is no timer. The sweep runs when the panel reads
765
+ // its settings (and on demand), at most once a day: a background interval
766
+ // would keep the host process alive and would archive conversations while
767
+ // nobody is looking at the panel.
768
+ async function autoArchiveSweep(opts = {}) {
769
+ const store = await autoArchive.read()
770
+ const days = store.settings.inactiveDays
771
+ if (!days) return { ok: true, skipped: 'disabled', archived: 0 }
772
+ const now = Date.now()
773
+ if (!(opts && opts.force) && autoArchive.isFresh(store, now)) {
774
+ return { ok: true, skipped: 'throttled', archived: 0, lastRunAt: store.lastRunAt, lastArchivedCount: store.lastArchivedCount }
775
+ }
776
+ const items = await allSessionItems({ usage: true })
777
+ const candidates = pickInactiveCandidates(items, {
778
+ inactiveDays: days,
779
+ skipStarred: store.settings.skipStarred,
780
+ activeSessionId: getActiveSessionId(ctx),
781
+ now,
782
+ })
783
+ let archived = 0
784
+ const failed = []
785
+ for (const sid of candidates) {
786
+ try {
787
+ const result = await archiveOne(sid)
788
+ if (result && result.archived) archived++
789
+ } catch (e) {
790
+ failed.push({ sessionId: sid, error: String((e && e.message) || e) })
791
+ }
792
+ }
793
+ await autoArchive.recordRun(archived, now)
794
+ return { ok: true, archived, candidates: candidates.length, failed, lastRunAt: now }
795
+ }
796
+
700
797
  async function sidebarAuthority() {
701
798
  const ids = []
702
799
  try { for (const header of await sp.list()) ids.push(String(header.id)) } catch (e) {}
@@ -881,7 +978,7 @@ export function apply(ctx) {
881
978
  const items = []
882
979
  const CHUNK = 6
883
980
  for (let i = 0; i < idStrs.length; i += CHUNK) {
884
- const res2 = await Promise.all(idStrs.slice(i, i + CHUNK).map(resolveOne))
981
+ const res2 = await Promise.all(idStrs.slice(i, i + CHUNK).map((id) => resolveOne(id)))
885
982
  items.push.apply(items, res2)
886
983
  }
887
984
  json(res, { items })
@@ -1230,6 +1327,65 @@ export function apply(ctx) {
1230
1327
  },
1231
1328
  }))
1232
1329
 
1330
+ // Storage usage rollup (read-only): per-workspace totals + largest sessions.
1331
+ disposers.push(ctx.webServer.register({
1332
+ kind: 'exact',
1333
+ path: '/archived-sessions/storage',
1334
+ handler: async (req, res) => {
1335
+ try {
1336
+ const body = await readJsonBody(req)
1337
+ json(res, await buildStorage({ topN: body && body.topN }))
1338
+ } catch (e) {
1339
+ json(res, { error: String((e && e.message) || e) }, 500)
1340
+ }
1341
+ },
1342
+ }))
1343
+
1344
+ // Auto-archive settings. A plain read (no patch keys) doubles as the lazy
1345
+ // sweep trigger — that is how the once-a-day cleanup gets a chance to run
1346
+ // without a background timer.
1347
+ disposers.push(ctx.webServer.register({
1348
+ kind: 'exact',
1349
+ path: '/archived-sessions/auto-archive/settings',
1350
+ handler: async (req, res) => {
1351
+ try {
1352
+ const body = await readJsonBody(req)
1353
+ const patch = {}
1354
+ if (body && Object.prototype.hasOwnProperty.call(body, 'inactiveDays')) patch.inactiveDays = body.inactiveDays
1355
+ if (body && Object.prototype.hasOwnProperty.call(body, 'skipStarred')) patch.skipStarred = body.skipStarred
1356
+ const settings = Object.keys(patch).length
1357
+ ? await autoArchive.update(patch)
1358
+ : (await autoArchive.read()).settings
1359
+ const sweep = await autoArchiveSweep()
1360
+ const store = await autoArchive.read()
1361
+ json(res, {
1362
+ ok: true,
1363
+ settings,
1364
+ lastRunAt: store.lastRunAt,
1365
+ lastArchivedCount: store.lastArchivedCount,
1366
+ sweep,
1367
+ })
1368
+ } catch (e) {
1369
+ json(res, { ok: false, error: String((e && e.message) || e) }, errorStatus(e))
1370
+ }
1371
+ },
1372
+ }))
1373
+
1374
+ // Run the auto-archive sweep right now, ignoring the once-a-day throttle.
1375
+ disposers.push(ctx.webServer.register({
1376
+ kind: 'exact',
1377
+ path: '/archived-sessions/auto-archive/run',
1378
+ handler: async (req, res) => {
1379
+ try {
1380
+ const sweep = await autoArchiveSweep({ force: true })
1381
+ const store = await autoArchive.read()
1382
+ json(res, { ok: true, ...sweep, settings: store.settings, lastRunAt: store.lastRunAt, lastArchivedCount: store.lastArchivedCount })
1383
+ } catch (e) {
1384
+ json(res, { ok: false, error: String((e && e.message) || e) }, 500)
1385
+ }
1386
+ },
1387
+ }))
1388
+
1233
1389
  return () => { for (const d of disposers) d() }
1234
1390
  }, 'dsh-sessions-manager: routes')
1235
1391
  }
@@ -0,0 +1,94 @@
1
+ // Storage usage aggregation (pure, no I/O).
2
+ //
3
+ // Kept out of the host bundle so it can be unit-tested directly
4
+ // (tests/storage-stats.test.js). Takes the session items the host already
5
+ // builds (with `sizeBytes` filled in) and rolls them up per workspace plus a
6
+ // "largest sessions" leaderboard.
7
+ //
8
+ // Sessions without a workspace path land in a single "未分组" bucket — that is
9
+ // DSH's own label for orphans, so the panel speaks the same language as the
10
+ // sidebar.
11
+
12
+ export const UNGROUPED_KEY = '__ungrouped__'
13
+
14
+ function isFiniteSize(value) {
15
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0
16
+ }
17
+
18
+ /**
19
+ * Roll session sizes up per workspace and pick the largest sessions.
20
+ *
21
+ * @param {Array<{sessionId: string, title?: string|null, workspacePath?: string|null,
22
+ * workspaceTitle?: string|null, sizeBytes?: number|null}>} items
23
+ * @param {object} [options]
24
+ * @param {number} [options.topN=10] - How many entries the leaderboard holds.
25
+ * @returns {{
26
+ * totalBytes: number,
27
+ * sessionCount: number,
28
+ * sizedSessions: number,
29
+ * unknownSessions: number,
30
+ * workspaces: Array<{key: string, path: string|null, title: string|null,
31
+ * bytes: number, sessions: number, share: number}>,
32
+ * top: Array<{sessionId: string, title: string|null, workspacePath: string|null,
33
+ * workspaceTitle: string|null, sizeBytes: number}>
34
+ * }}
35
+ */
36
+ export function aggregateStorage(items, options = {}) {
37
+ const topN = Number.isInteger(options.topN) && options.topN > 0 ? options.topN : 10
38
+ const list = Array.isArray(items) ? items : []
39
+
40
+ const buckets = new Map()
41
+ const sized = []
42
+ let totalBytes = 0
43
+ let unknownSessions = 0
44
+ // Counted entries only: sessionCount must always equal
45
+ // sizedSessions + unknownSessions, or the panel would show a total that
46
+ // disagrees with its own breakdown.
47
+ let counted = 0
48
+
49
+ for (const item of list) {
50
+ if (!item || item.sessionId == null) continue
51
+ counted++
52
+ const id = String(item.sessionId)
53
+ const path = item.workspacePath ? String(item.workspacePath) : null
54
+ const key = path || UNGROUPED_KEY
55
+
56
+ let bucket = buckets.get(key)
57
+ if (!bucket) {
58
+ bucket = { key, path, title: item.workspaceTitle ? String(item.workspaceTitle) : null, bytes: 0, sessions: 0 }
59
+ buckets.set(key, bucket)
60
+ }
61
+ bucket.sessions++
62
+
63
+ if (isFiniteSize(item.sizeBytes)) {
64
+ bucket.bytes += item.sizeBytes
65
+ totalBytes += item.sizeBytes
66
+ sized.push({
67
+ sessionId: id,
68
+ title: item.title || null,
69
+ workspacePath: path,
70
+ workspaceTitle: bucket.title,
71
+ sizeBytes: item.sizeBytes,
72
+ })
73
+ } else {
74
+ unknownSessions++
75
+ }
76
+ }
77
+
78
+ const workspaces = [...buckets.values()]
79
+ .sort((a, b) => (b.bytes - a.bytes) || (b.sessions - a.sessions) || a.key.localeCompare(b.key))
80
+ .map((bucket) => ({ ...bucket, share: totalBytes > 0 ? bucket.bytes / totalBytes : 0 }))
81
+
82
+ const top = sized
83
+ .sort((a, b) => (b.sizeBytes - a.sizeBytes) || a.sessionId.localeCompare(b.sessionId))
84
+ .slice(0, topN)
85
+
86
+ return {
87
+ totalBytes,
88
+ sessionCount: counted,
89
+ sizedSessions: sized.length,
90
+ unknownSessions,
91
+ workspaces,
92
+ top,
93
+ }
94
+ }