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/README.en.md +21 -21
- package/README.md +21 -21
- package/assets/screenshot-session-autoarch.png +0 -0
- package/assets/screenshot-session-details.png +0 -0
- package/assets/screenshot-session-settings.png +0 -0
- package/assets/screenshot-session-starred.png +0 -0
- package/assets/screenshot-session-storage.png +0 -0
- package/assets/screenshot-session-trash.png +0 -0
- package/lib/client.js +161 -4
- package/lib/client.js.map +2 -2
- package/lib/index.js +305 -25
- package/lib/index.js.map +4 -4
- package/package.json +1 -1
- package/src/auto-archive.js +168 -0
- package/src/client/index.jsx +177 -5
- package/src/index.js +161 -5
- package/src/storage-stats.js +94 -0
- package/assets/screenshot-session-settingsmenu.png +0 -0
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
|
Binary file
|