dsh-manage-session 0.2.3 → 0.3.3

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.
@@ -1,29 +1,69 @@
1
1
  /**
2
- * 批量体检:枚举会话日志、跑四门、给出汇总(供 `session-health-scan` 使用)。
2
+ * 批量体检:枚举会话日志、跑门、给出汇总(供 `session-health-scan` 使用)。
3
3
  *
4
4
  * 只读;不激活任何 Agent,也不写任何会话数据。为控制成本,默认限制单次扫描的
5
5
  * 会话数与并发度,并支持 `limit` / `since` 过滤。
6
+ *
7
+ * **发现按代次而不是按固定文件名。** 一个会话目录的当前日志名由目录实测的最高
8
+ * 代次决定(见 `generation.ts`):历史代是 `session.jsonl.zstd`,已发布的当前代
9
+ * 是 `session.v<N>.jsonl.zstd`。早期版本在这里写死 `session.jsonl.zstd`,于是
10
+ * 61 个只留当前代的会话**一条都发现不了** —— 既扫不到,`findSessionLog` 也返回
11
+ * undefined,连 `session-health-repair` 都会先报「未找到会话日志」。
6
12
  */
7
- import { existsSync, readdirSync, statSync, type Dirent } from 'node:fs'
13
+ import { existsSync, readdirSync, type Dirent } from 'node:fs'
8
14
  import { homedir } from 'node:os'
9
15
  import { join } from 'node:path'
16
+ import {
17
+ latestArtifactMtime,
18
+ readSessionGenerations,
19
+ sessionPriority,
20
+ type SessionGenerations,
21
+ type SessionPriority,
22
+ } from './generation.ts'
10
23
  import { buildSessionReport, type GateContext, type SessionHealthReport } from './gates.ts'
11
24
 
12
- /** 一个已发现的会话日志。 */
25
+ /** 一个已发现的会话。 */
13
26
  export interface DiscoveredSession {
14
27
  sessionId: string
15
- logPath: string
28
+ /** 承载日志的代次目录。 */
29
+ dir: string
30
+ /**
31
+ * 当前代日志路径。
32
+ *
33
+ * 目录内只有迁移暂存(尚未发布当前代)时为 undefined —— 该会话确实存在,
34
+ * 但没有可读的规范产物,由 `generation` / `log-integrity` 两门如实报出,
35
+ * 而不是当作「没有这个会话」。
36
+ */
37
+ logPath?: string
38
+ /** 代次事实(当前代 / 历史代 / 暂存残留)。 */
39
+ generations: SessionGenerations
40
+ /** 处置优先级:`high` = 当前代是从暂存(TMP)发布出来的。 */
41
+ priority: SessionPriority
16
42
  updatedAt: number
17
43
  bytes: number
18
44
  }
19
45
 
20
- /** 会话根目录(`<dshHome>/sessions/<project>/<sessionId>/session.jsonl.zstd`)。 */
46
+ /** 会话根目录(`<dshHome>/sessions/<project>/<sessionId>/`)。 */
21
47
  export function sessionsRoot(dshHome?: string): string {
22
48
  return join(dshHome ?? join(homedir(), '.dsh'), 'sessions')
23
49
  }
24
50
 
