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/CHANGELOG.md +43 -0
- package/README.md +31 -11
- package/ROADMAP.md +13 -12
- package/client.js +1272 -328
- package/docs/01-design.md +49 -2
- package/docs/02-open-source-and-marketplace.md +19 -0
- package/docs/HANDOVER-2026-10-29.md +105 -0
- package/docs/PLAN-2026-10-29.md +245 -0
- package/docs/PLAN-2026-11-02-family-move.md +72 -0
- package/docs/RELEASE-CHECKLIST-0.1.4.md +6 -5
- package/docs/RELEASE-CHECKLIST-0.2.0.md +79 -0
- package/docs/RELEASE-CHECKLIST-0.2.1.md +46 -0
- package/lib/api.js +44 -11
- package/lib/catalog.js +773 -0
- package/lib/service.js +595 -41
- package/lib/state.js +46 -25
- package/lib/store-jsonl.js +76 -2
- package/package.json +1 -1
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
|
-
/**
|
|
17
|
-
|
|
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
|
|
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:
|
|
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 ||
|
|
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:
|
|
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
|
|
315
|
-
|
|
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
|
package/lib/store-jsonl.js
CHANGED
|
@@ -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
|
|
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",
|