dsh-session-mng 0.1.4 → 0.2.1

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/state.js CHANGED
@@ -13,8 +13,13 @@
13
13
  import { zstdAvailable } from './store-jsonl.js'
14
14
  import { canReconcileMove } from './workspace-sync.js'
15
15
 
16
- /** Read a service without declaring it as a hard dependency. */
17
- function serviceOf(ctx, name) {
16
+ /**
17
+ * Read a service without declaring it as a hard dependency.
18
+ *
19
+ * `ctx.get(name)` may throw for an unknown or not-yet-started service; an
20
+ * absent service must degrade one feature, never fail the request.
21
+ */
22
+ export function serviceOf(ctx, name) {
18
23
  try {
19
24
  return ctx.get?.(name)
20
25
  } catch {
@@ -32,7 +37,7 @@ function liveSession(ctx, id) {
32
37
  }
33
38
 
34
39
  /** Best-effort running state for one id. */
35
- function isRunning(ctx, id) {
40
+ export function isRunningSession(ctx, id) {
36
41
  try {
37
42
  const agent = ctx.get?.('agents')?.get?.(id)
38
43
  return agent?.status === 'running' || agent?.running === true
@@ -41,6 +46,39 @@ function isRunning(ctx, id) {
41
46
  }
42
47
  }
43
48
 
49
+ /**
50
+ * Capabilities this host currently offers, shared by `/state` and the 0.2.0
51
+ * catalog so both surfaces answer the same question the same way.
52
+ */
53
+ export function capabilitiesOf(ctx) {
54
+ const jsonl =
55
+ typeof ctx.sessionPersistence?.locate === 'function' && typeof ctx.sessionPersistence?.stat === 'function'
56
+ return {
57
+ title: Boolean(ctx.llm),
58
+ move: jsonl && zstdAvailable() && canReconcileMove(serviceOf(ctx, 'workspaceRegistry')),
59
+ delete: jsonl,
60
+ backend: jsonl ? 'jsonl' : 'other',
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Per-row action flags derived from one row's state and the host capabilities.
66
+ *
67
+ * Keeping this shared is what makes `/state` rows and catalog rows offer the
68
+ * same buttons: a live or running session is never moved or deleted, a branch
69
+ * is never moved on its own, and deletion stays archived-only.
70
+ */
71
+ export function canOf(row, capabilities) {
72
+ return {
73
+ title: capabilities.title,
74
+ archive: !row.archived,
75
+ restore: row.archived,
76
+ move: capabilities.move && !row.live && !row.running && row.kind === 'root',
77
+ delete: capabilities.delete && row.archived && !row.live,
78
+ open: true,
79
+ }
80
+ }
81
+
44
82
  /** Best-effort snapshot list; an unavailable backend yields no snapshots. */
45
83
  async function persistenceSnapshots(ctx, signal) {
46
84
  try {
@@ -69,7 +107,7 @@ async function sessionSummaries(ctx, signal) {
69
107
  sessionId: record.header.id,
70
108
  updatedAt: record.header.createdAt,
71
109
  agentAvailable: false,
72
- running: isRunning(ctx, record.header.id),
110
+ running: isRunningSession(ctx, record.header.id),
73
111
  blank: false,
74
112
  cwd: record.header.cwd,
75
113
  origin: record.header.origin,
@@ -241,7 +279,7 @@ export async function buildState(ctx, config, options = {}) {
241
279
  workspaceBySession.get(id) ??
242
280
  (typeof cwd === 'string' && cwd !== '' ? workspaceByPath.get(cwd) : undefined)
243
281
  const live = liveSession(ctx, id) !== undefined
244
- const running = summary.running === true || isRunning(ctx, id)
282
+ const running = summary.running === true || isRunningSession(ctx, id)
245
283
  const title =
246
284
  typeof summary.projections?.values?.title === 'string' ? summary.projections.values.title : ''
247
285
  if (title === '') missingTitles.push(id)
@@ -284,7 +322,7 @@ export async function buildState(ctx, config, options = {}) {
284
322
  cwd: header.cwd,
285
323
  origin: header.origin,
286
324
  parentSessionId: header.parentSession,
287
- running: isRunning(ctx, header.id),
325
+ running: isRunningSession(ctx, header.id),
288
326
  blank: false,
289
327
  formatStatus: snapshot.formatStatus,
290
328
  })
@@ -311,25 +349,8 @@ export async function buildState(ctx, config, options = {}) {
311
349
  row.forkCount = forkCountByParent.get(row.id) ?? 0
312
350
  }
313
351
 
314
- const jsonl =
315
- typeof ctx.sessionPersistence?.locate === 'function' && typeof ctx.sessionPersistence?.stat === 'function'
316
- const capabilities = {
317
- title: Boolean(ctx.llm),
318
- move: jsonl && zstdAvailable() && canReconcileMove(serviceOf(ctx, 'workspaceRegistry')),
319
- delete: jsonl,
320
- backend: jsonl ? 'jsonl' : 'other',
321
- }
322
-
323
- for (const row of rows) {
324
- row.can = {
325
- title: capabilities.title,
326
- archive: !row.archived,
327
- restore: row.archived,
328
- move: capabilities.move && !row.live && !row.running && row.kind === 'root',
329
- delete: capabilities.delete && row.archived && !row.live,
330
- open: true,
331
- }
332
- }
352
+ const capabilities = capabilitiesOf(ctx)
353
+ for (const row of rows) row.can = canOf(row, capabilities)
333
354
 
334
355
  // Archived ids whose log is gone can never be rendered; surface them so the
335
356
  // UI can offer to drop the stale archive marker. Every id the corpus knows
@@ -21,7 +21,7 @@
21
21
 
22
22
  import zlib from 'node:zlib'
23
23
  import { homedir } from 'node:os'
24
- import { dirname, join } from 'node:path'
24
+ import { basename, dirname, join } from 'node:path'
25
25
  import { access, cp, mkdir, open, readdir, readFile, rename, rm, stat } from 'node:fs/promises'
26
26
 
27
27
  import { SessionMngError, messageOf } from './errors.js'
@@ -160,7 +160,81 @@ export async function currentLogFile(dir) {
160
160
  }
161
161
  }
162
162
  if (best === undefined) return undefined
163
- return { path: join(dir, best.name), name: best.name, compressed: best.compressed }
163
+ return { path: join(dir, best.name), name: best.name, compressed: best.compressed, generation: bestGeneration }
164
+ }
165
+
166
+ /**
167
+ * Size and format facts of one stored session's current log, at the cost of
168
+ * exactly one readdir plus one stat.
169
+ *
170
+ * The directory comes from the backend's own zero-I/O locator, then
171
+ * {@link currentLogFile} picks the highest released generation inside it. That
172
+ * is deliberately NOT `findSessionDirs`/`stat(id)`: both fall back to scanning
173
+ * every project directory, which must never happen on a page request. The
174
+ * format status is inferred by comparing the selected generation with the one
175
+ * `locate()` names as current, so an older-generation log is reported honestly
176
+ * instead of being labelled current.
177
+ *
178
+ * @param {object} persistence - the host's JSONL persistence service.
179
+ * @param {string} id - stored session id.
180
+ * @param {string} [cwd] - the session's stored working directory.
181
+ * @returns {Promise<{sizeBytes: number, formatStatus: string, logName: string}|undefined>}
182
+ */
183
+ export async function readLogFacts(persistence, id, cwd) {
184
+ let located
185
+ try {
186
+ located = persistence?.locate?.({ id, cwd })
187
+ } catch {
188
+ return undefined
189
+ }
190
+ const path = typeof located?.path === 'string' ? located.path : ''
191
+ if (path === '') return undefined
192
+ const currentGeneration = generationOf(basename(path))
193
+ const log = await currentLogFile(dirname(path))
194
+ if (log === undefined) return undefined
195
+ let size
196
+ try {
197
+ size = (await stat(log.path)).size
198
+ } catch {
199
+ return undefined
200
+ }
201
+ const migrationRequired = currentGeneration !== undefined && log.generation < currentGeneration
202
+ return {
203
+ sizeBytes: size,
204
+ formatStatus: migrationRequired ? 'migration-required' : 'current',
205
+ logName: log.name,
206
+ }
207
+ }
208
+
209
+ /** Maximum concurrent per-log stat batches; small enough to stay polite. */
210
+ const FACT_CONCURRENCY = 8
211
+
212
+ /**
213
+ * Read {@link readLogFacts} for a bounded batch (one page at most in practice).
214
+ *
215
+ * @param {object} persistence
216
+ * @param {{id: string, cwd?: string}[]} items
217
+ * @param {number} [concurrency]
218
+ * @returns {Promise<Map<string, {sizeBytes: number, formatStatus: string, logName: string}>>}
219
+ */
220
+ export async function readLogFactsBatch(persistence, items, concurrency = FACT_CONCURRENCY) {
221
+ const found = new Map()
222
+ if (!Array.isArray(items) || items.length === 0) return found
223
+ let next = 0
224
+ const worker = async () => {
225
+ for (;;) {
226
+ const index = next
227
+ next += 1
228
+ if (index >= items.length) return
229
+ const item = items[index]
230
+ if (typeof item?.id !== 'string') continue
231
+ const facts = await readLogFacts(persistence, item.id, item.cwd).catch(() => undefined)
232
+ if (facts !== undefined) found.set(item.id, facts)
233
+ }
234
+ }
235
+ const width = Math.max(1, Math.min(Math.trunc(concurrency) || 1, items.length))
236
+ await Promise.all(Array.from({ length: width }, worker))
237
+ return found
164
238
  }
165
239
 
166
240
  /** Every offset where a Zstandard frame magic begins. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-session-mng",
3
- "version": "0.1.4",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "description": "Session management for DeepSeek Harness: one Settings page for every session — whole-session title generation, archive/restore, cross-workspace move, and permanent deletion of archived sessions.",
6
6
  "license": "MIT",