25
- /** 枚举全部会话日志(按 mtime 倒序),带可选上限。 */
26
- export function discoverSessions(dshHome?: string, limit = 200): DiscoveredSession[] {
51
+ /**
52
+ * 语料枚举上限。
53
+ *
54
+ * 这是防御无界遍历的**边界**,不是策略:早期版本写 200,实测语料 449 个会话时
55
+ * 它静默截掉了一半(体检进度分母恒为 200,末 249 个会话永远扫不到)。
56
+ */
57
+ const DISCOVERY_LIMIT = 100_000
58
+
59
+ /**
60
+ * 枚举全部会话(按 mtime 倒序),带可选上限。
61
+ *
62
+ * 「是会话目录」的判据是**目录里有规范代次产物或迁移暂存**,不要求任何具体文件名。
63
+ * @param dshHome - DSH home(缺省 ~/.dsh)。
64
+ * @param limit - 返回上限(缺省 `DISCOVERY_LIMIT`)。
65
+ */
66
+ export function discoverSessions(dshHome?: string, limit = DISCOVERY_LIMIT): DiscoveredSession[] {
27
67
  const root = sessionsRoot(dshHome)
28
68
  const out: DiscoveredSession[] = []
29
69
  if (!existsSync(root)) return out
@@ -38,22 +78,23 @@ export function discoverSessions(dshHome?: string, limit = 200): DiscoveredSessi
38
78
  continue
39
79
  }
40
80
  for (const entry of entries) {
81
+ if (!entry.isDirectory()) continue
41
82
  const path = join(dir, entry.name)
42
- if (entry.isDirectory()) {
43
- const candidate = join(path, 'session.jsonl.zstd')
44
- if (existsSync(candidate)) {
45
- let stats: { mtimeMs: number; size: number }
46
- try {
47
- const stat = statSync(candidate)
48
- stats = { mtimeMs: stat.mtimeMs, size: stat.size }
49
- } catch {
50
- continue
51
- }
52
- out.push({ sessionId: entry.name, logPath: candidate, updatedAt: stats.mtimeMs, bytes: stats.size })
53
- continue
54
- }
83
+ const generations = readSessionGenerations(path)
84
+ if (generations.canonical.length === 0 && generations.staging.length === 0) {
55
85
  stack.push(path)
86
+ continue
56
87
  }
88
+ const current = generations.current
89
+ out.push({
90
+ sessionId: entry.name,
91
+ dir: path,
92
+ ...(current === undefined ? {} : { logPath: current.path }),
93
+ generations,
94
+ priority: sessionPriority(generations),
95
+ updatedAt: latestArtifactMtime(generations) ?? 0,
96
+ bytes: current?.bytes ?? 0,
97
+ })
57
98
  }
58
99
  }
59
100
  out.sort((left, right) => right.updatedAt - left.updatedAt)
@@ -63,7 +104,7 @@ export function discoverSessions(dshHome?: string, limit = 200): DiscoveredSessi
63
104
  /**
64
105
  * 语料总数:只做目录枚举,**不跑体检**。
65
106
  *
66
- * 用于判断缓存是否已过期——枚举很便宜,而重扫要解 zstd、跑四门。
107
+ * 用于判断缓存是否已过期——枚举很便宜,而重扫要解 zstd、跑门。
67
108
  * 正因为两者代价差着量级,「对账」才不构成缓存失效策略本身。
68
109
  * @param dshHome - DSH home(缺省 ~/.dsh)。
69
110
  */
@@ -71,9 +112,21 @@ export function countCorpus(dshHome?: string): number {
71
112
  return discoverSessions(dshHome, DISCOVERY_LIMIT).length
72
113
  }
73
114
 
74
- /** 定位一个会话的日志路径(跨工程目录查找)。 */
115
+ /** 按 id 定位一个会话(跨工程目录查找)。 */
116
+ export function findSession(sessionId: string, dshHome?: string): DiscoveredSession | undefined {
117
+ return discoverSessions(dshHome, DISCOVERY_LIMIT).find(session => session.sessionId === sessionId)
118
+ }
119
+
120
+ /**
121
+ * 定位一个会话的**当前代**日志路径(跨工程目录查找)。
122
+ *
123
+ * 返回 undefined 有两种含义,调用方必须区分:会话不存在,或会话存在但尚未发布
124
+ * 当前代(只有迁移暂存)。需要区分时用 `findSession`。
125
+ * @param sessionId - 会话 id。
126
+ * @param dshHome - DSH home(缺省 ~/.dsh)。
127
+ */
75
128
  export function findSessionLog(sessionId: string, dshHome?: string): string | undefined {
76
- return discoverSessions(dshHome, 100_000).find(session => session.sessionId === sessionId)?.logPath
129
+ return findSession(sessionId, dshHome)?.logPath
77
130
  }
78
131
 
79
132
  /** 扫描结果。 */
@@ -94,9 +147,6 @@ export interface HealthScanResult {
94
147
  error?: string
95
148
  }
96
149
 
97
- /** 语料枚举上限(进度分母的来源;与 route 侧 MAX_SCAN_LIMIT 同量级)。 */
98
- const DISCOVERY_LIMIT = 200
99
-
100
150
  /** 未显式传 limit 时的单批会话数。 */
101
151
  const DEFAULT_BATCH_LIMIT = 50
102
152
 
@@ -106,6 +156,9 @@ const DEFAULT_BATCH_LIMIT = 50
106
156
  * 语料先整体列出(按 mtime 倒序,≤ DISCOVERY_LIMIT),再按 `[offset, offset+limit)`
107
157
  * 切片扫描。这样客户端可以用小批次连续调用、自己累计真实进度,而宿主保持无状态
108
158
  * ——不必把同步循环改成异步,也不必新增进度轮询端点。
159
+ *
160
+ * 批内**命中顺序**按处置优先级排(`high` 在前,稳定排序)。分批切片仍按 mtime,
161
+ * 所以 offset/total 的算术不受影响:只有同一批里的呈现顺序变了。
109
162
  * @param options - dshHome / 批大小 / 批起点 / 是否只返回非 ok / 归属查询 / 热态状态提供者。
110
163
  */
111
164
  export function scanSessions(options: {
@@ -137,7 +190,8 @@ export function scanSessions(options: {
137
190
  const report = buildSessionReport({
138
191
  sessionId: session.sessionId,
139
192
  ...(options.dshHome === undefined ? {} : { dshHome: options.dshHome }),
140
- logPath: session.logPath,
193
+ ...(session.logPath === undefined ? {} : { logPath: session.logPath }),
194
+ generations: session.generations,
141
195
  ...(projectionState === undefined ? {} : { projectionState }),
142
196
  ...(options.attribute === undefined ? {} : { attribute: options.attribute }),
143
197
  ...(options.now === undefined ? {} : { now: options.now }),
@@ -145,6 +199,9 @@ export function scanSessions(options: {
145
199
  if (options.onlyProblems === true && report.level === 'ok') continue
146
200
  findings.push(report)
147
201
  }
202
+ // 「从 TMP 移出来」的会话排在最前:它们是本插件此前读错产物(历史代)或
203
+ // 完全发现不了的会话,先看到才能先处置。
204
+ findings.sort((left, right) => (left.priority === right.priority ? 0 : left.priority === 'high' ? -1 : 1))
148
205
  return {
149
206
  ok: true,
150
207
  scanned: batch.length,
package/src/index.ts CHANGED
@@ -27,19 +27,36 @@ import { buildProjectionOwnerIndex, createAttributor, defaultProfileNodeModules
27
27
  import { HealthCache } from './host/health/cache.ts'
28
28
  import { buildSessionReport, type SessionHealthReport } from './host/health/gates.ts'
29
29
  import { assessRepair, prescribe, quarantineProjectionCache } from './host/health/repair.ts'
30
- import { countCorpus, findSessionLog, scanSessions } from './host/health/scan.ts'
30
+ import { countCorpus, findSession, scanSessions } from './host/health/scan.ts'
31
31
 
32
32
  export { DEFAULT_CONFIG, STEWARD_API_PREFIX, STEWARD_SETTINGS_NAMESPACE } from './config.ts'
33
33
  export type { StewardConfig } from './config.ts'
34
34
  export { readArchiveSet, pruneArchiveFile, editWorkspaceDocument } from './host/history/archive-source.ts'
35
35
  export { listHistory, pruneHistory } from './host/history/archive.ts'
36
36
  export { purgeHistory, locateSessionUsage, indexSessionDirs, isSafeChild, dirSize, sessionsRootFor, projCacheRootFor } from './host/history/purge.ts'
37
- export { buildSessionReport, gateColdRead, gateLogIntegrity, gateLosslessJson, gateProjectionCache, readProjectionCache, readTailFacts } from './host/health/gates.ts'
37
+ export { buildSessionReport, gateColdRead, gateGeneration, gateLogIntegrity, gateLosslessJson, gateProjectionCache, readProjectionCache, readTailFacts } from './host/health/gates.ts'
38
38
  export { firstLosslessViolation, isLossless } from './host/health/lossless.ts'
39
39
  export { decodeSessionLogBytes, decodeSessionLogFile, scanZstdFrames } from './host/health/decode.ts'
40
+ export {
41
+ classifyGenerationFilename,
42
+ generationLogFilename,
43
+ isMigrationStagingFilename,
44
+ latestArtifactMtime,
45
+ parseGenerationLogFilename,
46
+ readSessionGenerations,
47
+ sessionPriority,
48
+ } from './host/health/generation.ts'
49
+ export type {
50
+ GenerationArtifact,
51
+ LogArtifact,
52
+ LogCompression,
53
+ SessionGenerations,
54
+ SessionPriority,
55
+ } from './host/health/generation.ts'
40
56
  export { buildProjectionOwnerIndex, createAttributor } from './host/health/attribution.ts'
41
57
  export { prescribe, quarantineProjectionCache } from './host/health/repair.ts'
42
- export { countCorpus, discoverSessions, findSessionLog, scanSessions } from './host/health/scan.ts'
58
+ export { countCorpus, discoverSessions, findSession, findSessionLog, scanSessions } from './host/health/scan.ts'
59
+ export type { DiscoveredSession } from './host/health/scan.ts'
43
60
  export { HealthCache } from './host/health/cache.ts'
44
61
  export type { HealthCacheEntry } from './host/health/cache.ts'
45
62
  export { assessRepair } from './host/health/repair.ts'
@@ -335,14 +352,17 @@ export async function handleMethod(
335
352
  const request = (payload ?? {}) as { sessionId?: unknown }
336
353
  const sessionId = asSessionId(request.sessionId)
337
354
  if (sessionId === undefined) return { ok: false, error: '缺少合法的 sessionId' }
338
- const logPath = findSessionLog(sessionId, runtime.dshHome)
339
- if (logPath === undefined) return { ok: false, error: `未找到会话日志:${sessionId}` }
355
+ // 按代次定位:会话存在但尚未发布当前代时 logPath 为 undefined,此时仍要出报告
356
+ // (generation / log-integrity 两门如实报出),不能笼统回「未找到会话日志」。
357
+ const session = findSession(sessionId, runtime.dshHome)
358
+ if (session === undefined) return { ok: false, error: `未找到会话日志:${sessionId}` }
340
359
 
341
360
  const projectionState = runtime.projectionStateFor(sessionId)
342
361
  const reportFor = (): SessionHealthReport => buildSessionReport({
343
362
  sessionId,
344
363
  dshHome: runtime.dshHome,
345
- logPath,
364
+ ...(session.logPath === undefined ? {} : { logPath: session.logPath }),
365
+ generations: session.generations,
346
366
  ...(projectionState === undefined ? {} : { projectionState }),
347
367
  attribute: runtime.attribute,
348
368
  })
@@ -371,7 +391,7 @@ export async function handleMethod(
371
391
  verdict: clean.verdict,
372
392
  explanation: clean.explanation,
373
393
  residual: clean.residual,
374
- prescriptions: ['# 四门全绿:无需处置'],
394
+ prescriptions: ['# 全部检查通过:无需处置'],
375
395
  }
376
396
  }
377
397
  const repair = quarantineProjectionCache(sessionId, runtime.dshHome)